okengine 0.23.0 → 0.23.1

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 (87) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +1 -1
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/index.mdx +15 -14
  8. package/site/content/docs/plugins/otp.mdx +3 -3
  9. package/site/content/docs/reference/fx.mdx +12 -4
  10. package/src/auth/tenants.ts +17 -0
  11. package/src/cli/doctor-diff.test.ts +29 -1
  12. package/src/cli/doctor-diff.ts +35 -0
  13. package/src/cli/manifest-pr-diff.ts +16 -0
  14. package/src/compiler/effects-fetch.test.ts +77 -0
  15. package/src/compiler/effects-infer.ts +87 -16
  16. package/src/compiler/effects-join.test.ts +48 -0
  17. package/src/compiler/extract.test.ts +28 -0
  18. package/src/compiler/extract.ts +11 -1
  19. package/src/compiler/fx-index.ts +58 -4
  20. package/src/console/index.ts +1 -1
  21. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
  32. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
  40. package/src/console/ui-next/dist/index.html +1 -1
  41. package/src/drivers/journal-postgres.test.ts +22 -1
  42. package/src/drivers/journal-postgres.ts +223 -12
  43. package/src/drivers/signal-compete.test.ts +234 -0
  44. package/src/drivers/signal-postgres.ts +39 -19
  45. package/src/drivers/signal-redis.ts +167 -11
  46. package/src/drivers/signal-types.ts +12 -0
  47. package/src/elements/ai/mcp-protocol.ts +5 -6
  48. package/src/elements/ai/runtime.ts +20 -19
  49. package/src/elements/channel/runtime.ts +11 -0
  50. package/src/elements/channel/sql-ledger.ts +251 -0
  51. package/src/elements/signal/runtime.ts +9 -0
  52. package/src/elements/signal.test.ts +1 -0
  53. package/src/elements/store/cache.test.ts +120 -15
  54. package/src/elements/store/cache.ts +200 -39
  55. package/src/elements/store/prepare-row.test.ts +6 -2
  56. package/src/elements/store/sql-session.test.ts +2 -0
  57. package/src/elements/store/sql-session.ts +95 -9
  58. package/src/elements/store.ts +1 -0
  59. package/src/kernel/app.ts +32 -16
  60. package/src/kernel/auto-cache.test.ts +228 -0
  61. package/src/kernel/boot-bind/channel.ts +63 -2
  62. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  63. package/src/kernel/boot-bind/signal.ts +61 -25
  64. package/src/kernel/boot.ts +16 -2
  65. package/src/kernel/errors-compiler.ts +11 -0
  66. package/src/kernel/errors-text.ts +16 -0
  67. package/src/kernel/errors.ts +8 -0
  68. package/src/kernel/flow.ts +33 -3
  69. package/src/kernel/fx-call-types.test.ts +32 -0
  70. package/src/kernel/fx-decide.ts +11 -7
  71. package/src/kernel/fx-sql-handle.ts +46 -7
  72. package/src/kernel/fx.test.ts +1 -3
  73. package/src/kernel/fx.ts +27 -16
  74. package/src/kernel/idempotency.test.ts +2 -1
  75. package/src/kernel/journal.test.ts +114 -0
  76. package/src/kernel/journal.ts +369 -53
  77. package/src/kernel/pipeline-tenant.ts +5 -1
  78. package/src/kernel/pipeline.ts +5 -0
  79. package/src/kernel/signal-tx.ts +28 -0
  80. package/src/kernel/store-transaction.test.ts +95 -0
  81. package/src/mcp/docs-server.ts +19 -2
  82. package/src/mcp/mcp.test.ts +83 -0
  83. package/src/mcp/protocol.ts +8 -2
  84. package/src/mcp/server.ts +23 -2
  85. package/src/mcp/versions.ts +60 -0
  86. package/src/plugins/index.ts +1 -0
  87. package/src/plugins/mena.ts +56 -0
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
7
 
8
- import type { JournalRun } from "../kernel/journal.ts";
8
+ import { createJournal, type JournalRun } from "../kernel/journal.ts";
9
9
  import { createPostgresJournalFake, createPostgresJournalStore } from "./journal-postgres.ts";
10
10
 
11
11
  function seedRun(patch: Partial<JournalRun> & { id: string }): JournalRun {
@@ -174,4 +174,25 @@ describe("postgres JournalStore (fake)", () => {
174
174
  expect(row?.leaseExpiresAt).toBeUndefined();
175
175
  await store.close();
176
176
  });
177
+
178
+ test("step writes stay linear as the journal grows", async () => {
179
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
180
+ const journal = createJournal({ store, now: () => 1, codeVersion: "v1" });
181
+ const session = await journal.start("long");
182
+ const steps = 40;
183
+ const payload = "x".repeat(200);
184
+ for (let i = 0; i < steps; i++) {
185
+ await session.step(`s${i}`, () => payload);
186
+ }
187
+ const perStep = JSON.stringify({
188
+ kind: "step",
189
+ name: "s0",
190
+ value: payload,
191
+ at: 1,
192
+ }).length;
193
+ expect(store.writeBytes).toBeLessThan(steps * (perStep + 80) * 3);
194
+ const row = await store.get(session.runId);
195
+ expect(row?.entries).toHaveLength(steps);
196
+ await store.close();
197
+ });
177
198
  });
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Same concurrency physics as Signal's `once` delivery and Clock's postgres
5
5
  * CronStore: claim with `FOR UPDATE SKIP LOCKED`; a crashed holder's lease is
6
- * reclaimed lazily on the next claim attempt (no sweeper, no fencing token —
7
- * completed journal steps replay instead of re-running).
6
+ * reclaimed lazily on the next claim attempt (no sweeper). Each acquire bumps
7
+ * a fencing token; writes from a previous holder are rejected. Entries are
8
+ * append-only rows, not a rewritten JSON blob.
8
9
  */
9
10
 
10
11
  import {
@@ -22,6 +23,7 @@ import {
22
23
  } from "../kernel/idempotency-store.ts";
23
24
  import type { DecisionLabelStore } from "../kernel/decision-label-store.ts";
24
25
  import type { AgentEventStore } from "../kernel/agent-event-store.ts";
26
+ import { throwOke } from "../kernel/errors.ts";
25
27
  import { lazyRequire } from "../kernel/lazy-require.ts";
26
28
  import {
27
29
  JOURNAL_DEFAULT_LEASE_MS,
@@ -29,6 +31,7 @@ import {
29
31
  type JournalLeaseStore,
30
32
  type JournalRun,
31
33
  type JournalStore,
34
+ type JournalWriteFence,
32
35
  } from "../kernel/journal.ts";
33
36
  import {
34
37
  resolvePostgresUrl,
@@ -53,6 +56,19 @@ interface JournalDbRow {
53
56
  created_at: number;
54
57
  updated_at: number;
55
58
  tenant: string | null;
59
+ lease_token: number | null;
60
+ code_version: string | null;
61
+ }
62
+
63
+ /** One append-only journal entry row. */
64
+ interface JournalEntryDbRow {
65
+ run_id: string;
66
+ seq: number;
67
+ kind: string;
68
+ name: string | null;
69
+ resource: string | null;
70
+ value: string | null;
71
+ at: number;
56
72
  }
57
73
 
58
74
  /** Minimal SQL + transaction surface for the postgres journal store. */
@@ -85,7 +101,11 @@ const CLAIM_DUE_SQL = `SELECT * FROM oke_journal_runs WHERE status='sleeping' AN
85
101
  /** Boot-time orphan discovery: running/sleeping runs with no live lease. */
86
102
  const ORPHANS_SQL = `SELECT * FROM oke_journal_runs WHERE (status='running' OR status='sleeping' OR status='compensating') AND ((locked_by IS NULL) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?))`;
87
103
 
88
- const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ? WHERE id = ?`;
104
+ const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ?, lease_token = COALESCE(lease_token, 0) + 1 WHERE id = ?`;
105
+
106
+ const INSERT_ENTRY_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run_id, seq) DO UPDATE SET kind = EXCLUDED.kind, name = EXCLUDED.name, resource = EXCLUDED.resource, value = EXCLUDED.value, at = EXCLUDED.at`;
107
+
108
+ const SELECT_ENTRIES_SQL = `SELECT * FROM oke_journal_entries WHERE run_id = ? ORDER BY seq ASC`;
89
109
 
90
110
  /** Guarded release — never clears another holder's lease. */
91
111
  const RELEASE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = NULL, lease_expires_at = NULL WHERE id = ? AND locked_by = ?`;
@@ -171,9 +191,70 @@ function rowToRun(row: Record<string, unknown>): JournalRun {
171
191
  if (r.tenant !== null && r.tenant !== undefined && String(r.tenant).length > 0) {
172
192
  run.tenant = String(r.tenant);
173
193
  }
194
+ const leaseToken = numOrNull(r.lease_token);
195
+ if (leaseToken !== null) run.leaseToken = leaseToken;
196
+ if (
197
+ r.code_version !== null &&
198
+ r.code_version !== undefined &&
199
+ String(r.code_version).length > 0
200
+ ) {
201
+ run.codeVersion = String(r.code_version);
202
+ }
174
203
  return run;
175
204
  }
176
205
 
206
+ function entryToParams(runId: string, seq: number, entry: JournalEntry): unknown[] {
207
+ if (entry.kind === "step") {
208
+ return [runId, seq, "step", entry.name, null, JSON.stringify(entry.value ?? null), entry.at];
209
+ }
210
+ if (entry.kind === "sleep") {
211
+ return [
212
+ runId,
213
+ seq,
214
+ "sleep",
215
+ entry.label,
216
+ entry.duration,
217
+ JSON.stringify(entry.wakeAt),
218
+ entry.at,
219
+ ];
220
+ }
221
+ return [
222
+ runId,
223
+ seq,
224
+ "effect",
225
+ entry.effectKind,
226
+ entry.resource,
227
+ JSON.stringify(entry.value ?? null),
228
+ entry.at,
229
+ ];
230
+ }
231
+
232
+ function entryFromRow(row: Record<string, unknown>): JournalEntry {
233
+ const kind = String(row.kind);
234
+ const at = Number(row.at);
235
+ const value =
236
+ row.value === null || row.value === undefined ? null : JSON.parse(String(row.value));
237
+ if (kind === "step") {
238
+ return { kind: "step", name: String(row.name ?? ""), value, at };
239
+ }
240
+ if (kind === "sleep") {
241
+ return {
242
+ kind: "sleep",
243
+ label: String(row.name ?? ""),
244
+ duration: String(row.resource ?? ""),
245
+ wakeAt: Number(value),
246
+ at,
247
+ };
248
+ }
249
+ return {
250
+ kind: "effect",
251
+ effectKind: String(row.name ?? ""),
252
+ resource: String(row.resource ?? ""),
253
+ value,
254
+ at,
255
+ };
256
+ }
257
+
177
258
  function runToParams(run: JournalRun): unknown[] {
178
259
  return [
179
260
  run.id,
@@ -189,6 +270,8 @@ function runToParams(run: JournalRun): unknown[] {
189
270
  run.createdAt,
190
271
  run.updatedAt,
191
272
  run.tenant ?? null,
273
+ run.leaseToken ?? null,
274
+ run.codeVersion ?? null,
192
275
  ];
193
276
  }
194
277
 
@@ -209,6 +292,18 @@ async function ensureSchema(sql: PostgresJournalSql): Promise<void> {
209
292
  tenant TEXT
210
293
  )`);
211
294
  await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS tenant TEXT`);
295
+ await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS lease_token BIGINT`);
296
+ await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS code_version TEXT`);
297
+ await sql.exec(`CREATE TABLE IF NOT EXISTS oke_journal_entries (
298
+ run_id TEXT NOT NULL,
299
+ seq BIGINT NOT NULL,
300
+ kind TEXT NOT NULL,
301
+ name TEXT,
302
+ resource TEXT,
303
+ value TEXT,
304
+ at BIGINT NOT NULL,
305
+ PRIMARY KEY (run_id, seq)
306
+ )`);
212
307
  await sql.exec(
213
308
  `CREATE INDEX IF NOT EXISTS oke_journal_runs_wake ON oke_journal_runs (status, wake_at)`,
214
309
  );
@@ -290,6 +385,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
290
385
  event_type: string | null;
291
386
  event_name: string | null;
292
387
  }[];
388
+ entries: JournalEntryDbRow[];
293
389
  };
294
390
 
295
391
  let committed: State = {
@@ -300,6 +396,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
300
396
  candidates: [],
301
397
  agentRuns: [],
302
398
  agentEvents: [],
399
+ entries: [],
303
400
  };
304
401
  let active: { state: State; locked: Set<string>; done: boolean } | null = null;
305
402
  /** Run ids held by other active transactions (SKIP LOCKED). */
@@ -320,6 +417,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
320
417
  candidates: s.candidates.map((r) => ({ ...r })),
321
418
  agentRuns: s.agentRuns.map((r) => ({ ...r })),
322
419
  agentEvents: s.agentEvents.map((r) => ({ ...r })),
420
+ entries: s.entries.map((r) => ({ ...r })),
323
421
  };
324
422
  }
325
423
 
@@ -481,6 +579,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
481
579
  .map((row) => ({ ...row }));
482
580
  }
483
581
 
582
+ if (/FROM\s+oke_journal_entries/i.test(text)) {
583
+ const runId = String(params[0] ?? "");
584
+ return state.entries
585
+ .filter((row) => row.run_id === runId)
586
+ .sort((a, b) => a.seq - b.seq)
587
+ .map((row) => ({ ...row }));
588
+ }
589
+
484
590
  throw new Error(`postgres journal fake: unsupported query: ${sql}`);
485
591
  },
486
592
  async exec(sql, params = []) {
@@ -518,6 +624,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
518
624
  created_at: Number(record.created_at ?? 0),
519
625
  updated_at: Number(record.updated_at ?? 0),
520
626
  tenant: (record.tenant as string | null) ?? null,
627
+ lease_token:
628
+ record.lease_token === undefined || record.lease_token === null
629
+ ? (state.rows.find((r) => r.id === String(record.id))?.lease_token ?? null)
630
+ : Number(record.lease_token),
631
+ code_version:
632
+ record.code_version === undefined || record.code_version === null
633
+ ? (state.rows.find((r) => r.id === String(record.id))?.code_version ?? null)
634
+ : String(record.code_version),
521
635
  };
522
636
  const idx = state.rows.findIndex((r) => r.id === next.id);
523
637
  if (idx >= 0) state.rows[idx] = next;
@@ -526,15 +640,33 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
526
640
  }
527
641
 
528
642
  const updLease =
529
- /^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(
643
+ /^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?/i.exec(
530
644
  text,
531
645
  );
532
- if (updLease) {
533
- const row = state.rows.find((r) => r.id === params[2]);
646
+ if (updLease && /WHERE\s+id\s*=\s*\?/i.test(text) && !/locked_by\s*=\s*NULL/i.test(text)) {
647
+ const id = String(params[params.length - 1]);
648
+ const row = state.rows.find((r) => r.id === id);
534
649
  if (!row) return { changes: 0 };
535
650
  row.locked_by = params[0] === null ? null : String(params[0]);
536
651
  row.lease_expires_at =
537
652
  params[1] === null || params[1] === undefined ? null : Number(params[1]);
653
+ if (/lease_token/i.test(text)) row.lease_token = (row.lease_token ?? 0) + 1;
654
+ return { changes: 1 };
655
+ }
656
+
657
+ if (/^INSERT\s+INTO\s+oke_journal_entries\b/i.test(text)) {
658
+ const row: JournalEntryDbRow = {
659
+ run_id: String(params[0]),
660
+ seq: Number(params[1]),
661
+ kind: String(params[2]),
662
+ name: params[3] === null || params[3] === undefined ? null : String(params[3]),
663
+ resource: params[4] === null || params[4] === undefined ? null : String(params[4]),
664
+ value: params[5] === null || params[5] === undefined ? null : String(params[5]),
665
+ at: Number(params[6]),
666
+ };
667
+ const idx = state.entries.findIndex((e) => e.run_id === row.run_id && e.seq === row.seq);
668
+ if (idx >= 0) state.entries[idx] = row;
669
+ else state.entries.push(row);
538
670
  return { changes: 1 };
539
671
  }
540
672
 
@@ -807,12 +939,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
807
939
  return api;
808
940
  }
809
941
 
810
- const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant`;
942
+ const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant, lease_token, code_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant, lease_token = EXCLUDED.lease_token, code_version = EXCLUDED.code_version`;
811
943
 
812
944
  /** Postgres journal store with run-level lease coordination. */
813
945
  export type PostgresJournalStore = JournalStore &
814
946
  JournalLeaseStore & {
815
947
  readonly sql: PostgresJournalSql;
948
+ /** Bytes written for entry rows plus header entry payloads. */
949
+ readonly writeBytes: number;
816
950
  close(): Promise<void>;
817
951
  };
818
952
 
@@ -888,22 +1022,77 @@ export async function createPostgresJournalStore(
888
1022
 
889
1023
  await ensureSchema(sql);
890
1024
 
1025
+ let writeBytes = 0;
1026
+
1027
+ async function hydrate(row: Record<string, unknown>): Promise<JournalRun> {
1028
+ const run = rowToRun(row);
1029
+ const entryRows = await sql.query(SELECT_ENTRIES_SQL, [run.id]);
1030
+ if (entryRows.length > 0) {
1031
+ (run as { entries: JournalEntry[] }).entries = entryRows.map((entry) => entryFromRow(entry));
1032
+ return run;
1033
+ }
1034
+ if (run.entries.length > 0) {
1035
+ for (let seq = 0; seq < run.entries.length; seq++) {
1036
+ const entry = run.entries[seq];
1037
+ if (!entry) continue;
1038
+ const params = entryToParams(run.id, seq, entry);
1039
+ writeBytes += JSON.stringify(entry).length;
1040
+ await sql.exec(INSERT_ENTRY_SQL, params);
1041
+ }
1042
+ }
1043
+ return run;
1044
+ }
1045
+
1046
+ function assertFence(run: JournalRun | undefined, fence: JournalWriteFence | undefined): void {
1047
+ if (!fence || !run) return;
1048
+ const expired = run.leaseExpiresAt !== undefined && run.leaseExpiresAt <= fence.now;
1049
+ if (run.lockedBy !== fence.lockedBy || run.leaseToken !== fence.leaseToken || expired) {
1050
+ throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
1051
+ }
1052
+ }
1053
+
891
1054
  const store: PostgresJournalStore = {
892
1055
  sql,
1056
+ get writeBytes() {
1057
+ return writeBytes;
1058
+ },
893
1059
  idempotency: createPostgresIdempotencyStore(sql),
894
1060
  decisions: lazyDecisionLabels(sql),
895
1061
  agentEvents: lazyAgentEvents(sql),
896
1062
  async get(runId) {
897
1063
  const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
898
1064
  if (!rows[0]) return undefined;
899
- return rowToRun(rows[0]);
1065
+ return hydrate(rows[0]);
900
1066
  },
901
- async put(run) {
1067
+ async put(run, fence) {
1068
+ if (fence) {
1069
+ const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [run.id]);
1070
+ assertFence(rows[0] ? rowToRun(rows[0]) : undefined, fence);
1071
+ }
1072
+ writeBytes += JSON.stringify(run.entries).length;
902
1073
  await sql.exec(UPSERT_SQL, runToParams(run));
1074
+ for (let seq = 0; seq < run.entries.length; seq++) {
1075
+ const entry = run.entries[seq];
1076
+ if (!entry) continue;
1077
+ writeBytes += JSON.stringify(entry).length;
1078
+ await sql.exec(INSERT_ENTRY_SQL, entryToParams(run.id, seq, entry));
1079
+ }
1080
+ },
1081
+ async appendEntry(runId, seq, entry, fence) {
1082
+ if (fence) {
1083
+ const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
1084
+ const current = rows[0] ? rowToRun(rows[0]) : undefined;
1085
+ if (!current) throw new Error(`journal: run "${runId}" not found`);
1086
+ assertFence(current, fence);
1087
+ }
1088
+ writeBytes += JSON.stringify(entry).length;
1089
+ await sql.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
903
1090
  },
904
1091
  async list() {
905
1092
  const rows = await sql.query(`SELECT * FROM oke_journal_runs`);
906
- return rows.map((r) => rowToRun(r));
1093
+ const runs: JournalRun[] = [];
1094
+ for (const row of rows) runs.push(await hydrate(row));
1095
+ return runs;
907
1096
  },
908
1097
  async acquireLease(runId, instanceId, now, leaseMs) {
909
1098
  return sql.begin(async (tx) => {
@@ -925,10 +1114,28 @@ export async function createPostgresJournalStore(
925
1114
  return { lease: true, leaseExpiresAt: current.leaseExpiresAt };
926
1115
  }
927
1116
  const current = rowToRun(row);
1117
+ const entryRows = await tx.query(SELECT_ENTRIES_SQL, [runId]);
1118
+ if (entryRows.length > 0) {
1119
+ (current as { entries: JournalEntry[] }).entries = entryRows.map((entry) =>
1120
+ entryFromRow(entry),
1121
+ );
1122
+ }
928
1123
  const next = update(current) ?? current;
1124
+ const renew =
1125
+ current.lockedBy === instanceId &&
1126
+ current.leaseExpiresAt !== undefined &&
1127
+ current.leaseExpiresAt > now &&
1128
+ current.leaseToken !== undefined;
929
1129
  next.lockedBy = instanceId;
930
1130
  next.leaseExpiresAt = now + leaseMs;
931
- await tx.exec(UPSERT_SQL, runToParams(next));
1131
+ next.leaseToken = renew ? current.leaseToken : (current.leaseToken ?? 0) + 1;
1132
+ for (let seq = 0; seq < next.entries.length; seq++) {
1133
+ const entry = next.entries[seq];
1134
+ if (!entry) continue;
1135
+ await tx.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
1136
+ }
1137
+ const header = { ...next, entries: [] as JournalEntry[] };
1138
+ await tx.exec(UPSERT_SQL, runToParams(header));
932
1139
  return "ok";
933
1140
  });
934
1141
  },
@@ -941,7 +1148,11 @@ export async function createPostgresJournalStore(
941
1148
  const row = rows[0];
942
1149
  if (!row) return undefined;
943
1150
  await tx.exec(UPDATE_LEASE_SQL, [instanceId, now + leaseMs, String(row.id)]);
944
- return rowToRun(row);
1151
+ const run = rowToRun(row);
1152
+ run.lockedBy = instanceId;
1153
+ run.leaseExpiresAt = now + leaseMs;
1154
+ run.leaseToken = (run.leaseToken ?? 0) + 1;
1155
+ return run;
945
1156
  });
946
1157
  },
947
1158
  async listOrphans(now) {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Two consumers, one bus: postgres SKIP LOCKED and redis consumer groups.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { signal } from "../elements/signal.ts";
7
+ import {
8
+ openPostgresChannelLedger,
9
+ type ChannelLedgerSql,
10
+ } from "../elements/channel/sql-ledger.ts";
11
+ import { createPostgresSignalFake, openPostgresSignal } from "./signal-postgres.ts";
12
+ import {
13
+ createBunSignalRedisClient,
14
+ createSignalRedisFake,
15
+ openRedisSignal,
16
+ } from "./signal-redis.ts";
17
+
18
+ function decls(name: string, retries = 3) {
19
+ const decl = signal.once(name, { retries, deadLetter: true });
20
+ return new Map([[decl.name, decl]]);
21
+ }
22
+
23
+ describe("postgres signal competing consumers", () => {
24
+ test("one message is delivered once across two buses", async () => {
25
+ const sql = createPostgresSignalFake();
26
+ const name = "order-placed";
27
+ const a = await openPostgresSignal({ signals: decls(name), sql });
28
+ const b = await openPostgresSignal({ signals: decls(name), sql });
29
+ const got: string[] = [];
30
+ await a.subscribe(name, "a", async () => {
31
+ got.push("a");
32
+ });
33
+ await b.subscribe(name, "b", async () => {
34
+ got.push("b");
35
+ });
36
+ await a.emit(name, { id: "1" });
37
+ await Bun.sleep(5);
38
+ await a.drain();
39
+ await b.drain();
40
+ expect(got).toHaveLength(1);
41
+ await a.close();
42
+ await b.close();
43
+ });
44
+ });
45
+
46
+ describe("redis streams competing consumers", () => {
47
+ test("one message is delivered once across two groups", async () => {
48
+ const redis = createSignalRedisFake();
49
+ const name = "order-placed";
50
+ const shared = { signals: decls(name), redis, compete: true as const };
51
+ const a = await openRedisSignal({ ...shared, consumerId: "a" });
52
+ const b = await openRedisSignal({ ...shared, consumerId: "b" });
53
+ const got: string[] = [];
54
+ await a.subscribe(name, "a", async () => {
55
+ got.push("a");
56
+ });
57
+ await b.subscribe(name, "b", async () => {
58
+ got.push("b");
59
+ });
60
+ await a.emit(name, { id: "1" });
61
+ await a.drain();
62
+ await b.drain();
63
+ expect(got).toHaveLength(1);
64
+ await a.close();
65
+ await b.close();
66
+ });
67
+
68
+ test("a failed handler leaves the entry pending for redelivery", async () => {
69
+ const redis = createSignalRedisFake();
70
+ const name = "flaky";
71
+ const bus = await openRedisSignal({
72
+ signals: decls(name, 2),
73
+ redis,
74
+ compete: true,
75
+ consumerId: "a",
76
+ });
77
+ let calls = 0;
78
+ await bus.subscribe(name, "a", async () => {
79
+ calls += 1;
80
+ if (calls === 1) throw new Error("boom");
81
+ });
82
+ await bus.emit(name, { id: "1" });
83
+ await bus.drain();
84
+ expect(calls).toBe(1);
85
+ await bus.drain();
86
+ expect(calls).toBe(2);
87
+ await bus.close();
88
+ });
89
+
90
+ test("another consumer reclaims an unacked entry", async () => {
91
+ const redis = createSignalRedisFake();
92
+ const name = "handoff";
93
+ const shared = { signals: decls(name, 3), redis, compete: true as const };
94
+ const a = await openRedisSignal({ ...shared, consumerId: "a" });
95
+ const b = await openRedisSignal({ ...shared, consumerId: "b" });
96
+ let aCalls = 0;
97
+ const got: string[] = [];
98
+ await a.subscribe(name, "a", async () => {
99
+ aCalls += 1;
100
+ throw new Error("down");
101
+ });
102
+ await b.subscribe(name, "b", async () => {
103
+ got.push("b");
104
+ });
105
+ await a.emit(name, { id: "1" });
106
+ await a.drain();
107
+ await b.drain();
108
+ expect(aCalls).toBe(1);
109
+ expect(got).toEqual(["b"]);
110
+ await a.close();
111
+ await b.close();
112
+ });
113
+
114
+ test.skipIf(!process.env.REDIS_URL)("REDIS_URL: two clients ack once", async () => {
115
+ const url = process.env.REDIS_URL;
116
+ const name = `oke-compete-${Date.now()}`;
117
+ const redisA = createBunSignalRedisClient(url);
118
+ const redisB = createBunSignalRedisClient(url);
119
+ const a = await openRedisSignal({
120
+ signals: decls(name),
121
+ redis: redisA,
122
+ compete: true,
123
+ consumerId: "a",
124
+ });
125
+ const b = await openRedisSignal({
126
+ signals: decls(name),
127
+ redis: redisB,
128
+ compete: true,
129
+ consumerId: "b",
130
+ });
131
+ const got: string[] = [];
132
+ await a.subscribe(name, "a", async () => {
133
+ got.push("a");
134
+ });
135
+ await b.subscribe(name, "b", async () => {
136
+ got.push("b");
137
+ });
138
+ await a.emit(name, { id: "1" });
139
+ await a.drain();
140
+ await b.drain();
141
+ expect(got).toHaveLength(1);
142
+ await a.close();
143
+ await b.close();
144
+ });
145
+ });
146
+
147
+ function memoryLedgerSql(): ChannelLedgerSql {
148
+ const consent = new Map<string, { subject: string; medium: string; at: number }>();
149
+ const bounce = new Map<string, { subject: string; medium: string; at: number }>();
150
+ const receipts = new Map<string, string>();
151
+
152
+ function pair(params: readonly unknown[]): string {
153
+ return `${String(params[0])}\0${String(params[1])}`;
154
+ }
155
+
156
+ return {
157
+ async query(sql) {
158
+ if (sql.includes("oke_channel_consent")) {
159
+ return [...consent.values()];
160
+ }
161
+ if (sql.includes("oke_channel_bounce")) {
162
+ return [...bounce.values()];
163
+ }
164
+ if (sql.includes("oke_channel_receipt")) {
165
+ return [...receipts.entries()].map(([id, body]) => ({ id, body }));
166
+ }
167
+ return [];
168
+ },
169
+ async exec(sql, params = []) {
170
+ if (sql.startsWith("CREATE")) return { changes: 0 };
171
+ if (sql.includes("INSERT INTO oke_channel_consent")) {
172
+ consent.set(pair(params), {
173
+ subject: String(params[0]),
174
+ medium: String(params[1]),
175
+ at: Number(params[2]),
176
+ });
177
+ return { changes: 1 };
178
+ }
179
+ if (sql.includes("DELETE FROM oke_channel_consent")) {
180
+ consent.delete(pair(params));
181
+ return { changes: 1 };
182
+ }
183
+ if (sql.includes("INSERT INTO oke_channel_bounce")) {
184
+ bounce.set(pair(params), {
185
+ subject: String(params[0]),
186
+ medium: String(params[1]),
187
+ at: Number(params[2]),
188
+ });
189
+ return { changes: 1 };
190
+ }
191
+ if (sql.includes("DELETE FROM oke_channel_bounce")) {
192
+ bounce.delete(pair(params));
193
+ return { changes: 1 };
194
+ }
195
+ if (sql.includes("INSERT INTO oke_channel_receipt")) {
196
+ receipts.set(String(params[0]), String(params[1]));
197
+ return { changes: 1 };
198
+ }
199
+ if (sql.includes("UPDATE oke_channel_receipt")) {
200
+ receipts.set(String(params[1]), String(params[0]));
201
+ return { changes: 1 };
202
+ }
203
+ return { changes: 0 };
204
+ },
205
+ };
206
+ }
207
+
208
+ describe("channel ledger across instances", () => {
209
+ test("consent and suppression written on A are visible to B", async () => {
210
+ const sql = memoryLedgerSql();
211
+ const a = await openPostgresChannelLedger(sql);
212
+ const b = await openPostgresChannelLedger(sql);
213
+ a.suppression.optOut("a@b.c", "email");
214
+ a.suppression.addPriorBounce("c@d.e", "sms");
215
+ a.receipts.record({
216
+ id: "r1",
217
+ template: "note",
218
+ to: "a@b.c",
219
+ medium: "email",
220
+ status: "sent",
221
+ attempts: [],
222
+ at: 1,
223
+ });
224
+ await a.flush();
225
+ await b.reload();
226
+ expect(b.consent.isOptedOut("a@b.c", "email")).toBe(true);
227
+ expect(b.suppression.isSuppressed("c@d.e", "sms")).toEqual({
228
+ suppressed: true,
229
+ reason: "prior-bounce",
230
+ });
231
+ expect(b.receipts.all()).toHaveLength(1);
232
+ expect(b.receipts.all()[0]?.id).toBe("r1");
233
+ });
234
+ });