36 KiB
API Adapter Architecture
Overview
This document defines the adapter pattern implementation for the Hensei API layer. The adapter pattern provides a consistent interface for all API operations while handling transformations, error handling, caching, and reactive state management through Runed.
Current State Analysis
Existing API Structure
/lib/api/
├── core.ts # HTTP utilities (get, post, put, del)
├── client.ts # APIClient class with edit key management
├── resources/ # Resource-specific functions
│ ├── search.ts # Search functions (weapons, characters, summons)
│ ├── parties.ts # Party CRUD operations
│ ├── weapons.ts # Weapon entity operations
│ ├── characters.ts # Character entity operations
│ ├── summons.ts # Summon entity operations
│ └── grid.ts # Grid management operations
└── schemas/
├── transforms.ts # snake_case ↔ camelCase transformations
└── party.ts # Zod schemas for validation
Key Issues to Address
- Inconsistent patterns: Mix of functional and class-based approaches
- Repeated logic: Error handling, transformations duplicated across resources
- Type safety gaps: Some responses use
z.any(), losing compile-time safety - No cancellation support: Missing AbortController integration
- No unified caching: Each component manages its own caching logic
- Complex transformations: The "object" → entity renaming is manual and error-prone
Architecture Design
Layer Structure
┌─────────────────────────────────────┐
│ UI Components │
│ (SearchSidebar, PartyEditor, etc.) │
├─────────────────────────────────────┤
│ Runed Resources │ ← Reactive state management
│ (createResource, useDebounce) │
├─────────────────────────────────────┤
│ Adapters │ ← NEW LAYER
│ (SearchAdapter, PartyAdapter, etc.)│
├─────────────────────────────────────┤
│ Existing API Functions │ ← Keep unchanged initially
│ (searchWeapons, getParty, etc.) │
├─────────────────────────────────────┤
│ HTTP Client │
│ (core.ts) │
└─────────────────────────────────────┘
Core Concepts
1. Base Adapter
All adapters extend from a base class that provides:
- Response transformation (snake_case → camelCase)
- Error normalization
- Request cancellation
- Performance timing
- Debug logging
2. Resource Adapters
Specialized adapters for each resource type:
- SearchAdapter: Unified search interface
- PartyAdapter: Party CRUD with edit key management
- GridAdapter: Grid operations with optimistic updates
- EntityAdapter: Characters, weapons, summons
- UserAdapter: Authentication and profile management
3. Runed Integration
Reactive resources using Runed utilities:
- Automatic request cancellation
- Debounced inputs
- Loading states
- Error recovery
- Cache invalidation
Testing Methodology
Why Testing Matters for LLM Development
When Claude Code implements adapters, we need immediate verification that the code works without manual component testing. This section provides comprehensive testing strategies.
Test Infrastructure Setup
1. Install Testing Dependencies
# These should already be installed, but verify:
pnpm add -D vitest @vitest/ui msw @testing-library/svelte
2. Test Configuration
// vitest.config.adapter.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'adapters',
include: ['src/lib/api/adapters/**/*.test.ts'],
environment: 'node',
setupFiles: ['./src/lib/api/adapters/test-setup.ts'],
testTimeout: 5000
}
})
3. Mock Server Setup
// src/lib/api/adapters/test-setup.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { beforeAll, afterAll, afterEach } from 'vitest'
export const mockServer = setupServer()
beforeAll(() => mockServer.listen({ onUnhandledRequest: 'error' }))
afterEach(() => mockServer.resetHandlers())
afterAll(() => mockServer.close())
// Helper to add mock handlers
export function mockAPI(path: string, response: any, status = 200) {
mockServer.use(
http.post(`*/api/v1${path}`, () => {
return HttpResponse.json(response, { status })
})
)
}
Test Categories
1. Unit Tests (Adapter Logic)
Test adapters in isolation without real API calls.
// src/lib/api/adapters/__tests__/base.adapter.test.ts
import { describe, it, expect, vi } from 'vitest'
import { BaseAdapter } from '../base.adapter'
import { mockAPI } from '../test-setup'
describe('BaseAdapter', () => {
it('should transform snake_case to camelCase', async () => {
mockAPI('/test', { user_name: 'test', created_at: '2024-01-01' })
const adapter = new BaseAdapter()
const result = await adapter.request('/test')
expect(result).toEqual({
userName: 'test',
createdAt: '2024-01-01'
})
})
it('should handle request cancellation', async () => {
const adapter = new BaseAdapter()
const controller = new AbortController()
const promise = adapter.request('/slow', { signal: controller.signal })
controller.abort()
await expect(promise).rejects.toThrow('AbortError')
})
it('should normalize error responses', async () => {
mockAPI('/error', { error: 'Invalid request' }, 400)
const adapter = new BaseAdapter()
await expect(adapter.request('/error')).rejects.toMatchObject({
code: 'BAD_REQUEST',
status: 400,
message: 'Invalid request'
})
})
})
2. Integration Tests (With Mock API)
Test complete adapter flows with realistic responses.
// src/lib/api/adapters/__tests__/search.adapter.test.ts
import { describe, it, expect } from 'vitest'
import { SearchAdapter } from '../search.adapter'
import { mockAPI } from '../test-setup'
import mockSearchResponse from '../__fixtures__/search-response.json'
describe('SearchAdapter', () => {
it('should unify search responses across types', async () => {
mockAPI('/search/weapons', mockSearchResponse.weapons)
mockAPI('/search/characters', mockSearchResponse.characters)
const adapter = new SearchAdapter()
// Test weapon search
const weapons = await adapter.search({
type: 'weapon',
query: 'eternals',
filters: { element: [1, 2] }
})
expect(weapons).toMatchObject({
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
type: 'weapon'
})
]),
hasMore: expect.any(Boolean),
total: expect.any(Number)
})
})
it('should handle pagination correctly', async () => {
const adapter = new SearchAdapter()
// First page
mockAPI('/search/weapons', {
results: Array(20).fill({ id: '1' }),
total_pages: 3,
page: 1
})
const page1 = await adapter.search({ type: 'weapon', page: 1 })
expect(page1.hasMore).toBe(true)
expect(page1.nextCursor).toBe(2)
// Last page
mockAPI('/search/weapons', {
results: Array(5).fill({ id: '2' }),
total_pages: 3,
page: 3
})
const page3 = await adapter.search({ type: 'weapon', page: 3 })
expect(page3.hasMore).toBe(false)
expect(page3.nextCursor).toBeUndefined()
})
})
3. Contract Tests (Type Safety)
Ensure adapter outputs match expected TypeScript types.
// src/lib/api/adapters/__tests__/types.test.ts
import { describe, it, expectTypeOf } from 'vitest'
import type { SearchResult, PartyData } from '$lib/types'
import { SearchAdapter, PartyAdapter } from '../index'
describe('Adapter Type Contracts', () => {
it('SearchAdapter should return correct types', async () => {
const adapter = new SearchAdapter()
const result = await adapter.search({ type: 'weapon' })
expectTypeOf(result).toMatchTypeOf<{
items: SearchResult[]
total: number
hasMore: boolean
nextCursor?: number
}>()
})
it('PartyAdapter should return PartyData type', async () => {
const adapter = new PartyAdapter()
const result = await adapter.get('abc123')
expectTypeOf(result).toMatchTypeOf<PartyData>()
})
})
4. Runed Integration Tests
Test reactive behavior with Runed utilities.
// src/lib/api/adapters/__tests__/runed.test.ts
import { describe, it, expect, vi } from 'vitest'
import { createSearchResource } from '../resources/search.resource'
import { tick } from 'svelte'
describe('Runed Resource Integration', () => {
it('should debounce search input', async () => {
const searchFn = vi.fn()
const resource = createSearchResource('weapon', {
debounce: 100,
fetcher: searchFn
})
// Rapid input changes
resource.setQuery('a')
resource.setQuery('ab')
resource.setQuery('abc')
// Should not call immediately
expect(searchFn).not.toHaveBeenCalled()
// Wait for debounce
await new Promise(r => setTimeout(r, 150))
// Should call once with final value
expect(searchFn).toHaveBeenCalledOnce()
expect(searchFn).toHaveBeenCalledWith(
expect.objectContaining({ query: 'abc' })
)
})
it('should cancel previous requests', async () => {
const resource = createSearchResource('weapon')
// Start first search
const promise1 = resource.search('test1')
// Immediately start second search (should cancel first)
const promise2 = resource.search('test2')
// First should be cancelled
await expect(promise1).rejects.toThrow('AbortError')
// Second should complete
await expect(promise2).resolves.toBeDefined()
})
})
Test Fixtures
Mock Data Structure
// src/lib/api/adapters/__fixtures__/search-response.json
{
"weapons": {
"results": [
{
"id": "1040019000",
"granblue_id": "1040019000",
"name": { "en": "Eternal Sword", "ja": "永遠の剣" },
"element": 1,
"rarity": 5,
"proficiency1": 1,
"uncap_level": 5,
"transcendence_step": 5
}
],
"total": 150,
"page": 1,
"total_pages": 8
},
"characters": {
"results": [
{
"id": "3040001000",
"granblue_id": "3040001000",
"name": { "en": "Katalina", "ja": "カタリナ" },
"element": 3,
"rarity": 3,
"special": false
}
],
"total": 500,
"page": 1,
"total_pages": 25
}
}
Verification Scripts
Quick Smoke Test
# src/lib/api/adapters/smoke-test.js
#!/usr/bin/env node
import { SearchAdapter } from './search.adapter.js'
async function smokeTest() {
console.log('🔥 Running adapter smoke tests...\n')
const adapter = new SearchAdapter()
try {
// Test 1: Basic search
console.log('Test 1: Basic search')
const result = await adapter.search({
type: 'weapon',
query: 'sword'
})
console.assert(result.items, '✅ Returns items array')
console.assert(typeof result.total === 'number', '✅ Returns total count')
// Test 2: Cancellation
console.log('\nTest 2: Request cancellation')
const controller = new AbortController()
const promise = adapter.search({
type: 'weapon'
}, controller.signal)
controller.abort()
try {
await promise
console.error('❌ Should have thrown AbortError')
} catch (e) {
console.assert(e.name === 'AbortError', '✅ Cancellation works')
}
console.log('\n✨ All smoke tests passed!')
} catch (error) {
console.error('❌ Smoke test failed:', error)
process.exit(1)
}
}
smokeTest()
Testing Commands
Add to package.json:
{
"scripts": {
"test:adapters": "vitest run --config vitest.config.adapter.ts",
"test:adapters:watch": "vitest --config vitest.config.adapter.ts",
"test:adapters:ui": "vitest --ui --config vitest.config.adapter.ts",
"test:adapter:smoke": "node src/lib/api/adapters/smoke-test.js",
"test:adapter:types": "tsc --noEmit -p src/lib/api/adapters/tsconfig.json"
}
}
LLM Testing Workflow
When implementing each adapter, follow this workflow:
- Implement the adapter
- Run type checking:
pnpm test:adapter:types - Run unit tests:
pnpm test:adapters -- search.adapter - Run smoke tests:
pnpm test:adapter:smoke - Run all tests:
pnpm test:adapters
Only proceed to the next adapter after all tests pass.
Implementation Plan
Phase 1: Base Infrastructure
Goal: Create the foundation that all adapters will use.
Tasks:
- Install Runed:
pnpm add runed - Create
/lib/api/adapters/directory structure - Implement
base.adapter.tswith core functionality - Create
types.tswith common interfaces - Setup
errors.tswith normalized error classes - Implement transformation utilities
- Add base adapter tests
- Verify with smoke tests
Base Adapter Implementation:
// src/lib/api/adapters/base.adapter.ts
import { snakeToCamel, camelToSnake } from '../schemas/transforms'
export interface AdapterOptions {
baseURL?: string
timeout?: number
retries?: number
onError?: (error: AdapterError) => void
}
export interface RequestOptions extends RequestInit {
params?: Record<string, any>
timeout?: number
retries?: number
}
export class AdapterError extends Error {
constructor(
public code: string,
public status: number,
message: string,
public details?: any
) {
super(message)
this.name = 'AdapterError'
}
}
export abstract class BaseAdapter {
protected abortControllers = new Map<string, AbortController>()
constructor(protected options: AdapterOptions = {}) {}
protected async request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const url = this.buildURL(path, options.params)
const requestId = this.generateRequestId(path)
// Cancel any existing request to the same endpoint
this.cancelRequest(requestId)
// Create new abort controller
const controller = new AbortController()
this.abortControllers.set(requestId, controller)
try {
const response = await this.fetchWithRetry(url, {
...options,
signal: controller.signal
})
if (!response.ok) {
throw await this.handleErrorResponse(response)
}
const data = await response.json()
return this.transformResponse(data)
} catch (error) {
if (error.name === 'AbortError') {
throw new AdapterError('CANCELLED', 0, 'Request was cancelled')
}
throw this.normalizeError(error)
} finally {
this.abortControllers.delete(requestId)
}
}
protected transformResponse<T>(data: any): T {
// Apply transformations (snake_case to camelCase, etc.)
return snakeToCamel(data) as T
}
protected transformRequest(data: any): any {
return camelToSnake(data)
}
protected cancelRequest(requestId: string): void {
const controller = this.abortControllers.get(requestId)
controller?.abort()
}
cancelAll(): void {
this.abortControllers.forEach(controller => controller.abort())
this.abortControllers.clear()
}
private async fetchWithRetry(
url: string,
options: RequestInit,
attempt = 1
): Promise<Response> {
try {
return await fetch(url, options)
} catch (error) {
const maxRetries = this.options.retries ?? 3
if (attempt < maxRetries && this.isRetryable(error)) {
await this.delay(Math.pow(2, attempt) * 1000) // Exponential backoff
return this.fetchWithRetry(url, options, attempt + 1)
}
throw error
}
}
private isRetryable(error: any): boolean {
return error.name === 'NetworkError' || error.code === 'ECONNRESET'
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
private buildURL(path: string, params?: Record<string, any>): string {
const url = new URL(path, this.options.baseURL ?? window.location.origin)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value))
}
})
}
return url.toString()
}
private generateRequestId(path: string): string {
return `${this.constructor.name}:${path}`
}
private async handleErrorResponse(response: Response): Promise<AdapterError> {
try {
const error = await response.json()
return new AdapterError(
this.getErrorCode(response.status),
response.status,
error.message || error.error || response.statusText,
error
)
} catch {
return new AdapterError(
this.getErrorCode(response.status),
response.status,
response.statusText
)
}
}
private getErrorCode(status: number): string {
const codes: Record<number, string> = {
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
403: 'FORBIDDEN',
404: 'NOT_FOUND',
409: 'CONFLICT',
422: 'VALIDATION_ERROR',
429: 'RATE_LIMITED',
500: 'SERVER_ERROR',
503: 'SERVICE_UNAVAILABLE'
}
return codes[status] ?? 'UNKNOWN_ERROR'
}
private normalizeError(error: any): AdapterError {
if (error instanceof AdapterError) return error
return new AdapterError(
'UNKNOWN_ERROR',
0,
error.message || 'An unknown error occurred',
error
)
}
}
Phase 2: Search Adapter
Goal: Unify search across weapons, characters, and summons.
Tasks:
- Create
search.adapter.ts - Implement unified search interface
- Add pagination support
- Add filter normalization
- Create search-specific tests
- Test with mock data
- Verify cancellation works
Search Adapter Implementation:
// src/lib/api/adapters/search.adapter.ts
import { BaseAdapter } from './base.adapter'
import type { SearchFilters } from '$lib/types'
export interface SearchParams {
type: 'weapon' | 'character' | 'summon'
query?: string
filters?: SearchFilters
page?: number
perPage?: number
signal?: AbortSignal
}
export interface SearchResult<T = any> {
items: T[]
total: number
page: number
totalPages: number
hasMore: boolean
nextCursor?: number
}
export class SearchAdapter extends BaseAdapter {
async search<T>(params: SearchParams): Promise<SearchResult<T>> {
const endpoint = `/search/${params.type}s` // weapons, characters, summons
const response = await this.request<any>(endpoint, {
method: 'POST',
body: JSON.stringify(this.transformRequest({
query: params.query,
filters: params.filters,
page: params.page ?? 1,
per: params.perPage ?? 20
})),
signal: params.signal,
headers: {
'Content-Type': 'application/json'
}
})
return this.normalizeSearchResponse(response, params.page ?? 1)
}
async searchAll<T>(params: Omit<SearchParams, 'type'>): Promise<SearchResult<T>> {
const response = await this.request<any>('/search/all', {
method: 'POST',
body: JSON.stringify(this.transformRequest({
query: params.query,
filters: params.filters,
page: params.page ?? 1,
per: params.perPage ?? 20
})),
signal: params.signal,
headers: {
'Content-Type': 'application/json'
}
})
return this.normalizeSearchResponse(response, params.page ?? 1)
}
private normalizeSearchResponse(response: any, currentPage: number): SearchResult {
// Handle different response formats from the API
const results = response.results || response.items || []
const total = response.total ?? response.totalCount ?? results.length
const totalPages = response.totalPages ?? response.total_pages ?? Math.ceil(total / 20)
const hasMore = currentPage < totalPages
return {
items: results.map(this.normalizeItem.bind(this)),
total,
page: currentPage,
totalPages,
hasMore,
nextCursor: hasMore ? currentPage + 1 : undefined
}
}
private normalizeItem(item: any): any {
// Add type field based on searchable_type if present
if (item.searchableType) {
item.type = item.searchableType.toLowerCase()
}
// Normalize name field
if (typeof item.name === 'object') {
item.displayName = item.name.en || item.name.ja
} else {
item.displayName = item.name
}
return item
}
}
Phase 3: Party Adapter
Goal: Handle party CRUD with edit key management.
Tasks:
- Create
party.adapter.ts - Port edit key management from APIClient
- Implement optimistic updates
- Add conflict resolution
- Create party-specific tests
- Test CRUD operations
- Test edit key persistence
Party Adapter Implementation:
// src/lib/api/adapters/party.adapter.ts
import { BaseAdapter } from './base.adapter'
import type { Party, PartyPayload } from '$lib/types'
export class PartyAdapter extends BaseAdapter {
private editKeys = new Map<string, string>()
constructor(options?: AdapterOptions) {
super(options)
this.loadEditKeys()
}
async get(idOrShortcode: string): Promise<Party> {
return this.request(`/parties/${idOrShortcode}`)
}
async create(payload: PartyPayload): Promise<{ party: Party; editKey?: string }> {
const response = await this.request<any>('/parties', {
method: 'POST',
body: JSON.stringify(this.transformRequest(payload)),
headers: { 'Content-Type': 'application/json' }
})
if (response.editKey && response.party?.shortcode) {
this.storeEditKey(response.party.shortcode, response.editKey)
}
return {
party: this.normalizeParty(response.party),
editKey: response.editKey
}
}
async update(id: string, payload: Partial<PartyPayload>): Promise<Party> {
const editKey = this.getEditKey(id)
const response = await this.request<any>(`/parties/${id}`, {
method: 'PUT',
body: JSON.stringify(this.transformRequest(payload)),
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
}
})
return this.normalizeParty(response.party || response)
}
async delete(id: string): Promise<void> {
const editKey = this.getEditKey(id)
await this.request(`/parties/${id}`, {
method: 'DELETE',
headers: {
...(editKey ? { 'X-Edit-Key': editKey } : {})
}
})
this.removeEditKey(id)
}
private normalizeParty(party: any): Party {
// Transform "object" fields to proper entity names
if (party.weapons) {
party.weapons = party.weapons.map((item: any) => {
if (item.object) {
const { object, ...rest } = item
return { ...rest, weapon: object }
}
return item
})
}
if (party.characters) {
party.characters = party.characters.map((item: any) => {
if (item.object) {
const { object, ...rest } = item
return { ...rest, character: object }
}
return item
})
}
if (party.summons) {
party.summons = party.summons.map((item: any) => {
if (item.object) {
const { object, ...rest } = item
return { ...rest, summon: object }
}
return item
})
}
return party
}
private getEditKey(id: string): string | null {
return this.editKeys.get(id) || null
}
private storeEditKey(id: string, key: string): void {
this.editKeys.set(id, key)
if (typeof window !== 'undefined') {
localStorage.setItem(`edit_key_${id}`, key)
}
}
private removeEditKey(id: string): void {
this.editKeys.delete(id)
if (typeof window !== 'undefined') {
localStorage.removeItem(`edit_key_${id}`)
}
}
private loadEditKeys(): void {
if (typeof window === 'undefined') return
// Load all edit keys from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('edit_key_')) {
const id = key.replace('edit_key_', '')
const value = localStorage.getItem(key)
if (value) this.editKeys.set(id, value)
}
}
}
}
Phase 4: Runed Integration
Goal: Create reactive resources using Runed.
Tasks:
- Create
/lib/api/adapters/resources/directory - Implement
createSearchResourcewith Runed - Add debouncing support
- Implement
createPartyResource - Add optimistic updates
- Create Runed-specific tests
- Test reactivity and cancellation
Runed Resource Implementation:
// src/lib/api/adapters/resources/search.resource.ts
import { createResource, useDebounce } from 'runed'
import { SearchAdapter, type SearchParams, type SearchResult } from '../search.adapter'
export interface SearchResourceOptions {
debounce?: number
cacheTime?: number
initialData?: SearchResult
}
export function createSearchResource(
type: SearchParams['type'],
options: SearchResourceOptions = {}
) {
const adapter = new SearchAdapter()
const cache = new Map<string, { data: SearchResult; timestamp: number }>()
// Reactive state
let query = $state('')
let filters = $state<SearchParams['filters']>({})
let page = $state(1)
// Debounced query
const debouncedQuery = useDebounce(() => query, options.debounce ?? 300)
// Create resource
const resource = createResource({
keys: () => ({ type, query: debouncedQuery, filters, page }),
fetcher: async ({ signal }) => {
const cacheKey = JSON.stringify({ type, query: debouncedQuery, filters, page })
// Check cache
if (options.cacheTime) {
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < options.cacheTime) {
return cached.data
}
}
// Fetch data
const result = await adapter.search({
type,
query: debouncedQuery,
filters,
page,
signal
})
// Update cache
if (options.cacheTime) {
cache.set(cacheKey, { data: result, timestamp: Date.now() })
}
return result
},
initialValue: options.initialData
})
// Computed values
const items = $derived(resource.value?.items ?? [])
const hasMore = $derived(resource.value?.hasMore ?? false)
const total = $derived(resource.value?.total ?? 0)
const isLoading = $derived(resource.loading)
const error = $derived(resource.error)
// Methods
function setQuery(newQuery: string) {
query = newQuery
page = 1 // Reset page on new search
}
function setFilters(newFilters: SearchParams['filters']) {
filters = newFilters
page = 1 // Reset page on filter change
}
function loadMore() {
if (hasMore && !isLoading) {
page++
}
}
function reset() {
query = ''
filters = {}
page = 1
cache.clear()
}
function refresh() {
cache.clear()
resource.refetch()
}
return {
// State
query: $derived(query),
debouncedQuery,
filters: $derived(filters),
page: $derived(page),
// Results
items,
hasMore,
total,
isLoading,
error,
// Methods
setQuery,
setFilters,
loadMore,
reset,
refresh,
// Direct resource access
resource
}
}
// src/lib/api/adapters/resources/party.resource.ts
import { createResource } from 'runed'
import { PartyAdapter } from '../party.adapter'
import type { Party, PartyPayload } from '$lib/types'
export interface PartyResourceOptions {
autoSave?: boolean
autoSaveDelay?: number
optimisticUpdates?: boolean
}
export function createPartyResource(
partyId: string,
options: PartyResourceOptions = {}
) {
const adapter = new PartyAdapter()
// State
let localParty = $state<Party | null>(null)
let pendingUpdates = $state<Partial<PartyPayload>>({})
let saveTimer: NodeJS.Timeout | null = null
// Load party
const resource = createResource({
keys: () => ({ id: partyId }),
fetcher: async ({ signal }) => {
const party = await adapter.get(partyId)
localParty = party
return party
}
})
// Computed
const party = $derived(options.optimisticUpdates ? localParty : resource.value)
const isLoading = $derived(resource.loading)
const isSaving = $state(false)
const error = $derived(resource.error)
// Methods
async function update(updates: Partial<PartyPayload>) {
if (options.optimisticUpdates && localParty) {
// Apply optimistic update
localParty = { ...localParty, ...updates }
}
// Accumulate pending updates
pendingUpdates = { ...pendingUpdates, ...updates }
// Auto-save logic
if (options.autoSave) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(
() => save(),
options.autoSaveDelay ?? 1000
)
}
}
async function save() {
if (Object.keys(pendingUpdates).length === 0) return
isSaving = true
try {
const updated = await adapter.update(partyId, pendingUpdates)
localParty = updated
pendingUpdates = {}
// Refresh from server to ensure consistency
resource.refetch()
} catch (error) {
// Rollback optimistic update on error
if (options.optimisticUpdates) {
localParty = resource.value
}
throw error
} finally {
isSaving = false
}
}
async function deleteParty() {
await adapter.delete(partyId)
}
function refresh() {
pendingUpdates = {}
resource.refetch()
}
// Cleanup
function cleanup() {
if (saveTimer) {
clearTimeout(saveTimer)
save() // Save any pending changes
}
}
return {
// State
party,
isLoading,
isSaving,
error,
hasPendingChanges: $derived(Object.keys(pendingUpdates).length > 0),
// Methods
update,
save,
delete: deleteParty,
refresh,
cleanup
}
}
Evolution Roadmap
Stage 1: Foundation (Current)
- Basic CRUD operations
- Search functionality
- Error handling
- Edit key management
Stage 2: Enhanced Features (3-6 months)
- Spark Tracking: CollectionAdapter for managing user collections
- Guides: GuideAdapter with rich text support
- Batch Operations: Bulk updates for collections
Stage 3: Advanced Features (6-12 months)
- Crew Management: CrewAdapter with real-time updates
- Moderation: ModerationAdapter with audit logging
- WebSocket Support: Real-time data for Unite and Fight
Stage 4: Performance & Scale (12+ months)
- Offline Support: IndexedDB caching
- GraphQL Migration: If backend adopts GraphQL
- Federation: Multiple API backends
Migration Strategy
Step 1: Parallel Implementation
Keep existing API functions working while building adapters:
// Use adapter internally but maintain old interface
export async function searchWeapons(params: SearchParams) {
const adapter = new SearchAdapter()
return adapter.search({ ...params, type: 'weapon' })
}
Step 2: Component Migration
Update components one at a time:
<!-- Old -->
<script>
import { searchWeapons } from '$lib/api/resources/search'
const results = await searchWeapons({ query: 'sword' })
</script>
<!-- New -->
<script>
import { createSearchResource } from '$lib/api/adapters/resources'
const search = createSearchResource('weapon')
search.setQuery('sword')
</script>
Step 3: Deprecation
Once all components migrated:
- Mark old functions as deprecated
- Add console warnings in development
- Update documentation
- Remove after grace period
Performance Considerations
Caching Strategy
class CacheManager {
private cache = new Map<string, { data: any; expires: number }>()
set(key: string, data: any, ttl = 5 * 60 * 1000) {
this.cache.set(key, {
data,
expires: Date.now() + ttl
})
}
get(key: string) {
const entry = this.cache.get(key)
if (!entry) return null
if (Date.now() > entry.expires) {
this.cache.delete(key)
return null
}
return entry.data
}
clear(pattern?: string) {
if (pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
} else {
this.cache.clear()
}
}
}
Request Deduplication
class RequestDeduplicator {
private pending = new Map<string, Promise<any>>()
async dedupe<T>(
key: string,
factory: () => Promise<T>
): Promise<T> {
const existing = this.pending.get(key)
if (existing) return existing
const promise = factory().finally(() => {
this.pending.delete(key)
})
this.pending.set(key, promise)
return promise
}
}
LLM Implementation Instructions
For Claude Code
When implementing adapters, follow this exact sequence:
-
Start with Base Adapter
# Create the file touch src/lib/api/adapters/base.adapter.ts # Implement the base adapter (copy from this doc) # Create test file touch src/lib/api/adapters/__tests__/base.adapter.test.ts # Run tests pnpm test:adapters -- base.adapter -
Verify Each Step
- After implementing each adapter, run its tests
- Don't proceed until tests pass
- If tests fail, check error messages and fix
-
Common Pitfalls to Avoid
- Don't forget to transform responses (snake_case → camelCase)
- Always handle request cancellation
- Store edit keys in localStorage for parties
- Clean up abort controllers after requests
- Use proper TypeScript types (avoid
anywhere possible)
-
Testing Checklist
# Type checking pnpm test:adapter:types # Unit tests for specific adapter pnpm test:adapters -- [adapter-name] # Smoke tests pnpm test:adapter:smoke # All tests pnpm test:adapters -
Debug Failed Tests
- Check mock server handlers match expected endpoints
- Verify transformation logic is correct
- Ensure abort controllers are properly cleaned up
- Check that cache keys are consistent
Implementation Order
-
Phase 1: Base infrastructure (1-2 hours)
- base.adapter.ts
- errors.ts
- types.ts
- Base tests
-
Phase 2: Search adapter (1 hour)
- search.adapter.ts
- Search tests
- Mock data fixtures
-
Phase 3: Party adapter (1-2 hours)
- party.adapter.ts
- Edit key management
- Party tests
-
Phase 4: Runed resources (2 hours)
- search.resource.ts
- party.resource.ts
- Integration tests
-
Phase 5: Additional adapters (as needed)
- grid.adapter.ts
- user.adapter.ts
- entity.adapter.ts
Verification Commands
After implementing everything, run this verification sequence:
# Install dependencies
pnpm add runed
pnpm add -D msw @testing-library/svelte
# Type checking
pnpm test:adapter:types
# Run all adapter tests
pnpm test:adapters
# Run smoke tests
pnpm test:adapter:smoke
# Check bundle size
pnpm build
ls -lh .svelte-kit/output/client/_app/immutable/chunks/
Conclusion
This adapter architecture provides:
- Consistent API interface across all resources
- Testable code with comprehensive test coverage
- Type safety with TypeScript
- Reactive state management with Runed
- Progressive enhancement - start simple, add complexity as needed
The testing methodology ensures that LLM implementations can be verified immediately without manual component testing, providing confidence that the code works correctly before integration.
Start with Phase 1 and progress through each phase only after tests pass. This incremental approach minimizes risk and ensures a solid foundation for future features.