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,136 @@
1
+ /**
2
+ * Project-wide `store.live` default — real boot.
3
+ *
4
+ * `oke({ store: { live: true } })` flips NEW `store.schema.table()` posts to
5
+ * live-by-default: a resource mounted with `.all()` (no explicit `live: true`)
6
+ * gets a real `GET <path>/live` SSE route, and `store.schema.live(false)` opts
7
+ * a table out. The off-flag app keeps today's behavior — no live route.
8
+ */
9
+
10
+ import { afterEach, describe, expect, test } from "bun:test";
11
+ import { gate } from "../gate.ts";
12
+ import { oke } from "../../kernel/app.ts";
13
+ import { resetFlowSeq } from "../../kernel/flow.ts";
14
+ import { on, resetBindings } from "../../kernel/on.ts";
15
+ import { http } from "../../kernel/triggers.ts";
16
+ import { createTestApp } from "../../test/create-test-app.ts";
17
+ import { field, id, store } from "../store.ts";
18
+ import { resetPendingResourceLive } from "../../kernel/resource-live.ts";
19
+ import { z } from "zod";
20
+
21
+ afterEach(() => {
22
+ resetBindings();
23
+ resetFlowSeq();
24
+ resetPendingResourceLive();
25
+ });
26
+
27
+ const tasks = store.schema.table(
28
+ "tasks",
29
+ {
30
+ id: field.text().primaryKey().defaultFn(id),
31
+ title: field.text().notNull(),
32
+ },
33
+ [store.schema.policy.owner("owner")],
34
+ );
35
+
36
+ const archive = store.schema.table(
37
+ "archive",
38
+ {
39
+ id: field.text().primaryKey().defaultFn(id),
40
+ label: field.text().notNull(),
41
+ },
42
+ [store.schema.policy.owner("owner"), store.schema.live(false)],
43
+ );
44
+
45
+ /**
46
+ * Mount the two CRUD resources. The `/tasks` resource's own `POST /tasks`
47
+ * create verb is used for the live-window write.
48
+ */
49
+ function mountResource(db: ReturnType<typeof store.sql>) {
50
+ const tasksR = store.resource(db, tasks, {
51
+ in: z.object({ title: z.string() }),
52
+ out: z.object({ id: z.string(), title: z.string() }),
53
+ });
54
+ const archiveR = store.resource(db, archive, {
55
+ in: z.object({ label: z.string() }),
56
+ out: z.object({ id: z.string(), label: z.string() }),
57
+ });
58
+ on(http.resource("/tasks", tasksR.all()).gate(gate.public));
59
+ on(http.resource("/archive", archiveR.all()).gate(gate.public));
60
+ }
61
+
62
+ describe("project-wide store.live default — real boot", () => {
63
+ test("flag on: omitted live mounts GET /tasks/live; live(false) table gets no live route", async () => {
64
+ resetBindings();
65
+ resetFlowSeq();
66
+ resetPendingResourceLive();
67
+
68
+ const db = store.sql("app", { schema: { tasks, archive } });
69
+ mountResource(db);
70
+
71
+ const app = oke({
72
+ name: "live-default-app",
73
+ store: { live: true },
74
+ gate: { policies: [gate.public] },
75
+ });
76
+ Object.assign(app.$options as never, {
77
+ env: "test",
78
+ stores: [db],
79
+ unguardedHttp: "allow",
80
+ });
81
+ try {
82
+ await createTestApp(app, {
83
+ boot: {
84
+ stores: [db],
85
+ config: { drivers: { store: { sql: { test: "pglite" } } } },
86
+ },
87
+ });
88
+
89
+ // The pending resource live mount drained — GET /tasks/live streams 200.
90
+ const live = await app.fetch(new Request("http://localhost/tasks/live"));
91
+ expect(live.status).toBe(200);
92
+ expect(live.headers.get("content-type")).toMatch(/text\/event-stream/);
93
+ await live.body!.cancel();
94
+
95
+ // The archive table opted out via store.schema.live(false) — no /live route.
96
+ const archiveLive = await app.fetch(new Request("http://localhost/archive/live"));
97
+ expect(archiveLive.status).not.toBe(200);
98
+ } finally {
99
+ await app.stop();
100
+ }
101
+ });
102
+
103
+ test("flag off: omitted live stays today's behavior — no /live route", async () => {
104
+ resetBindings();
105
+ resetFlowSeq();
106
+ resetPendingResourceLive();
107
+
108
+ const db = store.sql("app", { schema: { tasks, archive } });
109
+ mountResource(db);
110
+
111
+ const app = oke({
112
+ name: "live-off-app",
113
+ gate: { policies: [gate.public] },
114
+ });
115
+ Object.assign(app.$options as never, {
116
+ env: "test",
117
+ stores: [db],
118
+ unguardedHttp: "allow",
119
+ });
120
+ try {
121
+ await createTestApp(app, {
122
+ boot: {
123
+ stores: [db],
124
+ config: { drivers: { store: { sql: { test: "pglite" } } } },
125
+ },
126
+ });
127
+
128
+ const live = await app.fetch(new Request("http://localhost/tasks/live"));
129
+ expect(live.status).not.toBe(200);
130
+ const archiveLive = await app.fetch(new Request("http://localhost/archive/live"));
131
+ expect(archiveLive.status).not.toBe(200);
132
+ } finally {
133
+ await app.stop();
134
+ }
135
+ });
136
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Manual live query e2e — a hand-written flow on `.live(table)` streams
3
+ * classified CDC events through `liveQuery` over real pglite + RLS.
4
+ *
5
+ * Proves the full manual path: declaration (`http.get(...).live(tasks)`) →
6
+ * compiled Manifest (signal synthesis + guardrails, covered in
7
+ * extract.test.ts) → real boot (realtime bridge binds, CDC sink live) →
8
+ * hand-written flow body calls `liveQuery(fx, tasks, input)` → SSE frames
9
+ * carry only the subscriber's rows — cross-table writes (activity) never
10
+ * appear (see live-isolation.test.ts for the adversarial leg).
11
+ */
12
+
13
+ import { afterEach, describe, expect, test } from "bun:test";
14
+ import { gate } from "../gate.ts";
15
+ import { oke } from "../../kernel/app.ts";
16
+ import { flow, resetFlowSeq } from "../../kernel/flow.ts";
17
+ import { on, resetBindings } from "../../kernel/on.ts";
18
+ import { http } from "../../kernel/triggers.ts";
19
+ import { createTestApp } from "../../test/create-test-app.ts";
20
+ import { field, id, store } from "../store.ts";
21
+ import { liveQuery } from "./live-query.ts";
22
+
23
+ afterEach(() => {
24
+ resetBindings();
25
+ resetFlowSeq();
26
+ });
27
+
28
+ const tasks = store.schema.table("tasks", {
29
+ id: field.text().primaryKey().defaultFn(id),
30
+ title: field.text().notNull(),
31
+ status: field.text().notNull(),
32
+ });
33
+
34
+ const activity = store.schema.table("activity", {
35
+ id: field.text().primaryKey().defaultFn(id),
36
+ label: field.text().notNull(),
37
+ });
38
+
39
+ /** Minimal stream reader — avoids DOM `ReadableStreamReadResult` / `readMany` lib skew. */
40
+ type SseByteReader = {
41
+ read(): Promise<{ done: boolean; value?: Uint8Array }>;
42
+ cancel(): Promise<void>;
43
+ };
44
+
45
+ /**
46
+ * Drain SSE until a classified upsert frame appears, without dropping a
47
+ * late chunk via Promise.race (a timed-out `read()` must stay the pending
48
+ * one across loop iterations).
49
+ */
50
+ async function readUntilUpsert(reader: SseByteReader, timeoutMs: number): Promise<string> {
51
+ const dec = new TextDecoder();
52
+ let body = "";
53
+ const deadline = Date.now() + timeoutMs;
54
+ let pending: Promise<{ done: boolean; value?: Uint8Array }> | undefined;
55
+ while (Date.now() < deadline && !body.includes('"kind":"upsert"')) {
56
+ pending ??= reader.read();
57
+ const remaining = Math.max(1, deadline - Date.now());
58
+ const step = await Promise.race([
59
+ pending.then((r) => ({ kind: "read" as const, r })),
60
+ new Promise<{ kind: "timeout" }>((resolve) =>
61
+ setTimeout(() => resolve({ kind: "timeout" }), Math.min(500, remaining)),
62
+ ),
63
+ ]);
64
+ if (step.kind === "timeout") continue;
65
+ pending = undefined;
66
+ if (step.r.done) break;
67
+ if (step.r.value) body += dec.decode(step.r.value, { stream: true });
68
+ }
69
+ return body;
70
+ }
71
+
72
+ describe("manual live flow (.live(table) + liveQuery) — real boot", () => {
73
+ // PGLite boot + CDC fan-out needs headroom over Bun's default 5s; one
74
+ // tasks insert yields a single classified upsert frame.
75
+ test("SSE frames carry classified task events; activity writes never surface", async () => {
76
+ resetBindings();
77
+ resetFlowSeq();
78
+
79
+ const db = store.sql("app", { schema: { tasks, activity } });
80
+
81
+ const tasksLive = on(
82
+ http.get("/tasks/live").public().live(tasks),
83
+ flow("tasks.live", {
84
+ in: { unknown: true },
85
+ effects: { reads: ["sql:app"] },
86
+ do: async (input, fx) =>
87
+ liveQuery(fx, tasks, input, {
88
+ filter: [tasks.status],
89
+ search: [tasks.title],
90
+ order: "all",
91
+ }),
92
+ }),
93
+ );
94
+ // A plain flow writing to BOTH tables — the keel `writeActivity` shape.
95
+ const createTask = on(
96
+ http.post("/tasks").public(),
97
+ flow("tasks.create", {
98
+ in: { title: { type: "string" }, status: { type: "string" } },
99
+ effects: { writes: ["sql:app"] },
100
+ do: async (input, fx) => {
101
+ const s = fx.store(db);
102
+ await s.insert(tasks).values({
103
+ title: String((input as { title: unknown }).title),
104
+ status: String((input as { status: unknown }).status),
105
+ });
106
+ await s.insert(activity).values({ label: "created task" });
107
+ return { ok: true };
108
+ },
109
+ }),
110
+ );
111
+
112
+ const app = oke({
113
+ name: "manual-live-app",
114
+ gate: { policies: [gate.public] },
115
+ }).adopt({ tasksLive, createTask });
116
+ Object.assign(app.$options, { env: "test", stores: [db], unguardedHttp: "allow" });
117
+ // PGLite boot (real app path) — the bridge binds on the pglite primary.
118
+ // Passed via boot.config: the createTestApp harness defaults to memory
119
+ // SQL unless boot.config.drivers.store.sql is set explicitly.
120
+ try {
121
+ await createTestApp(app, {
122
+ boot: {
123
+ stores: [db],
124
+ config: { drivers: { store: { sql: { test: "pglite" } } } },
125
+ },
126
+ });
127
+
128
+ // Subscribe to the manual live route and collect SSE frames.
129
+ const res = await app.fetch(new Request("http://localhost/tasks/live?status=eq.open"));
130
+ if (res.status !== 200) {
131
+ console.error("live route failed:", res.status, await res.text());
132
+ }
133
+ expect(res.status).toBe(200);
134
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
135
+ const reader = res.body!.getReader();
136
+
137
+ // Write through the two-table flow — CDC flows globally, but only the
138
+ // tasks row (matching the ?status=eq.open window) reaches this stream.
139
+ const post = await app.fetch(
140
+ new Request("http://localhost/tasks", {
141
+ method: "POST",
142
+ headers: { "content-type": "application/json" },
143
+ body: JSON.stringify({ title: "hello", status: "open" }),
144
+ }),
145
+ );
146
+ expect(post.status).toBe(200);
147
+
148
+ const body = await readUntilUpsert(reader, 10_000);
149
+ await reader.cancel().catch(() => undefined);
150
+
151
+ expect(body).toContain("data:");
152
+ expect(body).toContain('"kind":"upsert"');
153
+ expect(body).toContain('"title":"hello"');
154
+ expect(body).not.toContain("created task");
155
+ expect(body).not.toContain('"kind":"revoked"');
156
+ } finally {
157
+ await app.stop();
158
+ }
159
+ }, 15_000);
160
+ });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Cross-table event-isolation gate (Realtime Round 2):
3
+ *
4
+ * CDC capture is GLOBAL — every committed write on every table enters the
5
+ * same sink → outbox → runtime ingest pipeline. This suite adversarially
6
+ * proves a subscriber's stream stays confined to its own table:
7
+ *
8
+ * 1. Two RLS tables: `tasks`-shaped (subscribed, with a query window) and
9
+ * an `activity`-shaped table with NO live subscribers.
10
+ * 2. Interleaved concurrent writes across BOTH tables while the tasks
11
+ * subscriber is live — direct sink dispatch AND batched outbox
12
+ * redelivery (the plausible leak path).
13
+ * 3. Every delivered event must trace to a tasks-table row (id + payload),
14
+ * never an activity id — including no `revoked` events masquerading a
15
+ * cross-table leak. `checkFailures` must stay 0.
16
+ */
17
+
18
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
19
+ import { connectPglite } from "../../drivers/pglite.ts";
20
+ import {
21
+ installOkeRlsHelpers,
22
+ ROW_PASSES_POLICIES_STATEMENTS as ROW_PASSES,
23
+ type RlsIdentity,
24
+ } from "../../drivers/pg-rls.ts";
25
+ import { CdcOutbox, CdcOutboxRunner, type OutboxRow } from "../../drivers/cdc-outbox.ts";
26
+ import type { SqlConnection } from "../../drivers/types.ts";
27
+ import { liveQueryRuntimeFromConn } from "./live-query-server.ts";
28
+ import type { LiveQueryEvent } from "./live-query-runtime.ts";
29
+
30
+ const ALICE: RlsIdentity = { gate: "member", userId: "alice", scopes: ["member"] };
31
+ const TASKS = "oke_live_gate_tasks";
32
+ const ACTIVITY = "oke_live_gate_activity";
33
+
34
+ /** All delivered ids must belong to the tasks table, never activity. */
35
+ function assertIsolation(delivered: readonly LiveQueryEvent[]): void {
36
+ for (const event of delivered) {
37
+ if (event.kind === "upsert") {
38
+ expect(TASK_EVENT_IDS.has(String((event.row as Record<string, unknown>).id))).toBe(true);
39
+ } else {
40
+ // revoked / delete — the id must still be a tasks id (a cross-table
41
+ // leak masquerading as revoked/delete must fail here).
42
+ expect(TASK_EVENT_IDS.has(String((event as { id: unknown }).id))).toBe(true);
43
+ }
44
+ }
45
+ }
46
+
47
+ /** The full task-id universe this suite ever writes. */
48
+ const TASK_EVENT_IDS = new Set(["t1", "t2", "t3", "t4"]);
49
+ /** Activity ids written adversarially — must never appear in any stream. */
50
+ const ACTIVITY_EVENT_IDS = ["a1", "a2", "a3", "a4", "a5", "a6"];
51
+
52
+ describe("cross-table live isolation (pglite, global CDC)", () => {
53
+ let conn: SqlConnection;
54
+
55
+ beforeAll(async () => {
56
+ conn = await connectPglite({ url: "memory://oke-live-isolation", role: "primary" });
57
+ await installOkeRlsHelpers((sql) => conn.exec(sql));
58
+ for (const stmt of ROW_PASSES) await conn.exec(stmt);
59
+ for (const table of [TASKS, ACTIVITY]) {
60
+ await conn.exec(`DROP TABLE IF EXISTS ${table}`);
61
+ await conn.exec(`CREATE TABLE ${table} (
62
+ id text PRIMARY KEY, owner text NOT NULL, status text NOT NULL)`);
63
+ await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON ${table} TO oke_app`);
64
+ await conn.exec(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
65
+ await conn.exec(`CREATE POLICY owner_all ON ${table} AS PERMISSIVE FOR ALL TO public
66
+ USING (owner = oke.user()) WITH CHECK (owner = oke.user())`);
67
+ }
68
+ }, 30_000);
69
+
70
+ afterAll(async () => {
71
+ await conn.close();
72
+ });
73
+
74
+ async function seed(table: string, id: string, owner: string, status: string): Promise<void> {
75
+ await conn.query(`INSERT INTO ${table} VALUES (?, ?, ?)`, [id, owner, status]);
76
+ }
77
+
78
+ test("interleaved concurrent multi-table writes — stream stays tasks-only", async () => {
79
+ const runtime = liveQueryRuntimeFromConn(conn);
80
+ const delivered: LiveQueryEvent[] = [];
81
+ const unsub = runtime.subscribe({
82
+ id: "sse-iso-tasks",
83
+ ref: "sql:app",
84
+ table: TASKS,
85
+ pkColumn: "id",
86
+ identity: ALICE,
87
+ whereSql: `status = 'open'`,
88
+ deliver(e) {
89
+ delivered.push(e);
90
+ },
91
+ });
92
+ try {
93
+ // Overlapping tick work: every write below lands while the subscriber
94
+ // is live and the shared ingest pipeline is busy on BOTH tables.
95
+ await seed(TASKS, "t1", "alice", "open");
96
+ await seed(ACTIVITY, "a1", "alice", "log");
97
+ await seed(TASKS, "t2", "alice", "open");
98
+ await seed(ACTIVITY, "a2", "alice", "log");
99
+ await seed(TASKS, "t3", "alice", "open");
100
+ await seed(ACTIVITY, "a3", "alice", "log");
101
+
102
+ // Concurrent interleave — tasks and activity CDC events hit the shared
103
+ // sink in the same ticks (Promise.all keeps them in-flight together).
104
+ await Promise.all([
105
+ conn.query(`UPDATE ${TASKS} SET status = 'done' WHERE id = 't2'`),
106
+ conn.query(`INSERT INTO ${ACTIVITY} VALUES ('a4', 'alice', 'log')`),
107
+ conn.query(`UPDATE ${TASKS} SET status = 'open' WHERE id = 't2'`),
108
+ conn.query(`INSERT INTO ${ACTIVITY} VALUES ('a5', 'alice', 'log')`),
109
+ ]);
110
+
111
+ // Feed every committed change through the shared runtime ingest —
112
+ // activity events ride the exact same pipeline as task events.
113
+ runtime.onCdc({
114
+ tableName: ACTIVITY,
115
+ op: "insert",
116
+ before: null,
117
+ after: { id: "a4", owner: "alice", status: "log" },
118
+ });
119
+ runtime.onCdc({
120
+ tableName: TASKS,
121
+ op: "update",
122
+ before: { id: "t2", owner: "alice", status: "done" },
123
+ after: { id: "t2", owner: "alice", status: "open" },
124
+ });
125
+ runtime.onCdc({
126
+ tableName: ACTIVITY,
127
+ op: "update",
128
+ before: { id: "a2", owner: "alice", status: "log" },
129
+ after: { id: "a2", owner: "alice", status: "log2" },
130
+ });
131
+ runtime.onCdc({
132
+ tableName: TASKS,
133
+ op: "delete",
134
+ before: { id: "t3", owner: "alice", status: "open" },
135
+ after: null,
136
+ });
137
+ runtime.onCdc({
138
+ tableName: ACTIVITY,
139
+ op: "delete",
140
+ before: { id: "a3", owner: "alice", status: "log" },
141
+ after: null,
142
+ });
143
+ runtime.onCdc({
144
+ tableName: ACTIVITY,
145
+ op: "insert",
146
+ before: null,
147
+ after: { id: "a6", owner: "alice", status: "log" },
148
+ });
149
+
150
+ // t1 exits the query window — commit the heap change first (mirror
151
+ // production ordering), then feed the CDC event.
152
+ await conn.query(`UPDATE ${TASKS} SET status = 'done' WHERE id = 't1'`);
153
+ runtime.onCdc({
154
+ tableName: TASKS,
155
+ op: "update",
156
+ before: { id: "t1", owner: "alice", status: "open" },
157
+ after: { id: "t1", owner: "alice", status: "done" },
158
+ });
159
+
160
+ await new Promise((r) => setTimeout(r, 80));
161
+ assertIsolation(delivered);
162
+ expect(delivered).toEqual([
163
+ // t2 done→open (after-image re-enters window) → upsert;
164
+ // t3 deleted → delete (ingest order);
165
+ // t1 open→done exits the window → revoked(reason query).
166
+ // Every activity event (a1–a6) absent — global CDC never leaks.
167
+ { kind: "upsert", row: { id: "t2", owner: "alice", status: "open" } },
168
+ { kind: "delete", id: "t3" },
169
+ { kind: "revoked", id: "t1", reason: "query" },
170
+ ]);
171
+ expect(runtime.metrics.checkFailures).toBe(0);
172
+ } finally {
173
+ unsub();
174
+ }
175
+ });
176
+
177
+ test("batched outbox redelivery with mixed tables — isolation survives replay", async () => {
178
+ const outbox = new CdcOutbox(conn);
179
+ await outbox.ensure();
180
+
181
+ const runtime = liveQueryRuntimeFromConn(conn);
182
+ const drainedEvents: LiveQueryEvent[] = [];
183
+ runtime.subscribe({
184
+ id: "sse-iso-poller",
185
+ ref: "sql:app",
186
+ table: TASKS,
187
+ pkColumn: "id",
188
+ identity: ALICE,
189
+ whereSql: `status = 'open'`,
190
+ deliver(e) {
191
+ drainedEvents.push(e);
192
+ },
193
+ });
194
+
195
+ // Another host committed these; a mixed batch (tasks + activity) drains
196
+ // back through the shared runtime in one runner tick. Fresh ids — the
197
+ // replay leg must not depend on test 1's rows.
198
+ await seed(TASKS, "t4", "alice", "open");
199
+ await seed(ACTIVITY, "b1", "alice", "log");
200
+ await outbox.append({
201
+ tableName: ACTIVITY,
202
+ op: "insert",
203
+ before: null,
204
+ after: { id: "b1", owner: "alice", status: "log" },
205
+ });
206
+ await outbox.append({
207
+ tableName: TASKS,
208
+ op: "insert",
209
+ before: null,
210
+ after: { id: "t4", owner: "alice", status: "open" },
211
+ });
212
+ await outbox.append({
213
+ tableName: ACTIVITY,
214
+ op: "update",
215
+ before: { id: "b1", owner: "alice", status: "log" },
216
+ after: { id: "b1", owner: "alice", status: "log2" },
217
+ });
218
+
219
+ let batches = 0;
220
+ const runner = new CdcOutboxRunner(
221
+ outbox,
222
+ async (rows: readonly OutboxRow[]) => {
223
+ batches += 1;
224
+ for (const row of rows) {
225
+ runtime.onCdc({
226
+ tableName: row.tableName,
227
+ op: row.op,
228
+ before: row.before,
229
+ after: row.after,
230
+ ...(Number.isFinite(row.seq) ? { seq: row.seq } : {}),
231
+ ...(row.mutationId !== undefined ? { mutationId: row.mutationId } : {}),
232
+ });
233
+ }
234
+ },
235
+ { intervalMs: 10_000 },
236
+ );
237
+ const drained = await runner.tick();
238
+ expect(drained).toBe(3);
239
+ expect(batches).toBe(1);
240
+ await new Promise((r) => setTimeout(r, 80));
241
+
242
+ // Mixed batch: exactly one tasks event survives classification; the two
243
+ // activity rows in the same batch never surface — no revoked, no upsert.
244
+ // The replayed task event carries the outbox seq (round-trip stamp).
245
+ expect(drainedEvents).toEqual([
246
+ { kind: "upsert", row: { id: "t4", owner: "alice", status: "open" }, seq: 2 },
247
+ ]);
248
+ assertIsolation(drainedEvents);
249
+ for (const id of ACTIVITY_EVENT_IDS) {
250
+ expect(drainedEvents.some((e) => JSON.stringify(e).includes(id))).toBe(false);
251
+ }
252
+ await runner.stop?.();
253
+ });
254
+
255
+ test("activity-table subscriber sees only activity rows (symmetry control)", async () => {
256
+ const runtime = liveQueryRuntimeFromConn(conn);
257
+ const delivered: LiveQueryEvent[] = [];
258
+ const unsub = runtime.subscribe({
259
+ id: "sse-iso-activity",
260
+ ref: "sql:app",
261
+ table: ACTIVITY,
262
+ pkColumn: "id",
263
+ identity: ALICE,
264
+ deliver(e) {
265
+ delivered.push(e);
266
+ },
267
+ });
268
+ try {
269
+ await seed(ACTIVITY, "a9", "alice", "log");
270
+ await seed(TASKS, "t9", "alice", "open");
271
+ runtime.onCdc({
272
+ tableName: ACTIVITY,
273
+ op: "insert",
274
+ before: null,
275
+ after: { id: "a9", owner: "alice", status: "log" },
276
+ });
277
+ runtime.onCdc({
278
+ tableName: TASKS,
279
+ op: "insert",
280
+ before: null,
281
+ after: { id: "t9", owner: "alice", status: "open" },
282
+ });
283
+ await new Promise((r) => setTimeout(r, 80));
284
+ expect(delivered).toEqual([
285
+ { kind: "upsert", row: { id: "a9", owner: "alice", status: "log" } },
286
+ ]);
287
+ } finally {
288
+ unsub();
289
+ }
290
+ });
291
+ });