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,194 @@
1
+ /**
2
+ * G1 — Serialized RLS stamp queue (PGlite).
3
+ *
4
+ * Measures stamp-frame serialization latency vs concurrency on ONE shared
5
+ * PGlite connection (`withRlsStampLock` physics). Two bind shapes:
6
+ * • 3 binds — gate / user / scopes (no tenantId)
7
+ * • 4 binds — gate / user / scopes + tenantId
8
+ *
9
+ * Expectation: roughly linear inverse degradation with N (the queue working
10
+ * as designed). Super-linear collapse is flagged as an issue.
11
+ *
12
+ * Run: bun test src/bench/g01-rls-stamp.bench.ts --timeout 120000
13
+ * Env: OKE_BENCH=1 (PGlite only — no Docker needed). OKE_BENCH_CAL=1 shrinks
14
+ * iterations for a calibration pass.
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
18
+ import { connectPglite } from "../drivers/pglite.ts";
19
+ import { installOkeRlsHelpers } from "../drivers/pg-rls.ts";
20
+ import type { SqlConnection } from "../drivers/types.ts";
21
+ import { createSqlStoreHandle, type SqlStoreHandle } from "../elements/store/sql-session.ts";
22
+ import { DISCLAIMER, HARDWARE, summarize, writeArtifact } from "./lib/report.ts";
23
+
24
+ const CAL = process.env.OKE_BENCH_CAL === "1";
25
+ /** Concurrent callers per sweep point. */
26
+ const CONCURRENCY = CAL ? [10, 50] : [10, 50, 100, 200, 500];
27
+ /** Repeats per (N, shape) point — pooled into that point's samples. */
28
+ const ROUNDS = CAL ? 1 : 3;
29
+
30
+ const NOTES_DDL = `CREATE TABLE notes (
31
+ id text PRIMARY KEY,
32
+ owner text NOT NULL,
33
+ body text NOT NULL
34
+ )`;
35
+
36
+ const OWNER_POLICY = `CREATE POLICY owner_select ON notes
37
+ AS PERMISSIVE FOR SELECT TO public
38
+ USING (owner = oke.user())`;
39
+
40
+ let conn: SqlConnection;
41
+ /** 3-bind stamp (gate/user/scopes). */
42
+ let asUser3: SqlStoreHandle;
43
+ /** 4-bind stamp (+tenantId). */
44
+ let asUser4: SqlStoreHandle;
45
+
46
+ const PROBE_SQL = `SELECT oke.user() AS u, oke.tenant() AS t`;
47
+
48
+ beforeAll(async () => {
49
+ conn = await connectPglite({ url: "memory://bench-g01-rls-stamp", role: "primary" });
50
+ await conn.exec(NOTES_DDL);
51
+ await conn.exec(`INSERT INTO notes VALUES ('a', 'alice', 'hi'), ('b', 'bob', 'yo')`);
52
+ await conn.exec(`ALTER TABLE notes ENABLE ROW LEVEL SECURITY`);
53
+ await installOkeRlsHelpers((sql) => conn.exec(sql));
54
+ await conn.exec(OWNER_POLICY);
55
+
56
+ const base = {
57
+ classifications: new Map(),
58
+ routedRole: "primary",
59
+ domainDdl: "off",
60
+ } as const;
61
+ asUser3 = createSqlStoreHandle("sql:app", {
62
+ ...base,
63
+ connection: conn,
64
+ rls: { gate: "member", userId: "alice", scopes: ["member"] },
65
+ });
66
+ asUser4 = createSqlStoreHandle("sql:app", {
67
+ ...base,
68
+ connection: conn,
69
+ rls: { gate: "member", userId: "bob", scopes: ["member"], tenantId: "acme" },
70
+ });
71
+ }, 15_000);
72
+
73
+ afterAll(async () => {
74
+ await conn.close();
75
+ });
76
+
77
+ interface PointResult {
78
+ readonly n: number;
79
+ readonly shape: "binds3" | "binds4";
80
+ readonly p50Ms: number;
81
+ readonly p99Ms: number;
82
+ readonly maxMs: number;
83
+ /** Batch throughput: calls / wall-clock seconds. */
84
+ readonly opsPerSec: number;
85
+ }
86
+
87
+ /**
88
+ * Fire `n` concurrent stamped probes; return per-call end-to-end latency.
89
+ */
90
+ async function sweepPoint(
91
+ handle: SqlStoreHandle,
92
+ n: number,
93
+ rounds: number,
94
+ ): Promise<{ latencies: number[]; opsPerSec: number }> {
95
+ const latencies: number[] = [];
96
+ let wallMs = 0;
97
+ for (let r = 0; r < rounds; r += 1) {
98
+ const t0 = performance.now();
99
+ const times = await Promise.all(
100
+ Array.from({ length: n }, async () => {
101
+ const s = performance.now();
102
+ await handle.raw(PROBE_SQL);
103
+ return performance.now() - s;
104
+ }),
105
+ );
106
+ wallMs += performance.now() - t0;
107
+ latencies.push(...times);
108
+ }
109
+ return { latencies, opsPerSec: (n * rounds) / (wallMs / 1000) };
110
+ }
111
+
112
+ describe.skipIf(!process.env.OKE_BENCH)("G1 — RLS stamp serialization (pglite)", () => {
113
+ test("correctness sanity: concurrent identities do not leak across stamps", async () => {
114
+ const [u3, u4, u3again] = await Promise.all([
115
+ asUser3.raw(PROBE_SQL),
116
+ asUser4.raw(PROBE_SQL),
117
+ asUser3.raw(PROBE_SQL),
118
+ ]);
119
+ expect((u3[0] as { u: string }).u).toBe("alice");
120
+ expect((u4[0] as { u: string }).u).toBe("bob");
121
+ expect((u3[0] as { t: string | null }).t).toBeNull();
122
+ expect((u4[0] as { t: string }).t).toBe("acme");
123
+ expect((u3again[0] as { u: string }).u).toBe("alice");
124
+ });
125
+
126
+ test(
127
+ "sweep concurrency × bind shapes",
128
+ async () => {
129
+ const points: PointResult[] = [];
130
+ for (const n of CONCURRENCY) {
131
+ for (const [shape, handle] of [
132
+ ["binds3", asUser3],
133
+ ["binds4", asUser4],
134
+ ] as const) {
135
+ const { latencies, opsPerSec } = await sweepPoint(handle, n, ROUNDS);
136
+ const s = summarize(latencies);
137
+ const maxMs = Number(Math.max(...latencies).toFixed(3));
138
+ points.push({
139
+ n,
140
+ shape,
141
+ p50Ms: s.p50Ms,
142
+ p99Ms: s.p99Ms,
143
+ maxMs,
144
+ opsPerSec: Number(opsPerSec.toFixed(1)),
145
+ });
146
+ }
147
+ }
148
+
149
+ // Report + issue detection: super-linear collapse = queue physics broken.
150
+ const issues: string[] = [];
151
+ for (const shape of ["binds3", "binds4"] as const) {
152
+ const pts = points.filter((p) => p.shape === shape);
153
+ for (let i = 1; i < pts.length; i += 1) {
154
+ const prev = pts[i - 1]!;
155
+ const cur = pts[i]!;
156
+ const nRatio = cur.n / prev.n;
157
+ const p50Ratio = cur.p50Ms / Math.max(prev.p50Ms, 0.001);
158
+ if (p50Ratio > nRatio * 2.5) {
159
+ issues.push(
160
+ `super-linear collapse at N=${cur.n} (${shape}): p50 grew ${p50Ratio.toFixed(1)}x for ${nRatio.toFixed(1)}x concurrency`,
161
+ );
162
+ }
163
+ }
164
+ }
165
+
166
+ console.log("[G1] points:", JSON.stringify(points));
167
+ if (issues.length > 0) console.warn("[G1] ISSUES:", issues);
168
+
169
+ const metrics: Record<string, number> = {};
170
+ for (const p of points) {
171
+ metrics[`n${p.n}.${p.shape}.p50Ms`] = p.p50Ms;
172
+ metrics[`n${p.n}.${p.shape}.p99Ms`] = p.p99Ms;
173
+ metrics[`n${p.n}.${p.shape}.maxMs`] = p.maxMs;
174
+ metrics[`n${p.n}.${p.shape}.opsPerSec`] = p.opsPerSec;
175
+ }
176
+
177
+ const path = await writeArtifact({
178
+ group: "G1",
179
+ hardware: HARDWARE,
180
+ disclaimer: DISCLAIMER,
181
+ command: CAL
182
+ ? "OKE_BENCH=1 OKE_BENCH_CAL=1 bun test src/bench/g01-rls-stamp.bench.ts --timeout 120000"
183
+ : "OKE_BENCH=1 bun test src/bench/g01-rls-stamp.bench.ts --timeout 120000",
184
+ metrics,
185
+ issues,
186
+ fixes: [],
187
+ remeasured: null,
188
+ });
189
+ console.log(`[G1] artifact: ${path}`);
190
+ expect(points.length).toBe(CONCURRENCY.length * 2);
191
+ },
192
+ CAL ? 60_000 : 120_000,
193
+ );
194
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * G2 — clock.perTenant lease contention (live Postgres via pgdog).
3
+ *
4
+ * Seeds ≥100 `oke_crons` rows named via {@link perTenantCronName}, then fires
5
+ * concurrent lease acquisitions through `acquireLease` (which runs
6
+ * `CLAIM_LEASE_SQL` = `FOR UPDATE SKIP LOCKED`). Two phases:
7
+ *
8
+ * 1. Correctness: C instances race for ONE name with a long lease —
9
+ * exactly 1 winner, duplicate-fire count === 0.
10
+ * 2. Throughput: N tenants × K concurrent claimers sweep; histogram of
11
+ * claim latencies + claims/sec.
12
+ *
13
+ * Run: OKE_BENCH=1 OKE_TEST_POSTGRES=1 DATABASE_URL=… bun test ./src/bench/g02-clock-per-tenant.bench.ts --timeout 180000
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17
+ import { perTenantCronName } from "../elements/clock/declare.ts";
18
+ import type { CronRow } from "../elements/clock/reconcile.ts";
19
+ import { createPostgresCronStore, type PostgresCronSql } from "../drivers/clock-postgres.ts";
20
+ import { LIVE_PG } from "./lib/infra.ts";
21
+ import { DISCLAIMER, HARDWARE, percentile, writeArtifact } from "./lib/report.ts";
22
+
23
+ const CAL = process.env.OKE_BENCH_CAL === "1";
24
+ const TENANTS = CAL ? 20 : 120;
25
+ const CLAIMERS_PER_TENANT = CAL ? [4] : [4, 16];
26
+ const CONTENTION_LEASE_MS = CAL ? 5_000 : 30_000;
27
+ /** Prefix so we only ever touch our own rows on cleanup. */
28
+ const TEMPLATE = "bench-g2-cron";
29
+
30
+ let store: Awaited<ReturnType<typeof createPostgresCronStore>>;
31
+
32
+ function seedRow(name: string): CronRow {
33
+ return {
34
+ name,
35
+ declaredEvery: "1h",
36
+ effectiveEvery: "1h",
37
+ timezone: "UTC",
38
+ overridable: false,
39
+ status: "active",
40
+ };
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ if (!LIVE_PG) throw new Error("G2 needs live Postgres: set OKE_TEST_POSTGRES=1 + DATABASE_URL");
45
+ store = await createPostgresCronStore({ url: LIVE_PG });
46
+ }, 15_000);
47
+
48
+ afterAll(async () => {
49
+ if (!store) return;
50
+ // Delete only rows this bench seeded.
51
+ await store.sql.exec(`DELETE FROM oke_crons WHERE name LIKE ?`, [`${TEMPLATE}#%`]);
52
+ await store.close();
53
+ });
54
+
55
+ describe.skipIf(!process.env.OKE_BENCH || !LIVE_PG)("G2 — perTenant lease contention", () => {
56
+ test("correctness: concurrent claimers → exactly one holder, zero duplicate fires", async () => {
57
+ const name = perTenantCronName(TEMPLATE, "dupe-check");
58
+ const existing = await store.get(name);
59
+ if (!existing) await store.put(seedRow(name));
60
+
61
+ const C = 24;
62
+ const results = await Promise.all(
63
+ Array.from({ length: C }, (_, i) =>
64
+ store.acquireLease(name, `g2-instance-${i}`, Date.now(), CONTENTION_LEASE_MS),
65
+ ),
66
+ );
67
+ const winners = results.filter(Boolean).length;
68
+ expect(winners).toBe(1);
69
+
70
+ const row = await store.get(name);
71
+ expect(row?.leaderInstanceId ?? (await rawLeader())).toBeTruthy();
72
+ }, 60_000);
73
+
74
+ test(
75
+ "throughput sweep: tenants × concurrent claimers",
76
+ async () => {
77
+ const names = Array.from({ length: TENANTS }, (_, i) => perTenantCronName(TEMPLATE, `t${i}`));
78
+ // Seed (idempotent).
79
+ const have = new Set((await store.list()).map((r) => r.name));
80
+ for (const n of names) if (!have.has(n)) await store.put(seedRow(n));
81
+
82
+ const metrics: Record<string, number> = {};
83
+ let totalDuplicateFires = 0;
84
+ const allLatencies: number[] = [];
85
+
86
+ for (const k of CLAIMERS_PER_TENANT) {
87
+ const latencies: number[] = [];
88
+ let wins = 0;
89
+ const t0 = performance.now();
90
+ // Wave: every tenant contested by k claimers at once.
91
+ const rounds = CAL ? 1 : 3;
92
+ for (let r = 0; r < rounds; r += 1) {
93
+ await Promise.all(
94
+ names.flatMap((name) =>
95
+ Array.from({ length: k }, (_, i) => {
96
+ const s = performance.now();
97
+ return store
98
+ .acquireLease(name, `g2-sweep-${r}-${i}`, Date.now(), 60_000)
99
+ .then((ok) => {
100
+ latencies.push(performance.now() - s);
101
+ if (ok) wins += 1;
102
+ });
103
+ }),
104
+ ),
105
+ );
106
+ }
107
+ const wallS = (performance.now() - t0) / 1000;
108
+ // Each (tenant, round) must elect exactly one holder per lease window.
109
+ const expectedWins = names.length * rounds;
110
+ const duplicateFires = Math.max(0, wins - expectedWins);
111
+ totalDuplicateFires += duplicateFires;
112
+
113
+ const p50 = percentile(latencies, 50);
114
+ const p99 = percentile(latencies, 99);
115
+ metrics[`k${k}.claims`] = latencies.length;
116
+ metrics[`k${k}.p50Ms`] = Number(p50.toFixed(3));
117
+ metrics[`k${k}.p99Ms`] = Number(p99.toFixed(3));
118
+ metrics[`k${k}.maxMs`] = Number(Math.max(...latencies).toFixed(3));
119
+ metrics[`k${k}.claimsPerSec`] = Number((latencies.length / wallS).toFixed(1));
120
+ metrics[`k${k}.duplicateFires`] = duplicateFires;
121
+ allLatencies.push(...latencies);
122
+ }
123
+
124
+ console.log("[G2] metrics:", JSON.stringify(metrics));
125
+
126
+ const issues: string[] = [];
127
+ if (totalDuplicateFires > 0) {
128
+ issues.push(
129
+ `duplicate lease fires observed: ${totalDuplicateFires} (CLAIM_LEASE_SQL correctness)`,
130
+ );
131
+ }
132
+
133
+ const path = await writeArtifact({
134
+ group: "G2",
135
+ hardware: HARDWARE,
136
+ disclaimer: DISCLAIMER,
137
+ command:
138
+ "OKE_BENCH=1 OKE_TEST_POSTGRES=1 DATABASE_URL=$DATABASE_URL bun test ./src/bench/g02-clock-per-tenant.bench.ts --timeout 180000",
139
+ metrics,
140
+ issues,
141
+ fixes: [],
142
+ remeasured: null,
143
+ });
144
+ console.log(`[G2] artifact: ${path}`);
145
+ expect(totalDuplicateFires).toBe(0);
146
+ },
147
+ CAL ? 90_000 : 180_000,
148
+ );
149
+ });
150
+
151
+ /** Fallback leader read when the CronRow mapping hides the lease columns. */
152
+ async function rawLeader(): Promise<string | null> {
153
+ const sql: PostgresCronSql = store.sql;
154
+ const rows = await sql.query(`SELECT locked_by FROM oke_crons WHERE name = ?`, [
155
+ perTenantCronName(TEMPLATE, "dupe-check"),
156
+ ]);
157
+ return (rows[0] as { locked_by?: string | null } | undefined)?.locked_by ?? null;
158
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * G3a — competing once-consumer sweep against the live postgres signal driver.
3
+ *
4
+ * Opens a real transactional-outbox bus (`openPostgresSignal`) on live
5
+ * Postgres (pgdog), subscribes N competing consumers on one `once` signal,
6
+ * emits M messages, and measures delivery throughput + `throughputPerSec()`
7
+ * from `bus.inspect()` for N ∈ {1, 2, 4, 8, 16}.
8
+ *
9
+ * Honesty note: Bun.SQL has no LISTEN/NOTIFY API, so wakeups are fanned out
10
+ * in-process by `lib/signal-pg.ts` (real SQL outbox + real claims; only the
11
+ * cross-process wakeup path is not exercised).
12
+ *
13
+ * Run: OKE_BENCH=1 OKE_TEST_POSTGRES=1 DATABASE_URL=… bun test ./src/bench/g03-signal-once.bench.ts --timeout 180000
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17
+ import { openPostgresSignal } from "../drivers/signal-postgres.ts";
18
+ import type { SignalBus } from "../drivers/signal-types.ts";
19
+ import { signal } from "../elements/signal/declare.ts";
20
+ import { LIVE_PG } from "./lib/infra.ts";
21
+ import { createBunSignalSql } from "./lib/signal-pg.ts";
22
+ import { DISCLAIMER, HARDWARE, writeArtifact } from "./lib/report.ts";
23
+
24
+ const CAL = process.env.OKE_BENCH_CAL === "1";
25
+ const SIGNAL_NAME = "g3-once";
26
+ const MESSAGES_PER_LEVEL = CAL ? 50 : 200;
27
+ const LEVELS = CAL ? [1, 4] : [1, 2, 4, 8, 16];
28
+ const DRAIN_TIMEOUT_MS = 30_000;
29
+
30
+ let sql: ReturnType<typeof createBunSignalSql>;
31
+ let bus: SignalBus;
32
+
33
+ beforeAll(async () => {
34
+ if (!LIVE_PG) throw new Error("G3 needs live Postgres: set OKE_TEST_POSTGRES=1 + DATABASE_URL");
35
+ sql = createBunSignalSql(LIVE_PG);
36
+ const decl = signal(SIGNAL_NAME, { delivery: "once", retries: 2, deadLetter: true });
37
+ bus = await openPostgresSignal({
38
+ signals: new Map([[decl.name, decl]]),
39
+ sql,
40
+ leaseMs: 5_000,
41
+ });
42
+ }, 20_000);
43
+
44
+ afterAll(async () => {
45
+ try {
46
+ await sql.exec(`DELETE FROM oke_signal_messages WHERE signal LIKE 'g3-%'`);
47
+ } catch {
48
+ /* best-effort cleanup */
49
+ }
50
+ try {
51
+ await bus?.close();
52
+ } catch {
53
+ /* already closed */
54
+ }
55
+ try {
56
+ await sql.close();
57
+ } catch {
58
+ /* best-effort */
59
+ }
60
+ });
61
+
62
+ describe.skipIf(!process.env.OKE_BENCH || !LIVE_PG)("G3a — once-consumer sweep", () => {
63
+ test(`${MESSAGES_PER_LEVEL} msgs × competing consumers ${LEVELS.join("/")} — throughput + inspect()`, async () => {
64
+ const perLevel: Array<{
65
+ consumers: number;
66
+ delivered: number;
67
+ msgsPerSec: number;
68
+ wallMs: number;
69
+ throughputPerSec: number;
70
+ }> = [];
71
+ const issues: string[] = [];
72
+
73
+ for (const n of LEVELS) {
74
+ // Fresh consumer cohort per level.
75
+ const received: number[] = [];
76
+ const unsubs: Array<() => void | Promise<void>> = [];
77
+ for (let i = 0; i < n; i++) {
78
+ const id = `g3-c${n}-${i}`;
79
+ const unsub = await bus.subscribe(SIGNAL_NAME, id, async () => {
80
+ received.push(1);
81
+ });
82
+ unsubs.push(unsub);
83
+ }
84
+
85
+ const t0 = performance.now();
86
+ // One emit == one outbox message; fire MESSAGES_PER_LEVEL of them
87
+ // (small concurrent batches), then closed-loop drain until all are
88
+ // delivered or timeout.
89
+ const BATCH = 25;
90
+ for (let i = 0; i < MESSAGES_PER_LEVEL; i += BATCH) {
91
+ await Promise.all(
92
+ Array.from({ length: Math.min(BATCH, MESSAGES_PER_LEVEL - i) }, (_, j) =>
93
+ bus.emit(SIGNAL_NAME, { level: n, seq: i + j }),
94
+ ),
95
+ );
96
+ }
97
+
98
+ // Closed-loop drain until every message is delivered (or timeout).
99
+ const deadline = Date.now() + DRAIN_TIMEOUT_MS;
100
+ while (received.length < MESSAGES_PER_LEVEL && Date.now() < deadline) {
101
+ await bus.drain();
102
+ await Bun.sleep(2);
103
+ }
104
+ const wallMs = performance.now() - t0;
105
+
106
+ const stats = (await bus.inspect()).find((s) => s.signal === SIGNAL_NAME);
107
+ perLevel.push({
108
+ consumers: n,
109
+ delivered: received.length,
110
+ msgsPerSec: Number((received.length / (wallMs / 1000)).toFixed(1)),
111
+ wallMs: Number(wallMs.toFixed(0)),
112
+ throughputPerSec: stats?.throughputPerSec ?? -1,
113
+ });
114
+
115
+ for (const u of unsubs) await u();
116
+
117
+ if (received.length !== MESSAGES_PER_LEVEL) {
118
+ issues.push(
119
+ `consumers=${n}: delivered ${received.length}/${MESSAGES_PER_LEVEL} within ${DRAIN_TIMEOUT_MS}ms`,
120
+ );
121
+ }
122
+ // Exactly-once across competing consumers.
123
+ expect(received.length).toBeLessThanOrEqual(MESSAGES_PER_LEVEL);
124
+ }
125
+
126
+ const metrics: Record<string, number> = {};
127
+ for (const l of perLevel) {
128
+ metrics[`consumers${l.consumers}MsgsPerSec`] = l.msgsPerSec;
129
+ metrics[`consumers${l.consumers}ThroughputInspect`] = l.throughputPerSec;
130
+ metrics[`consumers${l.consumers}WallMs`] = l.wallMs;
131
+ }
132
+ metrics.totalDelivered = perLevel.reduce((a, l) => a + l.delivered, 0);
133
+ // Super-linear collapse check: 16 consumers should retain ≥25% of the
134
+ // 1-consumer rate (queue physics allow some degradation, not collapse).
135
+ const base = perLevel.find((l) => l.consumers === LEVELS[0])?.msgsPerSec ?? 0;
136
+ const top = perLevel[perLevel.length - 1]?.msgsPerSec ?? 0;
137
+ if (base > 0 && top > 0 && top < base * 0.25) {
138
+ issues.push(`super-linear collapse: ${LEVELS[0]}c=${base}/s vs ${top}c=${top}/s`);
139
+ }
140
+
141
+ console.log("[G3a] per-level:", JSON.stringify(perLevel));
142
+ const path = await writeArtifact({
143
+ group: "G3a-signal-once",
144
+ hardware: HARDWARE,
145
+ disclaimer: DISCLAIMER,
146
+ command:
147
+ "OKE_BENCH=1 OKE_TEST_POSTGRES=1 DATABASE_URL=$DATABASE_URL bun test ./src/bench/g03-signal-once.bench.ts --timeout 180000",
148
+ metrics,
149
+ issues,
150
+ fixes: [],
151
+ remeasured: null,
152
+ });
153
+ console.log(`[G3a] artifact: ${path}`);
154
+
155
+ expect(metrics.totalDelivered!).toBeGreaterThan(0);
156
+ }, 150_000);
157
+ });