okengine 0.7.0 → 0.8.0

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 (61) hide show
  1. package/package.json +2 -2
  2. package/site/content/docs/elements/channel.mdx +23 -12
  3. package/site/content/docs/elements/clock.mdx +17 -15
  4. package/site/content/docs/elements/flow.mdx +6 -2
  5. package/site/content/docs/elements/store.mdx +131 -0
  6. package/site/content/docs/get-started/installation.mdx +18 -16
  7. package/site/content/docs/plugins/magic-link.mdx +42 -0
  8. package/site/content/docs/plugins/phone-number.mdx +78 -17
  9. package/site/content/docs/plugins/two-factor.mdx +1 -0
  10. package/site/content/docs/reference/cli.md +2 -0
  11. package/site/content/docs/reference/configuration.mdx +5 -3
  12. package/site/content/docs/reference/environment-variables.mdx +20 -8
  13. package/src/cli/db-seed.ts +359 -0
  14. package/src/cli/db.test.ts +341 -3
  15. package/src/cli/db.ts +75 -8
  16. package/src/cli/load-config.images.test.ts +22 -0
  17. package/src/cli/load-config.ts +7 -2
  18. package/src/cli/registry.ts +37 -1
  19. package/src/compiler/effects-infer.ts +1 -0
  20. package/src/config/index.ts +4 -0
  21. package/src/drivers/channel-sently.test.ts +8 -0
  22. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  23. package/src/drivers/channel-types.ts +71 -0
  24. package/src/drivers/clock-postgres.test.ts +258 -0
  25. package/src/drivers/clock-postgres.ts +410 -0
  26. package/src/drivers/index.ts +18 -0
  27. package/src/drivers/journal-postgres.test.ts +175 -0
  28. package/src/drivers/journal-postgres.ts +492 -0
  29. package/src/elements/channel/runtime.ts +51 -0
  30. package/src/elements/channel.test.ts +71 -0
  31. package/src/elements/clock/chaos-child.ts +280 -41
  32. package/src/elements/clock/durable.ts +7 -0
  33. package/src/elements/clock/reconcile.ts +2 -2
  34. package/src/elements/clock/runtime.ts +5 -3
  35. package/src/elements/clock.ts +1 -1
  36. package/src/elements/store/seed.test.ts +27 -0
  37. package/src/elements/store/seed.ts +68 -0
  38. package/src/elements/store/sql-session.test.ts +39 -0
  39. package/src/elements/store/sql-session.ts +55 -0
  40. package/src/elements/store/upsert-app.test.ts +103 -0
  41. package/src/elements/store.ts +5 -0
  42. package/src/index.ts +15 -0
  43. package/src/kernel/app.ts +165 -14
  44. package/src/kernel/boot-bind/channel.test.ts +16 -0
  45. package/src/kernel/boot-bind/channel.ts +13 -0
  46. package/src/kernel/boot-bind/clock.ts +17 -6
  47. package/src/kernel/boot-bind/honor-config.test.ts +105 -4
  48. package/src/kernel/boot-bind/journal.ts +89 -0
  49. package/src/kernel/boot.test.ts +6 -4
  50. package/src/kernel/boot.ts +53 -13
  51. package/src/kernel/concurrency.ts +1 -1
  52. package/src/kernel/fx.test.ts +6 -0
  53. package/src/kernel/fx.ts +126 -5
  54. package/src/kernel/index.ts +6 -0
  55. package/src/kernel/journal-boot.test.ts +397 -0
  56. package/src/kernel/journal-suspend.ts +35 -0
  57. package/src/kernel/journal.test.ts +142 -0
  58. package/src/kernel/journal.ts +202 -27
  59. package/src/plugins/auth-methods.security.test.ts +10 -7
  60. package/src/plugins/phone-number.ts +67 -10
  61. package/src/plugins/taqnyat.live.test.ts +174 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * `postgres` clock driver — multi-host CronStore via SKIP LOCKED + lease reclaim.
3
+ *
4
+ * Same concurrency physics as Signal's `once` delivery (`signal-postgres.ts`):
5
+ * claim with `FOR UPDATE SKIP LOCKED`; a crashed holder's lease is reclaimed
6
+ * lazily on the next claim attempt (no sweeper).
7
+ */
8
+
9
+ import type { CronRow, CronStatus, CronStore } from "../elements/clock/reconcile.ts";
10
+ import { toPostgresParams } from "./postgres.ts";
11
+
12
+ /** Row shape in `oke_crons` (lease columns mirror `oke_signal_messages`). */
13
+ interface CronDbRow {
14
+ name: string;
15
+ declared_cron: string | null;
16
+ declared_every: string | null;
17
+ override_cron: string | null;
18
+ override_every: string | null;
19
+ effective_cron: string | null;
20
+ effective_every: string | null;
21
+ timezone: string;
22
+ overridable: boolean | number;
23
+ status: string;
24
+ locked_by: string | null;
25
+ lease_expires_at: number | null;
26
+ last_run_at: number | null;
27
+ next_run_at: number | null;
28
+ dst_ambiguity: string | null;
29
+ }
30
+
31
+ /** Minimal SQL + transaction surface for the postgres cron store. */
32
+ export interface PostgresCronSql {
33
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
34
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
35
+ /**
36
+ * Run `fn` inside a transaction. Nested calls join the outer txn.
37
+ *
38
+ * @param fn - Body
39
+ */
40
+ begin<T>(fn: (sql: PostgresCronSql) => Promise<T>): Promise<T>;
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Claim a cron row for lease acquire / renew / reclaim.
46
+ *
47
+ * Claimable when unlocked, same holder renewing, or lease expired
48
+ * (lazy reclaim — matches Signal's once-claim predicate).
49
+ */
50
+ const CLAIM_LEASE_SQL = `SELECT * FROM oke_crons WHERE name=? AND ((locked_by IS NULL) OR (locked_by=?) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?)) FOR UPDATE SKIP LOCKED`;
51
+
52
+ /** Options for {@link createPostgresCronStore}. */
53
+ export interface CreatePostgresCronStoreOptions {
54
+ /** Postgres connection URL (Bun.SQL). Ignored when `sql` is injected. */
55
+ readonly url?: string;
56
+ /** Injected SQL surface (tests / fakes). */
57
+ readonly sql?: PostgresCronSql;
58
+ /** Injected Bun.SQL-compatible client. */
59
+ readonly client?: BunCronClient;
60
+ }
61
+
62
+ /** Minimal Bun.SQL surface used by the real driver. */
63
+ export interface BunCronClient {
64
+ unsafe(
65
+ sql: string,
66
+ values?: unknown[],
67
+ ): PromiseLike<Record<string, unknown>[] | { length: number; changes?: number }>;
68
+ begin<T>(fn: (tx: BunCronClient) => Promise<T> | T): Promise<T>;
69
+ close?(options?: { timeout?: number }): Promise<void>;
70
+ }
71
+
72
+ function wrapBunClient(client: BunCronClient): PostgresCronSql {
73
+ const api: PostgresCronSql = {
74
+ async query(sql, params = []) {
75
+ const pg = toPostgresParams(sql);
76
+ const result = await client.unsafe(pg, [...params]);
77
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
78
+ return Array.from(result as ArrayLike<Record<string, unknown>>);
79
+ },
80
+ async exec(sql, params = []) {
81
+ const pg = toPostgresParams(sql);
82
+ const result = await client.unsafe(pg, [...params]);
83
+ if (
84
+ result &&
85
+ typeof result === "object" &&
86
+ "changes" in result &&
87
+ typeof (result as { changes: unknown }).changes === "number"
88
+ ) {
89
+ return { changes: (result as { changes: number }).changes };
90
+ }
91
+ if (Array.isArray(result)) return { changes: result.length };
92
+ return { changes: 0 };
93
+ },
94
+ async begin(fn) {
95
+ return client.begin(async (tx) => fn(wrapBunClient(tx)));
96
+ },
97
+ async close() {
98
+ await client.close?.();
99
+ },
100
+ };
101
+ return api;
102
+ }
103
+
104
+ function strOrUndef(v: unknown): string | undefined {
105
+ if (v === undefined || v === null || v === "") return undefined;
106
+ return String(v);
107
+ }
108
+
109
+ function numOrUndef(v: unknown): number | undefined {
110
+ if (v === undefined || v === null || v === "") return undefined;
111
+ const n = Number(v);
112
+ return Number.isFinite(n) ? n : undefined;
113
+ }
114
+
115
+ function rowToCron(row: Record<string, unknown>): CronRow {
116
+ const dstRaw = row.dst_ambiguity;
117
+ let dstAmbiguity: CronRow["dstAmbiguity"];
118
+ if (typeof dstRaw === "string" && dstRaw.trim()) {
119
+ dstAmbiguity = JSON.parse(dstRaw) as CronRow["dstAmbiguity"];
120
+ }
121
+ return {
122
+ name: String(row.name),
123
+ declaredCron: strOrUndef(row.declared_cron),
124
+ declaredEvery: strOrUndef(row.declared_every),
125
+ overrideCron: strOrUndef(row.override_cron),
126
+ overrideEvery: strOrUndef(row.override_every),
127
+ effectiveCron: strOrUndef(row.effective_cron),
128
+ effectiveEvery: strOrUndef(row.effective_every),
129
+ timezone: String(row.timezone ?? "UTC"),
130
+ overridable: Boolean(row.overridable),
131
+ status: String(row.status) as CronStatus,
132
+ leaderInstanceId: strOrUndef(row.locked_by),
133
+ leaderLeaseUntil: numOrUndef(row.lease_expires_at),
134
+ lastRunAt: numOrUndef(row.last_run_at),
135
+ nextRunAt: numOrUndef(row.next_run_at),
136
+ dstAmbiguity,
137
+ };
138
+ }
139
+
140
+ function cronToParams(row: CronRow): unknown[] {
141
+ return [
142
+ row.name,
143
+ row.declaredCron ?? null,
144
+ row.declaredEvery ?? null,
145
+ row.overrideCron ?? null,
146
+ row.overrideEvery ?? null,
147
+ row.effectiveCron ?? null,
148
+ row.effectiveEvery ?? null,
149
+ row.timezone,
150
+ row.overridable,
151
+ row.status,
152
+ row.leaderInstanceId ?? null,
153
+ row.leaderLeaseUntil ?? null,
154
+ row.lastRunAt ?? null,
155
+ row.nextRunAt ?? null,
156
+ row.dstAmbiguity ? JSON.stringify(row.dstAmbiguity) : null,
157
+ ];
158
+ }
159
+
160
+ async function ensureSchema(sql: PostgresCronSql): Promise<void> {
161
+ await sql.exec(`CREATE TABLE IF NOT EXISTS oke_crons (
162
+ name TEXT PRIMARY KEY,
163
+ declared_cron TEXT,
164
+ declared_every TEXT,
165
+ override_cron TEXT,
166
+ override_every TEXT,
167
+ effective_cron TEXT,
168
+ effective_every TEXT,
169
+ timezone TEXT NOT NULL,
170
+ overridable BOOLEAN NOT NULL DEFAULT FALSE,
171
+ status TEXT NOT NULL,
172
+ locked_by TEXT,
173
+ lease_expires_at BIGINT,
174
+ last_run_at BIGINT,
175
+ next_run_at BIGINT,
176
+ dst_ambiguity TEXT
177
+ )`);
178
+ }
179
+
180
+ /**
181
+ * In-memory Postgres-protocol fake with transactions + SKIP LOCKED for tests.
182
+ */
183
+ export function createPostgresCronFake(): PostgresCronSql & {
184
+ /** Force-kill mid-transaction (drops uncommitted state). */
185
+ killActiveTransaction(): void;
186
+ } {
187
+ type State = { rows: CronDbRow[] };
188
+
189
+ let committed: State = { rows: [] };
190
+ let active: { state: State; locked: Set<string>; done: boolean } | null = null;
191
+ /** Names held by other active transactions (SKIP LOCKED). */
192
+ const heldByTxn = new Set<string>();
193
+ /** Serialize top-level begins so concurrent acquires cannot join one txn. */
194
+ let beginGate: Promise<void> = Promise.resolve();
195
+
196
+ function view(): State {
197
+ return active?.state ?? committed;
198
+ }
199
+
200
+ function cloneState(s: State): State {
201
+ return { rows: s.rows.map((r) => ({ ...r })) };
202
+ }
203
+
204
+ const api: PostgresCronSql & { killActiveTransaction(): void } = {
205
+ killActiveTransaction() {
206
+ if (active) {
207
+ for (const name of active.locked) heldByTxn.delete(name);
208
+ active = null;
209
+ }
210
+ },
211
+ async query(sql, params = []) {
212
+ const text = sql.trim();
213
+ const state = view();
214
+
215
+ const isClaim = /FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) && /oke_crons/i.test(text);
216
+ if (isClaim) {
217
+ const name = String(params[0]);
218
+ const holder = String(params[1]);
219
+ const leaseCutoff = Number(params[2]);
220
+ if (heldByTxn.has(name) && !(active?.locked.has(name) ?? false)) {
221
+ return [];
222
+ }
223
+ const row = state.rows.find((r) => {
224
+ if (r.name !== name) return false;
225
+ const unlocked = r.locked_by === null;
226
+ const renew = r.locked_by === holder;
227
+ const expired = r.lease_expires_at !== null && r.lease_expires_at <= leaseCutoff;
228
+ return unlocked || renew || expired;
229
+ });
230
+ if (!row) return [];
231
+ if (active) {
232
+ active.locked.add(name);
233
+ heldByTxn.add(name);
234
+ }
235
+ return [{ ...row }];
236
+ }
237
+
238
+ const byName = /^SELECT\s+\*\s+FROM\s+oke_crons\s+WHERE\s+name\s*=\s*\?\s*$/i.exec(text);
239
+ if (byName) {
240
+ return state.rows.filter((r) => r.name === params[0]).map((r) => ({ ...r }));
241
+ }
242
+
243
+ const all = /^SELECT\s+\*\s+FROM\s+oke_crons\s*$/i.exec(text);
244
+ if (all) {
245
+ return state.rows.map((r) => ({ ...r }));
246
+ }
247
+
248
+ throw new Error(`postgres cron fake: unsupported query: ${sql}`);
249
+ },
250
+ async exec(sql, params = []) {
251
+ const text = sql.trim();
252
+ const state = view();
253
+
254
+ if (/^CREATE\s+TABLE/i.test(text)) return { changes: 0 };
255
+
256
+ const upsert =
257
+ /^INSERT\s+INTO\s+oke_crons\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*ON\s+CONFLICT\s*\(\s*name\s*\)\s*DO\s+UPDATE\s+SET\s+.+$/i.exec(
258
+ text,
259
+ );
260
+ if (upsert) {
261
+ const cols = upsert[1]!.split(",").map((c) => c.trim());
262
+ const record: Record<string, unknown> = {};
263
+ cols.forEach((c, i) => {
264
+ record[c] = params[i];
265
+ });
266
+ const next: CronDbRow = {
267
+ name: String(record.name),
268
+ declared_cron: (record.declared_cron as string | null) ?? null,
269
+ declared_every: (record.declared_every as string | null) ?? null,
270
+ override_cron: (record.override_cron as string | null) ?? null,
271
+ override_every: (record.override_every as string | null) ?? null,
272
+ effective_cron: (record.effective_cron as string | null) ?? null,
273
+ effective_every: (record.effective_every as string | null) ?? null,
274
+ timezone: String(record.timezone ?? "UTC"),
275
+ overridable: Boolean(record.overridable),
276
+ status: String(record.status),
277
+ locked_by: (record.locked_by as string | null) ?? null,
278
+ lease_expires_at:
279
+ record.lease_expires_at === undefined || record.lease_expires_at === null
280
+ ? null
281
+ : Number(record.lease_expires_at),
282
+ last_run_at:
283
+ record.last_run_at === undefined || record.last_run_at === null
284
+ ? null
285
+ : Number(record.last_run_at),
286
+ next_run_at:
287
+ record.next_run_at === undefined || record.next_run_at === null
288
+ ? null
289
+ : Number(record.next_run_at),
290
+ dst_ambiguity: (record.dst_ambiguity as string | null) ?? null,
291
+ };
292
+ const idx = state.rows.findIndex((r) => r.name === next.name);
293
+ if (idx >= 0) state.rows[idx] = next;
294
+ else state.rows.push(next);
295
+ return { changes: 1 };
296
+ }
297
+
298
+ const updLease =
299
+ /^UPDATE\s+oke_crons\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?\s+WHERE\s+name\s*=\s*\?\s*$/i.exec(
300
+ text,
301
+ );
302
+ if (updLease) {
303
+ const row = state.rows.find((r) => r.name === params[2]);
304
+ if (!row) return { changes: 0 };
305
+ row.locked_by = params[0] === null ? null : String(params[0]);
306
+ row.lease_expires_at =
307
+ params[1] === null || params[1] === undefined ? null : Number(params[1]);
308
+ return { changes: 1 };
309
+ }
310
+
311
+ throw new Error(`postgres cron fake: unsupported exec: ${sql}`);
312
+ },
313
+ async begin(fn) {
314
+ // Nested begin (same call stack) joins the open txn.
315
+ if (active && !active.done) {
316
+ return fn(api);
317
+ }
318
+ // Top-level begins serialize so Promise.all racing acquires stay exclusive.
319
+ let release!: () => void;
320
+ const slot = new Promise<void>((resolve) => {
321
+ release = resolve;
322
+ });
323
+ const prev = beginGate;
324
+ beginGate = slot;
325
+ await prev;
326
+ active = { state: cloneState(committed), locked: new Set(), done: false };
327
+ try {
328
+ const result = await fn(api);
329
+ if (active) {
330
+ for (const name of active.locked) heldByTxn.delete(name);
331
+ committed = active.state;
332
+ active.done = true;
333
+ active = null;
334
+ }
335
+ return result;
336
+ } catch (err) {
337
+ if (active) {
338
+ for (const name of active.locked) heldByTxn.delete(name);
339
+ }
340
+ active = null;
341
+ throw err;
342
+ } finally {
343
+ release();
344
+ }
345
+ },
346
+ async close() {
347
+ active = null;
348
+ heldByTxn.clear();
349
+ },
350
+ };
351
+
352
+ return api;
353
+ }
354
+
355
+ const UPSERT_SQL = `INSERT INTO oke_crons (name, declared_cron, declared_every, override_cron, override_every, effective_cron, effective_every, timezone, overridable, status, locked_by, lease_expires_at, last_run_at, next_run_at, dst_ambiguity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET declared_cron = EXCLUDED.declared_cron, declared_every = EXCLUDED.declared_every, override_cron = EXCLUDED.override_cron, override_every = EXCLUDED.override_every, effective_cron = EXCLUDED.effective_cron, effective_every = EXCLUDED.effective_every, timezone = EXCLUDED.timezone, overridable = EXCLUDED.overridable, status = EXCLUDED.status, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, last_run_at = EXCLUDED.last_run_at, next_run_at = EXCLUDED.next_run_at, dst_ambiguity = EXCLUDED.dst_ambiguity`;
356
+
357
+ /**
358
+ * Open a postgres-backed CronStore (multi-host leader election).
359
+ *
360
+ * @param options - URL / injected sql / Bun.SQL client
361
+ */
362
+ export async function createPostgresCronStore(
363
+ options: CreatePostgresCronStoreOptions = {},
364
+ ): Promise<CronStore & { readonly sql: PostgresCronSql; close(): Promise<void> }> {
365
+ const sql =
366
+ options.sql ??
367
+ wrapBunClient(
368
+ options.client ??
369
+ (new Bun.SQL(
370
+ options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke",
371
+ ) as unknown as BunCronClient),
372
+ );
373
+
374
+ await ensureSchema(sql);
375
+
376
+ const store: CronStore & { readonly sql: PostgresCronSql; close(): Promise<void> } = {
377
+ kind: "postgres",
378
+ sql,
379
+ async get(name) {
380
+ const rows = await sql.query(`SELECT * FROM oke_crons WHERE name = ?`, [name]);
381
+ if (!rows[0]) return undefined;
382
+ return rowToCron(rows[0]);
383
+ },
384
+ async put(row) {
385
+ await sql.exec(UPSERT_SQL, cronToParams(row));
386
+ },
387
+ async list() {
388
+ const rows = await sql.query(`SELECT * FROM oke_crons`);
389
+ return rows.map((r) => rowToCron(r));
390
+ },
391
+ async acquireLease(name, instanceId, now, leaseMs) {
392
+ return sql.begin(async (tx) => {
393
+ const claimed = await tx.query(CLAIM_LEASE_SQL, [name, instanceId, now]);
394
+ if (!claimed[0]) return false;
395
+ const until = now + leaseMs;
396
+ await tx.exec(`UPDATE oke_crons SET locked_by = ?, lease_expires_at = ? WHERE name = ?`, [
397
+ instanceId,
398
+ until,
399
+ name,
400
+ ]);
401
+ return true;
402
+ });
403
+ },
404
+ async close() {
405
+ await sql.close();
406
+ },
407
+ };
408
+
409
+ return store;
410
+ }
@@ -106,6 +106,23 @@ export {
106
106
  type PostgresSignalSql,
107
107
  } from "./signal-postgres.ts";
108
108
 
109
+ export {
110
+ createPostgresCronStore,
111
+ createPostgresCronFake,
112
+ type PostgresCronSql,
113
+ type CreatePostgresCronStoreOptions,
114
+ type BunCronClient,
115
+ } from "./clock-postgres.ts";
116
+
117
+ export {
118
+ createPostgresJournalStore,
119
+ createPostgresJournalFake,
120
+ type PostgresJournalSql,
121
+ type PostgresJournalStore,
122
+ type CreatePostgresJournalStoreOptions,
123
+ type BunJournalClient,
124
+ } from "./journal-postgres.ts";
125
+
109
126
  export {
110
127
  redisSignalDriver,
111
128
  openRedisSignal,
@@ -144,6 +161,7 @@ export { smtpChannelDriver, openSmtpChannel } from "./channel-smtp.ts";
144
161
  export { resendChannelDriver, openResendChannel } from "./channel-resend.ts";
145
162
  export { sndrChannelDriver, openSndrChannel } from "./channel-sndr.ts";
146
163
  export { taqnyatChannelDriver, openTaqnyatChannel } from "./channel-taqnyat.ts";
164
+ export { taqnyatMailChannelDriver, openTaqnyatMailChannel } from "./channel-taqnyat-mail.ts";
147
165
  export { msegatChannelDriver, openMsegatChannel } from "./channel-msegat.ts";
148
166
  export { unifonicChannelDriver, openUnifonicChannel } from "./channel-unifonic.ts";
149
167
  export { waCloudChannelDriver, openWaCloudChannel } from "./channel-wa-cloud.ts";
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Postgres JournalStore — unit (fake). Live multi-process boot chaos lives in
3
+ * `src/elements/clock/journal-boot.test.ts` (same LIVE_URL gate as clock).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+
8
+ import type { JournalRun } from "../kernel/journal.ts";
9
+ import { createPostgresJournalFake, createPostgresJournalStore } from "./journal-postgres.ts";
10
+
11
+ function seedRun(patch: Partial<JournalRun> & { id: string }): JournalRun {
12
+ return {
13
+ flow: "charge",
14
+ input: undefined,
15
+ status: "running",
16
+ entries: [],
17
+ createdAt: 1,
18
+ updatedAt: 1,
19
+ ...patch,
20
+ };
21
+ }
22
+
23
+ describe("postgres JournalStore (fake)", () => {
24
+ test("put / get / list round-trip incl. lease + sleep fields", async () => {
25
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
26
+ await store.put(
27
+ seedRun({
28
+ id: "r1",
29
+ input: { orderId: "o1" },
30
+ entries: [
31
+ { kind: "step", name: "create-intent", value: { id: "pi_1" }, at: 2 },
32
+ { kind: "sleep", label: "verify", duration: "7d", wakeAt: 1000, at: 3 },
33
+ ],
34
+ status: "sleeping",
35
+ wakeAt: 1000,
36
+ lockedBy: "inst-a",
37
+ leaseExpiresAt: 500,
38
+ output: { ok: true },
39
+ }),
40
+ );
41
+ const row = await store.get("r1");
42
+ expect(row?.flow).toBe("charge");
43
+ expect(row?.input).toEqual({ orderId: "o1" });
44
+ expect(row?.entries).toHaveLength(2);
45
+ expect(row?.status).toBe("sleeping");
46
+ expect(row?.wakeAt).toBe(1000);
47
+ expect(row?.lockedBy).toBe("inst-a");
48
+ expect(row?.leaseExpiresAt).toBe(500);
49
+ expect(row?.output).toEqual({ ok: true });
50
+ expect(await store.list()).toHaveLength(1);
51
+ await store.close();
52
+ });
53
+
54
+ test("acquireLease: exactly one of two racing claimants wins", async () => {
55
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
56
+ await store.put(seedRun({ id: "race" }));
57
+
58
+ const a = store.acquireLease("race", "a", 1_000, 500);
59
+ const b = store.acquireLease("race", "b", 1_000, 500);
60
+ const [wa, wb] = await Promise.all([a, b]);
61
+ expect([wa, wb].filter(Boolean)).toHaveLength(1);
62
+
63
+ const row = await store.get("race");
64
+ expect(row?.lockedBy).toBe(wa ? "a" : "b");
65
+ expect(row?.leaseExpiresAt).toBe(1_500);
66
+ await store.close();
67
+ });
68
+
69
+ test("acquireLease: expired lease is reclaimed lazily (no sweeper)", async () => {
70
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
71
+ await store.put(seedRun({ id: "reclaim", lockedBy: "dead", leaseExpiresAt: 100 }));
72
+
73
+ expect(await store.acquireLease("reclaim", "survivor", 100, 50)).toBe(true);
74
+ const row = await store.get("reclaim");
75
+ expect(row?.lockedBy).toBe("survivor");
76
+ expect(row?.leaseExpiresAt).toBe(150);
77
+ await store.close();
78
+ });
79
+
80
+ test("acquireLease: live lease blocks other instance; same holder renews", async () => {
81
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
82
+ await store.put(seedRun({ id: "held" }));
83
+ expect(await store.acquireLease("held", "leader", 0, 1_000)).toBe(true);
84
+ expect(await store.acquireLease("held", "other", 100, 1_000)).toBe(false);
85
+ expect(await store.acquireLease("held", "leader", 100, 1_000)).toBe(true);
86
+ await store.close();
87
+ });
88
+
89
+ test("claimDueSleep: claims due sleep, skips future + live-leased", async () => {
90
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
91
+ await store.put(seedRun({ id: "future", status: "sleeping", wakeAt: 10_000 }));
92
+ await store.put(
93
+ seedRun({
94
+ id: "leased",
95
+ status: "sleeping",
96
+ wakeAt: 100,
97
+ lockedBy: "other",
98
+ leaseExpiresAt: 9_000,
99
+ }),
100
+ );
101
+ await store.put(seedRun({ id: "due", status: "sleeping", wakeAt: 100 }));
102
+ await store.put(seedRun({ id: "done", status: "completed" }));
103
+
104
+ const claimed = await store.claimDueSleep("me", 1_000, 500);
105
+ expect(claimed?.id).toBe("due");
106
+ const row = await store.get("due");
107
+ expect(row?.lockedBy).toBe("me");
108
+ expect(row?.leaseExpiresAt).toBe(1_500);
109
+
110
+ // Nothing else claimable.
111
+ expect(await store.claimDueSleep("me", 1_000, 500)).toBeUndefined();
112
+ await store.close();
113
+ });
114
+
115
+ test("claimDueSleep: two racing claims hand the run to exactly one instance", async () => {
116
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
117
+ await store.put(seedRun({ id: "due", status: "sleeping", wakeAt: 100 }));
118
+
119
+ const [a, b] = await Promise.all([
120
+ store.claimDueSleep("a", 1_000, 500),
121
+ store.claimDueSleep("b", 1_000, 500),
122
+ ]);
123
+ const winners = [a, b].filter((r) => r?.id === "due");
124
+ expect(winners).toHaveLength(1);
125
+ await store.close();
126
+ });
127
+
128
+ test("claimDueSleep: expired lease on a sleeping run is reclaimable", async () => {
129
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
130
+ await store.put(
131
+ seedRun({
132
+ id: "stale",
133
+ status: "sleeping",
134
+ wakeAt: 100,
135
+ lockedBy: "dead",
136
+ leaseExpiresAt: 50,
137
+ }),
138
+ );
139
+ const claimed = await store.claimDueSleep("survivor", 1_000, 500);
140
+ expect(claimed?.id).toBe("stale");
141
+ expect((await store.get("stale"))?.lockedBy).toBe("survivor");
142
+ await store.close();
143
+ });
144
+
145
+ test("listOrphans: running/sleeping without a live lease; never completed", async () => {
146
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
147
+ await store.put(seedRun({ id: "running-unlocked" }));
148
+ await store.put(seedRun({ id: "running-expired", lockedBy: "dead", leaseExpiresAt: 10 }));
149
+ await store.put(seedRun({ id: "running-live", lockedBy: "alive", leaseExpiresAt: 9_000 }));
150
+ await store.put(seedRun({ id: "sleeping-future", status: "sleeping", wakeAt: 99_000 }));
151
+ await store.put(seedRun({ id: "done", status: "completed" }));
152
+
153
+ const orphans = (await store.listOrphans(1_000)).map((r) => r.id);
154
+ expect(orphans).toContain("running-unlocked");
155
+ expect(orphans).toContain("running-expired");
156
+ expect(orphans).toContain("sleeping-future");
157
+ expect(orphans).not.toContain("running-live");
158
+ expect(orphans).not.toContain("done");
159
+ await store.close();
160
+ });
161
+
162
+ test("releaseLease: holder releases; other holder cannot", async () => {
163
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
164
+ await store.put(seedRun({ id: "r", lockedBy: "a", leaseExpiresAt: 500 }));
165
+
166
+ await store.releaseLease("r", "b");
167
+ expect((await store.get("r"))?.lockedBy).toBe("a");
168
+
169
+ await store.releaseLease("r", "a");
170
+ const row = await store.get("r");
171
+ expect(row?.lockedBy).toBeUndefined();
172
+ expect(row?.leaseExpiresAt).toBeUndefined();
173
+ await store.close();
174
+ });
175
+ });