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
@@ -4,48 +4,55 @@
4
4
  * Args:
5
5
  * tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]
6
6
  * hold-lease <storePath> <instanceId> <markerPath> <leaseMs>
7
+ * tick-loop-pg <databaseUrl> <instanceId> <fireLogPath> <leaseMs> [every]
8
+ * hold-lease-pg <databaseUrl> <instanceId> <markerPath> <leaseMs>
7
9
  * durable-mid-step <journalPath> <markerPath> <runIdPath>
10
+ * journal-pg-start <databaseUrl> <instanceId> <stepLogPath> <markerPath> <leaseMs> <blockMs>
11
+ * journal-pg-resume <databaseUrl> <instanceId> <stepLogPath> <donePath> <leaseMs>
12
+ * journal-pg-park <databaseUrl> <instanceId> <stepLogPath> <parkedPath> <wakeMs>
13
+ * journal-pg-claim <databaseUrl> <instanceId> <stepLogPath> <leaseMs>
8
14
  *
9
15
  * Modes:
10
- * tick-loop — reconcile + tick until this instance fires once, append fire log
11
- * hold-lease — acquire lease, write marker, hang (parent SIGKILLs)
12
- * durable-mid-step — journal step 1, write marker + runId, hang (parent SIGKILLs)
16
+ * tick-loop / tick-loop-pg — reconcile + tick until this instance fires once
17
+ * hold-lease / hold-lease-pg — acquire lease, write marker, hang (parent SIGKILLs)
18
+ * durable-mid-step — journal step 1, write marker + runId, hang
19
+ * journal-pg-start — boot oke() app, start durable run over HTTP, hang (parent SIGKILLs)
20
+ * journal-pg-resume — boot oke() app; orphan sweep resumes the dead run; exit when done
21
+ * journal-pg-park — boot oke() app, park a durable sleep over HTTP, exit
22
+ * journal-pg-claim — boot oke() app; race claimDueSleep; exit 0 on own execution
13
23
  */
14
24
 
15
25
  import { join } from "node:path";
16
26
 
17
- import { flow } from "../../kernel/flow.ts";
27
+ import { createPostgresCronStore } from "../../drivers/clock-postgres.ts";
28
+ import { oke, type OkeApp } from "../../kernel/app.ts";
29
+ import { flow, type AnyFlowDef } from "../../kernel/flow.ts";
18
30
  import { createFileJournalStore } from "../../kernel/journal.ts";
31
+ import { on } from "../../kernel/on.ts";
32
+ import { http } from "../../kernel/triggers.ts";
19
33
  import { clock } from "./declare.ts";
20
34
  import { runDurable } from "./durable.ts";
21
- import { createFileCronStore } from "./reconcile.ts";
35
+ import { createFileCronStore, type CronStore } from "./reconcile.ts";
22
36
  import { createClockRuntime } from "./runtime.ts";
23
37
 
24
38
  const mode = process.argv[2];
25
39
 
26
40
  if (!mode) {
27
- console.error("usage: chaos-child <tick-loop|hold-lease|durable-mid-step> …");
41
+ console.error(
42
+ "usage: chaos-child <tick-loop|hold-lease|tick-loop-pg|hold-lease-pg|durable-mid-step> …",
43
+ );
28
44
  process.exit(2);
29
45
  }
30
46
 
31
47
  const CRON_NAME = "chaos-job";
32
48
 
33
- if (mode === "tick-loop") {
34
- const storePath = process.argv[3];
35
- const instanceId = process.argv[4];
36
- const fireLogPath = process.argv[5];
37
- const leaseMs = Number(process.argv[6] ?? 100);
38
- // Default 1h so a second process cannot become due again within the test window
39
- // after the winner sets lastRunAt (proves one fire per tick, not one per lease).
40
- const every = process.argv[7] ?? "1h";
41
- if (!storePath || !instanceId || !fireLogPath) {
42
- console.error(
43
- "usage: chaos-child tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]",
44
- );
45
- process.exit(2);
46
- }
47
-
48
- const store = createFileCronStore(join(storePath));
49
+ async function runTickLoop(
50
+ store: CronStore,
51
+ instanceId: string,
52
+ fireLogPath: string,
53
+ leaseMs: number,
54
+ every: string,
55
+ ) {
49
56
  const rt = createClockRuntime({
50
57
  instanceId,
51
58
  store,
@@ -69,17 +76,12 @@ if (mode === "tick-loop") {
69
76
  process.exit(3);
70
77
  }
71
78
 
72
- if (mode === "hold-lease") {
73
- const storePath = process.argv[3];
74
- const instanceId = process.argv[4];
75
- const markerPath = process.argv[5];
76
- const leaseMs = Number(process.argv[6] ?? 100);
77
- if (!storePath || !instanceId || !markerPath) {
78
- console.error("usage: chaos-child hold-lease <storePath> <instanceId> <markerPath> <leaseMs>");
79
- process.exit(2);
80
- }
81
-
82
- const store = createFileCronStore(join(storePath));
79
+ async function runHoldLease(
80
+ store: CronStore,
81
+ instanceId: string,
82
+ markerPath: string,
83
+ leaseMs: number,
84
+ ) {
83
85
  const rt = createClockRuntime({
84
86
  instanceId,
85
87
  store,
@@ -88,7 +90,6 @@ if (mode === "hold-lease") {
88
90
  rt.register(clock(CRON_NAME, { every: "50ms" }));
89
91
  await rt.reconcile();
90
92
 
91
- // Fire once so lastRunAt + lease are set (this instance is leader).
92
93
  rt.onCron(CRON_NAME, () => {});
93
94
  const ok = await rt.runNow(CRON_NAME);
94
95
  if (!ok) {
@@ -104,12 +105,61 @@ if (mode === "hold-lease") {
104
105
  leaderLeaseUntil: row?.leaderLeaseUntil,
105
106
  }),
106
107
  );
107
- // Hang while holding the lease — parent will SIGKILL.
108
108
  await Bun.sleep(60_000);
109
109
  process.exit(0);
110
110
  }
111
111
 
112
- if (mode === "durable-mid-step") {
112
+ if (mode === "tick-loop") {
113
+ const storePath = process.argv[3];
114
+ const instanceId = process.argv[4];
115
+ const fireLogPath = process.argv[5];
116
+ const leaseMs = Number(process.argv[6] ?? 100);
117
+ const every = process.argv[7] ?? "1h";
118
+ if (!storePath || !instanceId || !fireLogPath) {
119
+ console.error(
120
+ "usage: chaos-child tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]",
121
+ );
122
+ process.exit(2);
123
+ }
124
+ await runTickLoop(createFileCronStore(join(storePath)), instanceId, fireLogPath, leaseMs, every);
125
+ } else if (mode === "hold-lease") {
126
+ const storePath = process.argv[3];
127
+ const instanceId = process.argv[4];
128
+ const markerPath = process.argv[5];
129
+ const leaseMs = Number(process.argv[6] ?? 100);
130
+ if (!storePath || !instanceId || !markerPath) {
131
+ console.error("usage: chaos-child hold-lease <storePath> <instanceId> <markerPath> <leaseMs>");
132
+ process.exit(2);
133
+ }
134
+ await runHoldLease(createFileCronStore(join(storePath)), instanceId, markerPath, leaseMs);
135
+ } else if (mode === "tick-loop-pg") {
136
+ const databaseUrl = process.argv[3];
137
+ const instanceId = process.argv[4];
138
+ const fireLogPath = process.argv[5];
139
+ const leaseMs = Number(process.argv[6] ?? 100);
140
+ const every = process.argv[7] ?? "1h";
141
+ if (!databaseUrl || !instanceId || !fireLogPath) {
142
+ console.error(
143
+ "usage: chaos-child tick-loop-pg <databaseUrl> <instanceId> <fireLogPath> <leaseMs> [every]",
144
+ );
145
+ process.exit(2);
146
+ }
147
+ const store = await createPostgresCronStore({ url: databaseUrl });
148
+ await runTickLoop(store, instanceId, fireLogPath, leaseMs, every);
149
+ } else if (mode === "hold-lease-pg") {
150
+ const databaseUrl = process.argv[3];
151
+ const instanceId = process.argv[4];
152
+ const markerPath = process.argv[5];
153
+ const leaseMs = Number(process.argv[6] ?? 100);
154
+ if (!databaseUrl || !instanceId || !markerPath) {
155
+ console.error(
156
+ "usage: chaos-child hold-lease-pg <databaseUrl> <instanceId> <markerPath> <leaseMs>",
157
+ );
158
+ process.exit(2);
159
+ }
160
+ const store = await createPostgresCronStore({ url: databaseUrl });
161
+ await runHoldLease(store, instanceId, markerPath, leaseMs);
162
+ } else if (mode === "durable-mid-step") {
113
163
  const journalPath = process.argv[3];
114
164
  const markerPath = process.argv[4];
115
165
  const runIdPath = process.argv[5];
@@ -124,14 +174,12 @@ if (mode === "durable-mid-step") {
124
174
  durable: true,
125
175
  do: async (_input, fx) => {
126
176
  const intent = await fx.step("create-intent", async () => {
127
- // Persist run id as soon as the step body runs (session already started).
128
177
  const runs = await journalStore.list();
129
178
  const run = runs[0];
130
179
  if (run) await Bun.write(runIdPath, run.id);
131
180
  await Bun.write(markerPath, "step1");
132
181
  return { id: "pi_chaos" };
133
182
  });
134
- // Hang after journaling step 1 — parent SIGKILLs before confirm.
135
183
  await Bun.sleep(60_000);
136
184
  return fx.step("confirm", () => intent.id === "pi_chaos");
137
185
  },
@@ -142,9 +190,200 @@ if (mode === "durable-mid-step") {
142
190
  input: { orderId: "o-chaos" },
143
191
  journalStore,
144
192
  });
145
- // Unreachable under SIGKILL.
146
193
  process.exit(0);
194
+ } else if (mode === "journal-pg-start") {
195
+ const [databaseUrl, instanceId, stepLogPath, markerPath] = process.argv.slice(3);
196
+ const leaseMs = Number(process.argv[7] ?? 300);
197
+ const blockMs = Number(process.argv[8] ?? 60_000);
198
+ if (!databaseUrl || !instanceId || !stepLogPath || !markerPath) {
199
+ console.error(
200
+ "usage: chaos-child journal-pg-start <databaseUrl> <instanceId> <stepLogPath> <markerPath> <leaseMs> <blockMs>",
201
+ );
202
+ process.exit(2);
203
+ }
204
+ const app = await bootJournalPgApp({
205
+ url: databaseUrl!,
206
+ instanceId: instanceId!,
207
+ leaseMs,
208
+ flow: chargeFlow(instanceId!, stepLogPath!, blockMs),
209
+ });
210
+ void app.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
211
+ // Marker once step 1 is journaled — then hang; the parent SIGKILLs mid-run.
212
+ const deadline = Date.now() + 15_000;
213
+ for (;;) {
214
+ const row = (await app.bootResult!.journal!.store.list())[0];
215
+ if (row && row.entries.length >= 1) {
216
+ await Bun.write(markerPath!, JSON.stringify({ runId: row.id, instanceId }));
217
+ break;
218
+ }
219
+ if (Date.now() > deadline) {
220
+ console.error("journal-pg-start: step 1 never journaled");
221
+ process.exit(3);
222
+ }
223
+ await Bun.sleep(10);
224
+ }
225
+ await Bun.sleep(3_600_000);
226
+ process.exit(0);
227
+ } else if (mode === "journal-pg-resume") {
228
+ const [databaseUrl, instanceId, stepLogPath, donePath] = process.argv.slice(3);
229
+ const leaseMs = Number(process.argv[7] ?? 300);
230
+ if (!databaseUrl || !instanceId || !stepLogPath || !donePath) {
231
+ console.error(
232
+ "usage: chaos-child journal-pg-resume <databaseUrl> <instanceId> <stepLogPath> <donePath> <leaseMs>",
233
+ );
234
+ process.exit(2);
235
+ }
236
+ // No fetch — the boot orphan scan / resume sweep must discover the dead run.
237
+ const app = await bootJournalPgApp({
238
+ url: databaseUrl!,
239
+ instanceId: instanceId!,
240
+ leaseMs,
241
+ flow: chargeFlow(instanceId!, stepLogPath!, 0),
242
+ });
243
+ const deadline = Date.now() + 20_000;
244
+ for (;;) {
245
+ await app.resumeDurable();
246
+ const row = (await app.bootResult!.journal!.store.list())[0];
247
+ if (row?.status === "completed") {
248
+ await Bun.write(donePath!, JSON.stringify({ runId: row.id, completedBy: instanceId }));
249
+ process.exit(0);
250
+ }
251
+ if (Date.now() > deadline) {
252
+ console.error("journal-pg-resume: orphan never resumed");
253
+ process.exit(3);
254
+ }
255
+ await Bun.sleep(25);
256
+ }
257
+ } else if (mode === "journal-pg-park") {
258
+ const [databaseUrl, instanceId, stepLogPath, parkedPath] = process.argv.slice(3);
259
+ const wakeMs = Number(process.argv[7] ?? 400);
260
+ if (!databaseUrl || !instanceId || !stepLogPath || !parkedPath) {
261
+ console.error(
262
+ "usage: chaos-child journal-pg-park <databaseUrl> <instanceId> <stepLogPath> <parkedPath> <wakeMs>",
263
+ );
264
+ process.exit(2);
265
+ }
266
+ const app = await bootJournalPgApp({
267
+ url: databaseUrl!,
268
+ instanceId: instanceId!,
269
+ leaseMs: 30_000,
270
+ flow: sleeperFlow(instanceId!, stepLogPath!, wakeMs),
271
+ });
272
+ const res = await app.fetch(
273
+ new Request("http://localhost/sleep", { method: "POST", body: "{}" }),
274
+ );
275
+ if (res.status !== 204) {
276
+ console.error(`journal-pg-park: expected 204 park, got ${res.status}`);
277
+ process.exit(3);
278
+ }
279
+ const row = (await app.bootResult!.journal!.store.list())[0];
280
+ await Bun.write(parkedPath!, JSON.stringify({ runId: row?.id, instanceId }));
281
+ process.exit(0);
282
+ } else if (mode === "journal-pg-claim") {
283
+ const [databaseUrl, instanceId, stepLogPath] = process.argv.slice(3);
284
+ const leaseMs = Number(process.argv[6] ?? 300);
285
+ if (!databaseUrl || !instanceId || !stepLogPath) {
286
+ console.error(
287
+ "usage: chaos-child journal-pg-claim <databaseUrl> <instanceId> <stepLogPath> <leaseMs>",
288
+ );
289
+ process.exit(2);
290
+ }
291
+ const app = await bootJournalPgApp({
292
+ url: databaseUrl!,
293
+ instanceId: instanceId!,
294
+ leaseMs,
295
+ flow: sleeperFlow(instanceId!, stepLogPath!, 400),
296
+ });
297
+ const deadline = Date.now() + 15_000;
298
+ for (;;) {
299
+ await app.resumeDurable();
300
+ if (await logHasStep(stepLogPath!, "confirm", instanceId!)) process.exit(0);
301
+ const row = (await app.bootResult!.journal!.store.list())[0];
302
+ if (row?.status === "completed") process.exit(3); // someone else claimed it
303
+ if (Date.now() > deadline) process.exit(3);
304
+ await Bun.sleep(25);
305
+ }
306
+ } else {
307
+ console.error(`unknown mode: ${mode}`);
308
+ process.exit(2);
309
+ }
310
+
311
+ /** Append one JSON line to the shared step log. */
312
+ async function appendStepLog(
313
+ path: string,
314
+ entry: { instanceId: string; step: string; at: number },
315
+ ): Promise<void> {
316
+ const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
317
+ await Bun.write(path, `${prev}${JSON.stringify(entry)}\n`);
318
+ }
319
+
320
+ /** True when the step log holds `step` executed by `instanceId`. */
321
+ async function logHasStep(path: string, step: string, instanceId: string): Promise<boolean> {
322
+ if (!(await Bun.file(path).exists())) return false;
323
+ const lines = (await Bun.file(path).text()).trim().split("\n").filter(Boolean);
324
+ return lines.some((l) => {
325
+ const e = JSON.parse(l) as { instanceId: string; step: string };
326
+ return e.step === step && e.instanceId === instanceId;
327
+ });
328
+ }
329
+
330
+ /** Durable charge flow: step 1 → block → step 2 (both logged). */
331
+ function chargeFlow(instanceId: string, stepLogPath: string, blockMs: number): AnyFlowDef {
332
+ return flow({
333
+ name: "chaos.journal.charge",
334
+ durable: true,
335
+ do: async (_input, fx) => {
336
+ const intent = await fx.step("create-intent", async () => {
337
+ await appendStepLog(stepLogPath, { instanceId, step: "create-intent", at: Date.now() });
338
+ return { id: "pi_chaos" };
339
+ });
340
+ if (blockMs > 0) await Bun.sleep(blockMs);
341
+ await fx.step("mid-flight", async () => {
342
+ await appendStepLog(stepLogPath, { instanceId, step: "mid-flight", at: Date.now() });
343
+ return intent.id === "pi_chaos";
344
+ });
345
+ return { ok: true };
346
+ },
347
+ });
348
+ }
349
+
350
+ /** Durable sleeper flow: step 1 → sleep → confirm (steps logged). */
351
+ function sleeperFlow(instanceId: string, stepLogPath: string, wakeMs: number): AnyFlowDef {
352
+ return flow({
353
+ name: "chaos.journal.sleeper",
354
+ durable: true,
355
+ do: async (_input, fx) => {
356
+ await fx.step("create-intent", async () => {
357
+ await appendStepLog(stepLogPath, { instanceId, step: "create-intent", at: Date.now() });
358
+ return { id: "pi_chaos" };
359
+ });
360
+ await fx.clock.sleep("verify-window", `${wakeMs}ms`);
361
+ await fx.step("confirm", async () => {
362
+ await appendStepLog(stepLogPath, { instanceId, step: "confirm", at: Date.now() });
363
+ return true;
364
+ });
365
+ return { ok: true };
366
+ },
367
+ });
147
368
  }
148
369
 
149
- console.error(`unknown mode: ${mode}`);
150
- process.exit(2);
370
+ /** Boot a real `oke()` app against live postgres (drivers.journal). */
371
+ async function bootJournalPgApp(options: {
372
+ readonly url: string;
373
+ readonly instanceId: string;
374
+ readonly leaseMs: number;
375
+ readonly flow: AnyFlowDef;
376
+ }): Promise<OkeApp> {
377
+ process.env.DATABASE_URL = options.url;
378
+ on(http.post("/charge"), options.flow);
379
+ on(http.post("/sleep"), options.flow);
380
+ const app = oke({
381
+ name: `chaos-journal-${options.instanceId}`,
382
+ env: "test",
383
+ startScheduler: false,
384
+ gate: { unguardedHttp: "allow" },
385
+ config: { drivers: { journal: { test: "postgres" } } },
386
+ });
387
+ await app.boot({ instanceId: options.instanceId, journalLeaseMs: options.leaseMs });
388
+ return app;
389
+ }
@@ -13,6 +13,7 @@ import {
13
13
  createJournal,
14
14
  isJournalSuspend,
15
15
  type Journal,
16
+ type JournalLeaseOptions,
16
17
  type JournalSession,
17
18
  type JournalStore,
18
19
  } from "../../kernel/journal.ts";
@@ -45,6 +46,11 @@ export interface RunDurableOptions {
45
46
  readonly journalStore: JournalStore;
46
47
  /** Resume an existing run id (crash recovery). */
47
48
  readonly runId?: string;
49
+ /**
50
+ * Run-level lease (SKIP LOCKED + lazy reclaim when the store supports it).
51
+ * Resume throws `JournalLeaseBusy` when another live instance holds the run.
52
+ */
53
+ readonly lease?: JournalLeaseOptions;
48
54
  /** Injectable clock. */
49
55
  readonly now?: () => number;
50
56
  /** Extra fx options (secrets, store runtime, …). */
@@ -63,6 +69,7 @@ export async function runDurable<O = unknown>(
63
69
  const journal: Journal = createJournal({
64
70
  store: options.journalStore,
65
71
  now,
72
+ ...(options.lease ? { lease: options.lease } : {}),
66
73
  });
67
74
 
68
75
  if (options.runId) {
@@ -52,8 +52,8 @@ export interface CronRow {
52
52
 
53
53
  /** Store surface for reconciled crons. */
54
54
  export interface CronStore {
55
- /** Backing kind when known (`memory` · `file`). */
56
- readonly kind?: "memory" | "file";
55
+ /** Backing kind when known (`memory` · `file` · `postgres`). */
56
+ readonly kind?: "memory" | "file" | "postgres";
57
57
  /**
58
58
  * @param name - Cron name
59
59
  */
@@ -44,9 +44,9 @@ export interface CreateClockRuntimeOptions {
44
44
  export interface ClockRuntime {
45
45
  /**
46
46
  * Effective driver id selected at construction
47
- * (`memory` · `file` · `frozen`).
47
+ * (`memory` · `file` · `postgres` · `frozen`).
48
48
  */
49
- readonly driverId: "memory" | "file" | "frozen";
49
+ readonly driverId: "memory" | "file" | "postgres" | "frozen";
50
50
  /** Instance id used for leases. */
51
51
  readonly instanceId: string;
52
52
  /** Cron store the scheduler reads. */
@@ -116,7 +116,9 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
116
116
  ? "frozen"
117
117
  : store.kind === "file"
118
118
  ? "file"
119
- : "memory";
119
+ : store.kind === "postgres"
120
+ ? "postgres"
121
+ : "memory";
120
122
 
121
123
  async function fire(name: string): Promise<boolean> {
122
124
  const row = await store.get(name);
@@ -2,7 +2,7 @@
2
2
  * Clock element — time.
3
3
  *
4
4
  * Physics: cron · delay · timeout · durable sleep · TTL.
5
- * Drivers (protocol-named): `memory` · `postgres`.
5
+ * Drivers (protocol-named): `memory` · `postgres` · `file` · `frozen`.
6
6
  *
7
7
  * Durability lives on the Flow (`durable: true`); the journal records every
8
8
  * `fx` call. Crons leader-elect; schedules are reconciled into the Store at
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineSeed, normalizeSeedFns, resolveSeedCategory, type SeedFn } from "./seed.ts";
3
+
4
+ describe("defineSeed / resolveSeedCategory", () => {
5
+ test("defineSeed returns the same def", () => {
6
+ const fn: SeedFn = async () => {};
7
+ const def = defineSeed({ essential: fn, dev: [fn], prod: fn });
8
+ expect(def.essential).toBe(fn);
9
+ expect(def.dev).toEqual([fn]);
10
+ expect(def.prod).toBe(fn);
11
+ });
12
+
13
+ test("normalizeSeedFns preserves array order", () => {
14
+ const a: SeedFn = async () => {};
15
+ const b: SeedFn = async () => {};
16
+ expect(normalizeSeedFns([a, b])).toEqual([a, b]);
17
+ expect(normalizeSeedFns(a)).toEqual([a]);
18
+ expect(normalizeSeedFns()).toEqual([]);
19
+ });
20
+
21
+ test("resolveSeedCategory matches the env matrix", () => {
22
+ expect(resolveSeedCategory("local")).toBe("dev");
23
+ expect(resolveSeedCategory("docker")).toBe("dev");
24
+ expect(resolveSeedCategory("prod")).toBe("prod");
25
+ expect(resolveSeedCategory("test")).toBe(null);
26
+ });
27
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Database seed declarations — `defineSeed({ essential, dev, prod })`.
3
+ *
4
+ * Seed never runs at boot; only via `oke db seed`.
5
+ */
6
+
7
+ import type { ConfigEnv } from "../../config/index.ts";
8
+ import type { Fx } from "../../kernel/fx.ts";
9
+
10
+ /** One seed function — receives a privileged `fx` with store access. */
11
+ export type SeedFn = (fx: Fx) => Promise<void>;
12
+
13
+ /** Single function or ordered array (array order is execution order). */
14
+ export type SeedFns = SeedFn | readonly SeedFn[];
15
+
16
+ /**
17
+ * Seed categories for {@link defineSeed}.
18
+ *
19
+ * - `essential` — every environment, always
20
+ * - `dev` — `local` or `docker` only
21
+ * - `prod` — `prod` only
22
+ */
23
+ export interface SeedDef {
24
+ readonly essential?: SeedFns;
25
+ readonly dev?: SeedFns;
26
+ readonly prod?: SeedFns;
27
+ }
28
+
29
+ /** Env-selected category that runs alongside `essential` (or neither). */
30
+ export type SeedCategory = "dev" | "prod";
31
+
32
+ /**
33
+ * Freeze a seed declaration for `src/seed/index.ts` default export.
34
+ *
35
+ * @param def - Essential / dev / prod function bags
36
+ */
37
+ export function defineSeed(def: SeedDef): SeedDef {
38
+ return def;
39
+ }
40
+
41
+ /**
42
+ * Normalize a seed key to an ordered function list.
43
+ *
44
+ * @param fns - Single function, array, or omitted
45
+ */
46
+ export function normalizeSeedFns(fns?: SeedFns): SeedFn[] {
47
+ if (fns === undefined) return [];
48
+ if (typeof fns === "function") return [fns];
49
+ return [...fns];
50
+ }
51
+
52
+ /**
53
+ * Which optional category runs for `env` (single source of truth).
54
+ *
55
+ * | env | category |
56
+ * | ------ | -------- |
57
+ * | local | dev |
58
+ * | docker | dev |
59
+ * | test | (none) |
60
+ * | prod | prod |
61
+ *
62
+ * @param env - Resolved {@link ConfigEnv}
63
+ */
64
+ export function resolveSeedCategory(env: ConfigEnv): SeedCategory | null {
65
+ if (env === "local" || env === "docker") return "dev";
66
+ if (env === "prod") return "prod";
67
+ return null;
68
+ }
@@ -56,6 +56,7 @@ describe("SqlStoreHandle — no relational query surface (path b)", () => {
56
56
  "findById",
57
57
  "delete",
58
58
  "exists",
59
+ "upsert",
59
60
  "increment",
60
61
  "raw",
61
62
  "count",
@@ -133,3 +134,41 @@ describe("SqlStoreHandle — orderBy / limit select chain", () => {
133
134
  await conn.close();
134
135
  });
135
136
  });
137
+
138
+ describe("SqlStoreHandle — upsert", () => {
139
+ test("default inserts once then already-existed without touching the row", async () => {
140
+ const { handle, conn } = await openHandle();
141
+ const first = await handle.upsert(
142
+ posts,
143
+ { id: "welcome" },
144
+ { id: "welcome", title: "Hello", createdAt: 1 },
145
+ );
146
+ expect(first.status).toBe("upserted");
147
+
148
+ const second = await handle.upsert(
149
+ posts,
150
+ { id: "welcome" },
151
+ { id: "welcome", title: "Changed", createdAt: 2 },
152
+ );
153
+ expect(second.status).toBe("already-existed");
154
+
155
+ const row = await handle.findById(posts, "welcome");
156
+ expect(row).toEqual({ id: "welcome", title: "Hello", createdAt: 1 });
157
+ await conn.close();
158
+ });
159
+
160
+ test("onExisting update changes matched columns", async () => {
161
+ const { handle, conn } = await openHandle();
162
+ await handle.upsert(posts, { id: "n1" }, { id: "n1", title: "one", createdAt: 10 });
163
+ const updated = await handle.upsert(
164
+ posts,
165
+ { id: "n1" },
166
+ { id: "n1", title: "two", createdAt: 20 },
167
+ { onExisting: "update" },
168
+ );
169
+ expect(updated.status).toBe("changed");
170
+ const row = await handle.findById(posts, "n1");
171
+ expect(row).toEqual({ id: "n1", title: "two", createdAt: 20 });
172
+ await conn.close();
173
+ });
174
+ });
@@ -237,6 +237,21 @@ export interface SqlStoreHandle {
237
237
  * @param idOrWhere - Primary key string, or column equality map
238
238
  */
239
239
  exists(table: TableHandle | unknown, idOrWhere: string | WhereMap): Promise<boolean>;
240
+ /**
241
+ * Insert when no row matches `matchOn`; never touches an existing match
242
+ * unless `options.onExisting` is `"update"`.
243
+ *
244
+ * @param table - Table
245
+ * @param matchOn - Equality map or Drizzle condition identifying the row
246
+ * @param values - Row values for insert (and optional update)
247
+ * @param options - Per-call opt-in to update an existing match
248
+ */
249
+ upsert(
250
+ table: TableHandle | unknown,
251
+ matchOn: WhereMap | unknown,
252
+ values: SqlRow,
253
+ options?: { readonly onExisting?: "update" },
254
+ ): Promise<UpsertResult>;
240
255
  /**
241
256
  * Atomically add `by` to `column` on the PK row.
242
257
  *
@@ -276,6 +291,14 @@ export interface SqlStoreHandle {
276
291
  ensureTable(table: TableHandle): Promise<void>;
277
292
  }
278
293
 
294
+ /** Outcome of {@link SqlStoreHandle.upsert}. */
295
+ export type UpsertStatus = "upserted" | "changed" | "already-existed";
296
+
297
+ /** Result envelope for {@link SqlStoreHandle.upsert}. */
298
+ export interface UpsertResult {
299
+ readonly status: UpsertStatus;
300
+ }
301
+
279
302
  /** Options for {@link SqlStoreHandle.page}. */
280
303
  export interface SqlPageOptions {
281
304
  /** Filter condition (equality map or Drizzle SQL). */
@@ -617,6 +640,38 @@ export function createSqlStoreHandle(
617
640
  return rows.length > 0;
618
641
  },
619
642
 
643
+ async upsert(table, matchOn, values, upsertOptions) {
644
+ await ensureFromMeta(table);
645
+ const name = resolveTableName(table);
646
+ const compiled = compileTableWhere(table, matchOn);
647
+ if (!compiled.clause) {
648
+ throw new Error("upsert() requires at least one matchOn predicate");
649
+ }
650
+ const found = await query(
651
+ `SELECT 1 AS "ok" FROM ${quoteIdent(name)} WHERE ${compiled.clause} LIMIT 1`,
652
+ compiled.params,
653
+ );
654
+ if (found.length === 0) {
655
+ const prepared = prepareInsertRow(table, values);
656
+ const cols = Object.keys(prepared);
657
+ const placeholders = cols.map(() => "?").join(", ");
658
+ const colList = cols.map(quoteIdent).join(", ");
659
+ const params = cols.map((c) => prepared[c]);
660
+ await exec(`INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders})`, params);
661
+ return { status: "upserted" as const };
662
+ }
663
+ if (upsertOptions?.onExisting !== "update") {
664
+ return { status: "already-existed" as const };
665
+ }
666
+ const prepared = prepareUpdateRow(table, values);
667
+ const setEntries = Object.entries(prepared);
668
+ if (setEntries.length === 0) return { status: "changed" as const };
669
+ const setSql = setEntries.map(([col]) => `${quoteIdent(col)} = ?`).join(", ");
670
+ const params = [...setEntries.map(([, v]) => v), ...compiled.params];
671
+ await exec(`UPDATE ${quoteIdent(name)} SET ${setSql} WHERE ${compiled.clause}`, params);
672
+ return { status: "changed" as const };
673
+ },
674
+
620
675
  async increment(table, idValue, column, by = 1) {
621
676
  await ensureFromMeta(table);
622
677
  const name = resolveTableName(table);