okengine 0.2.8 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +57 -29
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +92 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +92 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +147 -0
  75. package/src/cli/openbao-bootstrap.ts +280 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +138 -0
  159. package/src/plugins/cors.ts +129 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +105 -0
  165. package/src/plugins/ip-allowlist.ts +76 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
@@ -0,0 +1,786 @@
1
+ /**
2
+ * `store.resource(db, table, opts)` — a CRUD + list resource factory.
3
+ *
4
+ * Declarative sugar, never new physics: every op is an ordinary `flow({…})`
5
+ * whose body composes `fx.store(db)` (select/insert/update/findById/delete +
6
+ * `page`/`count`) through the existing Drizzle-condition compiler. Wire
7
+ * binding stays in `on(http.resource(path, resource.all()))` — the factory
8
+ * itself registers no triggers.
9
+ *
10
+ * List URL (PostgREST-shaped, UTF-8 values — English, Arabic, …):
11
+ * `?cursor=` / `?offset=` / `?limit=` · `?search=` (`?q=` alias) ·
12
+ * `?col=eq.x|neq|gt|gte|lt|lte|like.*p*|ilike.*p*|in.(a,b)|is.true|false|null` ·
13
+ * `?or=(…)` / `?and=(…)` · `?order=col.desc,…` · `?select=id,title`.
14
+ * Every surface is whitelisted by a ColumnScope (`"all" | Column[] | "none"`).
15
+ */
16
+
17
+ import { flow, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
18
+ import { fail } from "../../kernel/errors.ts";
19
+ import type { Fx } from "../../kernel/fx.ts";
20
+ import { validationFailure } from "../../validation/standard-schema.ts";
21
+ import { z } from "zod";
22
+ import type { SqlStoreDecl } from "./declare.ts";
23
+ import type { SqlRow } from "../../drivers/types.ts";
24
+ import { resolveColumns, resolveTableName, type ResolvedColumn } from "./table.ts";
25
+ import type { SqlPageOptions } from "./sql-session.ts";
26
+
27
+ /** Whitelist for one list concern (`search` / `filter` / `order` / `select`). */
28
+ export type ColumnScope = "all" | readonly unknown[] | "none";
29
+
30
+ /** Pagination mode for a resource list. */
31
+ export type ListPageMode = "cursor" | "offset";
32
+
33
+ /** Offset-mode `COUNT(*)` policy. */
34
+ export type ListCountMode = "exact" | "none";
35
+
36
+ /** Options for {@link resource}. */
37
+ export interface ResourceOptions {
38
+ /** Create body schema (Standard Schema). */
39
+ readonly in: unknown;
40
+ /** Item schema (Standard Schema) — also the list item type. */
41
+ readonly out: unknown;
42
+ /** Update body schema; defaults to the partial of `in` when omitted. */
43
+ readonly update?: unknown;
44
+ /**
45
+ * Update `:id` schema; defaults to `z.object({ id: z.string() })`. Only
46
+ * used to extend `update` with the path id (wire sends `{ id, ...patch }`).
47
+ */
48
+ readonly idSchema?: unknown;
49
+ /** Typed errors for get / update / remove (default `{ NotFound }`). */
50
+ readonly errors?: FlowErrorMap;
51
+ /** `:id` column; defaults to the table primary key. */
52
+ readonly id?: unknown;
53
+ /** List surface. */
54
+ readonly list?: ResourceListOptions;
55
+ /** Flow unit scope (default: table name). Client namespace still comes from `adopt`. */
56
+ readonly unit?: string;
57
+ /**
58
+ * Acknowledge intentional Manifest contract breaks for the five flows
59
+ * (`breaking: true` on each). Use when migrating a handwritten CRUD unit
60
+ * onto `store.resource`.
61
+ */
62
+ readonly breaking?: boolean;
63
+ }
64
+
65
+ /** List options on {@link ResourceOptions}. */
66
+ export interface ResourceListOptions {
67
+ /** Pagination; default `"cursor"` when `cursor` columns are set. */
68
+ readonly mode?: ListPageMode;
69
+ /** Keyset columns (cursor mode). */
70
+ readonly cursor?: readonly unknown[];
71
+ /** Default sort when no `?order=` (default `"desc"`). */
72
+ readonly direction?: "asc" | "desc";
73
+ /** Default page size (default 20). */
74
+ readonly limit?: number;
75
+ /** Hard cap on `?limit=` (default 100). */
76
+ readonly maxLimit?: number;
77
+ /** Offset-only `COUNT(*)` (default `"exact"`). */
78
+ readonly count?: ListCountMode;
79
+ /** Substring search columns (`?search=` / `?q=`). Default `"none"`. */
80
+ readonly search?: ColumnScope;
81
+ /** Filter grammar columns (`?col=op.value`). Default `"none"`. */
82
+ readonly filter?: ColumnScope;
83
+ /** `?order=` columns. Default: cursor columns, else `"all"`. */
84
+ readonly order?: ColumnScope;
85
+ /** `?select=` projection columns (runtime only). Default `"all"`. */
86
+ readonly select?: ColumnScope;
87
+ }
88
+
89
+ /** One CRUD op bundle returned by {@link resource}. */
90
+ export interface ResourceFlowDefs {
91
+ readonly list: FlowDef<any, any, any>;
92
+ readonly create: FlowDef<any, any, any>;
93
+ readonly get: FlowDef<any, any, any>;
94
+ readonly update: FlowDef<any, any, any>;
95
+ readonly remove: FlowDef<any, any, any>;
96
+ }
97
+
98
+ /** Column lookup for {@link ResourceDef.page}. */
99
+ export interface ResourceColumns {
100
+ /** Column property on the table by URL key (`createdAt` → drizzle col). */
101
+ readonly columns: Readonly<Record<string, unknown>>;
102
+ /** SQL name per URL key (for order compilation). */
103
+ readonly sqlNameOf: (key: string) => string | undefined;
104
+ }
105
+
106
+ /** A resource factory result — FlowDefs plus introspection for `page`. */
107
+ export interface ResourceDef extends ResourceColumns {
108
+ readonly list: FlowDef<any, any, any>;
109
+ readonly create: FlowDef<any, any, any>;
110
+ readonly get: FlowDef<any, any, any>;
111
+ readonly update: FlowDef<any, any, any>;
112
+ readonly remove: FlowDef<any, any, any>;
113
+ /** Table name (SQL identifier). */
114
+ readonly table: string;
115
+ /** `:id` URL key. */
116
+ readonly idKey: string;
117
+ /** Default page size. */
118
+ readonly limit: number;
119
+ /** `?limit=` cap. */
120
+ readonly maxLimit: number;
121
+ /** Resolved list config (defaults applied). */
122
+ readonly listConfig: ResolvedListConfig;
123
+ /** All five ops for `http.resource(path, resource.all())`. */
124
+ all(): ResourceFlowDefs;
125
+ /** Input for {@link SqlStoreHandle.page} from validated list input. */
126
+ page(input: unknown): SqlPageOptions & { readonly meta: Record<string, unknown> };
127
+ }
128
+
129
+ /** List config after defaults are applied. */
130
+ export interface ResolvedListConfig {
131
+ readonly mode: ListPageMode;
132
+ readonly cursor: readonly ResolvedColumn[];
133
+ readonly direction: "asc" | "desc";
134
+ readonly limit: number;
135
+ readonly maxLimit: number;
136
+ readonly count: ListCountMode;
137
+ readonly search: readonly ResolvedColumn[];
138
+ readonly filter: readonly ResolvedColumn[] | "none";
139
+ readonly order: readonly ResolvedColumn[] | "none";
140
+ readonly select: readonly ResolvedColumn[] | "all";
141
+ }
142
+
143
+ /* ———————————————————————————— drizzle-op builders (no drizzle import) ——— */
144
+
145
+ /** Build a Drizzle-shaped `SQL` leaf/group without importing `drizzle-orm`. */
146
+ interface SqlOp {
147
+ readonly queryChunks: readonly unknown[];
148
+ }
149
+ function strChunk(text: string): unknown {
150
+ return { constructor: { name: "StringChunk" }, value: [text] };
151
+ }
152
+ function paramChunk(value: unknown): unknown {
153
+ return { value };
154
+ }
155
+ function leafOp(column: unknown, op: string, value: unknown): SqlOp {
156
+ if (op === "is null" || op === "is not null") {
157
+ return { queryChunks: [column, strChunk(` ${op}`)] };
158
+ }
159
+ if (op === "in") {
160
+ const values = (value as readonly unknown[]).map(paramChunk);
161
+ return { queryChunks: [column, strChunk(" in "), values] };
162
+ }
163
+ return { queryChunks: [column, strChunk(` ${op} `), paramChunk(value)] };
164
+ }
165
+ function groupOp(joiner: "and" | "or", parts: readonly SqlOp[]): SqlOp {
166
+ const chunks: unknown[] = [];
167
+ parts.forEach((part, i) => {
168
+ if (i > 0) chunks.push(strChunk(` ${joiner} `));
169
+ chunks.push(part);
170
+ });
171
+ return { queryChunks: chunks };
172
+ }
173
+ function ascOp(column: unknown): SqlOp {
174
+ return { queryChunks: [column, strChunk(" asc")] };
175
+ }
176
+ function descOp(column: unknown): SqlOp {
177
+ return { queryChunks: [column, strChunk(" desc")] };
178
+ }
179
+
180
+ /** Recursive keyset predicate for "rows strictly after the cursor point". */
181
+ function keysetAfter(
182
+ columns: readonly unknown[],
183
+ values: readonly unknown[],
184
+ descending: boolean,
185
+ ): SqlOp | undefined {
186
+ const cmp = descending ? "<" : ">";
187
+ const build = (i: number): SqlOp | undefined => {
188
+ if (i >= columns.length) return undefined;
189
+ const col = columns[i]!;
190
+ const rest = build(i + 1);
191
+ const tail =
192
+ rest === undefined ? undefined : groupOp("and", [leafOp(col, "=", values[i]), rest]);
193
+ return tail === undefined
194
+ ? leafOp(col, cmp, values[i])
195
+ : groupOp("or", [leafOp(col, cmp, values[i]), tail]);
196
+ };
197
+ return build(0);
198
+ }
199
+
200
+ /* —————————————————————————————— cursor codec —————————————————————————— */
201
+
202
+ function encodeCursor(values: readonly unknown[]): string {
203
+ return btoa(JSON.stringify(values));
204
+ }
205
+ function decodeCursor(raw: string, arity: number): readonly unknown[] | null {
206
+ try {
207
+ const value: unknown = JSON.parse(atob(raw));
208
+ if (!Array.isArray(value) || value.length !== arity) return null;
209
+ return value as readonly unknown[];
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /* —————————————————————————————— URL parsing ——————————————————————————— */
216
+
217
+ const RESERVED_PARAMS = new Set([
218
+ "cursor",
219
+ "limit",
220
+ "offset",
221
+ "search",
222
+ "q",
223
+ "order",
224
+ "select",
225
+ "or",
226
+ "and",
227
+ ]);
228
+
229
+ const FILTER_OPS = new Set(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]);
230
+
231
+ function badInput(message: string, path: string) {
232
+ return validationFailure([{ message, path: [path] }]);
233
+ }
234
+
235
+ /** Parse `eq.x` / `in.(a,b)` / `is.null` into a drizzle op over `column`. */
236
+ function filterOp(
237
+ column: unknown,
238
+ key: string,
239
+ raw: string,
240
+ ): SqlOp | { failure: ReturnType<typeof badInput> } {
241
+ const dot = raw.indexOf(".");
242
+ if (dot <= 0) return { failure: badInput(`expected "op.value" (e.g. ${key}=eq.x)`, key) };
243
+ const op = raw.slice(0, dot);
244
+ const value = raw.slice(dot + 1);
245
+ if (!FILTER_OPS.has(op)) {
246
+ return { failure: badInput(`unsupported filter op "${op}"`, key) };
247
+ }
248
+ switch (op) {
249
+ case "eq":
250
+ return leafOp(column, "=", value);
251
+ case "neq":
252
+ return leafOp(column, "!=", value);
253
+ case "gt":
254
+ return leafOp(column, ">", value);
255
+ case "gte":
256
+ return leafOp(column, ">=", value);
257
+ case "lt":
258
+ return leafOp(column, "<", value);
259
+ case "lte":
260
+ return leafOp(column, "<=", value);
261
+ case "like":
262
+ return leafOp(column, "like", value.replaceAll("*", "%"));
263
+ case "ilike":
264
+ return leafOp(column, "ilike", value.replaceAll("*", "%"));
265
+ case "is": {
266
+ if (value === "null") return leafOp(column, "is null", undefined);
267
+ if (value === "true") return leafOp(column, "=", true);
268
+ if (value === "false") return leafOp(column, "=", false);
269
+ return { failure: badInput(`is expects null|true|false`, key) };
270
+ }
271
+ case "in": {
272
+ const match = /^\((.*)\)$/.exec(value);
273
+ if (!match) return { failure: badInput(`in expects (a,b,c)`, key) };
274
+ const inner = match[1]!.trim();
275
+ const values = inner.length === 0 ? [] : inner.split(",");
276
+ if (values.length === 0) return { failure: badInput(`in expects at least one value`, key) };
277
+ return leafOp(column, "in", values);
278
+ }
279
+ }
280
+ return { failure: badInput(`unsupported filter op "${op}"`, key) };
281
+ }
282
+
283
+ /** Split `or=(a.ilike.*x*,b.eq.1)` inner list on commas. */
284
+ function groupInner(raw: string): readonly string[] | null {
285
+ const match = /^\((.*)\)$/.exec(raw.trim());
286
+ if (!match) return null;
287
+ const inner = match[1]!.trim();
288
+ return inner.length === 0 ? [] : inner.split(",");
289
+ }
290
+
291
+ /** One `col.op.value` term inside an `or=(…)` / `and=(…)` group. */
292
+ function groupedFilterTerm(
293
+ tableColumns: Readonly<Record<string, unknown>>,
294
+ term: string,
295
+ joiner: "or" | "and",
296
+ ): SqlOp | { failure: ReturnType<typeof badInput> } {
297
+ const dot = term.indexOf(".");
298
+ if (dot <= 0) {
299
+ return { failure: badInput(`expected "col.op.value" inside ${joiner}=(…)`, joiner) };
300
+ }
301
+ const col = tableColumns[term.slice(0, dot)];
302
+ if (col === undefined) {
303
+ return { failure: badInput(`unknown column "${term.slice(0, dot)}"`, joiner) };
304
+ }
305
+ return filterOp(col, joiner, term.slice(dot + 1));
306
+ }
307
+
308
+ /* —————————————————————————————— the factory ——————————————————————————— */
309
+
310
+ function scopeColumns(
311
+ scope: ColumnScope,
312
+ columns: readonly ResolvedColumn[],
313
+ tableColumns: Readonly<Record<string, unknown>>,
314
+ ): readonly ResolvedColumn[] | "none" | "all" {
315
+ if (scope === "none") return "none";
316
+ if (scope === "all") return columns;
317
+ const allowed = new Set(
318
+ scope
319
+ .map((c) =>
320
+ columns.find(
321
+ (col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
322
+ ),
323
+ )
324
+ .filter((c): c is ResolvedColumn => c !== undefined),
325
+ );
326
+ return columns.filter((c) => allowed.has(c));
327
+ }
328
+
329
+ /**
330
+ * Build a CRUD + list resource over a sql table. Returns FlowDefs plus
331
+ * `.all()` for `on(http.resource(path, resource.all()))`.
332
+ *
333
+ * @param db - Sql store decl (`store.sql(...)`)
334
+ * @param table - Drizzle / schema table
335
+ * @param options - Contracts + list surface
336
+ */
337
+ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOptions): ResourceDef {
338
+ const tableName = resolveTableName(table);
339
+ const columns = resolveColumns(table);
340
+ const tableColumns = table as Readonly<Record<string, unknown>>;
341
+ const unit = options.unit ?? tableName;
342
+
343
+ const pk = columns.find((c) => c.primary) ?? columns[0];
344
+ const idColumn =
345
+ options.id !== undefined
346
+ ? columns.find(
347
+ (c) =>
348
+ tableColumns[c.key] === options.id ||
349
+ c.sqlName === (options.id as { name?: string }).name,
350
+ )
351
+ : pk;
352
+ const idKey = idColumn?.key ?? "id";
353
+ const idDrizzleCol = idColumn !== undefined ? tableColumns[idColumn.key] : tableColumns.id;
354
+
355
+ const list = options.list ?? {};
356
+ const cursorCols = list.cursor ?? [];
357
+ const resolvedCursor = cursorCols
358
+ .map((c) =>
359
+ columns.find(
360
+ (col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
361
+ ),
362
+ )
363
+ .filter((c): c is ResolvedColumn => c !== undefined);
364
+ const mode: ListPageMode = list.mode ?? (resolvedCursor.length > 0 ? "cursor" : "offset");
365
+ const direction = list.direction ?? "desc";
366
+ const limit = list.limit ?? 20;
367
+ const maxLimit = list.maxLimit ?? 100;
368
+ const countMode = list.count ?? "exact";
369
+
370
+ const searchCols = scopeColumns(list.search ?? "none", columns, tableColumns);
371
+ const filterCols = scopeColumns(list.filter ?? "none", columns, tableColumns);
372
+ const orderScope =
373
+ list.order ?? (resolvedCursor.length > 0 ? (cursorCols as readonly unknown[]) : "all");
374
+ const orderCols = scopeColumns(orderScope, columns, tableColumns);
375
+ const selectScope = list.select ?? "all";
376
+
377
+ const filterAllowed = new Set(
378
+ filterCols === "none" || filterCols === "all" ? [] : filterCols.map((c) => c.key),
379
+ );
380
+ const orderAllowed = new Set(
381
+ orderCols === "none" || orderCols === "all" ? [] : orderCols.map((c) => c.key),
382
+ );
383
+ const searchList = searchCols === "none" || searchCols === "all" ? [] : searchCols;
384
+
385
+ const config: ResolvedListConfig = {
386
+ mode,
387
+ cursor: resolvedCursor,
388
+ direction,
389
+ limit,
390
+ maxLimit,
391
+ count: countMode,
392
+ search: searchCols === "none" || searchCols === "all" ? [] : searchCols,
393
+ filter: filterCols === "none" ? "none" : filterCols === "all" ? columns : filterCols,
394
+ order: orderCols === "none" ? "none" : orderCols === "all" ? columns : orderCols,
395
+ select:
396
+ selectScope === "all"
397
+ ? "all"
398
+ : (scopeColumns(selectScope, columns, tableColumns) as readonly ResolvedColumn[]),
399
+ };
400
+
401
+ const errors = options.errors ?? ({ NotFound: {} as never } as FlowErrorMap);
402
+ const breaking = options.breaking === true;
403
+
404
+ /** Parse validated list input → page options or a validation failure. */
405
+ function parseList(input: unknown):
406
+ | {
407
+ ok: true;
408
+ page: SqlPageOptions;
409
+ meta: Record<string, unknown>;
410
+ select?: readonly ResolvedColumn[];
411
+ }
412
+ | { ok: false; failure: unknown } {
413
+ const query = (input ?? {}) as Record<string, unknown>;
414
+ const str = (k: string): string | undefined => {
415
+ const v = query[k];
416
+ if (typeof v === "string" && v.length > 0) return v;
417
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
418
+ return undefined;
419
+ };
420
+
421
+ // limit
422
+ let pageLimit = limit;
423
+ const rawLimit = str("limit");
424
+ if (rawLimit !== undefined) {
425
+ const n = Number(rawLimit);
426
+ if (!Number.isInteger(n) || n < 1) {
427
+ return { ok: false, failure: badInput("limit must be a positive integer", "limit") };
428
+ }
429
+ pageLimit = Math.min(n, maxLimit);
430
+ }
431
+
432
+ const wheres: unknown[] = [];
433
+ let meta: Record<string, unknown> = {};
434
+
435
+ // search
436
+ const search = str("search");
437
+ const q = str("q");
438
+ if (search !== undefined && q !== undefined) {
439
+ return { ok: false, failure: badInput("use either search or q, not both", "search") };
440
+ }
441
+ const searchTerm = search ?? q;
442
+ if (searchTerm !== undefined) {
443
+ if (searchList.length === 0) {
444
+ return {
445
+ ok: false,
446
+ failure: badInput("search is not enabled for this resource", "search"),
447
+ };
448
+ }
449
+ const pattern = `%${searchTerm}%`;
450
+ const parts = searchList
451
+ .map((c) => tableColumns[c.key])
452
+ .filter((c): c is unknown => c !== undefined)
453
+ .map((c) => leafOp(c, "like", pattern) as SqlOp);
454
+ wheres.push(parts.length === 1 ? parts[0] : groupOp("or", parts));
455
+ meta.search = searchTerm;
456
+ }
457
+
458
+ // column filters
459
+ if (config.filter === "none") {
460
+ for (const key of Object.keys(query)) {
461
+ if (!RESERVED_PARAMS.has(key) && key !== "headers" && key !== "cookie") {
462
+ return { ok: false, failure: badInput(`unknown list param "${key}"`, key) };
463
+ }
464
+ }
465
+ } else {
466
+ for (const [key, value] of Object.entries(query)) {
467
+ if (RESERVED_PARAMS.has(key) || key === "headers" || key === "cookie") continue;
468
+ if (typeof value !== "string") continue;
469
+ const col = tableColumns[key];
470
+ const resolved = columns.find((c) => c.key === key);
471
+ if (col === undefined || resolved === undefined || !filterAllowed.has(key)) {
472
+ return { ok: false, failure: badInput(`unknown or unfilterable column "${key}"`, key) };
473
+ }
474
+ const op = filterOp(col, key, value);
475
+ if ("failure" in op) return { ok: false, failure: op.failure };
476
+ wheres.push(op);
477
+ }
478
+ // or= / and= groups (one level)
479
+ for (const joiner of ["or", "and"] as const) {
480
+ const rawGroup = str(joiner);
481
+ if (rawGroup === undefined) continue;
482
+ const inner = groupInner(rawGroup);
483
+ if (inner === null || inner.length === 0) {
484
+ return { ok: false, failure: badInput(`${joiner} expects (col.op.value,…)`, joiner) };
485
+ }
486
+ const parts: SqlOp[] = [];
487
+ for (const term of inner) {
488
+ const op = groupedFilterTerm(tableColumns, term, joiner);
489
+ if ("failure" in op) return { ok: false, failure: op.failure };
490
+ parts.push(op);
491
+ }
492
+ wheres.push(parts.length === 1 ? parts[0] : groupOp(joiner, parts));
493
+ }
494
+ }
495
+
496
+ // order
497
+ let orders: unknown[] | undefined;
498
+ const rawOrder = str("order");
499
+ if (rawOrder !== undefined) {
500
+ if (config.order === "none") {
501
+ return { ok: false, failure: badInput("order is not enabled for this resource", "order") };
502
+ }
503
+ orders = [];
504
+ for (const term of rawOrder.split(",")) {
505
+ const [key, dir] = term.split(".");
506
+ if (!key || (dir !== undefined && dir !== "asc" && dir !== "desc")) {
507
+ return { ok: false, failure: badInput(`bad order term "${term}"`, "order") };
508
+ }
509
+ const col = tableColumns[key];
510
+ const resolved = columns.find((c) => c.key === key);
511
+ if (col === undefined || resolved === undefined || !orderAllowed.has(key)) {
512
+ return {
513
+ ok: false,
514
+ failure: badInput(`unknown or unorderable column "${key}"`, "order"),
515
+ };
516
+ }
517
+ orders.push(dir === "asc" ? ascOp(col) : descOp(col));
518
+ }
519
+ meta.order = rawOrder;
520
+ } else if (resolvedCursor.length > 0) {
521
+ orders = resolvedCursor.map((c) => {
522
+ const col = tableColumns[c.key];
523
+ return direction === "asc" ? ascOp(col) : descOp(col);
524
+ });
525
+ }
526
+
527
+ // select projection
528
+ let select: ResolvedColumn[] | undefined;
529
+ const rawSelect = str("select");
530
+ if (rawSelect !== undefined) {
531
+ if (config.select !== "all") {
532
+ return {
533
+ ok: false,
534
+ failure: badInput("select is not enabled for this resource", "select"),
535
+ };
536
+ }
537
+ select = [];
538
+ for (const key of rawSelect.split(",")) {
539
+ const resolved = columns.find((c) => c.key === key);
540
+ if (resolved === undefined) {
541
+ return { ok: false, failure: badInput(`unknown column "${key}"`, "select") };
542
+ }
543
+ select.push(resolved);
544
+ }
545
+ }
546
+
547
+ // pagination
548
+ let offset: number | undefined;
549
+ let after: unknown;
550
+ if (mode === "offset") {
551
+ const rawOffset = str("offset");
552
+ if (rawOffset !== undefined) {
553
+ const n = Number(rawOffset);
554
+ if (!Number.isInteger(n) || n < 0) {
555
+ return {
556
+ ok: false,
557
+ failure: badInput("offset must be a non-negative integer", "offset"),
558
+ };
559
+ }
560
+ offset = n;
561
+ }
562
+ } else {
563
+ const rawCursor = str("cursor");
564
+ if (rawCursor !== undefined) {
565
+ if (resolvedCursor.length === 0) {
566
+ return { ok: false, failure: badInput("cursor pagination is not configured", "cursor") };
567
+ }
568
+ const values = decodeCursor(rawCursor, resolvedCursor.length);
569
+ if (values === null) {
570
+ return { ok: false, failure: badInput("invalid cursor", "cursor") };
571
+ }
572
+ after = keysetAfter(
573
+ resolvedCursor.map((c) => tableColumns[c.key]),
574
+ values,
575
+ direction === "desc",
576
+ );
577
+ meta.cursor = rawCursor;
578
+ }
579
+ }
580
+
581
+ const where =
582
+ wheres.length === 0
583
+ ? undefined
584
+ : wheres.length === 1
585
+ ? wheres[0]
586
+ : groupOp("and", wheres as SqlOp[]);
587
+ return {
588
+ ok: true,
589
+ page: {
590
+ where,
591
+ orderBy: orders,
592
+ limit: pageLimit + (mode === "cursor" ? 1 : 0),
593
+ offset,
594
+ after,
595
+ },
596
+ meta: { mode, limit: pageLimit, ...meta },
597
+ ...(select !== undefined ? { select } : {}),
598
+ };
599
+ }
600
+
601
+ /** Run the list query and shape rows + meta. */
602
+ async function runList(
603
+ input: unknown,
604
+ fx: Fx,
605
+ ): Promise<{ data: SqlRow[]; meta: Record<string, unknown> } | { failure: unknown }> {
606
+ const parsed = parseList(input);
607
+ if (!parsed.ok) return { failure: parsed.failure };
608
+ const store = fx.store(db) as {
609
+ page(t: unknown, o: SqlPageOptions): Promise<SqlRow[]>;
610
+ count(t: unknown, w?: unknown): Promise<number>;
611
+ select(columns?: unknown): {
612
+ from(t: unknown): {
613
+ where(w: unknown): {
614
+ orderBy(...o: unknown[]): { limit(n: number): Promise<SqlRow[]> };
615
+ };
616
+ };
617
+ };
618
+ };
619
+ const rows = await store.page(table, parsed.page);
620
+ const meta: Record<string, unknown> = { ...parsed.meta };
621
+
622
+ let data: SqlRow[] = rows;
623
+ if (mode === "cursor") {
624
+ const pageSize = Number(parsed.meta.limit);
625
+ const hasNextPage = rows.length > pageSize;
626
+ const pageRows = hasNextPage ? rows.slice(0, pageSize) : rows;
627
+ const last = hasNextPage && pageRows.length > 0 ? pageRows[pageRows.length - 1] : undefined;
628
+ meta.hasNextPage = hasNextPage;
629
+ meta.nextCursor =
630
+ last === undefined
631
+ ? null
632
+ : encodeCursor(resolvedCursor.map((c) => last[c.key] ?? last[c.sqlName]));
633
+ data = pageRows;
634
+ } else if (countMode === "exact") {
635
+ meta.total = await store.count(table, parsed.page.where);
636
+ meta.offset = parsed.page.offset ?? 0;
637
+ } else {
638
+ meta.offset = parsed.page.offset ?? 0;
639
+ }
640
+
641
+ if (parsed.select !== undefined) {
642
+ const keep = new Set(parsed.select.map((c) => c.key));
643
+ data = data.map((row) => {
644
+ const out: SqlRow = {};
645
+ for (const key of Object.keys(row)) if (keep.has(key)) out[key] = row[key];
646
+ return out;
647
+ });
648
+ }
649
+ return { data, meta };
650
+ }
651
+
652
+ const listFlow = flow({
653
+ name: "list",
654
+ unit,
655
+ ...(breaking ? { breaking: true as const } : {}),
656
+ // Loose record so the HTTP AoT infers `query` and lets every list URL
657
+ // key through; real validation happens in parseList (PostgREST grammar).
658
+ in: z.record(z.string(), z.unknown()) as never,
659
+ effects: { reads: [db.ref] },
660
+ do: async (input, fx) => {
661
+ const result = await runList(input, fx);
662
+ if ("failure" in result) return result.failure;
663
+ return fx.json.with(result.data, result.meta);
664
+ },
665
+ });
666
+
667
+ const createFlow = flow({
668
+ name: "create",
669
+ unit,
670
+ ...(breaking ? { breaking: true as const } : {}),
671
+ in: options.in as never,
672
+ effects: { writes: [db.ref] },
673
+ do: async (input, fx) => {
674
+ const store = fx.store(db) as {
675
+ insert(t: unknown): { values(row: SqlRow): { returning(): Promise<SqlRow[]> } };
676
+ };
677
+ const [row] = await store
678
+ .insert(table)
679
+ .values(input as SqlRow)
680
+ .returning();
681
+ return fx.json.create(row);
682
+ },
683
+ });
684
+
685
+ const getFlow = flow({
686
+ name: "get",
687
+ unit,
688
+ ...(breaking ? { breaking: true as const } : {}),
689
+ errors,
690
+ effects: { reads: [db.ref] },
691
+ do: async (input, fx) => {
692
+ const id = (input as Record<string, unknown>)[idKey];
693
+ const store = fx.store(db) as {
694
+ findById(t: unknown, id: string): Promise<SqlRow | null>;
695
+ };
696
+ const row = await store.findById(table, String(id));
697
+ if (!row) return fail("NotFound", {});
698
+ return row;
699
+ },
700
+ });
701
+
702
+ // Wire update body is `{ id, ...patch }`. The patch schema (`update`,
703
+ // default `in`) describes the mutable fields; the path id rides along and
704
+ // must survive validation, so extend the ZodObject with the id key.
705
+ const patchSchema: unknown = options.update ?? options.in;
706
+ const updateIn =
707
+ options.idSchema !== undefined
708
+ ? options.idSchema
709
+ : patchSchema instanceof z.ZodObject
710
+ ? patchSchema.extend({ [idKey]: z.string() })
711
+ : patchSchema;
712
+ const updateFlow = flow({
713
+ name: "update",
714
+ unit,
715
+ ...(breaking ? { breaking: true as const } : {}),
716
+ in: updateIn as never,
717
+ errors,
718
+ effects: { reads: [db.ref], writes: [db.ref] },
719
+ do: async (input, fx) => {
720
+ const { [idKey]: id, ...patch } = input as Record<string, unknown>;
721
+ const store = fx.store(db) as {
722
+ findById(t: unknown, id: string): Promise<SqlRow | null>;
723
+ update(t: unknown): { set(row: SqlRow): { where(w: unknown): Promise<number> } };
724
+ };
725
+ const existing = await store.findById(table, String(id));
726
+ if (!existing) return fail("NotFound", {});
727
+ if (Object.keys(patch).length > 0) {
728
+ await store
729
+ .update(table)
730
+ .set(patch as SqlRow)
731
+ .where(leafOp(idDrizzleCol, "=", String(id)));
732
+ }
733
+ const row = await store.findById(table, String(id));
734
+ if (!row) return fail("NotFound", {});
735
+ return row;
736
+ },
737
+ });
738
+
739
+ const removeFlow = flow({
740
+ name: "remove",
741
+ unit,
742
+ ...(breaking ? { breaking: true as const } : {}),
743
+ errors,
744
+ effects: { writes: [db.ref] },
745
+ do: async (input, fx) => {
746
+ const id = (input as Record<string, unknown>)[idKey];
747
+ const store = fx.store(db) as {
748
+ delete(t: unknown, id: string): Promise<boolean>;
749
+ };
750
+ const deleted = await store.delete(table, String(id));
751
+ if (!deleted) return fail("NotFound", {});
752
+ return fx.json.empty();
753
+ },
754
+ });
755
+
756
+ const defs: ResourceFlowDefs = {
757
+ list: listFlow as FlowDef<any, any, any>,
758
+ create: createFlow as FlowDef<any, any, any>,
759
+ get: getFlow as FlowDef<any, any, any>,
760
+ update: updateFlow as FlowDef<any, any, any>,
761
+ remove: removeFlow as FlowDef<any, any, any>,
762
+ };
763
+
764
+ return {
765
+ ...defs,
766
+ table: tableName,
767
+ idKey,
768
+ limit,
769
+ maxLimit,
770
+ listConfig: config,
771
+ columns: tableColumns,
772
+ sqlNameOf(key) {
773
+ return columns.find((c) => c.key === key)?.sqlName;
774
+ },
775
+ all: () => defs,
776
+ page(input) {
777
+ const parsed = parseList(input);
778
+ if (!parsed.ok) {
779
+ throw new TypeError(
780
+ `resource.page: invalid list input — ${JSON.stringify(parsed.failure)}`,
781
+ );
782
+ }
783
+ return { ...parsed.page, meta: parsed.meta };
784
+ },
785
+ };
786
+ }