import type { ColineActorRef, ColineCollection, ColineRecordEnvelope, ColineRecordQuery, } from "../capabilities/types"; import { canAccessRecord, DEFAULT_RECORD_ACCESS } from "../capabilities/record-access"; import { compareRecordValues, matchesRecordWhere } from "../capabilities/record-query"; import type { TestWorkspaceState } from "./fake-capabilities"; export function createFakeCollection( state: TestWorkspaceState, name: string, actor: ColineActorRef, installId: string, ): ColineCollection { if (!state.records.has(name)) state.records.set(name, new Map()); const records = state.records.get(name)!; const userId = actor.kind === "user" ? actor.userId : null; const fail = (code: string, message: string): never => { throw Object.assign(new Error(message), { code }); }; const visible = (row: ColineRecordEnvelope) => canAccessRecord(row.access, row.createdBy, userId, "read"); const wrap = (row: ColineRecordEnvelope) => structuredClone(row) as ColineRecordEnvelope; const changed = () => { state.recordRevisions.set(name, (state.recordRevisions.get(name) ?? 0) + 1); for (const listener of state.realtimeListeners.get("coline.records.changed") ?? []) listener({ event: "coline.records.changed", data: null }); }; function writable(id: string, expectedVersion?: number, manage = false) { const row = records.get(id); if (!row || !visible(row)) return fail("record_not_found", "Record not found."); if (!canAccessRecord(row.access, row.createdBy, userId, manage ? "manage" : "write")) return fail("record_forbidden", "You cannot change this record."); if (expectedVersion !== undefined && row.version !== expectedVersion) return fail("record_conflict", "This record changed. Refresh before saving again."); return row; } function query(input?: ColineRecordQuery) { const rows = [...records.values()].filter( (row) => visible(row) && matchesRecordWhere(row.data, input?.where), ); rows.sort((a, b) => { const av = input?.orderBy ? a.data[input.orderBy] : a.updatedAt; const bv = input?.orderBy ? b.data[input.orderBy] : b.updatedAt; const comparison = compareRecordValues(av, bv) || a.id.localeCompare(b.id); return input?.order === "asc" ? comparison : -comparison; }); const offset = Number(input?.cursor ?? 0), limit = input?.limit ?? 50; if (!Number.isSafeInteger(offset) || offset < 0) return fail("invalid_cursor", "Invalid collection cursor."); if (!Number.isInteger(limit) || limit < 1 || limit > 200) return fail("invalid_query", "Record limit must be 1–200."); return { records: rows.slice(offset, offset + limit).map(wrap), nextCursor: rows.length > offset + limit ? String(offset + limit) : null, }; } return { insert: async (data, options) => { const id = options?.id ?? state.nextRecordId(); const existing = records.get(id); if (existing) { if ( JSON.stringify(existing.createdBy) !== JSON.stringify(actor) || JSON.stringify(existing.data) !== JSON.stringify(data) || JSON.stringify(existing.access) !== JSON.stringify(options?.access ?? DEFAULT_RECORD_ACCESS) ) return fail("record_identity_conflict", "Record identity already in use."); return wrap(existing); } const row = { id, appId: "app_test", installId, collection: name, createdBy: actor, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1, access: structuredClone(options?.access ?? DEFAULT_RECORD_ACCESS), data: structuredClone(data) as Record, }; records.set(id, row); changed(); return wrap(row); }, get: async (id) => { const row = records.get(id); return row && visible(row) ? wrap(row) : null; }, update: async (id, patch, options) => { const row = writable(id, options?.expectedVersion); row.data = { ...row.data, ...structuredClone(patch) }; row.version++; row.updatedAt = new Date().toISOString(); changed(); return wrap(row); }, delete: async (id, options) => { writable(id, options?.expectedVersion); records.delete(id); changed(); }, setAccess: async (id, access, options) => { const row = writable(id, options?.expectedVersion, true); row.access = structuredClone(access); row.version++; row.updatedAt = new Date().toISOString(); changed(); return wrap(row); }, query: async (input) => query(input), sync: async (input, revision) => { const current = state.recordRevisions.get(name) ?? 0; return { revision: current, ...(revision === current ? { records: null, nextCursor: null } : query(input)), }; }, }; }