okengine 0.23.0 → 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 (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  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/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. package/src/runtime/types.ts +5 -0
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
20
20
  import { createPostgresCronStore } from "../drivers/clock-postgres.ts";
21
21
  import { createPostgresJournalStore } from "../drivers/journal-postgres.ts";
22
22
  import { memorySignalDriver } from "../drivers/signal-memory.ts";
23
+ import { createBunSignalRedisClient, redisSignalDriver } from "../drivers/signal-redis.ts";
24
+ import { bunRedisCacheClient } from "../elements/store/cache-bus.ts";
23
25
  import { postgresDriver } from "../drivers/postgres.ts";
24
26
  import { redisDriver } from "../drivers/redis.ts";
25
27
  import { clock } from "../elements/clock/declare.ts";
@@ -99,14 +101,40 @@ clockRt.onCron(CRON, async () => {
99
101
  await Bun.write(path, prev + line);
100
102
  });
101
103
 
104
+ const redisSignals = process.env.OKE_HORIZONTAL_REDIS_SIGNALS === "1";
102
105
  const signalRt = createSignalRuntime({
103
- driver: memorySignalDriver,
106
+ driver: redisSignals ? redisSignalDriver : memorySignalDriver,
107
+ ...(redisSignals
108
+ ? { redis: createBunSignalRedisClient(redisUrl), compete: true, consumerId: instanceId }
109
+ : {}),
104
110
  durablePath: signalPath,
105
111
  leaseMs: Number.isFinite(leaseMs) ? leaseMs : 300,
106
112
  });
107
113
  const job = signal.once(SIG, { retries: 3, deadLetter: true, optional: true });
108
114
  signalRt.register(job);
115
+ const news = signal.broadcast("horizontal-news", { optional: true });
116
+ const feed = signal.live("horizontal-feed", { optional: true });
117
+ if (redisSignals) {
118
+ signalRt.register(news);
119
+ signalRt.register(feed);
120
+ }
109
121
  const bus = await signalRt.start();
122
+ if (redisSignals) {
123
+ await bus.subscribe("horizontal-news", `news-${instanceId}`, async (msg) => {
124
+ const line = `${JSON.stringify({ instanceId, kind: "broadcast", id: msg.id, at: Date.now() })}\n`;
125
+ const path = join(workDir, "broadcast.jsonl");
126
+ const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
127
+ await Bun.write(path, prev + line);
128
+ });
129
+ void (async () => {
130
+ for await (const event of bus.live("horizontal-feed")) {
131
+ const line = `${JSON.stringify({ instanceId, kind: "live", id: event.id, at: Date.now() })}\n`;
132
+ const path = join(workDir, "live.jsonl");
133
+ const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
134
+ await Bun.write(path, prev + line);
135
+ }
136
+ })();
137
+ }
110
138
  await bus.subscribe(SIG, `consumer-${instanceId}`, async (msg) => {
111
139
  const line = `${JSON.stringify({ instanceId, kind: "signal", id: msg.id, at: Date.now() })}\n`;
112
140
  const path = join(workDir, "signal.jsonl");
@@ -129,6 +157,7 @@ const storeRt = createStoreRuntime({
129
157
  drivers: { sql: postgresDriver },
130
158
  sql: { db: { name: "db", primary: { url: pgUrl } } },
131
159
  now: () => Date.now(),
160
+ cacheBus: { kind: "redis", redis: bunRedisCacheClient(redisUrl), origin: instanceId },
132
161
  });
133
162
  storeRt.register(db);
134
163
 
@@ -138,7 +167,16 @@ const ping = flow("horizontal.ping", {
138
167
  const rate = flow("horizontal.rate", {
139
168
  do: () => ({ ok: true as const, instanceId }),
140
169
  });
170
+ let cachedReads = 0;
171
+ const cached = flow("horizontal.cached", {
172
+ effects: { reads: ["sql:db"] },
173
+ do: () => {
174
+ cachedReads += 1;
175
+ return { reads: cachedReads, instanceId };
176
+ },
177
+ });
141
178
  const write = flow("horizontal.write", {
179
+ effects: { writes: ["sql:db"] },
142
180
  do: async (_input, fx) => {
143
181
  const id = okid();
144
182
  await fx
@@ -159,6 +197,18 @@ const emit = flow("horizontal.emit", {
159
197
  return { emitted: true as const };
160
198
  },
161
199
  });
200
+ const publishNews = flow("horizontal.news", {
201
+ do: async (_input, fx) => {
202
+ await fx.emit("horizontal-news", { from: instanceId, at: Date.now() });
203
+ return { emitted: true as const };
204
+ },
205
+ });
206
+ const publishFeed = flow("horizontal.feed", {
207
+ do: async (_input, fx) => {
208
+ await fx.emit("horizontal-feed", { from: instanceId, at: Date.now() });
209
+ return { emitted: true as const };
210
+ },
211
+ });
162
212
  const charge = flow("horizontal.charge", {
163
213
  durable: true,
164
214
  do: async (_input, fx) => {
@@ -193,8 +243,11 @@ const charge = flow("horizontal.charge", {
193
243
  const bindings: Binding[] = [
194
244
  { trigger: http.get("/ping").public(), flow: ping as AnyFlowDef },
195
245
  { trigger: http.get("/rate").gate(rateGate), flow: rate as AnyFlowDef },
246
+ { trigger: http.get("/cached").public(), flow: cached as AnyFlowDef },
196
247
  { trigger: http.post("/write").public(), flow: write as AnyFlowDef },
197
248
  { trigger: http.post("/emit").public(), flow: emit as AnyFlowDef },
249
+ { trigger: http.post("/news").public(), flow: publishNews as AnyFlowDef },
250
+ { trigger: http.post("/feed").public(), flow: publishFeed as AnyFlowDef },
198
251
  { trigger: http.post("/charge").public(), flow: charge as AnyFlowDef },
199
252
  ];
200
253
 
@@ -124,10 +124,26 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
124
124
  const traffic = async () => {
125
125
  for (let i = 0; i < 8; i++) {
126
126
  await Promise.all([
127
- fetch(`${urlA}/write`, { method: "POST", body: "{}" }),
128
- fetch(`${urlB}/write`, { method: "POST", body: "{}" }),
129
- fetch(`${urlA}/emit`, { method: "POST", body: "{}" }),
130
- fetch(`${urlB}/emit`, { method: "POST", body: "{}" }),
127
+ fetch(`${urlA}/write`, {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json" },
130
+ body: "{}",
131
+ }),
132
+ fetch(`${urlB}/write`, {
133
+ method: "POST",
134
+ headers: { "content-type": "application/json" },
135
+ body: "{}",
136
+ }),
137
+ fetch(`${urlA}/emit`, {
138
+ method: "POST",
139
+ headers: { "content-type": "application/json" },
140
+ body: "{}",
141
+ }),
142
+ fetch(`${urlB}/emit`, {
143
+ method: "POST",
144
+ headers: { "content-type": "application/json" },
145
+ body: "{}",
146
+ }),
131
147
  fetch(`${urlA}/ping`),
132
148
  fetch(`${urlB}/ping`),
133
149
  ]);
@@ -150,7 +166,11 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
150
166
  expect(limited).toBeGreaterThanOrEqual(1);
151
167
 
152
168
  // (2) Start durable on A; hang mid-step.
153
- void fetch(`${urlA}/charge`, { method: "POST", body: "{}" });
169
+ void fetch(`${urlA}/charge`, {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: "{}",
173
+ });
154
174
  expect(await waitForFile(join(dir, "hang-inst-a.json"), 15_000)).toBe(true);
155
175
 
156
176
  // (5) Kill A mid combined scenario — B keeps serving.
@@ -177,8 +197,24 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
177
197
  // Keep traffic going on survivor (no deadlock).
178
198
  await trafficPromise.catch(() => {});
179
199
  for (let i = 0; i < 4; i++) {
180
- expect((await fetch(`${urlB}/write`, { method: "POST", body: "{}" })).status).toBe(200);
181
- expect((await fetch(`${urlB}/emit`, { method: "POST", body: "{}" })).status).toBe(200);
200
+ expect(
201
+ (
202
+ await fetch(`${urlB}/write`, {
203
+ method: "POST",
204
+ headers: { "content-type": "application/json" },
205
+ body: "{}",
206
+ })
207
+ ).status,
208
+ ).toBe(200);
209
+ expect(
210
+ (
211
+ await fetch(`${urlB}/emit`, {
212
+ method: "POST",
213
+ headers: { "content-type": "application/json" },
214
+ body: "{}",
215
+ })
216
+ ).status,
217
+ ).toBe(200);
182
218
  }
183
219
 
184
220
  // (1) Cron fired exactly once across both instances.
@@ -220,6 +256,94 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
220
256
  await cleanupC.close();
221
257
  }
222
258
  }, 90_000);
259
+
260
+ test("broadcast, live, and cache invalidation reach the other instance", async () => {
261
+ const pg = LIVE!.pg;
262
+ const redis = LIVE!.redis;
263
+ const dir = await mkdtemp(join(tmpdir(), "oke-horizontal-bus-"));
264
+ const signalPath = join(dir, "signal.json");
265
+ const post = (url: string) =>
266
+ fetch(url, {
267
+ method: "POST",
268
+ headers: { "content-type": "application/json" },
269
+ body: "{}",
270
+ });
271
+ const spawn = (instanceId: string, port: number) =>
272
+ Bun.spawn({
273
+ cmd: [
274
+ "bun",
275
+ childPath,
276
+ "serve",
277
+ instanceId,
278
+ String(port),
279
+ pg,
280
+ redis,
281
+ signalPath,
282
+ dir,
283
+ "5000",
284
+ ],
285
+ stdout: "pipe",
286
+ stderr: "pipe",
287
+ env: {
288
+ ...process.env,
289
+ DATABASE_URL: pg,
290
+ REDIS_URL: redis,
291
+ OKE_HORIZONTAL_REDIS_SIGNALS: "1",
292
+ },
293
+ });
294
+ const a = spawn("bus-a", 0);
295
+ const b = spawn("bus-b", 0);
296
+ try {
297
+ expect(await waitForFile(join(dir, "ready-bus-a.json"))).toBe(true);
298
+ expect(await waitForFile(join(dir, "ready-bus-b.json"))).toBe(true);
299
+ const readyA = (await Bun.file(join(dir, "ready-bus-a.json")).json()) as { port: number };
300
+ const readyB = (await Bun.file(join(dir, "ready-bus-b.json")).json()) as { port: number };
301
+ const urlA = `http://127.0.0.1:${readyA.port}`;
302
+ const urlB = `http://127.0.0.1:${readyB.port}`;
303
+ const first = (await (await fetch(`${urlA}/cached`)).json()) as { data: { reads: number } };
304
+ const second = (await (await fetch(`${urlA}/cached`)).json()) as { data: { reads: number } };
305
+ expect(first.data.reads).toBe(1);
306
+ expect(second.data.reads).toBe(1);
307
+ expect((await post(`${urlB}/write`)).status).toBe(200);
308
+ expect(
309
+ await waitFor(async () => {
310
+ const body = (await (await fetch(`${urlA}/cached`)).json()) as {
311
+ data: { reads: number };
312
+ };
313
+ return body.data.reads === 2;
314
+ }),
315
+ ).toBe(true);
316
+ expect((await post(`${urlA}/news`)).status).toBe(200);
317
+ expect((await post(`${urlA}/feed`)).status).toBe(200);
318
+ expect(
319
+ await waitFor(async () => {
320
+ const broadcast = await loadJsonl(join(dir, "broadcast.jsonl"));
321
+ const live = await loadJsonl(join(dir, "live.jsonl"));
322
+ const ids = (rows: Array<Record<string, unknown>>) =>
323
+ new Set(rows.map((row) => row.instanceId));
324
+ return (
325
+ ids(broadcast).has("bus-a") &&
326
+ ids(broadcast).has("bus-b") &&
327
+ ids(live).has("bus-a") &&
328
+ ids(live).has("bus-b")
329
+ );
330
+ }),
331
+ ).toBe(true);
332
+ } finally {
333
+ try {
334
+ a.kill(9);
335
+ } catch {
336
+ /* already dead */
337
+ }
338
+ try {
339
+ b.kill(9);
340
+ } catch {
341
+ /* ignore */
342
+ }
343
+ await Promise.allSettled([a.exited, b.exited]);
344
+ await rm(dir, { recursive: true, force: true });
345
+ }
346
+ }, 60_000);
223
347
  });
224
348
 
225
349
  if (!LIVE) {
@@ -349,12 +349,12 @@ describe("HTTP idempotency", () => {
349
349
  expect(await durableStore.idempotency!.forfeit(held!, held!.claimToken)).toBe(true);
350
350
  await Bun.sleep(120);
351
351
  const resumed = durable.fetch(post({ n: 1 }));
352
- expect(await waitFor(() => blockers.length === 2)).toBe(true);
353
- expect(step1).toEqual(["step1"]);
352
+ // The holder's heartbeat kept the lease, so the same process does not
353
+ // start a second session for the run still inside mid-flight.
354
+ expect((await resumed).status).toBe(409);
355
+ expect(blockers).toHaveLength(1);
354
356
  blockers[0]!();
355
- blockers[1]!();
356
357
  expect((await hung).status).toBe(200);
357
- expect((await resumed).status).toBe(200);
358
358
  expect(step1).toEqual(["step1"]);
359
359
  }, 30_000);
360
360
 
@@ -22,6 +22,7 @@ import {
22
22
  type PostgresJournalStore,
23
23
  } from "../drivers/journal-postgres.ts";
24
24
  import { oke, type OkeApp } from "./app.ts";
25
+ import { clearJournalHeartbeats } from "./journal.ts";
25
26
  import type { JournalRuntime } from "./boot-bind/journal.ts";
26
27
  import { flow, resetFlowSeq, type AnyFlowDef } from "./flow.ts";
27
28
  import type { Binding } from "./on.ts";
@@ -47,6 +48,7 @@ async function bootDurableApp(options: {
47
48
  readonly name: string;
48
49
  readonly journal: JournalRuntime;
49
50
  readonly bindings: readonly Binding[];
51
+ readonly now?: () => number;
50
52
  }): Promise<OkeApp> {
51
53
  const app = oke({
52
54
  name: options.name,
@@ -57,6 +59,7 @@ async function bootDurableApp(options: {
57
59
  // oke() in the process — sibling apps need their own copies.
58
60
  bindings: [...options.bindings],
59
61
  elements: { journal: options.journal },
62
+ ...(options.now ? { fx: { now: options.now } } : {}),
60
63
  });
61
64
  await app.boot();
62
65
  return app;
@@ -127,11 +130,18 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
127
130
  bindings,
128
131
  });
129
132
  // Start the run through the real HTTP path; it hangs inside step 2.
130
- void appA.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
133
+ void appA.fetch(
134
+ new Request("http://localhost/charge", {
135
+ method: "POST",
136
+ headers: { "content-type": "application/json" },
137
+ body: "{}",
138
+ }),
139
+ );
131
140
  expect(await waitFor(async () => blockers.length === 1)).toBe(true);
132
141
  const runId = (await store.list())[0]!.id;
133
- // "Crash": no commit, no release — the lease (120ms) is left to expire.
134
- // appA is deliberately never stopped; its promise stays dangling.
142
+ // "Crash": the process dies, so its heartbeat timers die with it.
143
+ // The in-flight promise stays dangling and the lease (120ms) expires.
144
+ clearJournalHeartbeats();
135
145
 
136
146
  await Bun.sleep(300);
137
147
  const appB = await bootDurableApp({
@@ -165,7 +175,11 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
165
175
  bindings,
166
176
  });
167
177
  const parked = await appA.fetch(
168
- new Request("http://localhost/sleep", { method: "POST", body: "{}" }),
178
+ new Request("http://localhost/sleep", {
179
+ method: "POST",
180
+ headers: { "content-type": "application/json" },
181
+ body: "{}",
182
+ }),
169
183
  );
170
184
  expect(parked.status).toBe(204); // durable park — lease released, row sleeping
171
185
  const runId = (await store.list())[0]!.id;
@@ -198,7 +212,13 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
198
212
  journal: journalRuntime(store, "A", 5_000),
199
213
  bindings,
200
214
  });
201
- await appA.fetch(new Request("http://localhost/sleep", { method: "POST", body: "{}" }));
215
+ await appA.fetch(
216
+ new Request("http://localhost/sleep", {
217
+ method: "POST",
218
+ headers: { "content-type": "application/json" },
219
+ body: "{}",
220
+ }),
221
+ );
202
222
  await appA.stop();
203
223
 
204
224
  // Both survivors boot while the sleep is future (orphan scan skips it).
@@ -217,10 +237,61 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
217
237
  await Promise.all([appB.resumeDurable(), appC.resumeDurable()]);
218
238
 
219
239
  expect(confirms).toHaveLength(1);
240
+
220
241
  await appB.stop();
221
242
  await appC.stop();
222
243
  });
223
244
 
245
+ test("resume uses a live clock: a lease written after the tick sample is not already expired", async () => {
246
+ let clock = 10_000_000;
247
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
248
+ const confirms: string[] = [];
249
+ const bindings = [sleeperBinding("120ms", confirms)];
250
+ const acquiredAt: number[] = [];
251
+ const acquire = store.acquireLease.bind(store);
252
+ store.acquireLease = async (runId, instanceId, at, leaseMs) => {
253
+ acquiredAt.push(at);
254
+ return acquire(runId, instanceId, at, leaseMs);
255
+ };
256
+ const claim = store.claimDueSleep.bind(store);
257
+ store.claimDueSleep = async (instanceId, at, leaseMs) => {
258
+ const due = await claim(instanceId, at, leaseMs);
259
+ // The tick already sampled `at`. A live run clock must see this jump.
260
+ if (due) clock += 2_700;
261
+ return due;
262
+ };
263
+
264
+ const appA = await bootDurableApp({
265
+ name: "clock-a",
266
+ journal: journalRuntime(store, "A", 5_000),
267
+ bindings,
268
+ now: () => clock,
269
+ });
270
+ await appA.fetch(
271
+ new Request("http://localhost/sleep", {
272
+ method: "POST",
273
+ headers: { "content-type": "application/json" },
274
+ body: "{}",
275
+ }),
276
+ );
277
+ await appA.stop();
278
+
279
+ const appB = await bootDurableApp({
280
+ name: "clock-b",
281
+ journal: journalRuntime(store, "B", 5_000),
282
+ bindings,
283
+ now: () => clock,
284
+ });
285
+ const before = clock;
286
+ clock = before + 500;
287
+ acquiredAt.length = 0;
288
+ await appB.resumeDurable();
289
+
290
+ const stamped = acquiredAt.find((at) => at >= before + 2_700);
291
+ expect(stamped).toBeDefined();
292
+ await appB.stop();
293
+ });
294
+
224
295
  test("boot orphan scan skips runs held under a live lease", async () => {
225
296
  const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
226
297
  const step1Calls: string[] = [];
@@ -233,7 +304,13 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
233
304
  journal: journalRuntime(store, "A", 30_000),
234
305
  bindings,
235
306
  });
236
- void appA.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
307
+ void appA.fetch(
308
+ new Request("http://localhost/charge", {
309
+ method: "POST",
310
+ headers: { "content-type": "application/json" },
311
+ body: "{}",
312
+ }),
313
+ );
237
314
  expect(await waitFor(async () => blockers.length === 1)).toBe(true);
238
315
 
239
316
  // B boots while A's lease is live — must not steal the run.
@@ -5,7 +5,10 @@
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
7
 
8
+ import { runDurable } from "../elements/clock/durable.ts";
9
+ import { flow } from "./flow.ts";
8
10
  import {
11
+ clearJournalHeartbeats,
9
12
  createJournal,
10
13
  createMemoryJournalStore,
11
14
  hasJournalLease,
@@ -13,6 +16,7 @@ import {
13
16
  isJournalSuspend,
14
17
  JOURNAL_DEFAULT_LEASE_MS,
15
18
  } from "./journal.ts";
19
+ import { OkeError } from "./errors.ts";
16
20
 
17
21
  describe("journal lease (memory store)", () => {
18
22
  test("built-in stores expose the lease surface", () => {
@@ -139,4 +143,158 @@ describe("journal lease (memory store)", () => {
139
143
  const again = await journal.resume(session.runId);
140
144
  expect(await again.step("s", () => 2)).toBe(1);
141
145
  });
146
+
147
+ test("a stale lease holder cannot append", async () => {
148
+ let t = 1_000;
149
+ const store = createMemoryJournalStore();
150
+ const a = createJournal({
151
+ store,
152
+ now: () => t,
153
+ lease: { instanceId: "a", leaseMs: 100 },
154
+ codeVersion: "v1",
155
+ });
156
+ const session = await a.start("charge");
157
+ await session.step("create-intent", () => ({ id: "pi_1" }));
158
+ t = 1_200;
159
+ const b = createJournal({
160
+ store,
161
+ now: () => t,
162
+ lease: { instanceId: "b", leaseMs: 100 },
163
+ codeVersion: "v1",
164
+ });
165
+ await b.resume(session.runId);
166
+ try {
167
+ await session.step("capture", () => ({ ok: true }));
168
+ expect.unreachable("stale holder must be rejected");
169
+ } catch (err) {
170
+ expect(err).toBeInstanceOf(OkeError);
171
+ expect((err as OkeError).code).toBe(1074);
172
+ }
173
+ });
174
+
175
+ test("heartbeat extends the lease, and a lost lease does not persist the step", async () => {
176
+ let t = 1_000;
177
+ const store = createMemoryJournalStore();
178
+ const a = createJournal({
179
+ store,
180
+ now: () => t,
181
+ lease: { instanceId: "a", leaseMs: 60 },
182
+ codeVersion: "v1",
183
+ });
184
+ const session = await a.start("charge");
185
+ t = 1_030;
186
+ await Bun.sleep(40);
187
+ expect((await store.get(session.runId))?.leaseExpiresAt).toBe(1_090);
188
+ t = 2_000;
189
+ const b = createJournal({
190
+ store,
191
+ now: () => t,
192
+ lease: { instanceId: "b", leaseMs: 60 },
193
+ codeVersion: "v1",
194
+ });
195
+ await b.resume(session.runId);
196
+ await Bun.sleep(40);
197
+ await expect(session.step("capture", () => 1)).rejects.toBeInstanceOf(OkeError);
198
+ expect((await store.get(session.runId))?.entries ?? []).toHaveLength(0);
199
+ expect((await store.get(session.runId))?.lockedBy).toBe("b");
200
+ clearJournalHeartbeats();
201
+ });
202
+
203
+ test("replay throws when the next call does not match the journal", async () => {
204
+ const store = createMemoryJournalStore();
205
+ const journal = createJournal({ store, now: () => 1, codeVersion: "v1" });
206
+ const session = await journal.start("charge");
207
+ await session.step("a", () => 1);
208
+ const resumed = await journal.resume(session.runId);
209
+ try {
210
+ await resumed.step("b", () => 2);
211
+ expect.unreachable("divergent replay must throw");
212
+ } catch (err) {
213
+ expect(err).toBeInstanceOf(OkeError);
214
+ expect((err as OkeError).code).toBe(1075);
215
+ }
216
+ });
217
+
218
+ test("a non-JSON journal value is OKE1077", async () => {
219
+ const store = createMemoryJournalStore();
220
+ const session = await createJournal({ store, now: () => 1 }).start("charge");
221
+ try {
222
+ await session.effect("call", "charge", () => 1n);
223
+ expect.unreachable("bigint must not journal");
224
+ } catch (err) {
225
+ expect(err).toBeInstanceOf(OkeError);
226
+ expect((err as OkeError).code).toBe(1077);
227
+ }
228
+ });
229
+
230
+ test("a 0.23.1 stamp resumes and is restamped when codeVersion is set", async () => {
231
+ const store = createMemoryJournalStore();
232
+ const started = await createJournal({ store, now: () => 1, codeVersion: "v1" }).start("charge");
233
+ const row = await store.get(started.runId);
234
+ if (row) {
235
+ row.codeVersion = "0.23.1";
236
+ await store.put(row);
237
+ }
238
+ const resumed = await createJournal({ store, now: () => 2, codeVersion: "v9" }).resume(
239
+ started.runId,
240
+ );
241
+ expect(resumed.run.codeVersion).toBe("app:v9");
242
+ expect((await store.get(started.runId))?.status).not.toBe("failed");
243
+ });
244
+
245
+ test("resume rejects a run stamped with another code version", async () => {
246
+ const store = createMemoryJournalStore();
247
+ const started = await createJournal({ store, now: () => 1, codeVersion: "v1" }).start("charge");
248
+ try {
249
+ await createJournal({ store, now: () => 2, codeVersion: "v2" }).resume(started.runId);
250
+ expect.unreachable("version skew must throw");
251
+ } catch (err) {
252
+ expect(err).toBeInstanceOf(OkeError);
253
+ expect((err as OkeError).code).toBe(1076);
254
+ }
255
+ });
256
+ });
257
+
258
+ describe("journal fetch snapshot", () => {
259
+ test("a durable flow replays fx.fetch without calling the network again", async () => {
260
+ const store = createMemoryJournalStore();
261
+ let calls = 0;
262
+ const original = globalThis.fetch;
263
+ globalThis.fetch = (async () => {
264
+ calls += 1;
265
+ return new Response("hello", { status: 201 });
266
+ }) as unknown as typeof fetch;
267
+ const load = flow("load", {
268
+ durable: true,
269
+ do: async (_input, fx) => {
270
+ const res = await fx.step("load", () => fx.fetch("https://example.com/data"));
271
+ return { status: res.status, text: await res.text() };
272
+ },
273
+ });
274
+ try {
275
+ const first = await runDurable({
276
+ flow: load,
277
+ input: {},
278
+ journalStore: store,
279
+ now: () => 1,
280
+ });
281
+ expect(first).toMatchObject({ status: "completed", output: { status: 201, text: "hello" } });
282
+ expect(calls).toBe(1);
283
+ const row = await store.get(first.runId);
284
+ expect(row?.entries.some((entry) => entry.kind === "step" && entry.name === "load")).toBe(
285
+ true,
286
+ );
287
+ const replay = await createJournal({ store, now: () => 2 }).resume(first.runId);
288
+ const again = await replay.step("load", () => {
289
+ calls += 1;
290
+ return new Response("nope", { status: 500 });
291
+ });
292
+ expect(again).toBeInstanceOf(Response);
293
+ expect(again.status).toBe(201);
294
+ expect(await again.text()).toBe("hello");
295
+ expect(calls).toBe(1);
296
+ } finally {
297
+ globalThis.fetch = original;
298
+ }
299
+ });
142
300
  });