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,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
|
+
}
|