import type { SupabaseClient } from '@supabase/supabase-js'; import type { Context } from '../types'; import type { ModuleAdapter } from './types'; export type AdapterConfig = { /** The module type identifier (e.g., 'note', 'category', 'lesson') */ moduleType: string; /** API functions for CRUD operations */ api: { list: () => Promise; listByContext: (contextType: string, contextId: string) => Promise; create: (input: CreateInput) => Promise; update: (id: string, patch: UpdateInput) => Promise; remove: (id: string) => Promise; }; /** Extract the title/name from an item for display */ getTitle: (item: T) => string; /** Optional Supabase client for realtime subscriptions */ supabaseClient?: SupabaseClient; /** Optional disassociate function for context-aware removal */ disassociate?: (body: { parentType: string; parentId: string; childType: string; childId: string }) => Promise; }; /** * Factory function to create a standardized ModuleAdapter * * This centralizes the common logic across all module adapters: * - Context-aware listing * - Context-aware removal (disassociate vs delete) * - Optional realtime subscriptions via Supabase * - Standard getKey implementation * * @example * ```ts * import { createClient } from '@supabase/supabase-js'; * import { createModuleAdapter } from '@micahgoodman/notes'; * * const supabase = createClient(url, key); * * const NoteAdapter = createModuleAdapter({ * moduleType: 'note', * api: { * list: fetchNotes, * listByContext: fetchNotesByContext, * create: createNote, * update: updateNote, * remove: deleteNote, * }, * getTitle: (note) => note.text.substring(0, 50), * supabaseClient: supabase, * disassociate: disassociateNote * }); * ``` */ export function createModuleAdapter( config: AdapterConfig ): ModuleAdapter { const { moduleType, api, getTitle, supabaseClient, disassociate } = config; return { async list(opts) { const ctx = opts?.context; if (ctx) return api.listByContext(ctx.type, ctx.id); return api.list(); }, async create(input) { return api.create(input); }, async update(id, patch) { return api.update(id, patch); }, async remove(id, opts) { const ctx = opts?.context; if (ctx && disassociate) { // Disassociate from the parent context instead of deleting the instance await disassociate({ parentType: ctx.type, parentId: String(ctx.id), childType: moduleType, childId: id }); } else { await api.remove(id); } }, getKey(item) { return item.id; }, getTitle(item) { return getTitle(item); }, subscribe: supabaseClient ? (onChange, opts) => { try { const suffix = Math.random().toString(36).slice(2, 8); const topic = `${moduleType}-changes-${opts?.context ? `${opts.context.type}-${opts.context.id}` : 'all'}-${suffix}`; const channel = supabaseClient.channel(topic); // Child row updates (module instance changes) channel.on('postgres_changes', { event: '*', schema: 'public', table: 'module_data', }, async (payload: any) => { try { const payloadModuleType = payload?.new?.module_type ?? payload?.old?.module_type; // Allow undefined moduleType to still trigger refresh if (payloadModuleType && payloadModuleType !== moduleType) return; await onChange(); } catch { // noop } }); // Parent association updates (when viewing by context) if (opts?.context) { channel.on('postgres_changes', { event: '*', schema: 'public', table: 'module_data', }, async (payload: any) => { try { const mt = payload?.new?.module_type ?? payload?.old?.module_type; const id = (payload?.new?.id ?? payload?.old?.id) as string | undefined; if (mt && mt !== opts.context!.type) return; if (String(id) === String(opts.context!.id)) { await onChange(); } } catch { // noop } }); } channel.subscribe(); return () => { try { supabaseClient.removeChannel(channel); } catch { /* noop */ } }; } catch { return () => {}; } } : undefined }; }