okengine 0.23.1 → 0.23.2

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 (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. package/src/runtime/types.ts +5 -0
@@ -5,8 +5,13 @@
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
7
 
8
+ import { OkeError } from "../kernel/errors.ts";
8
9
  import { createJournal, type JournalRun } from "../kernel/journal.ts";
9
- import { createPostgresJournalFake, createPostgresJournalStore } from "./journal-postgres.ts";
10
+ import {
11
+ createPostgresJournalFake,
12
+ createPostgresJournalStore,
13
+ postgresJournalStatements,
14
+ } from "./journal-postgres.ts";
10
15
 
11
16
  function seedRun(patch: Partial<JournalRun> & { id: string }): JournalRun {
12
17
  return {
@@ -195,4 +200,82 @@ describe("postgres JournalStore (fake)", () => {
195
200
  expect(row?.entries).toHaveLength(steps);
196
201
  await store.close();
197
202
  });
203
+
204
+ test("a stale fence cannot append, and an identical append is a no-op", async () => {
205
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
206
+ const journal = createJournal({
207
+ store,
208
+ now: () => 1_000,
209
+ codeVersion: "v1",
210
+ lease: { instanceId: "A", leaseMs: 5_000 },
211
+ });
212
+ const session = await journal.start("charge");
213
+ await session.step("one", () => 1);
214
+ const entry = { kind: "step" as const, name: "one", value: 1, at: 1_000 };
215
+ const append = store.appendEntry;
216
+ if (!append) throw new Error("appendEntry missing");
217
+ await append(session.runId, 0, entry, {
218
+ lockedBy: "A",
219
+ leaseToken: 1,
220
+ now: 1_000,
221
+ });
222
+ await expect(
223
+ append(
224
+ session.runId,
225
+ 1,
226
+ { kind: "step", name: "two", value: 2, at: 1_000 },
227
+ { lockedBy: "A", leaseToken: 99, now: 1_000 },
228
+ ),
229
+ ).rejects.toBeInstanceOf(OkeError);
230
+ expect((await store.get(session.runId))?.entries).toHaveLength(1);
231
+ expect((await store.get(session.runId))?.leaseToken).toBe(1);
232
+ await store.close();
233
+ });
234
+
235
+ test("cas rewrites only the entries that changed", async () => {
236
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
237
+ await store.put(
238
+ seedRun({
239
+ id: "cas",
240
+ lockedBy: "A",
241
+ leaseExpiresAt: 5_000,
242
+ leaseToken: 1,
243
+ entries: [
244
+ { kind: "step", name: "a", value: 1, at: 1 },
245
+ { kind: "step", name: "b", value: 2, at: 1 },
246
+ { kind: "step", name: "c", value: 3, at: 1 },
247
+ ],
248
+ }),
249
+ );
250
+ const before = store.writeBytes;
251
+ const result = await store.cas("cas", "A", 1_000, 5_000, (run) => ({
252
+ ...run,
253
+ entries: run.entries.map((item) =>
254
+ item.kind === "step" && item.name === "b" ? { ...item, value: 9 } : item,
255
+ ),
256
+ }));
257
+ expect(result).toBe("ok");
258
+ const row = await store.get("cas");
259
+ expect(row?.entries[1]).toMatchObject({ value: 9 });
260
+ expect(row?.entries[0]).toMatchObject({ value: 1 });
261
+ expect(store.writeBytes - before).toBeLessThan(JSON.stringify(row?.entries).length);
262
+ await store.close();
263
+ });
264
+
265
+ test("every journal statement matches the fake, and an unknown statement does not", async () => {
266
+ const sql = createPostgresJournalFake();
267
+ const pad = Array.from({ length: 20 }, () => 0);
268
+ for (const statement of postgresJournalStatements()) {
269
+ const run = /^\s*SELECT/i.test(statement)
270
+ ? sql.query(statement, pad)
271
+ : sql.exec(statement, pad);
272
+ try {
273
+ await run;
274
+ } catch (err) {
275
+ expect(String(err)).not.toContain("unsupported");
276
+ }
277
+ }
278
+ await expect(sql.exec("UPDATE oke_journal_runs SET nope = 1")).rejects.toThrow(/unsupported/);
279
+ await sql.close();
280
+ });
198
281
  });
@@ -103,7 +103,41 @@ const ORPHANS_SQL = `SELECT * FROM oke_journal_runs WHERE (status='running' OR s
103
103
 
104
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
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`;
106
+ const HEARTBEAT_SQL = `UPDATE oke_journal_runs SET lease_expires_at = GREATEST(COALESCE(lease_expires_at, 0), ?) WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ?`;
107
+
108
+ const INSERT_ENTRY_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`;
109
+
110
+ /** One entry row, inserted only while the caller's fence still holds. */
111
+ const APPEND_ENTRY_FENCED_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) SELECT ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM oke_journal_runs WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ? FOR SHARE) ON CONFLICT (run_id, seq) DO NOTHING`;
112
+
113
+ const UPDATE_ENTRY_FENCED_SQL = `UPDATE oke_journal_entries SET kind = ?, name = ?, resource = ?, value = ?, at = ? WHERE run_id = ? AND seq = ? AND EXISTS (SELECT 1 FROM oke_journal_runs WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ? FOR SHARE)`;
114
+
115
+ const FENCED_HEADER_SQL = `UPDATE oke_journal_runs SET flow = ?, input = ?, status = ?, entries = ?, wake_at = ?, error = ?, output = ?, locked_by = ?, lease_expires_at = ?, updated_at = ?, tenant = ?, lease_token = GREATEST(COALESCE(lease_token, 0), ?), code_version = ? WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ?`;
116
+
117
+ /** Header write inside `cas`, which already holds the row `FOR UPDATE`. */
118
+ const CAS_HEADER_SQL = `UPDATE oke_journal_runs SET flow = ?, input = ?, status = ?, entries = ?, wake_at = ?, error = ?, output = ?, locked_by = ?, lease_expires_at = ?, updated_at = ?, tenant = ?, lease_token = GREATEST(COALESCE(lease_token, 0), ?), code_version = ? WHERE id = ? AND COALESCE(lease_token, 0) = ?`;
119
+
120
+ /**
121
+ * Every statement the journal driver sends. The in-memory fake must match
122
+ * each one; a new statement that misses every branch fails the driver tests.
123
+ */
124
+ export function postgresJournalStatements(): readonly string[] {
125
+ return [
126
+ CLAIM_LEASE_SQL,
127
+ CLAIM_DUE_SQL,
128
+ ORPHANS_SQL,
129
+ UPDATE_LEASE_SQL,
130
+ HEARTBEAT_SQL,
131
+ INSERT_ENTRY_SQL,
132
+ APPEND_ENTRY_FENCED_SQL,
133
+ UPDATE_ENTRY_FENCED_SQL,
134
+ FENCED_HEADER_SQL,
135
+ CAS_HEADER_SQL,
136
+ SELECT_ENTRIES_SQL,
137
+ RELEASE_LEASE_SQL,
138
+ UPSERT_SQL,
139
+ ];
140
+ }
107
141
 
108
142
  const SELECT_ENTRIES_SQL = `SELECT * FROM oke_journal_entries WHERE run_id = ? ORDER BY seq ASC`;
109
143
 
@@ -203,6 +237,54 @@ function rowToRun(row: Record<string, unknown>): JournalRun {
203
237
  return run;
204
238
  }
205
239
 
240
+ function sameEntry(a: JournalEntry, b: JournalEntry): boolean {
241
+ return JSON.stringify(a) === JSON.stringify(b);
242
+ }
243
+
244
+ function fencedHeaderParams(run: JournalRun, fence: JournalWriteFence): unknown[] {
245
+ const base = runToParams(run);
246
+ return [
247
+ base[1],
248
+ base[2],
249
+ base[3],
250
+ JSON.stringify([]),
251
+ base[5],
252
+ base[6],
253
+ base[7],
254
+ base[8],
255
+ base[9],
256
+ base[11],
257
+ base[12],
258
+ fence.leaseToken,
259
+ base[14],
260
+ run.id,
261
+ fence.lockedBy,
262
+ fence.leaseToken,
263
+ fence.now,
264
+ ];
265
+ }
266
+
267
+ function casHeaderParams(run: JournalRun, heldToken: number): unknown[] {
268
+ const base = runToParams(run);
269
+ return [
270
+ base[1],
271
+ base[2],
272
+ base[3],
273
+ JSON.stringify([]),
274
+ base[5],
275
+ base[6],
276
+ base[7],
277
+ base[8],
278
+ base[9],
279
+ base[11],
280
+ base[12],
281
+ run.leaseToken ?? heldToken,
282
+ base[14],
283
+ run.id,
284
+ heldToken,
285
+ ];
286
+ }
287
+
206
288
  function entryToParams(runId: string, seq: number, entry: JournalEntry): unknown[] {
207
289
  if (entry.kind === "step") {
208
290
  return [runId, seq, "step", entry.name, null, JSON.stringify(entry.value ?? null), entry.at];
@@ -440,6 +522,50 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
440
522
  return r.locked_by === null || (r.lease_expires_at !== null && r.lease_expires_at <= cutoff);
441
523
  }
442
524
 
525
+ function fenceHolds(
526
+ state: { rows: JournalDbRow[] },
527
+ id: string,
528
+ lockedBy: string,
529
+ token: number,
530
+ now: number,
531
+ ): boolean {
532
+ const row = state.rows.find((r) => r.id === id);
533
+ if (!row || row.locked_by !== lockedBy) return false;
534
+ if ((row.lease_token ?? 0) !== token) return false;
535
+ return row.lease_expires_at !== null && row.lease_expires_at > now;
536
+ }
537
+
538
+ function applyFencedHeader(
539
+ state: { rows: JournalDbRow[] },
540
+ text: string,
541
+ params: readonly unknown[],
542
+ ): { changes: number } {
543
+ const tokenOnly = /COALESCE\(lease_token,\s*0\)\s*=\s*\?/i.test(text);
544
+ const id = String(params[13]);
545
+ const row = state.rows.find((r) => r.id === id);
546
+ if (!row) return { changes: 0 };
547
+ if (tokenOnly) {
548
+ if ((row.lease_token ?? 0) !== Number(params[14])) return { changes: 0 };
549
+ } else if (!fenceHolds(state, id, String(params[14]), Number(params[15]), Number(params[16]))) {
550
+ return { changes: 0 };
551
+ }
552
+ row.flow = String(params[0]);
553
+ row.input = params[1] === null || params[1] === undefined ? null : String(params[1]);
554
+ row.status = String(params[2]);
555
+ row.entries = String(params[3] ?? "[]");
556
+ row.wake_at = params[4] === null || params[4] === undefined ? null : Number(params[4]);
557
+ row.error = params[5] === null || params[5] === undefined ? null : String(params[5]);
558
+ row.output = params[6] === null || params[6] === undefined ? null : String(params[6]);
559
+ row.locked_by = params[7] === null || params[7] === undefined ? null : String(params[7]);
560
+ row.lease_expires_at = params[8] === null || params[8] === undefined ? null : Number(params[8]);
561
+ row.updated_at = Number(params[9] ?? 0);
562
+ row.tenant = params[10] === null || params[10] === undefined ? null : String(params[10]);
563
+ row.lease_token = Math.max(row.lease_token ?? 0, Number(params[11] ?? 0));
564
+ row.code_version =
565
+ params[12] === null || params[12] === undefined ? row.code_version : String(params[12]);
566
+ return { changes: 1 };
567
+ }
568
+
443
569
  function tryLock(id: string): boolean {
444
570
  if (heldByTxn.has(id) && !(active?.locked.has(id) ?? false)) return false;
445
571
  if (active) {
@@ -581,8 +707,9 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
581
707
 
582
708
  if (/FROM\s+oke_journal_entries/i.test(text)) {
583
709
  const runId = String(params[0] ?? "");
710
+ const seq = /seq\s*=\s*\?/i.test(text) ? Number(params[1]) : undefined;
584
711
  return state.entries
585
- .filter((row) => row.run_id === runId)
712
+ .filter((row) => row.run_id === runId && (seq === undefined || row.seq === seq))
586
713
  .sort((a, b) => a.seq - b.seq)
587
714
  .map((row) => ({ ...row }));
588
715
  }
@@ -624,18 +751,39 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
624
751
  created_at: Number(record.created_at ?? 0),
625
752
  updated_at: Number(record.updated_at ?? 0),
626
753
  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),
754
+ lease_token: (() => {
755
+ const prev = state.rows.find((r) => r.id === String(record.id))?.lease_token ?? 0;
756
+ const incoming =
757
+ record.lease_token === undefined || record.lease_token === null
758
+ ? prev
759
+ : Number(record.lease_token);
760
+ return Math.max(prev, incoming);
761
+ })(),
631
762
  code_version:
632
763
  record.code_version === undefined || record.code_version === null
633
764
  ? (state.rows.find((r) => r.id === String(record.id))?.code_version ?? null)
634
765
  : String(record.code_version),
635
766
  };
636
767
  const idx = state.rows.findIndex((r) => r.id === next.id);
637
- if (idx >= 0) state.rows[idx] = next;
638
- else state.rows.push(next);
768
+ if (idx >= 0) {
769
+ const prev = state.rows[idx]!;
770
+ next.created_at = prev.created_at;
771
+ state.rows[idx] = next;
772
+ } else state.rows.push(next);
773
+ return { changes: 1 };
774
+ }
775
+
776
+ if (/^UPDATE\s+oke_journal_runs\s+SET\s+lease_expires_at\s*=\s*GREATEST/i.test(text)) {
777
+ const id = String(params[1]);
778
+ const row = state.rows.find((r) => r.id === id);
779
+ if (
780
+ !row ||
781
+ !fenceHolds(state, id, String(params[2]), Number(params[3]), Number(params[4]))
782
+ ) {
783
+ return { changes: 0 };
784
+ }
785
+ const next = Number(params[0]);
786
+ row.lease_expires_at = Math.max(row.lease_expires_at ?? 0, next);
639
787
  return { changes: 1 };
640
788
  }
641
789
 
@@ -654,7 +802,31 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
654
802
  return { changes: 1 };
655
803
  }
656
804
 
805
+ if (/^UPDATE\s+oke_journal_runs\s+SET\s+flow\s*=/i.test(text)) {
806
+ return applyFencedHeader(state, text, params);
807
+ }
808
+
809
+ if (/^UPDATE\s+oke_journal_entries\s+SET\s+kind\s*=/i.test(text)) {
810
+ const runId = String(params[5]);
811
+ const seq = Number(params[6]);
812
+ const fenceRun = String(params[7]);
813
+ const lockedBy = String(params[8]);
814
+ const token = Number(params[9]);
815
+ const at = Number(params[10]);
816
+ if (!fenceHolds(state, fenceRun, lockedBy, token, at)) return { changes: 0 };
817
+ const idx = state.entries.findIndex((e) => e.run_id === runId && e.seq === seq);
818
+ if (idx < 0) return { changes: 0 };
819
+ const row = state.entries[idx]!;
820
+ row.kind = String(params[0]);
821
+ row.name = params[1] === null || params[1] === undefined ? null : String(params[1]);
822
+ row.resource = params[2] === null || params[2] === undefined ? null : String(params[2]);
823
+ row.value = params[3] === null || params[3] === undefined ? null : String(params[3]);
824
+ row.at = Number(params[4]);
825
+ return { changes: 1 };
826
+ }
827
+
657
828
  if (/^INSERT\s+INTO\s+oke_journal_entries\b/i.test(text)) {
829
+ const fenced = /WHERE\s+EXISTS/i.test(text);
658
830
  const row: JournalEntryDbRow = {
659
831
  run_id: String(params[0]),
660
832
  seq: Number(params[1]),
@@ -664,9 +836,25 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
664
836
  value: params[5] === null || params[5] === undefined ? null : String(params[5]),
665
837
  at: Number(params[6]),
666
838
  };
839
+ if (
840
+ fenced &&
841
+ !fenceHolds(
842
+ state,
843
+ String(params[7]),
844
+ String(params[8]),
845
+ Number(params[9]),
846
+ Number(params[10]),
847
+ )
848
+ ) {
849
+ return { changes: 0 };
850
+ }
667
851
  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);
852
+ if (idx >= 0) {
853
+ if (/DO\s+NOTHING/i.test(text)) return { changes: 0 };
854
+ state.entries[idx] = row;
855
+ return { changes: 1 };
856
+ }
857
+ state.entries.push(row);
670
858
  return { changes: 1 };
671
859
  }
672
860
 
@@ -939,7 +1127,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
939
1127
  return api;
940
1128
  }
941
1129
 
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`;
1130
+ 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 = oke_journal_runs.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant, lease_token = GREATEST(COALESCE(oke_journal_runs.lease_token, 0), COALESCE(EXCLUDED.lease_token, 0)), code_version = EXCLUDED.code_version`;
943
1131
 
944
1132
  /** Postgres journal store with run-level lease coordination. */
945
1133
  export type PostgresJournalStore = JournalStore &
@@ -1043,14 +1231,6 @@ export async function createPostgresJournalStore(
1043
1231
  return run;
1044
1232
  }
1045
1233
 
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
-
1054
1234
  const store: PostgresJournalStore = {
1055
1235
  sql,
1056
1236
  get writeBytes() {
@@ -1065,11 +1245,12 @@ export async function createPostgresJournalStore(
1065
1245
  return hydrate(rows[0]);
1066
1246
  },
1067
1247
  async put(run, fence) {
1248
+ writeBytes += JSON.stringify(run.entries).length;
1068
1249
  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);
1250
+ const wrote = await sql.exec(FENCED_HEADER_SQL, fencedHeaderParams(run, fence));
1251
+ if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
1252
+ return;
1071
1253
  }
1072
- writeBytes += JSON.stringify(run.entries).length;
1073
1254
  await sql.exec(UPSERT_SQL, runToParams(run));
1074
1255
  for (let seq = 0; seq < run.entries.length; seq++) {
1075
1256
  const entry = run.entries[seq];
@@ -1079,14 +1260,44 @@ export async function createPostgresJournalStore(
1079
1260
  }
1080
1261
  },
1081
1262
  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);
1263
+ writeBytes += JSON.stringify(entry).length;
1264
+ if (!fence) {
1265
+ await sql.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
1266
+ return;
1087
1267
  }
1268
+ const wrote = await sql.exec(APPEND_ENTRY_FENCED_SQL, [
1269
+ ...entryToParams(runId, seq, entry),
1270
+ runId,
1271
+ fence.lockedBy,
1272
+ fence.leaseToken,
1273
+ fence.now,
1274
+ ]);
1275
+ if (wrote.changes === 1) return;
1276
+ const stored = await sql.query(
1277
+ `SELECT * FROM oke_journal_entries WHERE run_id = ? AND seq = ?`,
1278
+ [runId, seq],
1279
+ );
1280
+ const row = stored[0];
1281
+ if (row && sameEntry(entryFromRow(row), entry)) return;
1282
+ throwOke("JOURNAL_STALE_LEASE", { runId });
1283
+ },
1284
+ async updateEntry(runId, seq, entry, fence) {
1088
1285
  writeBytes += JSON.stringify(entry).length;
1089
- await sql.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
1286
+ const params = entryToParams(runId, seq, entry);
1287
+ const wrote = await sql.exec(UPDATE_ENTRY_FENCED_SQL, [
1288
+ params[2],
1289
+ params[3],
1290
+ params[4],
1291
+ params[5],
1292
+ params[6],
1293
+ runId,
1294
+ seq,
1295
+ runId,
1296
+ fence.lockedBy,
1297
+ fence.leaseToken,
1298
+ fence.now,
1299
+ ]);
1300
+ if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1090
1301
  },
1091
1302
  async list() {
1092
1303
  const rows = await sql.query(`SELECT * FROM oke_journal_runs`);
@@ -1121,6 +1332,7 @@ export async function createPostgresJournalStore(
1121
1332
  );
1122
1333
  }
1123
1334
  const next = update(current) ?? current;
1335
+ const heldToken = current.leaseToken ?? 0;
1124
1336
  const renew =
1125
1337
  current.lockedBy === instanceId &&
1126
1338
  current.leaseExpiresAt !== undefined &&
@@ -1128,17 +1340,55 @@ export async function createPostgresJournalStore(
1128
1340
  current.leaseToken !== undefined;
1129
1341
  next.lockedBy = instanceId;
1130
1342
  next.leaseExpiresAt = now + leaseMs;
1131
- next.leaseToken = renew ? current.leaseToken : (current.leaseToken ?? 0) + 1;
1343
+ next.leaseToken = renew ? heldToken : heldToken + 1;
1344
+ const header = { ...next, entries: [] as JournalEntry[] };
1345
+ const wrote = await tx.exec(CAS_HEADER_SQL, casHeaderParams(header, heldToken));
1346
+ if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1347
+ const fence = { lockedBy: instanceId, leaseToken: next.leaseToken, now };
1132
1348
  for (let seq = 0; seq < next.entries.length; seq++) {
1133
1349
  const entry = next.entries[seq];
1134
1350
  if (!entry) continue;
1135
- await tx.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
1351
+ const prev = current.entries[seq];
1352
+ if (!prev) {
1353
+ const inserted = await tx.exec(APPEND_ENTRY_FENCED_SQL, [
1354
+ ...entryToParams(runId, seq, entry),
1355
+ runId,
1356
+ fence.lockedBy,
1357
+ fence.leaseToken,
1358
+ fence.now,
1359
+ ]);
1360
+ if (inserted.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1361
+ } else if (!sameEntry(prev, entry)) {
1362
+ const params = entryToParams(runId, seq, entry);
1363
+ const updated = await tx.exec(UPDATE_ENTRY_FENCED_SQL, [
1364
+ params[2],
1365
+ params[3],
1366
+ params[4],
1367
+ params[5],
1368
+ params[6],
1369
+ runId,
1370
+ seq,
1371
+ runId,
1372
+ fence.lockedBy,
1373
+ fence.leaseToken,
1374
+ fence.now,
1375
+ ]);
1376
+ if (updated.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
1377
+ }
1136
1378
  }
1137
- const header = { ...next, entries: [] as JournalEntry[] };
1138
- await tx.exec(UPSERT_SQL, runToParams(header));
1139
1379
  return "ok";
1140
1380
  });
1141
1381
  },
1382
+ async renewLeaseExpiry(runId, expiresAt, fence) {
1383
+ const wrote = await sql.exec(HEARTBEAT_SQL, [
1384
+ expiresAt,
1385
+ runId,
1386
+ fence.lockedBy,
1387
+ fence.leaseToken,
1388
+ fence.now,
1389
+ ]);
1390
+ return wrote.changes === 1;
1391
+ },
1142
1392
  async releaseLease(runId, instanceId) {
1143
1393
  await sql.exec(RELEASE_LEASE_SQL, [runId, instanceId]);
1144
1394
  },
@@ -9,6 +9,7 @@ import {
9
9
  isPostgresConnectionNoise,
10
10
  isSharedPostgresPaused,
11
11
  pauseSharedPostgresClients,
12
+ PostgresDriverError,
12
13
  resumeSharedPostgresClients,
13
14
  SharedPostgresPausedError,
14
15
  sharedPostgresClient,
@@ -101,3 +102,62 @@ describe("pauseSharedPostgresClients", () => {
101
102
  expect(isPostgresConnectionNoise(new Error("relation missing"))).toBe(false);
102
103
  });
103
104
  });
105
+
106
+ describe("PostgresDriverError", () => {
107
+ test("a non-Error rejection copies SQLSTATE fields and sets cause", async () => {
108
+ const cause = {
109
+ code: "ERR_POSTGRES_SERVER_ERROR",
110
+ errno: "23505",
111
+ constraint: "users_pkey",
112
+ table: "users",
113
+ column: "id",
114
+ message: "duplicate key",
115
+ };
116
+ const conn = await connectPostgres({
117
+ client: {
118
+ async unsafe() {
119
+ throw cause;
120
+ },
121
+ },
122
+ });
123
+ let caught: unknown;
124
+ try {
125
+ await conn.query("SELECT 1");
126
+ } catch (err) {
127
+ caught = err;
128
+ } finally {
129
+ await conn.close();
130
+ }
131
+ expect(caught).toBeInstanceOf(PostgresDriverError);
132
+ const wrapped = caught as PostgresDriverError;
133
+ expect(wrapped.code).toBe("ERR_POSTGRES_SERVER_ERROR");
134
+ expect(wrapped.errno).toBe("23505");
135
+ expect(wrapped.constraint).toBe("users_pkey");
136
+ expect(wrapped.table).toBe("users");
137
+ expect(wrapped.column).toBe("id");
138
+ expect(wrapped.cause).toBe(cause);
139
+ });
140
+
141
+ test("an Error rejection is rethrown unchanged", async () => {
142
+ const original = Object.assign(new Error("duplicate key"), {
143
+ code: "23505",
144
+ errno: "23505",
145
+ });
146
+ const conn = await connectPostgres({
147
+ client: {
148
+ async unsafe() {
149
+ throw original;
150
+ },
151
+ },
152
+ });
153
+ let caught: unknown;
154
+ try {
155
+ await conn.query("SELECT 1");
156
+ } catch (err) {
157
+ caught = err;
158
+ } finally {
159
+ await conn.close();
160
+ }
161
+ expect(caught).toBe(original);
162
+ });
163
+ });