import type { ColineCapabilities, ColineCollection, ColineRecordEnvelope, ColineRecordQuery, } from "./types"; /** * The HTTP transport for `coline.*` (plan §8): capability calls become * `POST { operation, args }` against the invocation's capability endpoint, * authorized by the invocation token. Used by external app backends and by * the hosted Dynamic Worker bootstrap — one wire contract for both. * * Client-side interaction capabilities (pickers, navigation, realtime) do * not exist in server contexts and reject immediately with * `capability_unavailable`. */ export interface CapabilityEndpoint { url: string; token: string; fetchImpl?: typeof fetch | undefined; } export class ColineCapabilityCallError extends Error { readonly code: string; readonly operation: string; constructor(operation: string, code: string, message: string) { super(message); this.name = "ColineCapabilityCallError"; this.code = code; this.operation = operation; } } async function callCapability( endpoint: CapabilityEndpoint, operation: string, args: unknown[], ): Promise { const fetchImpl = endpoint.fetchImpl ?? fetch; let response: Response; try { response = await fetchImpl(endpoint.url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${endpoint.token}`, }, body: JSON.stringify({ operation, args }), }); } catch (error) { throw new ColineCapabilityCallError( operation, "network_error", `Capability call failed to reach Coline: ${error instanceof Error ? error.message : String(error)}`, ); } let parsed: unknown; try { parsed = await response.json(); } catch { throw new ColineCapabilityCallError( operation, "invalid_response", `Capability endpoint returned a non-JSON response (${response.status}).`, ); } const record = parsed as { data?: unknown; error?: { code?: string; message?: string } }; if (record.error) { throw new ColineCapabilityCallError( operation, record.error.code ?? "capability_error", record.error.message ?? "Capability call failed.", ); } if (!response.ok) { throw new ColineCapabilityCallError( operation, "capability_error", `Capability endpoint returned ${response.status}.`, ); } return record.data as T; } function unavailable(operation: string): never { throw new ColineCapabilityCallError( operation, "capability_unavailable", `"${operation}" is a client-context capability and is not available to server-side app logic.`, ); } function createHttpCollection( endpoint: CapabilityEndpoint, name: string, ): ColineCollection { return { insert: (data) => callCapability>(endpoint, "storage.records.insert", [name, data]), get: (recordId) => callCapability | null>(endpoint, "storage.records.get", [name, recordId]), update: (recordId, data) => callCapability>(endpoint, "storage.records.update", [name, recordId, data]), delete: (recordId) => callCapability(endpoint, "storage.records.delete", [name, recordId]), query: (query?: ColineRecordQuery) => callCapability<{ records: Array>; nextCursor: string | null; }>(endpoint, "storage.records.query", [name, query ?? {}]), }; } export function createHttpCapabilities(endpoint: CapabilityEndpoint): ColineCapabilities { return { workspace: { get: () => callCapability(endpoint, "workspace.get", []), }, members: { list: (input) => callCapability(endpoint, "members.list", [input ?? {}]), get: (userId) => callCapability(endpoint, "members.get", [userId]), }, files: { list: (input) => callCapability(endpoint, "files.list", [input ?? {}]), get: (fileId) => callCapability(endpoint, "files.get", [fileId]), create: (input) => callCapability(endpoint, "files.create", [input]), update: (fileId, input) => callCapability(endpoint, "files.update", [fileId, input]), trash: (fileId) => callCapability(endpoint, "files.trash", [fileId]), getDocument: (fileId) => callCapability(endpoint, "files.getDocument", [fileId]), updateDocument: (fileId, document) => callCapability(endpoint, "files.updateDocument", [fileId, document]), }, drives: { getAppDrive: () => callCapability(endpoint, "drives.getAppDrive", []), }, storage: { kv: { get: (key) => callCapability(endpoint, "storage.kv.get", [key]), set: (key, value) => callCapability(endpoint, "storage.kv.set", [key, value]), delete: (key) => callCapability(endpoint, "storage.kv.delete", [key]), list: (input) => callCapability(endpoint, "storage.kv.list", [input ?? {}]), }, collection: (name: string) => createHttpCollection(endpoint, name), }, search: { query: (input) => callCapability(endpoint, "search.query", [input]), index: { upsert: (input) => callCapability(endpoint, "search.index.upsert", [input]), delete: (input) => callCapability(endpoint, "search.index.delete", [input]), }, }, notifications: { create: (input) => callCapability(endpoint, "notifications.create", [input]), }, ai: { generate: (prompt, options) => callCapability(endpoint, "ai.generate", [prompt, options ?? {}]), }, net: { fetch: (input) => callCapability(endpoint, "net.fetch", [input]), }, pickers: { file: () => unavailable("pickers.file"), member: () => unavailable("pickers.member"), confirm: () => unavailable("pickers.confirm"), }, navigation: { openFile: () => unavailable("navigation.openFile"), openAppHome: () => unavailable("navigation.openAppHome"), navigate: () => unavailable("navigation.navigate"), openReference: () => unavailable("navigation.openReference"), }, realtime: { // Server-side logic cannot hold a socket; it publishes, clients // subscribe (tier-2 via the bridge). subscribe: () => unavailable("realtime.subscribe"), publish: (event, data) => callCapability(endpoint, "realtime.publish", [{ event, data }]) as Promise<{ delivered: boolean; }>, }, events: { emit: (event, data) => callCapability(endpoint, "events.emit", [{ event, data }]) as Promise<{ emitted: boolean; }>, }, }; }