okengine 0.7.0 → 0.9.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 (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -5,7 +5,9 @@
5
5
  * 1. Vault — resolve every declared secret (list all gaps at once)
6
6
  * 2. Store — bind drivers per environment; open connections
7
7
  * 3. Signals — register declarations; start consumers
8
- * 4. Clocks — reconcile into the Store; start scheduler (leader election)
8
+ * 4. Clocks — reconcile into the Store
9
+ * 4b. Journal — bind the durable-run store (SKIP LOCKED + lease when shared)
10
+ * 4c. Scheduler — tick clocks + resume due durable runs (leader election)
9
11
  * 5. Channel — bind channel runtime
10
12
  * 6. AI — bind AI runtime
11
13
  * 7. Runs — open the runs store
@@ -33,6 +35,7 @@ import type {
33
35
  VaultRuntime,
34
36
  VaultSecretDecl,
35
37
  } from "../elements/vault.ts";
38
+ import type { JournalRuntime } from "./boot-bind/journal.ts";
36
39
  import type { CreateRunsRuntimeOptions, RunsRuntime } from "../runs/index.ts";
37
40
  import { createCapabilityToken, type CapabilityToken } from "./capability.ts";
38
41
  import type { AnyFlowDef } from "./flow.ts";
@@ -48,6 +51,8 @@ export interface ElementRuntimes {
48
51
  readonly channel?: ChannelRuntime;
49
52
  readonly ai?: AiRuntime;
50
53
  readonly runs?: RunsRuntime;
54
+ /** Pre-bound durable-run journal (skips driver resolution). */
55
+ readonly journal?: JournalRuntime;
51
56
  }
52
57
 
53
58
  /** Declarations + options consumed by {@link bootApplication}. */
@@ -112,6 +117,13 @@ export interface BootOptions {
112
117
  * @param payload - Payload
113
118
  */
114
119
  readonly onSignal?: (signal: string, payload: unknown) => void | Promise<void>;
120
+ /**
121
+ * Resume due durable runs — called on every scheduler tick when any flow
122
+ * declares `durable: true` (claimDueSleep on the shared journal store).
123
+ */
124
+ readonly onDurableResume?: () => void | Promise<void>;
125
+ /** Durable-run lease duration ms (default 30_000 — matches Signal claims). */
126
+ readonly journalLeaseMs?: number;
115
127
  /** Injectable clock for test / frozen harnesses. */
116
128
  readonly now?: () => number;
117
129
  /**
@@ -144,6 +156,8 @@ export interface BootResult {
144
156
  readonly channel?: ChannelRuntime;
145
157
  readonly ai?: AiRuntime;
146
158
  readonly runs?: RunsRuntime;
159
+ /** Durable-run journal (present when any flow declares `durable: true`). */
160
+ readonly journal?: JournalRuntime;
147
161
  /** Per-flow capability tokens minted from declared effects. */
148
162
  readonly capabilities: ReadonlyMap<string, CapabilityToken>;
149
163
  /** Stop the background scheduler (if started). */
@@ -162,6 +176,8 @@ export interface ElementNeeds {
162
176
  readonly channel: boolean;
163
177
  readonly ai: boolean;
164
178
  readonly runs: boolean;
179
+ /** Durable-run journal — any flow with `durable: true`. */
180
+ readonly journal: boolean;
165
181
  }
166
182
 
167
183
  /**
@@ -182,9 +198,11 @@ export function resolveElementNeeds(options: BootOptions): ElementNeeds {
182
198
  let channel = pre.channel !== undefined || options.channel !== undefined;
183
199
  let ai = pre.ai !== undefined || options.ai !== undefined;
184
200
  let runs = pre.runs !== undefined || options.runs !== undefined;
201
+ let journal = pre.journal !== undefined;
185
202
 
186
203
  const considerFlow = (f: AnyFlowDef): void => {
187
204
  const e = f.effects;
205
+ if (f.durable === true) journal = true;
188
206
  if ((e?.reads?.length ?? 0) > 0 || (e?.writes?.length ?? 0) > 0) {
189
207
  store = true;
190
208
  }
@@ -216,7 +234,7 @@ export function resolveElementNeeds(options: BootOptions): ElementNeeds {
216
234
  signal = true;
217
235
  }
218
236
 
219
- return { vault, store, signal, clock, gate, channel, ai, runs };
237
+ return { vault, store, signal, clock, gate, channel, ai, runs, journal };
220
238
  }
221
239
 
222
240
  /**
@@ -272,6 +290,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
272
290
  type StoreBind = typeof import("./boot-bind/store.ts");
273
291
  type SignalBind = typeof import("./boot-bind/signal.ts");
274
292
  type ClockBind = typeof import("./boot-bind/clock.ts");
293
+ type JournalBind = typeof import("./boot-bind/journal.ts");
275
294
  type GateBind = typeof import("./boot-bind/gate.ts");
276
295
  type ChannelBind = typeof import("./boot-bind/channel.ts");
277
296
  type AiBind = typeof import("./boot-bind/ai.ts");
@@ -280,6 +299,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
280
299
  let storeBind: StoreBind | undefined;
281
300
  let signalBind: SignalBind | undefined;
282
301
  let clockBind: ClockBind | undefined;
302
+ let journalBind: JournalBind | undefined;
283
303
  let gateBind: GateBind | undefined;
284
304
  let channelBind: ChannelBind | undefined;
285
305
  let aiBind: AiBind | undefined;
@@ -306,6 +326,13 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
306
326
  }),
307
327
  );
308
328
  }
329
+ if (needs.journal && !pre.journal) {
330
+ binderLoads.push(
331
+ loadBind<JournalBind>("journal").then((m) => {
332
+ journalBind = m;
333
+ }),
334
+ );
335
+ }
309
336
  if (needs.gate && !pre.gate) {
310
337
  binderLoads.push(
311
338
  loadBind<GateBind>("gate").then((m) => {
@@ -364,21 +391,31 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
364
391
  }
365
392
  }
366
393
 
367
- // 4. Clocks + optional scheduler
394
+ // 4. Clocks
368
395
  let clock = pre.clock;
369
- let schedulerTimer: ReturnType<typeof setInterval> | undefined;
370
396
  if (needs.clock) {
371
397
  const bound = await clockBind!.bindClock(options, env, now, clock);
372
398
  clock = bound.clock;
373
- const startScheduler = options.startScheduler ?? env !== "test";
374
- if (startScheduler) {
375
- const period = options.schedulerIntervalMs ?? 1000;
376
- const clockRt = clock;
377
- schedulerTimer = setInterval(() => {
378
- void clockRt.tick();
379
- }, period);
380
- schedulerTimer.unref?.();
381
- }
399
+ }
400
+
401
+ // 4b. Journal durable-run store (shared + leased when a driver is bound).
402
+ let journal = pre.journal;
403
+ if (needs.journal && !journal) {
404
+ journal = (await journalBind!.bindJournal(options, env)).journal;
405
+ }
406
+
407
+ // 4c. Scheduler — one timer drives clock ticks and durable-run resume.
408
+ let schedulerTimer: ReturnType<typeof setInterval> | undefined;
409
+ const startScheduler = options.startScheduler ?? env !== "test";
410
+ if (startScheduler && (clock !== undefined || journal !== undefined)) {
411
+ const period = options.schedulerIntervalMs ?? 1000;
412
+ const clockRt = clock;
413
+ const durableResume = options.onDurableResume;
414
+ schedulerTimer = setInterval(() => {
415
+ if (clockRt) void clockRt.tick();
416
+ if (journal && durableResume) void durableResume();
417
+ }, period);
418
+ schedulerTimer.unref?.();
382
419
  }
383
420
 
384
421
  // Gate (before AI)
@@ -427,6 +464,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
427
464
  channel,
428
465
  ai,
429
466
  runs,
467
+ journal,
430
468
  capabilities,
431
469
  stopScheduler() {
432
470
  if (schedulerTimer !== undefined) {
@@ -442,6 +480,8 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
442
480
  await signal?.close();
443
481
  await vault?.close();
444
482
  await runs?.flush();
483
+ const journalStore = journal?.store as { close?: () => Promise<void> } | undefined;
484
+ await journalStore?.close?.();
445
485
  },
446
486
  };
447
487
  }
@@ -14,7 +14,7 @@ import {
14
14
  linkAbort,
15
15
  withAbortSignal,
16
16
  } from "./abort-scope.ts";
17
- import { isJournalSuspend } from "./journal.ts";
17
+ import { isJournalSuspend } from "./journal-suspend.ts";
18
18
 
19
19
  /** A unit of work started under an abort scope. */
20
20
  export type FxThunk<T> = () => T | Promise<T>;
@@ -141,6 +141,15 @@ describe("fx — wholesale swap", () => {
141
141
  async send() {
142
142
  return { ok: true };
143
143
  },
144
+ async sendOtp() {
145
+ return { ok: true };
146
+ },
147
+ async verifyOtp() {
148
+ return { ok: true };
149
+ },
150
+ async deliverOtp(opts) {
151
+ return { ok: true as const, channel: opts.only ?? opts.channels[0] ?? "email" };
152
+ },
144
153
  async ask() {
145
154
  return {};
146
155
  },
package/src/kernel/fx.ts CHANGED
@@ -11,7 +11,14 @@
11
11
 
12
12
  import type { Effects, ResourceRef } from "../manifest/types.ts";
13
13
  import type {
14
+ FilesStoreDecl,
15
+ FilesStoreFxHandle,
16
+ IndexStoreDecl,
17
+ IndexStoreFxHandle,
18
+ KvStoreDecl,
19
+ KvStoreFxHandle,
14
20
  SelectOrderBuilder,
21
+ SqlStoreDecl,
15
22
  StoreDecl,
16
23
  StoreHandle,
17
24
  StoreRuntime,
@@ -228,6 +235,50 @@ export interface FxSendOptions {
228
235
  readonly acceptLanguage?: string;
229
236
  }
230
237
 
238
+ /** Options for {@link Fx.sendOtp} (provider-managed SMS OTP). */
239
+ export interface FxSendOtpOptions {
240
+ /** Recipient phone number (E.164). */
241
+ readonly to: string;
242
+ /** Unique id for this verification flow (required again on verify). */
243
+ readonly requestId: string;
244
+ /** Message language (`en` or `ar`). */
245
+ readonly lang?: "en" | "ar";
246
+ /** Optional note appended to the OTP SMS. */
247
+ readonly note?: string;
248
+ /** Sender id override. */
249
+ readonly from?: string;
250
+ }
251
+
252
+ /** Options for {@link Fx.verifyOtp}. */
253
+ export interface FxVerifyOtpOptions {
254
+ /** Recipient phone number (same as send). */
255
+ readonly to: string;
256
+ /** Same {@link FxSendOtpOptions.requestId} used when sending. */
257
+ readonly requestId: string;
258
+ /** OTP code the user entered. */
259
+ readonly code: string;
260
+ /** Message language (`en` or `ar`). */
261
+ readonly lang?: "en" | "ar";
262
+ /** Sender id override. */
263
+ readonly from?: string;
264
+ /** Optional note. */
265
+ readonly note?: string;
266
+ }
267
+
268
+ /** Options for {@link Fx.deliverOtp} (Tier-2 multi-channel delivery). */
269
+ export interface FxDeliverOtpOptions {
270
+ /** Preferred channel order. */
271
+ readonly channels: readonly ("sms" | "whatsapp" | "email")[];
272
+ /** Template name per medium. */
273
+ readonly templates: Readonly<Partial<Record<"sms" | "whatsapp" | "email", string>>>;
274
+ readonly email?: string;
275
+ readonly phone?: string;
276
+ readonly data: Readonly<Record<string, unknown>>;
277
+ readonly locale?: string;
278
+ /** Explicit single-channel resend — no cross-medium failover. */
279
+ readonly only?: "sms" | "whatsapp" | "email";
280
+ }
281
+
231
282
  /** Options for {@link Fx.ask}. */
232
283
  export interface FxAskOptions {
233
284
  readonly via?: readonly NamedRef[];
@@ -286,10 +337,15 @@ export interface Fx {
286
337
  * Open a store handle for `ref` (capability checked on each op).
287
338
  *
288
339
  * When a {@link CreateFxOptions.storeRuntime} is bound and `ref` is a
289
- * registered store declaration, returns the driver-backed handle.
340
+ * facet declaration, returns the driver-backed handle for that facet.
341
+ * String / `{ ref }` forms return the in-memory stub (tests).
290
342
  *
291
343
  * @param ref - Store resource ref, named handle, or store declaration
292
344
  */
345
+ store(ref: SqlStoreDecl): SqlStoreHandle;
346
+ store(ref: KvStoreDecl): KvStoreFxHandle;
347
+ store(ref: FilesStoreDecl): FilesStoreFxHandle;
348
+ store(ref: IndexStoreDecl): IndexStoreFxHandle;
293
349
  store(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle;
294
350
  /**
295
351
  * Emit a signal (records `emit`).
@@ -327,6 +383,34 @@ export interface Fx {
327
383
  * @param opts - Recipient / data
328
384
  */
329
385
  send(template: NamedRef, opts?: FxSendOptions): Promise<{ ok: true }>;
386
+ /**
387
+ * Send a provider-managed SMS OTP (records `send` on `sms-otp`).
388
+ *
389
+ * Vendor extra (Taqnyat Verify API) — requires a bound SMS driver that
390
+ * supports provider-managed OTP. Dry-run records would-have-fired without
391
+ * contacting the provider.
392
+ *
393
+ * @param opts - Recipient + requestId (+ lang / note / from)
394
+ */
395
+ sendOtp(opts: FxSendOtpOptions): Promise<{ ok: true }>;
396
+ /**
397
+ * Verify a provider-managed SMS OTP code (records `send` on `sms-otp`).
398
+ *
399
+ * @param opts - Recipient + requestId + code (+ lang / note / from)
400
+ */
401
+ verifyOtp(opts: FxVerifyOtpOptions): Promise<{ ok: true }>;
402
+ /**
403
+ * Deliver an app-owned OTP across declared channels (records `send` on `auth-otp`).
404
+ *
405
+ * Tier-2 only — uses Channel `deliverOtp` (sently FallbackTransport). Pass
406
+ * `only` for explicit user resend (single channel, no cross-medium failover).
407
+ *
408
+ * @param opts - Channels, templates, addresses, OTP data
409
+ */
410
+ deliverOtp(opts: FxDeliverOtpOptions): Promise<{
411
+ ok: true;
412
+ channel: "sms" | "whatsapp" | "email";
413
+ }>;
330
414
  /**
331
415
  * Ask an AI prompt (records `ask`). Stub returns `{}`.
332
416
  *
@@ -837,6 +921,13 @@ export function createFxContext(options: CreateFxOptions): FxContext {
837
921
  return h.exists(table, idOrWhere);
838
922
  });
839
923
  },
924
+ upsert(table, matchOn, values, upsertOptions) {
925
+ return gated("write", ref, async () => {
926
+ refuseDryRunWrite();
927
+ const h = await ensure();
928
+ return h.upsert(table, matchOn, values, upsertOptions);
929
+ });
930
+ },
840
931
  increment(table, id, column, by) {
841
932
  return gated("write", ref, async () => {
842
933
  refuseDryRunWrite();
@@ -872,10 +963,25 @@ export function createFxContext(options: CreateFxOptions): FxContext {
872
963
  } as SqlStoreHandle;
873
964
  }
874
965
 
966
+ function storeHandle(ref: SqlStoreDecl): SqlStoreHandle;
967
+ function storeHandle(ref: KvStoreDecl): KvStoreFxHandle;
968
+ function storeHandle(ref: FilesStoreDecl): FilesStoreFxHandle;
969
+ function storeHandle(ref: IndexStoreDecl): IndexStoreFxHandle;
970
+ function storeHandle(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle;
875
971
  function storeHandle(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle {
876
972
  const runtime = options.storeRuntime;
877
- if (runtime && typeof ref === "object" && ref !== null && "facet" in ref) {
973
+ if (typeof ref === "object" && ref !== null && "facet" in ref) {
878
974
  const decl = ref;
975
+ // SQL physics cannot run on the in-memory stub (insert(table).values ≠ stub insert(row)).
976
+ // Without a runtime, fail loudly — never return a stub missing upsert/select/….
977
+ if (!runtime) {
978
+ if (decl.facet === "sql") {
979
+ throw new Error(
980
+ `fx.store("${decl.ref}"): no store runtime — boot the app (stores / flow effects) before using SQL handles`,
981
+ );
982
+ }
983
+ return stubStoreHandle(decl.ref);
984
+ }
879
985
  const cache: { handle?: StoreHandle } = {};
880
986
  const open = async () => {
881
987
  if (!cache.handle) {
@@ -1030,9 +1136,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1030
1136
  };
1031
1137
 
1032
1138
  const fx: Fx = {
1033
- store(ref) {
1034
- return storeHandle(ref);
1035
- },
1139
+ store: storeHandle,
1036
1140
  emit(signal, payload, emitOptions) {
1037
1141
  const name = resolveName(signal);
1038
1142
  return gated("emit", name, async () => {
@@ -1092,6 +1196,72 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1092
1196
  return { ok: true as const };
1093
1197
  });
1094
1198
  },
1199
+ sendOtp(opts) {
1200
+ return gated("send", "sms-otp", async () => {
1201
+ if (isDryRun()) {
1202
+ recordWouldHaveFired("send", "sms-otp");
1203
+ return { ok: true as const };
1204
+ }
1205
+ if (!options.channelRuntime) {
1206
+ throw new Error(
1207
+ "fx.sendOtp needs a bound Channel — declare channel and set drivers.channel.sms (e.g. taqnyat)",
1208
+ );
1209
+ }
1210
+ await options.channelRuntime.sendOtp({
1211
+ to: opts.to,
1212
+ requestId: opts.requestId,
1213
+ ...(opts.lang ? { lang: opts.lang } : {}),
1214
+ ...(opts.note ? { note: opts.note } : {}),
1215
+ ...(opts.from ? { from: opts.from } : {}),
1216
+ });
1217
+ return { ok: true as const };
1218
+ });
1219
+ },
1220
+ verifyOtp(opts) {
1221
+ return gated("send", "sms-otp", async () => {
1222
+ if (isDryRun()) {
1223
+ recordWouldHaveFired("send", "sms-otp");
1224
+ return { ok: true as const };
1225
+ }
1226
+ if (!options.channelRuntime) {
1227
+ throw new Error(
1228
+ "fx.verifyOtp needs a bound Channel — declare channel and set drivers.channel.sms (e.g. taqnyat)",
1229
+ );
1230
+ }
1231
+ await options.channelRuntime.verifyOtp({
1232
+ to: opts.to,
1233
+ requestId: opts.requestId,
1234
+ code: opts.code,
1235
+ ...(opts.lang ? { lang: opts.lang } : {}),
1236
+ ...(opts.from ? { from: opts.from } : {}),
1237
+ ...(opts.note ? { note: opts.note } : {}),
1238
+ });
1239
+ return { ok: true as const };
1240
+ });
1241
+ },
1242
+ deliverOtp(opts) {
1243
+ return gated("send", "auth-otp", async () => {
1244
+ if (isDryRun()) {
1245
+ recordWouldHaveFired("send", "auth-otp");
1246
+ return { ok: true as const, channel: opts.only ?? opts.channels[0] ?? "email" };
1247
+ }
1248
+ if (!options.channelRuntime) {
1249
+ throw new Error(
1250
+ "fx.deliverOtp needs a bound Channel — declare channel templates and drivers for the configured media",
1251
+ );
1252
+ }
1253
+ const result = await options.channelRuntime.deliverOtp({
1254
+ channels: opts.channels,
1255
+ templates: opts.templates,
1256
+ ...(opts.email ? { email: opts.email } : {}),
1257
+ ...(opts.phone ? { phone: opts.phone } : {}),
1258
+ data: opts.data,
1259
+ ...(opts.locale ? { locale: opts.locale } : {}),
1260
+ ...(opts.only ? { only: opts.only } : {}),
1261
+ });
1262
+ return { ok: true as const, channel: result.channel };
1263
+ });
1264
+ },
1095
1265
  ask(prompt, input, opts) {
1096
1266
  const name = resolveName(prompt);
1097
1267
  return gated("ask", name, async () => {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Graceful shutdown — Clock / Journal lease release without waiting for TTL.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ import { createClockRuntime, createMemoryCronStore } from "../elements/clock.ts";
8
+ import { tryAcquireLease } from "../elements/clock/leader.ts";
9
+ import { createMemoryJournalStore, hasJournalLease, JOURNAL_DEFAULT_LEASE_MS } from "./journal.ts";
10
+ import { releaseInstanceLeases } from "./graceful-shutdown.ts";
11
+
12
+ describe("releaseInstanceLeases", () => {
13
+ test("releases Clock cron leases held by this instance", async () => {
14
+ const store = createMemoryCronStore();
15
+ await store.put({
16
+ name: "hourly",
17
+ status: "active",
18
+ timezone: "UTC",
19
+ overridable: false,
20
+ effectiveEvery: "1h",
21
+ });
22
+ const clock = createClockRuntime({ store, instanceId: "inst-a", leaseMs: 30_000 });
23
+ const t = 1_000_000;
24
+ expect(
25
+ await tryAcquireLease({
26
+ name: "hourly",
27
+ instanceId: "inst-a",
28
+ now: t,
29
+ leaseMs: 30_000,
30
+ store,
31
+ }),
32
+ ).toBe(true);
33
+ expect((await store.get("hourly"))!.leaderInstanceId).toBe("inst-a");
34
+
35
+ await releaseInstanceLeases({
36
+ bootResult: {
37
+ clock: {
38
+ instanceId: clock.instanceId,
39
+ store,
40
+ now: () => t + 1,
41
+ },
42
+ },
43
+ stop: async () => {},
44
+ });
45
+
46
+ const row = await store.get("hourly");
47
+ expect(row!.leaderInstanceId).toBeUndefined();
48
+ expect(row!.leaderLeaseUntil).toBeUndefined();
49
+ });
50
+
51
+ test("releases Journal run leases held by this instance", async () => {
52
+ const store = createMemoryJournalStore();
53
+ expect(hasJournalLease(store)).toBe(true);
54
+ const runId = "run-1";
55
+ await store.put({
56
+ id: runId,
57
+ flow: "charge",
58
+ status: "running",
59
+ input: {},
60
+ entries: [],
61
+ createdAt: 1,
62
+ updatedAt: 1,
63
+ });
64
+ expect(await store.acquireLease!(runId, "inst-a", 1_000, JOURNAL_DEFAULT_LEASE_MS)).toBe(true);
65
+ expect((await store.get(runId))!.lockedBy).toBe("inst-a");
66
+
67
+ await releaseInstanceLeases({
68
+ bootResult: {
69
+ journal: { instanceId: "inst-a", store },
70
+ },
71
+ stop: async () => {},
72
+ });
73
+
74
+ expect((await store.get(runId))!.lockedBy).toBeUndefined();
75
+ });
76
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Graceful shutdown — release Clock / Journal leases held by this instance,
3
+ * then stop the HTTP server and close element runtimes.
4
+ *
5
+ * Reuses existing `releaseLease` APIs. Signal message leases have no release
6
+ * surface today — survivors reclaim after TTL (lazy claim).
7
+ */
8
+
9
+ import { releaseLease } from "../elements/clock/leader.ts";
10
+ import type { CronStore } from "../elements/clock/reconcile.ts";
11
+ import type { ServerHandle } from "../runtime/types.ts";
12
+ import { hasJournalLease, type JournalStore } from "./journal.ts";
13
+
14
+ /** Minimal app surface for shutdown. */
15
+ export interface GracefulShutdownApp {
16
+ readonly bootResult?: {
17
+ readonly clock?: {
18
+ readonly instanceId: string;
19
+ readonly store: CronStore;
20
+ now(): number;
21
+ };
22
+ readonly journal?: {
23
+ readonly instanceId: string;
24
+ readonly store: JournalStore;
25
+ };
26
+ };
27
+ stop(): Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * Release Clock cron leases and Journal run leases held by this instance.
32
+ *
33
+ * @param app - Booted app
34
+ */
35
+ export async function releaseInstanceLeases(app: GracefulShutdownApp): Promise<void> {
36
+ const boot = app.bootResult;
37
+ if (!boot) return;
38
+
39
+ const clock = boot.clock;
40
+ if (clock) {
41
+ const t = clock.now();
42
+ for (const row of await clock.store.list()) {
43
+ if (row.leaderInstanceId === clock.instanceId) {
44
+ await releaseLease(clock.store, row.name, clock.instanceId, t);
45
+ }
46
+ }
47
+ }
48
+
49
+ const journal = boot.journal;
50
+ if (journal && hasJournalLease(journal.store)) {
51
+ for (const run of await journal.store.list()) {
52
+ if (run.lockedBy === journal.instanceId) {
53
+ await journal.store.releaseLease(run.id, journal.instanceId);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /** Options for {@link installGracefulShutdown}. */
60
+ export interface InstallGracefulShutdownOptions {
61
+ readonly app: GracefulShutdownApp;
62
+ readonly handle?: ServerHandle;
63
+ /** Signals to listen for (default SIGTERM + SIGINT). */
64
+ readonly signals?: readonly NodeJS.Signals[];
65
+ /** Exit after shutdown (default true). */
66
+ readonly exit?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Register SIGTERM/SIGINT handlers that release leases, drain the server,
71
+ * and stop the app.
72
+ *
73
+ * @param options - App + optional server handle
74
+ * @returns Dispose function that removes the handlers
75
+ */
76
+ export function installGracefulShutdown(options: InstallGracefulShutdownOptions): () => void {
77
+ const signals = options.signals ?? (["SIGTERM", "SIGINT"] as const);
78
+ let shuttingDown = false;
79
+
80
+ const onSignal = () => {
81
+ if (shuttingDown) return;
82
+ shuttingDown = true;
83
+ void (async () => {
84
+ try {
85
+ // Stop accepting new connections first (keep in-flight open).
86
+ options.handle?.stop(false);
87
+ await releaseInstanceLeases(options.app);
88
+ await options.app.stop();
89
+ } catch (err) {
90
+ console.error("oke: graceful shutdown failed", err);
91
+ } finally {
92
+ if (options.exit !== false) process.exit(0);
93
+ }
94
+ })();
95
+ };
96
+
97
+ for (const signal of signals) {
98
+ process.on(signal, onSignal);
99
+ }
100
+
101
+ return () => {
102
+ for (const signal of signals) {
103
+ process.off(signal, onSignal);
104
+ }
105
+ };
106
+ }