import { NextRequest, NextResponse } from 'next/server' import { Pool } from 'pg' /** * 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 * - PostgreSQL persistence (with InMemory fallback) */ // ============================================================================= // TYPES // ============================================================================= interface StoredState { state: unknown version: number userId?: string createdAt: string updatedAt: string } // ============================================================================= // STORAGE LAYER // ============================================================================= 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) 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) } } class PostgreSQLStateStore implements StateStore { private pool: Pool constructor(connectionString: string) { this.pool = new Pool({ connectionString, max: 5, // Set search_path for compliance schema options: '-c search_path=compliance,core,public', }) } async get(tenantId: string): Promise { const result = await this.pool.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 instanceof Date ? row.created_at.toISOString() : row.created_at, updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at, } } async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise { const now = new Date().toISOString() const stateWithTimestamp = { ...(state as object), lastModified: now, } // Use UPSERT with version check const result = await this.pool.query(` INSERT INTO sdk_states (tenant_id, user_id, state, version, created_at, updated_at) VALUES ($1, $2, $3::jsonb, 1, NOW(), NOW()) ON CONFLICT (tenant_id) DO UPDATE SET state = $3::jsonb, 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, user_id, created_at, updated_at `, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null]) if (result.rows.length === 0) { const error = new Error('Version conflict') as Error & { status: number } error.status = 409 throw error } const row = result.rows[0] return { state: stateWithTimestamp, version: row.version, userId: row.user_id, createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at, } } async delete(tenantId: string): Promise { const result = await this.pool.query( 'DELETE FROM sdk_states WHERE tenant_id = $1', [tenantId] ) return (result.rowCount ?? 0) > 0 } } // ============================================================================= // STORE INITIALIZATION // ============================================================================= function createStateStore(): StateStore { const databaseUrl = process.env.DATABASE_URL if (databaseUrl) { console.log('[SDK State] Using PostgreSQL state store') return new PostgreSQLStateStore(databaseUrl) } console.log('[SDK State] Using in-memory state store (no DATABASE_URL)') return new InMemoryStateStore() } const stateStore: StateStore = createStateStore() // ============================================================================= // 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', }, }) }