import { NextRequest, NextResponse } from 'next/server' /** * SDK State Management API * * GET /api/sdk/v1/state?tenantId=xxx - Load state for a tenant * POST /api/sdk/v1/state - Save state for a tenant * DELETE /api/sdk/v1/state?tenantId=xxx - Clear state for a tenant * * Features: * - Versioning for optimistic locking * - Last-Modified headers * - ETag support for caching * - Prepared for PostgreSQL migration */ // ============================================================================= // TYPES // ============================================================================= interface StoredState { state: unknown version: number userId?: string createdAt: string updatedAt: string } // ============================================================================= // STORAGE LAYER (Abstract - Easy to swap to PostgreSQL) // ============================================================================= /** * In-memory storage for development * TODO: Replace with PostgreSQL implementation * * PostgreSQL Schema: * CREATE TABLE sdk_states ( * id UUID PRIMARY KEY DEFAULT gen_random_uuid(), * tenant_id VARCHAR(255) NOT NULL UNIQUE, * user_id VARCHAR(255), * state JSONB NOT NULL, * version INTEGER DEFAULT 1, * created_at TIMESTAMP DEFAULT NOW(), * updated_at TIMESTAMP DEFAULT NOW() * ); * * CREATE INDEX idx_sdk_states_tenant ON sdk_states(tenant_id); */ interface StateStore { get(tenantId: string): Promise save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise delete(tenantId: string): Promise } class InMemoryStateStore implements StateStore { private store: Map = new Map() async get(tenantId: string): Promise { return this.store.get(tenantId) || null } async save( tenantId: string, state: unknown, userId?: string, expectedVersion?: number ): Promise { const existing = this.store.get(tenantId) // Optimistic locking check if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) { const error = new Error('Version conflict') as Error & { status: number } error.status = 409 throw error } const now = new Date().toISOString() const newVersion = existing ? existing.version + 1 : 1 const stored: StoredState = { state: { ...(state as object), lastModified: now, }, version: newVersion, userId, createdAt: existing?.createdAt || now, updatedAt: now, } this.store.set(tenantId, stored) return stored } async delete(tenantId: string): Promise { return this.store.delete(tenantId) } } // Future PostgreSQL implementation would look like: // class PostgreSQLStateStore implements StateStore { // private db: Pool // // constructor(connectionString: string) { // this.db = new Pool({ connectionString }) // } // // async get(tenantId: string): Promise { // const result = await this.db.query( // 'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1', // [tenantId] // ) // if (result.rows.length === 0) return null // const row = result.rows[0] // return { // state: row.state, // version: row.version, // userId: row.user_id, // createdAt: row.created_at, // updatedAt: row.updated_at, // } // } // // async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise { // // Use UPSERT with version check // const result = await this.db.query(` // INSERT INTO sdk_states (tenant_id, user_id, state, version) // VALUES ($1, $2, $3, 1) // ON CONFLICT (tenant_id) DO UPDATE SET // state = $3, // user_id = COALESCE($2, sdk_states.user_id), // version = sdk_states.version + 1, // updated_at = NOW() // WHERE ($4::int IS NULL OR sdk_states.version = $4) // RETURNING version, created_at, updated_at // `, [tenantId, userId, JSON.stringify(state), expectedVersion]) // // if (result.rows.length === 0) { // throw new Error('Version conflict') // } // // return { // state, // version: result.rows[0].version, // userId, // createdAt: result.rows[0].created_at, // updatedAt: result.rows[0].updated_at, // } // } // // async delete(tenantId: string): Promise { // const result = await this.db.query( // 'DELETE FROM sdk_states WHERE tenant_id = $1', // [tenantId] // ) // return result.rowCount > 0 // } // } // Use in-memory store for now const stateStore: StateStore = new InMemoryStateStore() // ============================================================================= // HELPER FUNCTIONS // ============================================================================= function generateETag(version: number, updatedAt: string): string { return `"${version}-${Buffer.from(updatedAt).toString('base64').slice(0, 8)}"` } // ============================================================================= // HANDLERS // ============================================================================= export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) const tenantId = searchParams.get('tenantId') if (!tenantId) { return NextResponse.json( { success: false, error: 'tenantId is required' }, { status: 400 } ) } const stored = await stateStore.get(tenantId) if (!stored) { return NextResponse.json( { success: false, error: 'State not found', tenantId }, { status: 404 } ) } const etag = generateETag(stored.version, stored.updatedAt) // Check If-None-Match header for caching const ifNoneMatch = request.headers.get('If-None-Match') if (ifNoneMatch === etag) { return new NextResponse(null, { status: 304 }) } return NextResponse.json( { success: true, data: { tenantId, state: stored.state, version: stored.version, lastModified: stored.updatedAt, }, }, { headers: { 'ETag': etag, 'Last-Modified': stored.updatedAt, 'Cache-Control': 'private, no-cache', }, } ) } catch (error) { console.error('Failed to load SDK state:', error) return NextResponse.json( { success: false, error: 'Failed to load state' }, { status: 500 } ) } } export async function POST(request: NextRequest) { try { const body = await request.json() const { tenantId, state, version } = body if (!tenantId) { return NextResponse.json( { success: false, error: 'tenantId is required' }, { status: 400 } ) } if (!state) { return NextResponse.json( { success: false, error: 'state is required' }, { status: 400 } ) } // Check If-Match header for optimistic locking const ifMatch = request.headers.get('If-Match') const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion) const etag = generateETag(stored.version, stored.updatedAt) return NextResponse.json( { success: true, data: { tenantId, state: stored.state, version: stored.version, lastModified: stored.updatedAt, }, }, { headers: { 'ETag': etag, 'Last-Modified': stored.updatedAt, }, } ) } catch (error) { const err = error as Error & { status?: number } // Handle version conflict if (err.status === 409 || err.message === 'Version conflict') { return NextResponse.json( { success: false, error: 'Version conflict. State was modified by another request.', code: 'VERSION_CONFLICT', }, { status: 409 } ) } console.error('Failed to save SDK state:', error) return NextResponse.json( { success: false, error: 'Failed to save state' }, { status: 500 } ) } } export async function DELETE(request: NextRequest) { try { const { searchParams } = new URL(request.url) const tenantId = searchParams.get('tenantId') if (!tenantId) { return NextResponse.json( { success: false, error: 'tenantId is required' }, { status: 400 } ) } const deleted = await stateStore.delete(tenantId) if (!deleted) { return NextResponse.json( { success: false, error: 'State not found', tenantId }, { status: 404 } ) } return NextResponse.json({ success: true, tenantId, deletedAt: new Date().toISOString(), }) } catch (error) { console.error('Failed to delete SDK state:', error) return NextResponse.json( { success: false, error: 'Failed to delete state' }, { status: 500 } ) } } // ============================================================================= // HEALTH CHECK // ============================================================================= export async function OPTIONS() { return NextResponse.json({ status: 'ok' }, { headers: { 'Allow': 'GET, POST, DELETE, OPTIONS', }, }) }