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
@@ -0,0 +1,403 @@
1
+ /**
2
+ * LiveQuery runtime — the Realtime round's core (Phase 2).
3
+ *
4
+ * Responsibilities:
5
+ * - Subscription registry: fixed-size per-connection Map (no server-side
6
+ * row-set tracking — classification is stateless, from CDC images only).
7
+ * - Fan-out: bounded worker pool (architecture contract). Concurrency and
8
+ * queue depth caps shed with explicit `liveFanoutShed` metrics instead of
9
+ * unbounded queueing.
10
+ * - Classification per subscriber, per event:
11
+ * after === null → delete | ignore
12
+ * stamped EXISTS(after) true → upsert
13
+ * beforeVisible replay true → revoked(reason "rls"|"query")
14
+ * pk in subscriber's last snapshot set? → revoked : ignore
15
+ *
16
+ * The visibility probes run through the SAME stamped identity production
17
+ * uses (`RlsIdentity` prelude), so RLS is Postgres-authoritative for both
18
+ * read and live paths. `beforeVisible` replays native policy expressions via
19
+ * `oke.row_passes_policies` against the before-image JSONB — no heap access,
20
+ * zero persistent per-subscription state.
21
+ */
22
+
23
+ import { rlsScopesJson, type RlsIdentity } from "../../drivers/pg-rls.ts";
24
+
25
+ /** Public wire taxonomy — `revoked.reason` is protocol, not internal. */
26
+ export type LiveQueryEvent =
27
+ | {
28
+ readonly kind: "upsert";
29
+ readonly row: Record<string, unknown>;
30
+ readonly seq?: number;
31
+ readonly mutationId?: string;
32
+ }
33
+ | { readonly kind: "revoked"; readonly id: string; readonly reason: "rls" | "query" }
34
+ | { readonly kind: "delete"; readonly id: string };
35
+
36
+ /** One subscriber's identity + query shape. */
37
+ export interface LiveSubscription {
38
+ /** Stable id (SSE session). */
39
+ readonly id: string;
40
+ /** `sql:` resource ref. */
41
+ readonly ref: `sql:${string}`;
42
+ /** Physical table name. */
43
+ readonly table: string;
44
+ /** Primary key column (compiler guardrail requires it for `live: true`). */
45
+ readonly pkColumn: string;
46
+ /**
47
+ * Column type hints (`jsName → primitive kind`) used to restore JSONB
48
+ * round-tripped images (outbox rows) to their declared shapes. Optional —
49
+ * tests and schema-less subscriptions omit it.
50
+ */
51
+ readonly tableColumns?: Readonly<Record<string, "string" | "number" | "boolean">>;
52
+ /** Subscriber stamp (gate/user/scopes/tenant) — re-stamped per probe. */
53
+ readonly identity: RlsIdentity;
54
+ /**
55
+ * Query-window filter (list `where`, compiled SQL clause + params under
56
+ * `?` placeholders). Empty = whole table within RLS.
57
+ */
58
+ readonly whereSql?: string;
59
+ readonly whereParams?: readonly unknown[];
60
+ /** Target for classified events. */
61
+ deliver(event: LiveQueryEvent): void;
62
+ }
63
+
64
+ /**
65
+ * Probe executor — abstracted so tests inject fakes while prod uses a real
66
+ * pooled {@link SqlConnection}.
67
+ */
68
+ export interface LiveProbeRunner {
69
+ /**
70
+ * Run `fn` with the subscription's identity stamped on one backend
71
+ * session (`SET LOCAL ROLE oke_app` + GUC prelude in prod).
72
+ */
73
+ runStamped<T>(identity: RlsIdentity, fn: (exec: LiveProbeExec) => Promise<T>): Promise<T>;
74
+ }
75
+
76
+ /** Statement surface available inside one stamped frame. */
77
+ export interface LiveProbeExec {
78
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
79
+ }
80
+
81
+ /** Fan-out pool knobs (architecture contract — never implicit/unbounded). */
82
+ export interface FanoutPoolOptions {
83
+ /** Max concurrent classification jobs (default 32). */
84
+ concurrency?: number;
85
+ /** Max queued events; excess sheds immediately (default 10_000). */
86
+ maxQueue?: number;
87
+ }
88
+
89
+ interface QueueItem {
90
+ readonly event: CdcEventInput;
91
+ readonly subscribers: readonly LiveSubscription[];
92
+ }
93
+
94
+ /** One CDC change entering the runtime. */
95
+ export interface CdcEventInput {
96
+ readonly tableName: string;
97
+ readonly op: "insert" | "update" | "delete";
98
+ readonly before: Record<string, unknown> | null;
99
+ readonly after: Record<string, unknown> | null;
100
+ readonly seq?: number;
101
+ /** Echoed from mutation headers so clients can dedupe their own writes. */
102
+ readonly mutationId?: string;
103
+ }
104
+
105
+ export class LiveQueryRuntime {
106
+ private readonly subs = new Map<string, LiveSubscription>();
107
+ private readonly byTable = new Map<string, Set<string>>();
108
+ private readonly queue: QueueItem[] = [];
109
+ private active = 0;
110
+ private draining = false;
111
+ private readonly probes: LiveProbeRunner;
112
+ private readonly concurrency: number;
113
+ private readonly maxQueue: number;
114
+ /** Counters surfaced through doctor/metrics. */
115
+ readonly metrics = { eventsIn: 0, eventsShed: 0, checksRun: 0, checkFailures: 0 };
116
+
117
+ constructor(probes: LiveProbeRunner, options: FanoutPoolOptions = {}) {
118
+ this.probes = probes;
119
+ this.concurrency = Math.max(1, options.concurrency ?? 32);
120
+ this.maxQueue = Math.max(1, options.maxQueue ?? 10_000);
121
+ }
122
+
123
+ /**
124
+ * Register a subscriber. Returns an unsubscriber.
125
+ *
126
+ * @param sub - Identity + query window + delivery sink
127
+ */
128
+ subscribe(sub: LiveSubscription): () => void {
129
+ this.subs.set(sub.id, sub);
130
+ let ids = this.byTable.get(sub.table);
131
+ if (!ids) {
132
+ ids = new Set();
133
+ this.byTable.set(sub.table, ids);
134
+ }
135
+ ids.add(sub.id);
136
+ return () => {
137
+ this.subs.delete(sub.id);
138
+ const set = this.byTable.get(sub.table);
139
+ if (set) {
140
+ set.delete(sub.id);
141
+ if (set.size === 0) this.byTable.delete(sub.table);
142
+ }
143
+ };
144
+ }
145
+
146
+ /** Current subscriber count (metrics/doctor). */
147
+ get size(): number {
148
+ return this.subs.size;
149
+ }
150
+
151
+ /** Queued-but-unprocessed CDC events right now. */
152
+ get queueDepth(): number {
153
+ return this.queue.length;
154
+ }
155
+
156
+ /**
157
+ * Ingest one CDC event (from the sql-session hook or outbox poller).
158
+ * Overload sheds explicitly — callers see `eventsShed` grow rather than
159
+ * silent latency collapse.
160
+ *
161
+ * @param event - Change payload
162
+ */
163
+ onCdc(event: CdcEventInput): void {
164
+ this.metrics.eventsIn += 1;
165
+ const subs = this.byTable.get(event.tableName);
166
+ if (!subs || subs.size === 0) return;
167
+ if (this.queue.length >= this.maxQueue) {
168
+ this.metrics.eventsShed += 1;
169
+ return;
170
+ }
171
+ const targets: LiveSubscription[] = [];
172
+ for (const id of subs) {
173
+ const sub = this.subs.get(id);
174
+ if (sub) targets.push(sub);
175
+ }
176
+ this.queue.push({ event, subscribers: targets });
177
+ void this.drain();
178
+ }
179
+
180
+ /** Worker-pool drain loop. */
181
+ private async drain(): Promise<void> {
182
+ if (this.draining) return;
183
+ this.draining = true;
184
+ try {
185
+ while (this.queue.length > 0 && this.active < this.concurrency) {
186
+ const item = this.queue.shift();
187
+ if (!item) break;
188
+ this.active += 1;
189
+ void this.classifyBatch(item).finally(() => {
190
+ this.active -= 1;
191
+ if (this.queue.length > 0) void this.drain();
192
+ });
193
+ }
194
+ } finally {
195
+ this.draining = false;
196
+ }
197
+ }
198
+
199
+ private async classifyBatch(item: QueueItem): Promise<void> {
200
+ const { event, subscribers } = item;
201
+ await Promise.all(
202
+ subscribers.map(async (sub) => {
203
+ try {
204
+ const event2 = await classifyForSubscriber(this.probes, sub, event);
205
+ if (event2 !== null) {
206
+ const withSeq: LiveQueryEvent =
207
+ event2.kind === "upsert"
208
+ ? {
209
+ ...event2,
210
+ ...(event.seq !== undefined ? { seq: event.seq } : {}),
211
+ ...(event.mutationId ? { mutationId: event.mutationId } : {}),
212
+ }
213
+ : event2;
214
+ sub.deliver(withSeq);
215
+ }
216
+ } catch {
217
+ this.metrics.checkFailures += 1;
218
+ // Fail loud in metrics, not silently wrong rows: subscriber keeps
219
+ // its stale row until resync — documented v1 trade-off (410/resub).
220
+ }
221
+ }),
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Stateless per-subscriber classification (see module doc for the algorithm).
228
+ *
229
+ * @param probes - Stamped executor factory
230
+ * @param sub - Subscriber under consideration
231
+ * @param event - CDC images
232
+ * @returns Event to deliver, or null to skip
233
+ */
234
+ export async function classifyForSubscriber(
235
+ probes: LiveProbeRunner,
236
+ sub: LiveSubscription,
237
+ event: CdcEventInput,
238
+ ): Promise<LiveQueryEvent | null> {
239
+ // Upserts carry the AFTER image verbatim so subscriber state converges to
240
+ // Postgres's row, not the CDC pipeline's shape (types restored from JSON).
241
+ const after = event.after === null ? null : restoreImage(sub.tableColumns, event.after);
242
+ const afterVisible =
243
+ after !== null &&
244
+ (await probes.runStamped(sub.identity, async (exec) => {
245
+ const { sql, params } = existsProbeSql(sub, after, "SELECT");
246
+ const rows = await exec.query(sql, params);
247
+ return rows.length > 0;
248
+ }));
249
+
250
+ if (afterVisible) {
251
+ return { kind: "upsert", row: after };
252
+ }
253
+
254
+ // Row gone or invisible now. Decide delete vs revoked vs ignore from the
255
+ // BEFORE image — zero row-set state on the server.
256
+ if (event.before === null) {
257
+ // Insert of an invisible row → not ours to remove.
258
+ return null;
259
+ }
260
+
261
+ const beforeWasVisible = await wasBeforeVisible(probes, sub, event);
262
+ if (!beforeWasVisible) return null;
263
+
264
+ if (event.op === "delete" || event.after === null) {
265
+ return { kind: "delete", id: String(event.before[sub.pkColumn]) };
266
+ }
267
+ // Row still exists but left our world: RLS denial vs query-window exit.
268
+ const deniedByRls = await rowPassesPolicies(probes, sub, event.after);
269
+ return {
270
+ kind: "revoked",
271
+ id: String(event.before[sub.pkColumn]),
272
+ reason: deniedByRls ? "query" : "rls",
273
+ };
274
+ }
275
+
276
+ /**
277
+ * JSONB round-trips through the outbox turn `number` / `boolean` columns into
278
+ * text (Postgres stores `row_to_json` numerics as numbers, but a `text` PK
279
+ * stays text — only real drift matters). Restore declared column types when
280
+ * the subscription knows its table's column kinds; unknown keys pass through.
281
+ *
282
+ * @param columns - Optional per-column type hints from the subscription
283
+ * @param image - Raw CDC image
284
+ */
285
+ function restoreImage(
286
+ columns: Readonly<Record<string, "string" | "number" | "boolean">> | undefined,
287
+ image: Record<string, unknown>,
288
+ ): Record<string, unknown> {
289
+ if (!columns) return image;
290
+ const out: Record<string, unknown> = { ...image };
291
+ for (const [key, kind] of Object.entries(columns)) {
292
+ const v = out[key];
293
+ if (v === undefined || v === null) continue;
294
+ if (kind === "number" && typeof v === "string") {
295
+ const n = Number(v);
296
+ if (Number.isFinite(n)) out[key] = n;
297
+ } else if (kind === "boolean" && typeof v === "string") {
298
+ if (v === "true") out[key] = true;
299
+ else if (v === "false") out[key] = false;
300
+ } else if (kind === "string" && typeof v !== "string") {
301
+ out[key] = String(v);
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /**
308
+ * Before-image visibility: policy replay (`oke.row_passes_policies`) AND,
309
+ * when the runtime knows the query window, the window predicate evaluated
310
+ * client-side over the JSONB image fields present in `before`.
311
+ *
312
+ * The parity gate guarantees replay === native verdict for every shipped
313
+ * policy family — this call must run under the SAME stamp as regular reads.
314
+ */
315
+ async function wasBeforeVisible(
316
+ probes: LiveProbeRunner,
317
+ sub: LiveSubscription,
318
+ event: CdcEventInput,
319
+ ): Promise<boolean> {
320
+ if (event.before === null) return false;
321
+ // Policy layer first — cheap, needed regardless of query window.
322
+ if (!(await rowPassesPolicies(probes, sub, event.before))) return false;
323
+ if (sub.whereSql !== undefined && sub.whereSql !== "") {
324
+ return probes.runStamped(sub.identity, async (exec) => {
325
+ // Project each image field as a real column so compiled list `where`
326
+ // clauses (bare column names) resolve against the JSONB payload.
327
+ const cols = Object.keys(event.before!)
328
+ .map((c) => {
329
+ const ident = /^[A-Za-z_][A-Za-z0-9_]*$/.test(c) ? c : null;
330
+ if (ident === null) return null;
331
+ return `before_row->>'${c.replaceAll("'", "''")}' AS ${JSON.stringify(c).replaceAll('"', '"')}`;
332
+ })
333
+ .filter((v): v is string => v !== null)
334
+ .join(", ");
335
+ const selectList = cols === "" ? "NULL AS ok" : cols;
336
+ const jsonbText = JSON.stringify(event.before!).replace(/'/g, "''");
337
+ const sql =
338
+ `SELECT 1 AS ok FROM (SELECT ${selectList} FROM ` +
339
+ `(SELECT '${jsonbText}'::jsonb AS before_row) _img) bv WHERE ${sub.whereSql}`;
340
+ const rows = await exec.query(sql, [...(sub.whereParams ?? [])]);
341
+ return rows.length > 0;
342
+ });
343
+ }
344
+ return true;
345
+ }
346
+
347
+ /**
348
+ * Replay native policies against a JSONB image under the subscriber's stamp.
349
+ *
350
+ * The row image is inlined as an escaped SQL literal, never bound: Bun.SQL
351
+ * binds JS strings with an unknown type, so `$1::jsonb` casts the wire text
352
+ * into a jsonb *string scalar* (`"\"{…}\""`), whose `->>` lookups return
353
+ * NULL and would silently flip every before-image verdict. Statement-level
354
+ * probes that used literals always passed — this parameter path was the
355
+ * live-PG divergence.
356
+ */
357
+ async function rowPassesPolicies(
358
+ probes: LiveProbeRunner,
359
+ sub: LiveSubscription,
360
+ image: Record<string, unknown>,
361
+ ): Promise<boolean> {
362
+ return probes.runStamped(sub.identity, async (exec) => {
363
+ const inline = JSON.stringify(image).replaceAll("'", "''");
364
+ const rows = await exec.query(
365
+ `SELECT oke.row_passes_policies('${sub.table.replaceAll("'", "''")}', '${inline}'::jsonb, 'SELECT') AS ok`,
366
+ [],
367
+ );
368
+ return rows[0]?.ok === true;
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Build the post-RLS EXISTS probe: PK equality plus the list query window.
374
+ * Query filters decide whether the row belongs to THIS live query
375
+ * ("query exit"), RLS decides visibility — Postgres stays the authority.
376
+ */
377
+ function existsProbeSql(
378
+ sub: LiveSubscription,
379
+ _row: Record<string, unknown>,
380
+ _command: "SELECT",
381
+ ): { sql: string; params: readonly unknown[] } {
382
+ const params: unknown[] = [String(_row[sub.pkColumn] ?? "")];
383
+ let sql = `SELECT 1 AS ok FROM "${sub.table}" WHERE "${sub.pkColumn}" = ?`;
384
+ if (sub.whereSql !== undefined && sub.whereSql !== "") {
385
+ sql += ` AND (${sub.whereSql})`;
386
+ params.push(...(sub.whereParams ?? []));
387
+ }
388
+ sql += " LIMIT 1";
389
+ return { sql, params };
390
+ }
391
+
392
+ /**
393
+ * Identity hash bucket — reserved for the v2 identical-stamp dedupe.
394
+ * Exported so the parity/bench groups can assert grouping behavior.
395
+ */
396
+ export function identityBucket(identity: RlsIdentity): string {
397
+ return [
398
+ identity.gate,
399
+ identity.userId,
400
+ rlsScopesJson(identity.scopes),
401
+ identity.tenantId ?? "",
402
+ ].join("|");
403
+ }