/**
* `store.resource(db, table, opts)` — a CRUD + list resource factory.
*
* Declarative sugar, never new physics: every op is an ordinary `flow(name, {…})`
* whose body composes `fx.store(db)` (select/insert/update/findById/delete +
* `page`/`count`) through the existing Drizzle-condition compiler. Wire
* binding stays in `on(http.resource(path, resource.all()))` — the factory
* itself registers no triggers.
*
* List URL grammar and parsing live in `list-query.ts` (shared with live
* query windows and hand-written flows); this factory resolves its
* declarative scopes and wires the parsed page into `fx.store(db)`.
*/
import { flow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
import { applyBoundaryContract, stampBoundaryContract } from "../../kernel/boundary-contract.ts";
import { fail } from "../../kernel/fail-helpers.ts";
import type { Fx } from "../../kernel/fx.ts";
import {
openLiveStream,
realtimeBridgeRuntime,
type LiveColumnKind,
} from "../../kernel/realtime-bind.ts";
import { compileWhere } from "./sql-condition.ts";
import { z } from "zod";
import type { SqlStoreDecl } from "./declare.ts";
import type { SqlRow } from "../../drivers/types.ts";
import { resolveColumns, resolveTableName } from "./table.ts";
import { registerPendingResourceLive } from "../../kernel/resource-live.ts";
import type { SqlPageOptions } from "./sql-session.ts";
import {
encodeCursor,
encodeOffsetCursor,
leafOp,
parseListQuery,
resolveListScope,
type ResolvedListConfig,
} from "./list-query.ts";
export type { ColumnScope, ListCountMode, ListPageMode } from "./list-query.ts";
export type { ResolvedListConfig } from "./list-query.ts";
/** Options for {@link resource}. */
export interface ResourceOptions {
/** Create body schema (Standard Schema). */
readonly in: unknown;
/** Item schema (Standard Schema) — also the list item type. */
readonly out: unknown;
/** Update body schema; defaults to the partial of `in` when omitted. */
readonly update?: unknown;
/**
* Update `:id` schema; defaults to `z.object({ id: z.string() })`. Only
* used to extend `update` with the path id (wire sends `{ id, ...patch }`).
*/
readonly idSchema?: unknown;
/**
* Extra typed errors for get / update / remove.
*
* Built-in `NotFound` is always on — omit unless tightening the payload.
*/
readonly errors?: FlowErrorMap;
/** `:id` column; defaults to the table primary key. */
readonly id?: unknown;
/** List surface. */
readonly list?: ResourceListOptions;
/**
* Acknowledge intentional Manifest contract breaks for the five flows
* (`breaking: true` on each). Use when migrating a handwritten CRUD unit
* onto `store.resource`.
*/
readonly breaking?: boolean;
/**
* Enable the live query stack: compiler synthesizes an internal Signal
* (`oke/live/sql:
`), wires CDC fan-out, and mounts
* `GET /live` when used with `http.resource`. Requires RLS-capable
* SQL drivers (postgres / pglite).
*/
readonly live?: boolean;
}
/** List options on {@link ResourceOptions}. */
export interface ResourceListOptions {
/** Pagination; default `"cursor"` when `cursor` columns are set. */
readonly mode?: import("./list-query.ts").ListPageMode;
/** Keyset columns (cursor mode). */
readonly cursor?: readonly unknown[];
/** Default sort when no `?order=` (default `"desc"`). */
readonly direction?: "asc" | "desc";
/** Default page size (default 20). */
readonly limit?: number;
/** Hard cap on `?limit=` (default 100). */
readonly maxLimit?: number;
/** Offset-only `COUNT(*)` (default `"exact"`). */
readonly count?: import("./list-query.ts").ListCountMode;
/** Substring search columns (`?search=` / `?q=`). Default `"none"`. */
readonly search?: import("./list-query.ts").ColumnScope;
/** Filter grammar columns (`?col=op.value`). Default `"none"`. */
readonly filter?: import("./list-query.ts").ColumnScope;
/** `?order=` columns. Default: cursor columns, else `"all"`. */
readonly order?: import("./list-query.ts").ColumnScope;
/** `?select=` projection columns (runtime only). Default `"all"`. */
readonly select?: import("./list-query.ts").ColumnScope;
}
/** One CRUD op bundle returned by {@link resource}. */
export interface ResourceFlowDefs {
readonly list: FlowDef;
readonly create: FlowDef;
readonly get: FlowDef;
readonly update: FlowDef;
readonly remove: FlowDef;
/** Live surface when `live: true` — mounted by `http.resource(…, …all())`. */
readonly live?: ResourceLiveSurface;
}
/** Column lookup for {@link ResourceDef.page}. */
export interface ResourceColumns {
/** Column property on the table by URL key (`createdAt` → drizzle col). */
readonly columns: Readonly>;
/** SQL name per URL key (for order compilation). */
readonly sqlNameOf: (key: string) => string | undefined;
}
/**
* Live query surface synthesized for `store.resource(…, { live: true })`.
*
* The signal carries classified {@link LiveQueryEvent}-shaped payloads;
* `flow` is a `delivery: "live"` SSE consumer (`fx.live` physics) that the
* app mounts on `GET /live`. The realtime bridge publishes to the
* same signal name, so transport, retention, and Last-Event-ID resume are
* the already-proven Signal stack.
*/
export interface ResourceLiveSurface {
/** Internal signal name — `oke/live/sql:`. */
readonly signal: string;
/** Default subscribe path — `/live` is derived at mount. */
readonly flow: AnyFlowDef;
}
/** A resource factory result — FlowDefs plus introspection for `page`. */
export interface ResourceDef extends ResourceColumns {
readonly list: FlowDef;
readonly create: FlowDef;
readonly get: FlowDef;
readonly update: FlowDef;
readonly remove: FlowDef;
/** Table name (SQL identifier). */
readonly table: string;
/** `:id` URL key. */
readonly idKey: string;
/** Default page size. */
readonly limit: number;
/** `?limit=` cap. */
readonly maxLimit: number;
/** Resolved list config (defaults applied). */
readonly listConfig: ResolvedListConfig;
/**
* Live query surface when `live: true` — signal name + SSE Flow to mount.
* Absent otherwise.
*/
readonly live?: ResourceLiveSurface;
/** All five ops for `http.resource(path, resource.all())`. */
all(): ResourceFlowDefs;
/** Input for {@link SqlStoreHandle.page} from validated list input. */
page(input: unknown): SqlPageOptions & { readonly meta: Record };
}
/**
* Build a CRUD + list resource over a sql table. Returns FlowDefs plus
* `.all()` for `on(http.resource(path, resource.all()))`.
*
* @param db - Sql store decl (`store.sql(...)`)
* @param table - Drizzle / schema table
* @param options - Contracts + list surface
*/
export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOptions): ResourceDef {
const tableName = resolveTableName(table);
const columns = resolveColumns(table);
const tableColumns = table as Readonly>;
const pk = columns.find((c) => c.primary) ?? columns[0];
const idColumn =
options.id !== undefined
? columns.find(
(c) =>
tableColumns[c.key] === options.id ||
c.sqlName === (options.id as { name?: string }).name,
)
: pk;
const idKey = idColumn?.key ?? "id";
const idDrizzleCol = idColumn !== undefined ? tableColumns[idColumn.key] : tableColumns.id;
const scope = resolveListScope(table, options.list);
const config = scope.listConfig;
const { mode, limit, maxLimit, cursorColumns: resolvedCursor } = scope;
const countMode = scope.count;
const errors = options.errors;
const breaking = options.breaking === true;
/** Run the list query and shape rows + meta. */
async function runList(
input: unknown,
fx: Fx,
): Promise<{ data: SqlRow[]; meta: Record } | { failure: unknown }> {
const parsed = parseListQuery(input, scope.query);
if (!parsed.ok) return { failure: parsed.failure };
const store = fx.store(db) as {
page(t: unknown, o: SqlPageOptions): Promise;
count(t: unknown, w?: unknown): Promise;
};
const rows = await store.page(table, parsed.page);
const meta: Record = { ...parsed.meta };
let data: SqlRow[] = rows;
if (mode === "cursor") {
const pageSize = Number(parsed.meta.limit);
const extra = rows.length > pageSize;
const sliced = extra ? rows.slice(0, pageSize) : rows;
const pageRows = parsed.cursorDir === "before" ? [...sliced].reverse() : sliced;
const first = pageRows[0];
const last = pageRows[pageRows.length - 1];
const cursorValues = (row: SqlRow) => resolvedCursor.map((c) => row[c.key] ?? row[c.sqlName]);
const hasNext = parsed.cursorDir === "before" ? pageRows.length > 0 : extra;
const hasPrevious = parsed.cursorDir === "before" ? extra : parsed.cursorDir === "after";
meta.next =
hasNext && last !== undefined
? { cursor: encodeCursor(cursorValues(last), "after") }
: null;
meta.prev =
hasPrevious && first !== undefined
? { cursor: encodeCursor(cursorValues(first), "before") }
: null;
data = pageRows;
} else if (countMode === "exact") {
meta.total = await store.count(table, parsed.page.where);
meta.offset = parsed.page.offset ?? 0;
const pageLimit = Number(parsed.meta.limit);
const hasPrevious = Number(meta.offset) > 0;
const hasNext = Number(meta.offset) + data.length < Number(meta.total);
meta.next = hasNext ? { cursor: encodeOffsetCursor(Number(meta.offset) + pageLimit) } : null;
meta.prev = hasPrevious
? { cursor: encodeOffsetCursor(Math.max(0, Number(meta.offset) - pageLimit)) }
: null;
} else {
meta.offset = parsed.page.offset ?? 0;
const pageLimit = Number(parsed.meta.limit);
const hasPrevious = Number(meta.offset) > 0;
const hasNext = data.length === pageLimit;
meta.next = hasNext ? { cursor: encodeOffsetCursor(Number(meta.offset) + pageLimit) } : null;
meta.prev = hasPrevious
? { cursor: encodeOffsetCursor(Math.max(0, Number(meta.offset) - pageLimit)) }
: null;
}
if (parsed.select !== undefined) {
const keep = new Set(parsed.select.map((c) => c.key));
data = data.map((row) => {
const out: SqlRow = {};
for (const key of Object.keys(row)) if (keep.has(key)) out[key] = row[key];
return out;
});
}
return { data, meta };
}
const listFlow = flow("list", {
// Loose record so the HTTP AoT infers `query` and lets every list URL
// key through; real validation happens in parseListQuery (PostgREST
// grammar).
effects: { reads: [db.ref] },
do: async (input, fx) => {
const result = await runList(input, fx);
if ("failure" in result) return result.failure;
return fx.json.with(result);
},
});
applyBoundaryContract(
listFlow,
stampBoundaryContract({
in: z.record(z.string(), z.unknown()) as never,
...(breaking ? { breaking: true } : {}),
}),
);
const createFlow = flow("create", {
effects: { writes: [db.ref] },
do: async (input, fx) => {
const store = fx.store(db) as {
insert(t: unknown): { values(row: SqlRow): { returning(): Promise } };
};
const [row] = await store
.insert(table)
.values(input as SqlRow)
.returning();
return fx.json.create(row);
},
});
applyBoundaryContract(
createFlow,
stampBoundaryContract({
in: options.in as never,
out: options.out as never,
...(breaking ? { breaking: true } : {}),
}),
);
const getFlow = flow("get", {
effects: { reads: [db.ref] },
do: async (input, fx) => {
const id = (input as Record)[idKey];
const store = fx.store(db) as {
findById(t: unknown, id: string): Promise;
};
const row = await store.findById(table, String(id));
if (!row) return fail.notFound();
return row;
},
});
applyBoundaryContract(
getFlow,
stampBoundaryContract({
out: options.out as never,
errors,
...(breaking ? { breaking: true } : {}),
}),
);
// Wire update body is `{ id, ...patch }`. The patch schema (`update`,
// default `in`) describes the mutable fields; the path id rides along and
// must survive validation, so extend the ZodObject with the id key.
const patchSchema: unknown = options.update ?? options.in;
const updateIn =
options.idSchema !== undefined
? options.idSchema
: patchSchema instanceof z.ZodObject
? patchSchema.extend({ [idKey]: z.string() })
: patchSchema;
const updateFlow = flow("update", {
effects: { reads: [db.ref], writes: [db.ref] },
do: async (input, fx) => {
const { [idKey]: id, ...patch } = input as Record;
const store = fx.store(db) as {
findById(t: unknown, id: string): Promise;
update(t: unknown): { set(row: SqlRow): { where(w: unknown): Promise } };
};
const existing = await store.findById(table, String(id));
if (!existing) return fail.notFound();
if (Object.keys(patch).length > 0) {
await store
.update(table)
.set(patch as SqlRow)
.where(leafOp(idDrizzleCol, "=", String(id)));
}
const row = await store.findById(table, String(id));
if (!row) return fail.notFound();
return row;
},
});
applyBoundaryContract(
updateFlow,
stampBoundaryContract({
in: updateIn as never,
out: options.out as never,
errors,
...(breaking ? { breaking: true } : {}),
}),
);
const removeFlow = flow("remove", {
effects: { writes: [db.ref] },
do: async (input, fx) => {
const id = (input as Record)[idKey];
const store = fx.store(db) as {
delete(t: unknown, id: string): Promise;
};
const deleted = await store.delete(table, String(id));
if (!deleted) return fail.notFound();
return fx.json.empty();
},
});
applyBoundaryContract(
removeFlow,
stampBoundaryContract({
errors,
...(breaking ? { breaking: true } : {}),
}),
);
const defs: ResourceFlowDefs = {
list: listFlow as FlowDef,
create: createFlow as FlowDef,
get: getFlow as FlowDef,
update: updateFlow as FlowDef,
remove: removeFlow as FlowDef,
};
// Live query surface — internal signal name + SSE stream Flow. Developers
// mount it via `http.resource` (or a manual `.live()` GET); the realtime
// bridge classifies CDC events per subscriber and pushes through this
// flow. Classified events are per-identity RLS verdicts, so delivery is
// direct per-subscriber push — never a shared signal tape.
//
// `live: false` is the explicit per-table opt-out for the project-wide
// `store.live` default — never builds a surface. `live: true` builds it
// immediately. When `live` is omitted the surface is deferred: it is
// recorded as a pending live default and drained by `oke()` only when
// `store: { live: true }` is set.
const buildLiveSurface = (): ResourceLiveSurface => {
const signalName = `oke/live/sql:${tableName}`;
const pkColumn = idColumn?.sqlName ?? idKey;
const columnKinds: Record = {};
for (const c of columns) {
const kind: LiveColumnKind | undefined =
c.sqlType === "BOOLEAN"
? "boolean"
: c.sqlType === "INTEGER" || c.sqlType === "BIGINT" || c.sqlType === "REAL"
? "number"
: c.sqlType === "JSONB"
? undefined
: "string";
if (kind !== undefined) columnKinds[c.key] = kind;
}
const liveFlow = flow(`_live_${tableName}`, {
effects: { reads: [db.ref, `signal:${signalName}`] },
do: async (input: unknown, fx: Fx) => {
const bridge = realtimeBridgeRuntime();
if (!bridge) {
throw new Error(
`live query for "${tableName}" requires an RLS-capable SQL driver (postgres / pglite)`,
);
}
// Same PostgREST window as the list Flow — filters decide whether
// a row belongs to this live query ("query exit"), RLS decides
// visibility. Pagination cursors don't gate membership.
const parsed = parseListQuery(input, scope.query);
if (!parsed.ok) return parsed.failure;
let whereSql: string | undefined;
let whereParams: readonly unknown[] = [];
const pageWhere = parsed.page.where;
if (pageWhere !== undefined) {
const compiled = compileWhere(pageWhere);
if (compiled.clause !== "") {
whereSql = compiled.clause;
whereParams = compiled.params;
}
}
const identity = fx.rlsIdentity;
if (!identity) {
throw new Error(`live query for "${tableName}" requires a gated identity`);
}
const stream = openLiveStream(fx.id(), {
table: tableName,
identity,
pkColumn,
...(whereSql !== undefined ? { whereSql } : {}),
...(whereParams.length > 0 ? { whereParams } : {}),
...(Object.keys(columnKinds).length > 0 ? { tableColumns: columnKinds } : {}),
});
return fx.json.stream(stream.chunks);
},
}) as unknown as AnyFlowDef;
if (options.out !== undefined) {
applyBoundaryContract(liveFlow, stampBoundaryContract({ out: options.out as never }));
}
return { signal: signalName, flow: liveFlow };
};
// Immediate vs deferred live surface:
// - `live: true` — build now (today's explicit opt-in, unchanged).
// - `live: false` — the per-table opt-out for the project default.
// - omitted — record a pending default surface, drained by `oke()` when
// `store: { live: true }` is set; otherwise discarded (today's behavior).
const signalName = `oke/live/sql:${tableName}`;
let liveSurface: ResourceLiveSurface | undefined;
const resolved: ResourceDef = {
...defs,
table: tableName,
idKey,
limit,
maxLimit,
listConfig: config,
columns: tableColumns,
sqlNameOf(key) {
return columns.find((c) => c.key === key)?.sqlName;
},
all: () => {
// Live surface rides along so `http.resource(path, notesR.all())` can
// mount `GET /live` from the resource even when the caller uses
// `.all()` (existing docs examples pass `.all()` directly). Prefer a
// drain-stamped surface, then the immediate closure surface.
const bag = defs as ResourceFlowDefs;
const stamped = (bag as { readonly live?: ResourceLiveSurface }).live;
const effective = stamped ?? liveSurface;
if (effective) {
Object.defineProperty(bag, "live", {
value: effective,
enumerable: true,
});
}
return bag;
},
page(input) {
const parsed = parseListQuery(input, scope.query);
if (!parsed.ok) {
throw new TypeError(
`resource.page: invalid list input — ${JSON.stringify(parsed.failure)}`,
);
}
return { ...parsed.page, meta: parsed.meta };
},
};
// Table-level `store.schema.live(false)` is the explicit per-table opt-out
// from the project-wide `store.live` default. It only cancels the DEFAULT —
// an explicit `options.live: true` on the resource still wins so today's
// opt-in behavior is unchanged.
const tableLiveOptedOut =
table !== null &&
typeof table === "object" &&
(table as { readonly live?: boolean }).live === false;
if (options.live === true) {
liveSurface = buildLiveSurface();
} else if (options.live !== false && !tableLiveOptedOut) {
const pending: { readonly signalName: string } = { signalName };
Object.defineProperty(defs, "pendingLive", { value: pending, configurable: true });
registerPendingResourceLive({
signalName,
def: resolved as object,
bag: defs as object,
build: buildLiveSurface,
});
}
if (liveSurface) {
Object.defineProperty(resolved, "live", {
value: liveSurface,
enumerable: true,
});
}
return resolved;
}