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.
- package/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end Phase 2 gate (pglite, real SQL):
|
|
3
|
+
*
|
|
4
|
+
* sql-session write → CDC sink → { runtime ingest, outbox append } →
|
|
5
|
+
* CdcOutboxRunner drain (redelivery path) → per-subscriber classification
|
|
6
|
+
* via stamped EXISTS + oke.row_passes_policies replay → delivered events.
|
|
7
|
+
*
|
|
8
|
+
* Proves the realtime leg against the SAME stamped production physics as the
|
|
9
|
+
* parity suite — not scripted probes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { connectPglite } from "../../drivers/pglite.ts";
|
|
14
|
+
import {
|
|
15
|
+
installOkeRlsHelpers,
|
|
16
|
+
buildRlsIdentityPreludeSql,
|
|
17
|
+
ROW_PASSES_POLICIES_STATEMENTS as ROW_PASSES,
|
|
18
|
+
type RlsIdentity,
|
|
19
|
+
} from "../../drivers/pg-rls.ts";
|
|
20
|
+
import { CdcOutbox, CdcOutboxRunner, type OutboxRow } from "../../drivers/cdc-outbox.ts";
|
|
21
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
22
|
+
import { liveQueryRuntimeFromConn } from "./live-query-server.ts";
|
|
23
|
+
import { openLiveStream } from "../../kernel/realtime-bind.ts";
|
|
24
|
+
import type { LiveQueryEvent } from "./live-query-runtime.ts";
|
|
25
|
+
|
|
26
|
+
const ALICE: RlsIdentity = { gate: "member", userId: "alice", scopes: ["member"] };
|
|
27
|
+
const TABLE = "oke_live_e2e_notes";
|
|
28
|
+
|
|
29
|
+
describe("realtime bridge e2e (pglite)", () => {
|
|
30
|
+
let conn: SqlConnection;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
conn = await connectPglite({ url: "memory://oke-live-e2e", role: "primary" });
|
|
34
|
+
await installOkeRlsHelpers((sql) => conn.exec(sql));
|
|
35
|
+
for (const stmt of ROW_PASSES) await conn.exec(stmt);
|
|
36
|
+
await conn.exec(`DROP TABLE IF EXISTS ${TABLE}`);
|
|
37
|
+
await conn.exec(`CREATE TABLE ${TABLE} (
|
|
38
|
+
id text PRIMARY KEY, owner text NOT NULL, status text NOT NULL)`);
|
|
39
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON ${TABLE} TO oke_app`);
|
|
40
|
+
await conn.exec(`ALTER TABLE ${TABLE} ENABLE ROW LEVEL SECURITY`);
|
|
41
|
+
await conn.exec(`CREATE POLICY owner_all ON ${TABLE} AS PERMISSIVE FOR ALL TO public
|
|
42
|
+
USING (owner = oke.user()) WITH CHECK (owner = oke.user())`);
|
|
43
|
+
}, 30_000);
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await conn.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Stamp-free raw insert (seed helper). */
|
|
50
|
+
async function seed(id: string, owner: string, status: string): Promise<void> {
|
|
51
|
+
await conn.query(`INSERT INTO ${TABLE} VALUES (?, ?, ?)`, [id, owner, status]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("insert visible to subscriber → upsert via stamped EXISTS", async () => {
|
|
55
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
56
|
+
const delivered: LiveQueryEvent[] = [];
|
|
57
|
+
const unsub = runtime.subscribe({
|
|
58
|
+
id: "sse-alice",
|
|
59
|
+
ref: "sql:app",
|
|
60
|
+
table: TABLE,
|
|
61
|
+
pkColumn: "id",
|
|
62
|
+
identity: ALICE,
|
|
63
|
+
deliver(e) {
|
|
64
|
+
delivered.push(e);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
await seed("n1", "alice", "open");
|
|
69
|
+
runtime.onCdc({
|
|
70
|
+
tableName: TABLE,
|
|
71
|
+
op: "insert",
|
|
72
|
+
before: null,
|
|
73
|
+
after: { id: "n1", owner: "alice", status: "open" },
|
|
74
|
+
});
|
|
75
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
76
|
+
expect(delivered).toEqual([
|
|
77
|
+
{ kind: "upsert", row: { id: "n1", owner: "alice", status: "open" } },
|
|
78
|
+
]);
|
|
79
|
+
} finally {
|
|
80
|
+
unsub();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("update still visible → upsert with fresh image; stamp honored", async () => {
|
|
85
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
86
|
+
const delivered: LiveQueryEvent[] = [];
|
|
87
|
+
const unsub = runtime.subscribe({
|
|
88
|
+
id: "sse-alice-u",
|
|
89
|
+
ref: "sql:app",
|
|
90
|
+
table: TABLE,
|
|
91
|
+
pkColumn: "id",
|
|
92
|
+
identity: ALICE,
|
|
93
|
+
deliver(e) {
|
|
94
|
+
delivered.push(e);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await seed("n2", "alice", "open");
|
|
99
|
+
runtime.onCdc({
|
|
100
|
+
tableName: TABLE,
|
|
101
|
+
op: "update",
|
|
102
|
+
before: { id: "n2", owner: "alice", status: "open" },
|
|
103
|
+
after: { id: "n2", owner: "alice", status: "done" },
|
|
104
|
+
});
|
|
105
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
106
|
+
expect(delivered).toEqual([
|
|
107
|
+
{ kind: "upsert", row: { id: "n2", owner: "alice", status: "done" } },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// bob's row under ALICE's stamp → EXISTS denied by native RLS → ignore.
|
|
111
|
+
await seed("n3", "bob", "open");
|
|
112
|
+
runtime.onCdc({
|
|
113
|
+
tableName: TABLE,
|
|
114
|
+
op: "insert",
|
|
115
|
+
before: null,
|
|
116
|
+
after: { id: "n3", owner: "bob", status: "open" },
|
|
117
|
+
});
|
|
118
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
119
|
+
expect(delivered).toHaveLength(1);
|
|
120
|
+
expect(runtime.metrics.checkFailures).toBe(0);
|
|
121
|
+
} finally {
|
|
122
|
+
unsub();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("query-window exit emits revoked(reason query) through real SQL", async () => {
|
|
127
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
128
|
+
const delivered: LiveQueryEvent[] = [];
|
|
129
|
+
const unsub = runtime.subscribe({
|
|
130
|
+
id: "sse-alice-q",
|
|
131
|
+
ref: "sql:app",
|
|
132
|
+
table: TABLE,
|
|
133
|
+
pkColumn: "id",
|
|
134
|
+
identity: ALICE,
|
|
135
|
+
whereSql: `status = 'open'`,
|
|
136
|
+
deliver(e) {
|
|
137
|
+
delivered.push(e);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
await seed("n4", "alice", "open");
|
|
142
|
+
// A real remote write commits before its CDC event reaches us — mirror
|
|
143
|
+
// that ordering so the EXISTS probe sees the committed state.
|
|
144
|
+
await conn.query(`UPDATE ${TABLE} SET status = 'closed' WHERE id = 'n4'`);
|
|
145
|
+
runtime.onCdc({
|
|
146
|
+
tableName: TABLE,
|
|
147
|
+
op: "update",
|
|
148
|
+
before: { id: "n4", owner: "alice", status: "open" },
|
|
149
|
+
after: { id: "n4", owner: "alice", status: "closed" },
|
|
150
|
+
});
|
|
151
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
152
|
+
expect(delivered).toEqual([{ kind: "revoked", id: "n4", reason: "query" }]);
|
|
153
|
+
} finally {
|
|
154
|
+
unsub();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("RLS revocation (reassigned away) emits revoked(reason rls)", async () => {
|
|
159
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
160
|
+
const delivered: LiveQueryEvent[] = [];
|
|
161
|
+
const unsub = runtime.subscribe({
|
|
162
|
+
id: "sse-alice-r",
|
|
163
|
+
ref: "sql:app",
|
|
164
|
+
table: TABLE,
|
|
165
|
+
pkColumn: "id",
|
|
166
|
+
identity: ALICE,
|
|
167
|
+
deliver(e) {
|
|
168
|
+
delivered.push(e);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
await seed("n5", "mallory", "done");
|
|
173
|
+
// Committed reassignment mirrors the CDC payload (native RLS denies
|
|
174
|
+
// ALICE on the heap row → EXISTS false; replay explains why).
|
|
175
|
+
runtime.onCdc({
|
|
176
|
+
tableName: TABLE,
|
|
177
|
+
op: "update",
|
|
178
|
+
before: { id: "n5", owner: "alice", status: "open" },
|
|
179
|
+
after: { id: "n5", owner: "mallory", status: "done" },
|
|
180
|
+
});
|
|
181
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
182
|
+
expect(delivered).toEqual([{ kind: "revoked", id: "n5", reason: "rls" }]);
|
|
183
|
+
} finally {
|
|
184
|
+
unsub();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("delete of previously-visible row → delete (before-image replay)", async () => {
|
|
189
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
190
|
+
const delivered: LiveQueryEvent[] = [];
|
|
191
|
+
const unsub = runtime.subscribe({
|
|
192
|
+
id: "sse-alice-d",
|
|
193
|
+
ref: "sql:app",
|
|
194
|
+
table: TABLE,
|
|
195
|
+
pkColumn: "id",
|
|
196
|
+
identity: ALICE,
|
|
197
|
+
deliver(e) {
|
|
198
|
+
delivered.push(e);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
await seed("n6", "alice", "open");
|
|
203
|
+
await conn.query(`DELETE FROM ${TABLE} WHERE id = 'n6'`);
|
|
204
|
+
runtime.onCdc({
|
|
205
|
+
tableName: TABLE,
|
|
206
|
+
op: "delete",
|
|
207
|
+
before: { id: "n6", owner: "alice", status: "open" },
|
|
208
|
+
after: null,
|
|
209
|
+
});
|
|
210
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
211
|
+
expect(delivered).toEqual([{ kind: "delete", id: "n6" }]);
|
|
212
|
+
} finally {
|
|
213
|
+
unsub();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("outbox append → runner tick → drained into runtime (multi-host leg)", async () => {
|
|
218
|
+
const outbox = new CdcOutbox(conn);
|
|
219
|
+
await outbox.ensure();
|
|
220
|
+
|
|
221
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
222
|
+
const drainedEvents: LiveQueryEvent[] = [];
|
|
223
|
+
runtime.subscribe({
|
|
224
|
+
id: "sse-poller",
|
|
225
|
+
ref: "sql:app",
|
|
226
|
+
table: TABLE,
|
|
227
|
+
pkColumn: "id",
|
|
228
|
+
identity: ALICE,
|
|
229
|
+
deliver(e) {
|
|
230
|
+
drainedEvents.push(e);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Another host committed this row; only the outbox carries the event
|
|
235
|
+
// across hosts. The heap row must exist here for parity with production —
|
|
236
|
+
// CDC events always follow the commit they describe.
|
|
237
|
+
await seed("n7", "alice", "open");
|
|
238
|
+
await outbox.append({
|
|
239
|
+
tableName: TABLE,
|
|
240
|
+
op: "insert",
|
|
241
|
+
before: null,
|
|
242
|
+
after: { id: "n7", owner: "alice", status: "open" },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
let acked: readonly OutboxRow[] = [];
|
|
246
|
+
let batches = 0;
|
|
247
|
+
const runner = new CdcOutboxRunner(
|
|
248
|
+
outbox,
|
|
249
|
+
async (rows) => {
|
|
250
|
+
batches += 1;
|
|
251
|
+
for (const row of rows) {
|
|
252
|
+
runtime.onCdc({
|
|
253
|
+
tableName: row.tableName,
|
|
254
|
+
op: row.op,
|
|
255
|
+
before: row.before,
|
|
256
|
+
after: row.after,
|
|
257
|
+
seq: row.seq,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
acked = rows;
|
|
261
|
+
},
|
|
262
|
+
{ intervalMs: 10_000 },
|
|
263
|
+
);
|
|
264
|
+
const drained = await runner.tick();
|
|
265
|
+
expect(drained).toBeGreaterThanOrEqual(1);
|
|
266
|
+
expect(acked.length).toBeGreaterThanOrEqual(1);
|
|
267
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
268
|
+
expect(drainedEvents.some((e) => e.kind === "upsert" && e.row.id === "n7")).toBe(true);
|
|
269
|
+
|
|
270
|
+
// Redelivery safety: a second tick must NOT re-hand delivered rows.
|
|
271
|
+
const second = await runner.tick();
|
|
272
|
+
expect(second).toBe(0);
|
|
273
|
+
expect(batches).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("full prelude round-trip mirrors production stamps (buildRlsIdentityPreludeSql)", async () => {
|
|
277
|
+
// Guardrail: prelude statements execute inside a transaction exactly like
|
|
278
|
+
// createConnProbeRunner does — catches single-statement regressions.
|
|
279
|
+
const tx = conn.transaction!;
|
|
280
|
+
await tx(async (txc) => {
|
|
281
|
+
for (const stmt of buildRlsIdentityPreludeSql(ALICE)) {
|
|
282
|
+
await txc.exec(stmt.sql, stmt.params ?? []);
|
|
283
|
+
}
|
|
284
|
+
const rows = await txc.query(`SELECT count(*) AS n FROM ${TABLE} WHERE owner = ?`, ["alice"]);
|
|
285
|
+
expect(Number(rows[0]?.n)).toBeGreaterThan(0);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("openLiveStream push→pull bridge (pglite)", () => {
|
|
291
|
+
let conn: SqlConnection;
|
|
292
|
+
|
|
293
|
+
beforeAll(async () => {
|
|
294
|
+
conn = await connectPglite({ url: "memory://oke-live-stream", role: "primary" });
|
|
295
|
+
await installOkeRlsHelpers((sql) => conn.exec(sql));
|
|
296
|
+
for (const stmt of ROW_PASSES) await conn.exec(stmt);
|
|
297
|
+
await conn.exec(`DROP TABLE IF EXISTS ${TABLE}`);
|
|
298
|
+
await conn.exec(`CREATE TABLE ${TABLE} (
|
|
299
|
+
id text PRIMARY KEY, owner text NOT NULL, status text NOT NULL)`);
|
|
300
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON ${TABLE} TO oke_app`);
|
|
301
|
+
await conn.exec(`ALTER TABLE ${TABLE} ENABLE ROW LEVEL SECURITY`);
|
|
302
|
+
await conn.exec(`CREATE POLICY owner_all ON ${TABLE} AS PERMISSIVE FOR ALL TO public
|
|
303
|
+
USING (owner = oke.user()) WITH CHECK (owner = oke.user())`);
|
|
304
|
+
}, 30_000);
|
|
305
|
+
|
|
306
|
+
afterAll(async () => {
|
|
307
|
+
await conn.close();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
async function seed(id: string, owner: string, status: string): Promise<void> {
|
|
311
|
+
await conn.query(`INSERT INTO ${TABLE} VALUES (?, ?, ?)`, [id, owner, status]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
test("classified events flow through the SSE chunk iterator", async () => {
|
|
315
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
316
|
+
const stream = openLiveStream(
|
|
317
|
+
"sse-stream-alice",
|
|
318
|
+
{
|
|
319
|
+
table: TABLE,
|
|
320
|
+
identity: ALICE,
|
|
321
|
+
pkColumn: "id",
|
|
322
|
+
},
|
|
323
|
+
runtime,
|
|
324
|
+
);
|
|
325
|
+
try {
|
|
326
|
+
await seed("s1", "alice", "open");
|
|
327
|
+
runtime.onCdc({
|
|
328
|
+
tableName: TABLE,
|
|
329
|
+
op: "insert",
|
|
330
|
+
before: null,
|
|
331
|
+
after: { id: "s1", owner: "alice", status: "open" },
|
|
332
|
+
});
|
|
333
|
+
const iter = stream.chunks[Symbol.asyncIterator]();
|
|
334
|
+
const step = await Promise.race([
|
|
335
|
+
iter.next(),
|
|
336
|
+
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 2000)),
|
|
337
|
+
]);
|
|
338
|
+
expect(step).not.toBe("timeout");
|
|
339
|
+
if (step === "timeout") return;
|
|
340
|
+
expect(step.done).toBe(false);
|
|
341
|
+
expect(step.value.data).toEqual({
|
|
342
|
+
kind: "upsert",
|
|
343
|
+
row: { id: "s1", owner: "alice", status: "open" },
|
|
344
|
+
});
|
|
345
|
+
} finally {
|
|
346
|
+
await stream.close();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("close() detaches the subscription — no further delivery", async () => {
|
|
351
|
+
const runtime = liveQueryRuntimeFromConn(conn);
|
|
352
|
+
const stream = openLiveStream(
|
|
353
|
+
"sse-stream-close",
|
|
354
|
+
{
|
|
355
|
+
table: TABLE,
|
|
356
|
+
identity: ALICE,
|
|
357
|
+
pkColumn: "id",
|
|
358
|
+
},
|
|
359
|
+
runtime,
|
|
360
|
+
);
|
|
361
|
+
await stream.close();
|
|
362
|
+
runtime.onCdc({
|
|
363
|
+
tableName: TABLE,
|
|
364
|
+
op: "insert",
|
|
365
|
+
before: null,
|
|
366
|
+
after: { id: "gone", owner: "alice", status: "open" },
|
|
367
|
+
});
|
|
368
|
+
await seed("gone", "alice", "open");
|
|
369
|
+
const iter = stream.chunks[Symbol.asyncIterator]();
|
|
370
|
+
const step = await Promise.race([
|
|
371
|
+
iter.next(),
|
|
372
|
+
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200)),
|
|
373
|
+
]);
|
|
374
|
+
// Closed stream ends iteration rather than delivering.
|
|
375
|
+
expect(step === "timeout" || step.done === true).toBe(true);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveQuery server leg — binds {@link LiveQueryRuntime} to a real
|
|
3
|
+
* {@link SqlConnection} on Postgres-capable drivers.
|
|
4
|
+
*
|
|
5
|
+
* Probes run through the subscriber's stamped identity using the SAME
|
|
6
|
+
* production path as reads: RLS prelude (`SET LOCAL ROLE oke_app` + GUCs)
|
|
7
|
+
* pinned inside one `SqlConnection.transaction` frame. Replay checks use
|
|
8
|
+
* `oke.row_passes_policies`, installed alongside the other `oke.*` helpers
|
|
9
|
+
* at first stamped write.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
buildRlsIdentityPreludeSql,
|
|
14
|
+
ROW_PASSES_POLICIES_STATEMENTS,
|
|
15
|
+
type RlsIdentity,
|
|
16
|
+
} from "../../drivers/pg-rls.ts";
|
|
17
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
18
|
+
import {
|
|
19
|
+
LiveQueryRuntime,
|
|
20
|
+
type LiveProbeExec,
|
|
21
|
+
type LiveProbeRunner,
|
|
22
|
+
} from "./live-query-runtime.ts";
|
|
23
|
+
|
|
24
|
+
/** One `oke.row_passes_policies` install per connection. */
|
|
25
|
+
const rowPassesInstalls = new WeakMap<SqlConnection, Promise<void>>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensure `oke.row_passes_policies` exists on this connection.
|
|
29
|
+
*
|
|
30
|
+
* @param conn - Primary SQL connection
|
|
31
|
+
*/
|
|
32
|
+
export async function ensureRowPassesPolicies(conn: SqlConnection): Promise<void> {
|
|
33
|
+
let installing = rowPassesInstalls.get(conn);
|
|
34
|
+
if (!installing) {
|
|
35
|
+
installing = (async () => {
|
|
36
|
+
for (const stmt of ROW_PASSES_POLICIES_STATEMENTS) {
|
|
37
|
+
await conn.exec(stmt);
|
|
38
|
+
}
|
|
39
|
+
})().catch((err) => {
|
|
40
|
+
rowPassesInstalls.delete(conn);
|
|
41
|
+
throw err;
|
|
42
|
+
});
|
|
43
|
+
rowPassesInstalls.set(conn, installing);
|
|
44
|
+
}
|
|
45
|
+
await installing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Probe runner over one connection.
|
|
50
|
+
*
|
|
51
|
+
* PGlite is a single backend session — concurrent identity frames are
|
|
52
|
+
* serialized exactly like the write path (`rlsStampTails`). Pooled postgres
|
|
53
|
+
* pins each frame with `transaction()`.
|
|
54
|
+
*
|
|
55
|
+
* @param conn - SQL connection shared with the store runtime
|
|
56
|
+
*/
|
|
57
|
+
export function createConnProbeRunner(conn: SqlConnection): LiveProbeRunner {
|
|
58
|
+
const tails = new WeakMap<SqlConnection, Promise<unknown>>();
|
|
59
|
+
const serialize = <T>(run: () => Promise<T>): Promise<T> => {
|
|
60
|
+
const prev = tails.get(conn) ?? Promise.resolve();
|
|
61
|
+
const next = prev.catch(() => undefined).then(run);
|
|
62
|
+
tails.set(
|
|
63
|
+
conn,
|
|
64
|
+
next.catch(() => undefined),
|
|
65
|
+
);
|
|
66
|
+
return next;
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
async runStamped<T>(
|
|
70
|
+
identity: RlsIdentity,
|
|
71
|
+
fn: (exec: LiveProbeExec) => Promise<T>,
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
await ensureRowPassesPolicies(conn);
|
|
74
|
+
const frame = async (tx: SqlConnection): Promise<T> => {
|
|
75
|
+
for (const stmt of buildRlsIdentityPreludeSql(identity)) {
|
|
76
|
+
await tx.exec(stmt.sql, stmt.params ?? []);
|
|
77
|
+
}
|
|
78
|
+
return fn({ query: (sql, params) => tx.query(sql, params) });
|
|
79
|
+
};
|
|
80
|
+
if (!conn.transaction) {
|
|
81
|
+
throw new Error("live probes need SqlConnection.transaction to pin SET LOCAL");
|
|
82
|
+
}
|
|
83
|
+
if (conn.driverId === "pglite") {
|
|
84
|
+
return serialize(() => conn.transaction!(frame));
|
|
85
|
+
}
|
|
86
|
+
return conn.transaction(frame);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the boot-time runtime wired to a primary connection.
|
|
93
|
+
*
|
|
94
|
+
* @param conn - Shared primary SQL connection (postgres / pglite)
|
|
95
|
+
* @param options - Pool knobs forwarded to {@link LiveQueryRuntime}
|
|
96
|
+
*/
|
|
97
|
+
export function liveQueryRuntimeFromConn(
|
|
98
|
+
conn: SqlConnection,
|
|
99
|
+
options?: ConstructorParameters<typeof LiveQueryRuntime>[1],
|
|
100
|
+
): LiveQueryRuntime {
|
|
101
|
+
return new LiveQueryRuntime(createConnProbeRunner(conn), options);
|
|
102
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `liveQuery(fx, table, input, opts?)` — open a live query window for a
|
|
3
|
+
* hand-written Flow, reusing 100% of the shipped realtime stack.
|
|
4
|
+
*
|
|
5
|
+
* The companion to the `.live(table)` trigger modifier: parse the same
|
|
6
|
+
* PostgREST grammar as `store.resource` lists (`parseListQuery`), compile
|
|
7
|
+
* the window to SQL, stamp the caller's RLS identity, and open a
|
|
8
|
+
* classified CDC stream (`openLiveStream`) — returned as an SSE body via
|
|
9
|
+
* `fx.json.stream`. No new transport, no new classification; just the
|
|
10
|
+
* declaration surface the resource path previously owned.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Fx } from "../../kernel/fx.ts";
|
|
14
|
+
import type { JsonStreamResult } from "../../kernel/fx.ts";
|
|
15
|
+
import {
|
|
16
|
+
openLiveStream,
|
|
17
|
+
realtimeBridgeRuntime,
|
|
18
|
+
type LiveColumnKind,
|
|
19
|
+
} from "../../kernel/realtime-bind.ts";
|
|
20
|
+
import { compileWhere } from "./sql-condition.ts";
|
|
21
|
+
import { parseListQuery, resolveListScope, type ListOptions } from "./list-query.ts";
|
|
22
|
+
import { resolveColumns, resolveTableName } from "./table.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe the current SSE response to classified live events for one
|
|
26
|
+
* table — filtered by the same list grammar as `store.resource` lists and
|
|
27
|
+
* classified per-subscriber (RLS + query window) by the realtime bridge.
|
|
28
|
+
*
|
|
29
|
+
* @param fx - Flow `fx` door (identity + json stream physics)
|
|
30
|
+
* @param table - `store.schema.table` (or drizzle / table handle) binding
|
|
31
|
+
* @param input - Query record (the GET request's parsed query params)
|
|
32
|
+
* @param opts - Optional list surface (search/filter/order/select scopes)
|
|
33
|
+
* @returns The SSE stream body to return from the Flow's `do`
|
|
34
|
+
*/
|
|
35
|
+
export function liveQuery(
|
|
36
|
+
fx: Fx,
|
|
37
|
+
table: unknown,
|
|
38
|
+
input: unknown,
|
|
39
|
+
opts?: ListOptions,
|
|
40
|
+
): JsonStreamResult {
|
|
41
|
+
const tableName = resolveTableName(table);
|
|
42
|
+
const columns = resolveColumns(table);
|
|
43
|
+
const scope = resolveListScope(table, opts);
|
|
44
|
+
|
|
45
|
+
const bridge = realtimeBridgeRuntime();
|
|
46
|
+
if (!bridge) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`live query for "${tableName}" requires an RLS-capable SQL driver (postgres / pglite)`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Same PostgREST window as the resource list Flow — filters decide
|
|
53
|
+
// whether a row belongs to this live query ("query exit"), RLS decides
|
|
54
|
+
// visibility. Pagination cursors don't gate membership.
|
|
55
|
+
const parsed = parseListQuery(input, scope.query);
|
|
56
|
+
if (!parsed.ok) return parsed.failure as unknown as JsonStreamResult;
|
|
57
|
+
|
|
58
|
+
let whereSql: string | undefined;
|
|
59
|
+
let whereParams: readonly unknown[] = [];
|
|
60
|
+
const pageWhere = parsed.page.where;
|
|
61
|
+
if (pageWhere !== undefined) {
|
|
62
|
+
const compiled = compileWhere(pageWhere);
|
|
63
|
+
if (compiled.clause !== "") {
|
|
64
|
+
whereSql = compiled.clause;
|
|
65
|
+
whereParams = compiled.params;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const identity = fx.rlsIdentity;
|
|
70
|
+
if (!identity) {
|
|
71
|
+
throw new Error(`live query for "${tableName}" requires a gated identity`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pkColumn = columns.find((c) => c.primary)?.sqlName ?? columns[0]?.sqlName ?? "id";
|
|
75
|
+
const columnKinds: Record<string, LiveColumnKind> = {};
|
|
76
|
+
for (const c of columns) {
|
|
77
|
+
const kind: LiveColumnKind | undefined =
|
|
78
|
+
c.sqlType === "BOOLEAN"
|
|
79
|
+
? "boolean"
|
|
80
|
+
: c.sqlType === "INTEGER" || c.sqlType === "BIGINT" || c.sqlType === "REAL"
|
|
81
|
+
? "number"
|
|
82
|
+
: c.sqlType === "JSONB"
|
|
83
|
+
? undefined
|
|
84
|
+
: "string";
|
|
85
|
+
if (kind !== undefined) columnKinds[c.key] = kind;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stream = openLiveStream(fx.id(), {
|
|
89
|
+
table: tableName,
|
|
90
|
+
identity,
|
|
91
|
+
pkColumn,
|
|
92
|
+
...(whereSql !== undefined ? { whereSql } : {}),
|
|
93
|
+
...(whereParams.length > 0 ? { whereParams } : {}),
|
|
94
|
+
...(Object.keys(columnKinds).length > 0 ? { tableColumns: columnKinds } : {}),
|
|
95
|
+
});
|
|
96
|
+
return fx.json.stream(stream.chunks);
|
|
97
|
+
}
|