okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -7,32 +7,37 @@
7
7
  * binding stays in `on(http.resource(path, resource.all()))` — the factory
8
8
  * itself registers no triggers.
9
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.null|not_null|true|false` ·
13
- * `?col=not.eq.x` · `?or=(…)` / `?and=(…)` (nested `not.and` / `not.or`) ·
14
- * `?order=col.desc,…` · `?select=id,title`.
15
- * Every surface is whitelisted by a ColumnScope (`"all" | Column[] | "none"`).
10
+ * List URL grammar and parsing live in `list-query.ts` (shared with live
11
+ * query windows and hand-written flows); this factory resolves its
12
+ * declarative scopes and wires the parsed page into `fx.store(db)`.
16
13
  */
17
14
 
18
- import { flow, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
15
+ import { flow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "../../kernel/flow.ts";
19
16
  import { fail } from "../../kernel/errors.ts";
20
17
  import type { Fx } from "../../kernel/fx.ts";
21
- import { validationFailure } from "../../validation/standard-schema.ts";
18
+ import {
19
+ openLiveStream,
20
+ realtimeBridgeRuntime,
21
+ type LiveColumnKind,
22
+ } from "../../kernel/realtime-bind.ts";
23
+ import { compileWhere } from "./sql-condition.ts";
22
24
  import { z } from "zod";
23
25
  import type { SqlStoreDecl } from "./declare.ts";
24
26
  import type { SqlRow } from "../../drivers/types.ts";
25
- import { resolveColumns, resolveTableName, type ResolvedColumn } from "./table.ts";
27
+ import { resolveColumns, resolveTableName } from "./table.ts";
28
+ import { registerPendingResourceLive } from "../../kernel/resource-live.ts";
26
29
  import type { SqlPageOptions } from "./sql-session.ts";
27
-
28
- /** Whitelist for one list concern (`search` / `filter` / `order` / `select`). */
29
- export type ColumnScope = "all" | readonly unknown[] | "none";
30
-
31
- /** Pagination mode for a resource list. */
32
- export type ListPageMode = "cursor" | "offset";
33
-
34
- /** Offset-mode `COUNT(*)` policy. */
35
- export type ListCountMode = "exact" | "none";
30
+ import {
31
+ encodeCursor,
32
+ encodeOffsetCursor,
33
+ leafOp,
34
+ parseListQuery,
35
+ resolveListScope,
36
+ type ResolvedListConfig,
37
+ } from "./list-query.ts";
38
+
39
+ export type { ColumnScope, ListCountMode, ListPageMode } from "./list-query.ts";
40
+ export type { ResolvedListConfig } from "./list-query.ts";
36
41
 
37
42
  /** Options for {@link resource}. */
38
43
  export interface ResourceOptions {
@@ -59,12 +64,19 @@ export interface ResourceOptions {
59
64
  * onto `store.resource`.
60
65
  */
61
66
  readonly breaking?: boolean;
67
+ /**
68
+ * Enable the live query stack: compiler synthesizes an internal Signal
69
+ * (`oke/live/sql:<table>`), wires CDC fan-out, and mounts
70
+ * `GET <path>/live` when used with `http.resource`. Requires RLS-capable
71
+ * SQL drivers (postgres / pglite).
72
+ */
73
+ readonly live?: boolean;
62
74
  }
63
75
 
64
76
  /** List options on {@link ResourceOptions}. */
65
77
  export interface ResourceListOptions {
66
78
  /** Pagination; default `"cursor"` when `cursor` columns are set. */
67
- readonly mode?: ListPageMode;
79
+ readonly mode?: import("./list-query.ts").ListPageMode;
68
80
  /** Keyset columns (cursor mode). */
69
81
  readonly cursor?: readonly unknown[];
70
82
  /** Default sort when no `?order=` (default `"desc"`). */
@@ -74,15 +86,15 @@ export interface ResourceListOptions {
74
86
  /** Hard cap on `?limit=` (default 100). */
75
87
  readonly maxLimit?: number;
76
88
  /** Offset-only `COUNT(*)` (default `"exact"`). */
77
- readonly count?: ListCountMode;
89
+ readonly count?: import("./list-query.ts").ListCountMode;
78
90
  /** Substring search columns (`?search=` / `?q=`). Default `"none"`. */
79
- readonly search?: ColumnScope;
91
+ readonly search?: import("./list-query.ts").ColumnScope;
80
92
  /** Filter grammar columns (`?col=op.value`). Default `"none"`. */
81
- readonly filter?: ColumnScope;
93
+ readonly filter?: import("./list-query.ts").ColumnScope;
82
94
  /** `?order=` columns. Default: cursor columns, else `"all"`. */
83
- readonly order?: ColumnScope;
95
+ readonly order?: import("./list-query.ts").ColumnScope;
84
96
  /** `?select=` projection columns (runtime only). Default `"all"`. */
85
- readonly select?: ColumnScope;
97
+ readonly select?: import("./list-query.ts").ColumnScope;
86
98
  }
87
99
 
88
100
  /** One CRUD op bundle returned by {@link resource}. */
@@ -92,6 +104,8 @@ export interface ResourceFlowDefs {
92
104
  readonly get: FlowDef<any, any, any>;
93
105
  readonly update: FlowDef<any, any, any>;
94
106
  readonly remove: FlowDef<any, any, any>;
107
+ /** Live surface when `live: true` — mounted by `http.resource(…, …all())`. */
108
+ readonly live?: ResourceLiveSurface;
95
109
  }
96
110
 
97
111
  /** Column lookup for {@link ResourceDef.page}. */
@@ -102,6 +116,22 @@ export interface ResourceColumns {
102
116
  readonly sqlNameOf: (key: string) => string | undefined;
103
117
  }
104
118
 
119
+ /**
120
+ * Live query surface synthesized for `store.resource(…, { live: true })`.
121
+ *
122
+ * The signal carries classified {@link LiveQueryEvent}-shaped payloads;
123
+ * `flow` is a `delivery: "live"` SSE consumer (`fx.live` physics) that the
124
+ * app mounts on `GET <resource>/live`. The realtime bridge publishes to the
125
+ * same signal name, so transport, retention, and Last-Event-ID resume are
126
+ * the already-proven Signal stack.
127
+ */
128
+ export interface ResourceLiveSurface {
129
+ /** Internal signal name — `oke/live/sql:<table>`. */
130
+ readonly signal: string;
131
+ /** Default subscribe path — `<mount path>/live` is derived at mount. */
132
+ readonly flow: AnyFlowDef;
133
+ }
134
+
105
135
  /** A resource factory result — FlowDefs plus introspection for `page`. */
106
136
  export interface ResourceDef extends ResourceColumns {
107
137
  readonly list: FlowDef<any, any, any>;
@@ -119,398 +149,17 @@ export interface ResourceDef extends ResourceColumns {
119
149
  readonly maxLimit: number;
120
150
  /** Resolved list config (defaults applied). */
121
151
  readonly listConfig: ResolvedListConfig;
152
+ /**
153
+ * Live query surface when `live: true` — signal name + SSE Flow to mount.
154
+ * Absent otherwise.
155
+ */
156
+ readonly live?: ResourceLiveSurface;
122
157
  /** All five ops for `http.resource(path, resource.all())`. */
123
158
  all(): ResourceFlowDefs;
124
159
  /** Input for {@link SqlStoreHandle.page} from validated list input. */
125
160
  page(input: unknown): SqlPageOptions & { readonly meta: Record<string, unknown> };
126
161
  }
127
162
 
128
- /** List config after defaults are applied. */
129
- export interface ResolvedListConfig {
130
- readonly mode: ListPageMode;
131
- readonly cursor: readonly ResolvedColumn[];
132
- readonly direction: "asc" | "desc";
133
- readonly limit: number;
134
- readonly maxLimit: number;
135
- readonly count: ListCountMode;
136
- readonly search: readonly ResolvedColumn[];
137
- readonly filter: readonly ResolvedColumn[] | "none";
138
- readonly order: readonly ResolvedColumn[] | "none";
139
- readonly select: readonly ResolvedColumn[] | "all";
140
- }
141
-
142
- /* ———————————————————————————— drizzle-op builders (no drizzle import) ——— */
143
-
144
- /** Build a Drizzle-shaped `SQL` leaf/group without importing `drizzle-orm`. */
145
- interface SqlOp {
146
- readonly queryChunks: readonly unknown[];
147
- }
148
- function strChunk(text: string): unknown {
149
- return { constructor: { name: "StringChunk" }, value: [text] };
150
- }
151
- function paramChunk(value: unknown): unknown {
152
- return { value };
153
- }
154
- function leafOp(column: unknown, op: string, value: unknown): SqlOp {
155
- if (op === "is null" || op === "is not null") {
156
- return { queryChunks: [column, strChunk(` ${op}`)] };
157
- }
158
- if (op === "in") {
159
- const values = (value as readonly unknown[]).map(paramChunk);
160
- return { queryChunks: [column, strChunk(" in "), values] };
161
- }
162
- return { queryChunks: [column, strChunk(` ${op} `), paramChunk(value)] };
163
- }
164
- function groupOp(joiner: "and" | "or", parts: readonly SqlOp[]): SqlOp {
165
- const chunks: unknown[] = [];
166
- parts.forEach((part, i) => {
167
- if (i > 0) chunks.push(strChunk(` ${joiner} `));
168
- chunks.push(part);
169
- });
170
- return { queryChunks: chunks };
171
- }
172
- function ascOp(column: unknown): SqlOp {
173
- return { queryChunks: [column, strChunk(" asc")] };
174
- }
175
- function descOp(column: unknown): SqlOp {
176
- return { queryChunks: [column, strChunk(" desc")] };
177
- }
178
-
179
- /** Recursive keyset predicate for "rows strictly after the cursor point". */
180
- function keysetAfter(
181
- columns: readonly unknown[],
182
- values: readonly unknown[],
183
- descending: boolean,
184
- ): SqlOp | undefined {
185
- const cmp = descending ? "<" : ">";
186
- const build = (i: number): SqlOp | undefined => {
187
- if (i >= columns.length) return undefined;
188
- const col = columns[i]!;
189
- const rest = build(i + 1);
190
- const tail =
191
- rest === undefined ? undefined : groupOp("and", [leafOp(col, "=", values[i]), rest]);
192
- return tail === undefined
193
- ? leafOp(col, cmp, values[i])
194
- : groupOp("or", [leafOp(col, cmp, values[i]), tail]);
195
- };
196
- return build(0);
197
- }
198
-
199
- /* —————————————————————————————— cursor codec —————————————————————————— */
200
-
201
- /** Keyset token direction — `?cursor=` carries either side. */
202
- type CursorDir = "after" | "before";
203
-
204
- function encodeOffsetCursor(offset: number): string {
205
- return btoa(JSON.stringify({ k: "off", o: offset }));
206
- }
207
-
208
- function decodeOffsetCursor(raw: string): number | null {
209
- try {
210
- const value: unknown = JSON.parse(atob(raw));
211
- if (
212
- value !== null &&
213
- typeof value === "object" &&
214
- "k" in value &&
215
- value.k === "off" &&
216
- "o" in value &&
217
- typeof value.o === "number" &&
218
- Number.isInteger(value.o) &&
219
- value.o >= 0
220
- ) {
221
- return value.o;
222
- }
223
- return null;
224
- } catch {
225
- return null;
226
- }
227
- }
228
-
229
- function encodeCursor(values: readonly unknown[], dir: CursorDir): string {
230
- return btoa(JSON.stringify({ v: values, d: dir }));
231
- }
232
- function decodeCursor(
233
- raw: string,
234
- arity: number,
235
- ): { readonly values: readonly unknown[]; readonly dir: CursorDir } | null {
236
- try {
237
- const value: unknown = JSON.parse(atob(raw));
238
- if (Array.isArray(value) && value.length === arity) {
239
- return { values: value, dir: "after" };
240
- }
241
- if (
242
- value !== null &&
243
- typeof value === "object" &&
244
- "v" in value &&
245
- "d" in value &&
246
- Array.isArray(value.v) &&
247
- value.v.length === arity &&
248
- (value.d === "after" || value.d === "before")
249
- ) {
250
- return { values: value.v as unknown[], dir: value.d };
251
- }
252
- return null;
253
- } catch {
254
- return null;
255
- }
256
- }
257
-
258
- function flipOrder(op: SqlOp): SqlOp {
259
- return {
260
- queryChunks: op.queryChunks.map((chunk) => {
261
- if (
262
- chunk !== null &&
263
- typeof chunk === "object" &&
264
- "value" in chunk &&
265
- Array.isArray((chunk as { value: unknown }).value)
266
- ) {
267
- const text = (chunk as { value: string[] }).value[0];
268
- if (text === " asc") return strChunk(" desc");
269
- if (text === " desc") return strChunk(" asc");
270
- }
271
- return chunk;
272
- }),
273
- };
274
- }
275
-
276
- /* —————————————————————————————— URL parsing ——————————————————————————— */
277
-
278
- const RESERVED_PARAMS = new Set([
279
- "cursor",
280
- "limit",
281
- "offset",
282
- "search",
283
- "q",
284
- "order",
285
- "select",
286
- "or",
287
- "and",
288
- ]);
289
-
290
- const FILTER_OPS = new Set(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]);
291
-
292
- function badInput(message: string, path: string) {
293
- return validationFailure([{ message, path: [path] }]);
294
- }
295
-
296
- function parseGroupLead(
297
- t: string,
298
- ): { readonly negated: boolean; readonly joiner: "and" | "or"; readonly body: string } | undefined {
299
- if (t.startsWith("not.and(")) return { negated: true, joiner: "and", body: t.slice(7) };
300
- if (t.startsWith("not.or(")) return { negated: true, joiner: "or", body: t.slice(6) };
301
- if (t.startsWith("and(")) return { negated: false, joiner: "and", body: t.slice(3) };
302
- if (t.startsWith("or(")) return { negated: false, joiner: "or", body: t.slice(2) };
303
- return undefined;
304
- }
305
-
306
- function negateTermString(term: string): string {
307
- const t = term.trim();
308
- if (t.startsWith("not.and(")) return `and${t.slice(7)}`;
309
- if (t.startsWith("not.or(")) return `or${t.slice(6)}`;
310
- if (t.startsWith("and(")) return `not.and${t.slice(3)}`;
311
- if (t.startsWith("or(")) return `not.or${t.slice(2)}`;
312
- const dot = t.indexOf(".");
313
- if (dot <= 0) return t;
314
- return `${t.slice(0, dot)}.${invertOpString(t.slice(dot + 1))}`;
315
- }
316
-
317
- /** Invert a PostgREST op string (`not.eq.x` → `neq.x`) so SQL stays a leaf. */
318
- function invertOpString(raw: string): string {
319
- let rest = raw.trim();
320
- if (rest.startsWith("not.")) return rest.slice(4);
321
- const dot = rest.indexOf(".");
322
- if (dot <= 0) return raw;
323
- const op = rest.slice(0, dot);
324
- const value = rest.slice(dot + 1);
325
- if (op === "eq") return `neq.${value}`;
326
- if (op === "neq") return `eq.${value}`;
327
- if (op === "gt") return `lte.${value}`;
328
- if (op === "gte") return `lt.${value}`;
329
- if (op === "lt") return `gte.${value}`;
330
- if (op === "lte") return `gt.${value}`;
331
- if (op === "is" && (value === "null" || value === "unknown")) return "is.not_null";
332
- if (op === "is" && value === "not_null") return "is.null";
333
- if (op === "is" && value === "true") return "is.false";
334
- if (op === "is" && value === "false") return "is.true";
335
- return raw;
336
- }
337
-
338
- function splitTopLevel(inner: string): string[] {
339
- const parts: string[] = [];
340
- let buf = "";
341
- let depth = 0;
342
- let quote = false;
343
- for (const ch of inner) {
344
- if (quote) {
345
- if (ch === '"') quote = false;
346
- buf += ch;
347
- continue;
348
- }
349
- if (ch === '"') {
350
- quote = true;
351
- buf += ch;
352
- continue;
353
- }
354
- if (ch === "(") {
355
- depth += 1;
356
- buf += ch;
357
- continue;
358
- }
359
- if (ch === ")") {
360
- depth -= 1;
361
- buf += ch;
362
- continue;
363
- }
364
- if (ch === "," && depth === 0) {
365
- parts.push(buf.trim());
366
- buf = "";
367
- continue;
368
- }
369
- buf += ch;
370
- }
371
- if (buf.trim().length > 0) parts.push(buf.trim());
372
- return parts;
373
- }
374
-
375
- function unquote(raw: string): string {
376
- const t = raw.trim();
377
- if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1);
378
- return t;
379
- }
380
-
381
- /** Parse `eq.x` / `not.eq.x` / `in.(a,"b,c")` / `is.null` into a drizzle op. */
382
- function filterOp(
383
- column: unknown,
384
- key: string,
385
- raw: string,
386
- ): SqlOp | { failure: ReturnType<typeof badInput> } {
387
- let rest = raw.trim();
388
- if (rest.startsWith("not.")) rest = invertOpString(rest);
389
- const dot = rest.indexOf(".");
390
- if (dot <= 0) return { failure: badInput(`expected "op.value" (e.g. ${key}=eq.x)`, key) };
391
- const op = rest.slice(0, dot);
392
- const value = rest.slice(dot + 1);
393
- if (!FILTER_OPS.has(op)) {
394
- return { failure: badInput(`unsupported filter op "${op}"`, key) };
395
- }
396
- let leaf: SqlOp | { failure: ReturnType<typeof badInput> };
397
- switch (op) {
398
- case "eq":
399
- leaf = leafOp(column, "=", unquote(value));
400
- break;
401
- case "neq":
402
- leaf = leafOp(column, "!=", unquote(value));
403
- break;
404
- case "gt":
405
- leaf = leafOp(column, ">", unquote(value));
406
- break;
407
- case "gte":
408
- leaf = leafOp(column, ">=", unquote(value));
409
- break;
410
- case "lt":
411
- leaf = leafOp(column, "<", unquote(value));
412
- break;
413
- case "lte":
414
- leaf = leafOp(column, "<=", unquote(value));
415
- break;
416
- case "like":
417
- leaf = leafOp(column, "like", unquote(value).replaceAll("*", "%"));
418
- break;
419
- case "ilike":
420
- leaf = leafOp(column, "ilike", unquote(value).replaceAll("*", "%"));
421
- break;
422
- case "is": {
423
- if (value === "null" || value === "unknown") leaf = leafOp(column, "is null", undefined);
424
- else if (value === "not_null") leaf = leafOp(column, "is not null", undefined);
425
- else if (value === "true") leaf = leafOp(column, "=", true);
426
- else if (value === "false") leaf = leafOp(column, "=", false);
427
- else leaf = { failure: badInput(`is expects null|not_null|true|false|unknown`, key) };
428
- break;
429
- }
430
- case "in": {
431
- const match = /^\((.*)\)$/.exec(value);
432
- if (!match) {
433
- leaf = { failure: badInput(`in expects (a,b,c)`, key) };
434
- break;
435
- }
436
- const inner = match[1]!.trim();
437
- const values = inner.length === 0 ? [] : splitTopLevel(inner).map(unquote);
438
- if (values.length === 0) {
439
- leaf = { failure: badInput(`in expects at least one value`, key) };
440
- break;
441
- }
442
- leaf = leafOp(column, "in", values);
443
- break;
444
- }
445
- default:
446
- leaf = { failure: badInput(`unsupported filter op "${op}"`, key) };
447
- }
448
- return leaf;
449
- }
450
-
451
- /** Split `or=(a.ilike.*x*,b.eq.1)` inner list on commas (nested-safe). */
452
- function groupInner(raw: string): readonly string[] | null {
453
- const match = /^\((.*)\)$/.exec(raw.trim());
454
- if (!match) return null;
455
- const inner = match[1]!.trim();
456
- return inner.length === 0 ? [] : splitTopLevel(inner);
457
- }
458
-
459
- /** One `col.op.value` or nested `not.and(…)` term inside `or=` / `and=`. */
460
- function groupedFilterTerm(
461
- tableColumns: Readonly<Record<string, unknown>>,
462
- term: string,
463
- joiner: "or" | "and",
464
- ): SqlOp | { failure: ReturnType<typeof badInput> } {
465
- const t = term.trim();
466
- const group = parseGroupLead(t);
467
- if (group) {
468
- const inner = groupInner(group.body);
469
- if (inner === null || inner.length === 0) {
470
- return { failure: badInput(`${group.joiner} expects (col.op.value,…)`, joiner) };
471
- }
472
- const innerJoiner = group.negated ? (group.joiner === "and" ? "or" : "and") : group.joiner;
473
- const parts: SqlOp[] = [];
474
- for (const piece of inner) {
475
- const next = group.negated ? negateTermString(piece) : piece;
476
- const op = groupedFilterTerm(tableColumns, next, innerJoiner);
477
- if ("failure" in op) return op;
478
- parts.push(op);
479
- }
480
- return parts.length === 1 ? parts[0]! : groupOp(innerJoiner, parts);
481
- }
482
- const dot = t.indexOf(".");
483
- if (dot <= 0) {
484
- return { failure: badInput(`expected "col.op.value" inside ${joiner}=(…)`, joiner) };
485
- }
486
- const col = tableColumns[t.slice(0, dot)];
487
- if (col === undefined) {
488
- return { failure: badInput(`unknown column "${t.slice(0, dot)}"`, joiner) };
489
- }
490
- return filterOp(col, joiner, t.slice(dot + 1));
491
- }
492
-
493
- /* —————————————————————————————— the factory ——————————————————————————— */
494
-
495
- function scopeColumns(
496
- scope: ColumnScope,
497
- columns: readonly ResolvedColumn[],
498
- tableColumns: Readonly<Record<string, unknown>>,
499
- ): readonly ResolvedColumn[] | "none" | "all" {
500
- if (scope === "none") return "none";
501
- if (scope === "all") return columns;
502
- const allowed = new Set(
503
- scope
504
- .map((c) =>
505
- columns.find(
506
- (col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
507
- ),
508
- )
509
- .filter((c): c is ResolvedColumn => c !== undefined),
510
- );
511
- return columns.filter((c) => allowed.has(c));
512
- }
513
-
514
163
  /**
515
164
  * Build a CRUD + list resource over a sql table. Returns FlowDefs plus
516
165
  * `.all()` for `on(http.resource(path, resource.all()))`.
@@ -536,285 +185,24 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
536
185
  const idKey = idColumn?.key ?? "id";
537
186
  const idDrizzleCol = idColumn !== undefined ? tableColumns[idColumn.key] : tableColumns.id;
538
187
 
539
- const list = options.list ?? {};
540
- const cursorCols = list.cursor ?? [];
541
- const resolvedCursor = cursorCols
542
- .map((c) =>
543
- columns.find(
544
- (col) => tableColumns[col.key] === c || col.sqlName === (c as { name?: string }).name,
545
- ),
546
- )
547
- .filter((c): c is ResolvedColumn => c !== undefined);
548
- const mode: ListPageMode = list.mode ?? (resolvedCursor.length > 0 ? "cursor" : "offset");
549
- const direction = list.direction ?? "desc";
550
- const limit = list.limit ?? 20;
551
- const maxLimit = list.maxLimit ?? 100;
552
- const countMode = list.count ?? "exact";
553
-
554
- const searchCols = scopeColumns(list.search ?? "none", columns, tableColumns);
555
- const filterCols = scopeColumns(list.filter ?? "none", columns, tableColumns);
556
- const orderScope =
557
- list.order ?? (resolvedCursor.length > 0 ? (cursorCols as readonly unknown[]) : "all");
558
- const orderCols = scopeColumns(orderScope, columns, tableColumns);
559
- const selectScope = list.select ?? "all";
560
-
561
- const filterAllowed = new Set(
562
- filterCols === "none" || filterCols === "all" ? [] : filterCols.map((c) => c.key),
563
- );
564
- const orderAllowed = new Set(
565
- orderCols === "none" || orderCols === "all" ? [] : orderCols.map((c) => c.key),
566
- );
567
- const searchList = searchCols === "none" || searchCols === "all" ? [] : searchCols;
568
-
569
- const config: ResolvedListConfig = {
570
- mode,
571
- cursor: resolvedCursor,
572
- direction,
573
- limit,
574
- maxLimit,
575
- count: countMode,
576
- search: searchCols === "none" || searchCols === "all" ? [] : searchCols,
577
- filter: filterCols === "none" ? "none" : filterCols === "all" ? columns : filterCols,
578
- order: orderCols === "none" ? "none" : orderCols === "all" ? columns : orderCols,
579
- select:
580
- selectScope === "all"
581
- ? "all"
582
- : (scopeColumns(selectScope, columns, tableColumns) as readonly ResolvedColumn[]),
583
- };
188
+ const scope = resolveListScope(table, options.list);
189
+ const config = scope.listConfig;
190
+ const { mode, limit, maxLimit, cursorColumns: resolvedCursor } = scope;
191
+ const countMode = scope.count;
584
192
 
585
193
  const errors = options.errors ?? ({ NotFound: {} as never } as FlowErrorMap);
586
194
  const breaking = options.breaking === true;
587
195
 
588
- /** Parse validated list input → page options or a validation failure. */
589
- function parseList(input: unknown):
590
- | {
591
- ok: true;
592
- page: SqlPageOptions;
593
- meta: Record<string, unknown>;
594
- select?: readonly ResolvedColumn[];
595
- cursorDir?: CursorDir;
596
- }
597
- | { ok: false; failure: unknown } {
598
- const query = (input ?? {}) as Record<string, unknown>;
599
- const str = (k: string): string | undefined => {
600
- const v = query[k];
601
- if (typeof v === "string" && v.length > 0) return v;
602
- if (typeof v === "number" && Number.isFinite(v)) return String(v);
603
- return undefined;
604
- };
605
-
606
- // limit
607
- let pageLimit = limit;
608
- const rawLimit = str("limit");
609
- if (rawLimit !== undefined) {
610
- const n = Number(rawLimit);
611
- if (!Number.isInteger(n) || n < 1) {
612
- return { ok: false, failure: badInput("limit must be a positive integer", "limit") };
613
- }
614
- pageLimit = Math.min(n, maxLimit);
615
- }
616
-
617
- const wheres: unknown[] = [];
618
- let meta: Record<string, unknown> = {};
619
-
620
- // search
621
- const search = str("search");
622
- const q = str("q");
623
- if (search !== undefined && q !== undefined) {
624
- return { ok: false, failure: badInput("use either search or q, not both", "search") };
625
- }
626
- const searchTerm = search ?? q;
627
- if (searchTerm !== undefined) {
628
- if (searchList.length === 0) {
629
- return {
630
- ok: false,
631
- failure: badInput("search is not enabled for this resource", "search"),
632
- };
633
- }
634
- const pattern = `%${searchTerm}%`;
635
- const parts = searchList
636
- .map((c) => tableColumns[c.key])
637
- .filter((c): c is unknown => c !== undefined)
638
- .map((c) => leafOp(c, "like", pattern) as SqlOp);
639
- wheres.push(parts.length === 1 ? parts[0] : groupOp("or", parts));
640
- meta.search = searchTerm;
641
- }
642
-
643
- // column filters
644
- if (config.filter === "none") {
645
- for (const key of Object.keys(query)) {
646
- if (!RESERVED_PARAMS.has(key) && key !== "headers" && key !== "cookie") {
647
- return { ok: false, failure: badInput(`unknown list param "${key}"`, key) };
648
- }
649
- }
650
- } else {
651
- for (const [key, value] of Object.entries(query)) {
652
- if (RESERVED_PARAMS.has(key) || key === "headers" || key === "cookie") continue;
653
- if (typeof value !== "string") continue;
654
- const col = tableColumns[key];
655
- const resolved = columns.find((c) => c.key === key);
656
- if (col === undefined || resolved === undefined || !filterAllowed.has(key)) {
657
- return { ok: false, failure: badInput(`unknown or unfilterable column "${key}"`, key) };
658
- }
659
- const op = filterOp(col, key, value);
660
- if ("failure" in op) return { ok: false, failure: op.failure };
661
- wheres.push(op);
662
- }
663
- // or= / and= groups (one level)
664
- for (const joiner of ["or", "and"] as const) {
665
- const rawGroup = str(joiner);
666
- if (rawGroup === undefined) continue;
667
- const inner = groupInner(rawGroup);
668
- if (inner === null || inner.length === 0) {
669
- return { ok: false, failure: badInput(`${joiner} expects (col.op.value,…)`, joiner) };
670
- }
671
- const parts: SqlOp[] = [];
672
- for (const term of inner) {
673
- const op = groupedFilterTerm(tableColumns, term, joiner);
674
- if ("failure" in op) return { ok: false, failure: op.failure };
675
- parts.push(op);
676
- }
677
- wheres.push(parts.length === 1 ? parts[0] : groupOp(joiner, parts));
678
- }
679
- }
680
-
681
- // order
682
- let orders: SqlOp[] | undefined;
683
- const rawOrder = str("order");
684
- if (rawOrder !== undefined) {
685
- if (config.order === "none") {
686
- return { ok: false, failure: badInput("order is not enabled for this resource", "order") };
687
- }
688
- orders = [];
689
- for (const term of rawOrder.split(",")) {
690
- const [key, dir] = term.split(".");
691
- if (!key || (dir !== undefined && dir !== "asc" && dir !== "desc")) {
692
- return { ok: false, failure: badInput(`bad order term "${term}"`, "order") };
693
- }
694
- const col = tableColumns[key];
695
- const resolved = columns.find((c) => c.key === key);
696
- if (col === undefined || resolved === undefined || !orderAllowed.has(key)) {
697
- return {
698
- ok: false,
699
- failure: badInput(`unknown or unorderable column "${key}"`, "order"),
700
- };
701
- }
702
- orders.push(dir === "asc" ? ascOp(col) : descOp(col));
703
- }
704
- meta.order = rawOrder;
705
- } else if (resolvedCursor.length > 0) {
706
- orders = resolvedCursor.map((c) => {
707
- const col = tableColumns[c.key];
708
- return direction === "asc" ? ascOp(col) : descOp(col);
709
- });
710
- }
711
-
712
- // select projection
713
- let select: ResolvedColumn[] | undefined;
714
- const rawSelect = str("select");
715
- if (rawSelect !== undefined) {
716
- if (config.select !== "all") {
717
- return {
718
- ok: false,
719
- failure: badInput("select is not enabled for this resource", "select"),
720
- };
721
- }
722
- select = [];
723
- for (const key of rawSelect.split(",")) {
724
- const resolved = columns.find((c) => c.key === key);
725
- if (resolved === undefined) {
726
- return { ok: false, failure: badInput(`unknown column "${key}"`, "select") };
727
- }
728
- select.push(resolved);
729
- }
730
- }
731
-
732
- // pagination
733
- let offset: number | undefined;
734
- let after: unknown;
735
- let before: unknown;
736
- let cursorDir: CursorDir | undefined;
737
- let orderBy = orders;
738
- if (mode === "offset") {
739
- const rawCursor = str("cursor");
740
- const rawOffset = str("offset");
741
- if (rawCursor !== undefined) {
742
- const fromCursor = decodeOffsetCursor(rawCursor);
743
- if (fromCursor === null) {
744
- return { ok: false, failure: badInput("invalid cursor", "cursor") };
745
- }
746
- offset = fromCursor;
747
- } else if (rawOffset !== undefined) {
748
- const n = Number(rawOffset);
749
- if (!Number.isInteger(n) || n < 0) {
750
- return {
751
- ok: false,
752
- failure: badInput("offset must be a non-negative integer", "offset"),
753
- };
754
- }
755
- offset = n;
756
- }
757
- } else {
758
- const rawCursor = str("cursor");
759
- if (rawCursor !== undefined) {
760
- if (resolvedCursor.length === 0) {
761
- return { ok: false, failure: badInput("cursor pagination is not configured", "cursor") };
762
- }
763
- const decoded = decodeCursor(rawCursor, resolvedCursor.length);
764
- if (decoded === null) {
765
- return { ok: false, failure: badInput("invalid cursor", "cursor") };
766
- }
767
- cursorDir = decoded.dir;
768
- const cols = resolvedCursor.map((c) => tableColumns[c.key]);
769
- if (decoded.dir === "before") {
770
- before = keysetAfter(cols, decoded.values, direction !== "desc");
771
- orderBy = (orders ?? []).map(flipOrder);
772
- } else {
773
- after = keysetAfter(cols, decoded.values, direction === "desc");
774
- }
775
- meta.cursor = rawCursor;
776
- }
777
- }
778
-
779
- const where =
780
- wheres.length === 0
781
- ? undefined
782
- : wheres.length === 1
783
- ? wheres[0]
784
- : groupOp("and", wheres as SqlOp[]);
785
- return {
786
- ok: true,
787
- page: {
788
- where,
789
- orderBy,
790
- limit: pageLimit + (mode === "cursor" ? 1 : 0),
791
- offset,
792
- after,
793
- before,
794
- },
795
- meta: { mode, limit: pageLimit, ...meta },
796
- ...(select !== undefined ? { select } : {}),
797
- ...(cursorDir !== undefined ? { cursorDir } : {}),
798
- };
799
- }
800
-
801
196
  /** Run the list query and shape rows + meta. */
802
197
  async function runList(
803
198
  input: unknown,
804
199
  fx: Fx,
805
200
  ): Promise<{ data: SqlRow[]; meta: Record<string, unknown> } | { failure: unknown }> {
806
- const parsed = parseList(input);
201
+ const parsed = parseListQuery(input, scope.query);
807
202
  if (!parsed.ok) return { failure: parsed.failure };
808
203
  const store = fx.store(db) as {
809
204
  page(t: unknown, o: SqlPageOptions): Promise<SqlRow[]>;
810
205
  count(t: unknown, w?: unknown): Promise<number>;
811
- select(columns?: unknown): {
812
- from(t: unknown): {
813
- where(w: unknown): {
814
- orderBy(...o: unknown[]): { limit(n: number): Promise<SqlRow[]> };
815
- };
816
- };
817
- };
818
206
  };
819
207
  const rows = await store.page(table, parsed.page);
820
208
  const meta: Record<string, unknown> = { ...parsed.meta };
@@ -874,7 +262,8 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
874
262
  const listFlow = flow("list", {
875
263
  ...(breaking ? { breaking: true as const } : {}),
876
264
  // Loose record so the HTTP AoT infers `query` and lets every list URL
877
- // key through; real validation happens in parseList (PostgREST grammar).
265
+ // key through; real validation happens in parseListQuery (PostgREST
266
+ // grammar).
878
267
  in: z.record(z.string(), z.unknown()) as never,
879
268
  effects: { reads: [db.ref] },
880
269
  do: async (input, fx) => {
@@ -973,7 +362,83 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
973
362
  remove: removeFlow as FlowDef<any, any, any>,
974
363
  };
975
364
 
976
- return {
365
+ // Live query surface — internal signal name + SSE stream Flow. Developers
366
+ // mount it via `http.resource` (or a manual `.live()` GET); the realtime
367
+ // bridge classifies CDC events per subscriber and pushes through this
368
+ // flow. Classified events are per-identity RLS verdicts, so delivery is
369
+ // direct per-subscriber push — never a shared signal tape.
370
+ //
371
+ // `live: false` is the explicit per-table opt-out for the project-wide
372
+ // `store.live` default — never builds a surface. `live: true` builds it
373
+ // immediately. When `live` is omitted the surface is deferred: it is
374
+ // recorded as a pending live default and drained by `oke()` only when
375
+ // `store: { live: true }` is set.
376
+ const buildLiveSurface = (): ResourceLiveSurface => {
377
+ const signalName = `oke/live/sql:${tableName}`;
378
+ const pkColumn = idColumn?.sqlName ?? idKey;
379
+ const columnKinds: Record<string, LiveColumnKind> = {};
380
+ for (const c of columns) {
381
+ const kind: LiveColumnKind | undefined =
382
+ c.sqlType === "BOOLEAN"
383
+ ? "boolean"
384
+ : c.sqlType === "INTEGER" || c.sqlType === "BIGINT" || c.sqlType === "REAL"
385
+ ? "number"
386
+ : c.sqlType === "JSONB"
387
+ ? undefined
388
+ : "string";
389
+ if (kind !== undefined) columnKinds[c.key] = kind;
390
+ }
391
+ const liveFlow = flow(`_live_${tableName}`, {
392
+ ...(options.out !== undefined ? { out: options.out } : {}),
393
+ effects: { reads: [db.ref, `signal:${signalName}`] },
394
+ do: async (input: unknown, fx: Fx) => {
395
+ const bridge = realtimeBridgeRuntime();
396
+ if (!bridge) {
397
+ throw new Error(
398
+ `live query for "${tableName}" requires an RLS-capable SQL driver (postgres / pglite)`,
399
+ );
400
+ }
401
+ // Same PostgREST window as the list Flow — filters decide whether
402
+ // a row belongs to this live query ("query exit"), RLS decides
403
+ // visibility. Pagination cursors don't gate membership.
404
+ const parsed = parseListQuery(input, scope.query);
405
+ if (!parsed.ok) return parsed.failure;
406
+ let whereSql: string | undefined;
407
+ let whereParams: readonly unknown[] = [];
408
+ const pageWhere = parsed.page.where;
409
+ if (pageWhere !== undefined) {
410
+ const compiled = compileWhere(pageWhere);
411
+ if (compiled.clause !== "") {
412
+ whereSql = compiled.clause;
413
+ whereParams = compiled.params;
414
+ }
415
+ }
416
+ const identity = fx.rlsIdentity;
417
+ if (!identity) {
418
+ throw new Error(`live query for "${tableName}" requires a gated identity`);
419
+ }
420
+ const stream = openLiveStream(fx.id(), {
421
+ table: tableName,
422
+ identity,
423
+ pkColumn,
424
+ ...(whereSql !== undefined ? { whereSql } : {}),
425
+ ...(whereParams.length > 0 ? { whereParams } : {}),
426
+ ...(Object.keys(columnKinds).length > 0 ? { tableColumns: columnKinds } : {}),
427
+ });
428
+ return fx.json.stream(stream.chunks);
429
+ },
430
+ }) as unknown as AnyFlowDef;
431
+ return { signal: signalName, flow: liveFlow };
432
+ };
433
+
434
+ // Immediate vs deferred live surface:
435
+ // - `live: true` — build now (today's explicit opt-in, unchanged).
436
+ // - `live: false` — the per-table opt-out for the project default.
437
+ // - omitted — record a pending default surface, drained by `oke()` when
438
+ // `store: { live: true }` is set; otherwise discarded (today's behavior).
439
+ const signalName = `oke/live/sql:${tableName}`;
440
+ let liveSurface: ResourceLiveSurface | undefined;
441
+ const resolved: ResourceDef = {
977
442
  ...defs,
978
443
  table: tableName,
979
444
  idKey,
@@ -984,9 +449,24 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
984
449
  sqlNameOf(key) {
985
450
  return columns.find((c) => c.key === key)?.sqlName;
986
451
  },
987
- all: () => defs,
452
+ all: () => {
453
+ // Live surface rides along so `http.resource(path, notesR.all())` can
454
+ // mount `GET <path>/live` from the resource even when the caller uses
455
+ // `.all()` (existing docs examples pass `.all()` directly). Prefer a
456
+ // drain-stamped surface, then the immediate closure surface.
457
+ const bag = defs as ResourceFlowDefs;
458
+ const stamped = (bag as { readonly live?: ResourceLiveSurface }).live;
459
+ const effective = stamped ?? liveSurface;
460
+ if (effective) {
461
+ Object.defineProperty(bag, "live", {
462
+ value: effective,
463
+ enumerable: true,
464
+ });
465
+ }
466
+ return bag;
467
+ },
988
468
  page(input) {
989
- const parsed = parseList(input);
469
+ const parsed = parseListQuery(input, scope.query);
990
470
  if (!parsed.ok) {
991
471
  throw new TypeError(
992
472
  `resource.page: invalid list input — ${JSON.stringify(parsed.failure)}`,
@@ -995,4 +475,33 @@ export function resource(db: SqlStoreDecl, table: unknown, options: ResourceOpti
995
475
  return { ...parsed.page, meta: parsed.meta };
996
476
  },
997
477
  };
478
+ // Table-level `store.schema.live(false)` is the explicit per-table opt-out
479
+ // from the project-wide `store.live` default. It only cancels the DEFAULT —
480
+ // an explicit `options.live: true` on the resource still wins so today's
481
+ // opt-in behavior is unchanged.
482
+ const tableLiveOptedOut =
483
+ table !== null &&
484
+ typeof table === "object" &&
485
+ (table as { readonly live?: boolean }).live === false;
486
+
487
+ if (options.live === true) {
488
+ liveSurface = buildLiveSurface();
489
+ } else if (options.live !== false && !tableLiveOptedOut) {
490
+ const pending: { readonly signalName: string } = { signalName };
491
+ Object.defineProperty(defs, "pendingLive", { value: pending, configurable: true });
492
+ registerPendingResourceLive({
493
+ signalName,
494
+ def: resolved as object,
495
+ bag: defs as object,
496
+ build: buildLiveSurface,
497
+ });
498
+ }
499
+
500
+ if (liveSurface) {
501
+ Object.defineProperty(resolved, "live", {
502
+ value: liveSurface,
503
+ enumerable: true,
504
+ });
505
+ }
506
+ return resolved;
998
507
  }