/** * The asking half of the hook-owned-state accessor. * * Lives in the hook's own process (the runner shim builds the context from * this), where every operation must cross the socket as a `store` frame. The * answering half is `hook-store.ts` broker-side. * * The one subtlety is `transaction` (hook-owned-state design D4): the hook's * callback runs entirely child-side against a BUFFERING view, and nothing is * sent until the callback resolves. A hook that writes three names and throws * therefore sends no frame at all — the discard is a property of the wire, not * a promise the broker has to keep. This is the inverse of plain `set`, which * lands when called; the design makes that trade deliberately and puts the * choice at the call site. * * Pure construction (Rule 10.4) — the transport is injected, so the buffering * rules are testable with no socket. */ import type { HookStore } from '@celilo/capabilities'; import type { BufferedStoreOp } from './hook-protocol'; /** One operation on a store, transported. The injectable boundary. */ export type StoreTransport = ( store: 'secrets' | 'config', method: 'get' | 'set' | 'delete' | 'transaction', args: unknown[], ) => Promise; /** * The names a transaction's callback must not call through its buffering view. * A nested transaction has no honest meaning — there is nothing to commit the * outer buffer against yet — and silently ignoring it would reorder writes. */ class NestedTransactionError extends Error { constructor() { super('transaction() cannot be called inside a transaction; apply the writes directly.'); this.name = 'NestedTransactionError'; } } /** * Build the object the hook sees as `context.secrets` / `context.config`: * the plain record of current values (the map surface older hooks still read), * plus the four accessor methods. * * Methods sit ON the record deliberately during the migration window — a * module declaring a secret or config key named `get`, `set`, `delete` or * `transaction` would shadow one. That collision is rejected at the manifest * layer only after task 3.6 removes the map surface; until then the risk is * carried, not hidden. */ export function buildStoreView>( store: 'secrets' | 'config', currentValues: T, transport: StoreTransport, ): T & HookStore { const call = (method: 'get' | 'set' | 'delete', args: unknown[]) => transport(store, method, args) as Promise; const view = { get: (name: string) => call('get', [name]) as Promise, set: (name: string, value: string) => call('set', [name, value]) as Promise, delete: (name: string) => call('delete', [name]) as Promise, transaction: async (fn: (buffered: HookStore) => void | Promise) => { const ops: BufferedStoreOp[] = []; const buffered: HookStore = { get: view.get, set: (name, value) => { ops.push({ op: 'set', name, value }); return Promise.resolve(); }, delete: (name) => { ops.push({ op: 'delete', name }); return Promise.resolve(); }, transaction: () => Promise.reject(new NestedTransactionError()), }; await fn(buffered); await transport(store, 'transaction', [ops]); }, }; return { ...currentValues, ...view } as T & HookStore; }