okengine 0.6.1 → 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 (167) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +3 -3
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +42 -25
  8. package/site/content/docs/elements/clock.mdx +59 -25
  9. package/site/content/docs/elements/flow.mdx +8 -2
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +595 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +52 -14
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/plugins/magic-link.mdx +42 -0
  18. package/site/content/docs/plugins/phone-number.mdx +78 -17
  19. package/site/content/docs/plugins/two-factor.mdx +1 -0
  20. package/site/content/docs/reference/cli.md +32 -2
  21. package/site/content/docs/reference/configuration.mdx +15 -13
  22. package/site/content/docs/reference/environment-variables.mdx +32 -18
  23. package/site/content/docs/reference/errors.mdx +12 -11
  24. package/site/content/docs/reference/fx.mdx +15 -13
  25. package/site/content/docs/reference/security.md +3 -1
  26. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  27. package/src/cli/ai-setup/apply.ts +214 -0
  28. package/src/cli/ai-setup/catalog.ts +263 -0
  29. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  30. package/src/cli/ai-setup/index.ts +228 -0
  31. package/src/cli/ai-setup/prompts.ts +649 -0
  32. package/src/cli/ai-setup/recommend.test.ts +100 -0
  33. package/src/cli/ai-setup/recommend.ts +203 -0
  34. package/src/cli/ai.ts +38 -0
  35. package/src/cli/db-auto-push.test.ts +6 -1
  36. package/src/cli/db-auto-push.ts +8 -3
  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-db-push.test.ts +59 -0
  41. package/src/cli/dev.test.ts +40 -0
  42. package/src/cli/dev.ts +55 -1
  43. package/src/cli/docker-clean.test.ts +179 -0
  44. package/src/cli/docker-clean.ts +406 -0
  45. package/src/cli/docker.ts +16 -4
  46. package/src/cli/ensure-drizzle-config.ts +1 -1
  47. package/src/cli/hero-meta.test.ts +6 -5
  48. package/src/cli/hero-meta.ts +7 -1
  49. package/src/cli/index.ts +5 -0
  50. package/src/cli/load-config.images.test.ts +22 -0
  51. package/src/cli/load-config.ts +7 -2
  52. package/src/cli/openbao-bootstrap.test.ts +1 -0
  53. package/src/cli/openbao-bootstrap.ts +9 -2
  54. package/src/cli/registry.ts +116 -2
  55. package/src/client/live-gap.test.ts +35 -0
  56. package/src/client/transport.test.ts +21 -0
  57. package/src/client/transport.ts +27 -5
  58. package/src/compiler/effects-infer.ts +1 -0
  59. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  60. package/src/config/define-config.test.ts +6 -6
  61. package/src/config/index.ts +5 -1
  62. package/src/console/server/console.test.ts +45 -0
  63. package/src/console/server/flows.ts +39 -9
  64. package/src/console/server/i18n.ts +41 -0
  65. package/src/console/server/vault.ts +12 -38
  66. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  67. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  75. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  76. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  77. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  78. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  79. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  80. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  81. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  82. package/src/console/ui/dist/index.html +1 -1
  83. package/src/console/ui/shell/components/ui.tsx +5 -1
  84. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  85. package/src/docker/cleanup.test.ts +193 -0
  86. package/src/docker/cleanup.ts +355 -0
  87. package/src/docker/docker.test.ts +4 -0
  88. package/src/docker/index.ts +19 -0
  89. package/src/docker/recipes/ollama.ts +10 -5
  90. package/src/drivers/ai-ollama.ts +45 -2
  91. package/src/drivers/ai-providers.test.ts +31 -0
  92. package/src/drivers/channel-sently.test.ts +8 -0
  93. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  94. package/src/drivers/channel-types.ts +71 -0
  95. package/src/drivers/clock-postgres.test.ts +258 -0
  96. package/src/drivers/clock-postgres.ts +410 -0
  97. package/src/drivers/index.ts +21 -0
  98. package/src/drivers/journal-postgres.test.ts +175 -0
  99. package/src/drivers/journal-postgres.ts +492 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/runtime.ts +51 -0
  107. package/src/elements/channel.test.ts +71 -0
  108. package/src/elements/clock/chaos-child.ts +389 -0
  109. package/src/elements/clock/chaos.test.ts +204 -0
  110. package/src/elements/clock/durable.ts +7 -0
  111. package/src/elements/clock/reconcile.ts +110 -0
  112. package/src/elements/clock/runtime.ts +13 -0
  113. package/src/elements/clock.test.ts +164 -2
  114. package/src/elements/clock.ts +2 -1
  115. package/src/elements/gate/runtime.ts +6 -0
  116. package/src/elements/signal/chaos-child.ts +41 -4
  117. package/src/elements/signal/declare.ts +4 -1
  118. package/src/elements/signal/delivery-modes.test.ts +179 -0
  119. package/src/elements/signal/key-ordering.test.ts +306 -0
  120. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  121. package/src/elements/signal/optional-emit.test.ts +76 -0
  122. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  123. package/src/elements/signal/orphan-messages.test.ts +152 -0
  124. package/src/elements/signal/runtime.ts +11 -4
  125. package/src/elements/signal/schema-emit.test.ts +107 -0
  126. package/src/elements/signal.test.ts +89 -5
  127. package/src/elements/store/files-fx.ts +104 -0
  128. package/src/elements/store/files-image.test.ts +251 -0
  129. package/src/elements/store/files-image.ts +494 -0
  130. package/src/elements/store/runtime.ts +60 -0
  131. package/src/elements/store/seed.test.ts +27 -0
  132. package/src/elements/store/seed.ts +68 -0
  133. package/src/elements/store/sql-session.test.ts +39 -0
  134. package/src/elements/store/sql-session.ts +55 -0
  135. package/src/elements/store/upsert-app.test.ts +103 -0
  136. package/src/elements/store.ts +29 -0
  137. package/src/elements/vault/boot-chain.ts +150 -0
  138. package/src/elements/vault/runtime.ts +8 -0
  139. package/src/index.ts +15 -0
  140. package/src/kernel/app.ts +168 -15
  141. package/src/kernel/boot-bind/channel.test.ts +16 -0
  142. package/src/kernel/boot-bind/channel.ts +13 -0
  143. package/src/kernel/boot-bind/clock.ts +63 -7
  144. package/src/kernel/boot-bind/gate.ts +71 -3
  145. package/src/kernel/boot-bind/honor-config.test.ts +348 -0
  146. package/src/kernel/boot-bind/journal.ts +89 -0
  147. package/src/kernel/boot-bind/signal.ts +64 -7
  148. package/src/kernel/boot-bind/vault.ts +27 -8
  149. package/src/kernel/boot.test.ts +6 -4
  150. package/src/kernel/boot.ts +63 -15
  151. package/src/kernel/concurrency.ts +1 -1
  152. package/src/kernel/errors.ts +8 -2
  153. package/src/kernel/fx.test.ts +19 -0
  154. package/src/kernel/fx.ts +153 -9
  155. package/src/kernel/index.ts +6 -0
  156. package/src/kernel/journal-boot.test.ts +397 -0
  157. package/src/kernel/journal-suspend.ts +35 -0
  158. package/src/kernel/journal.test.ts +142 -0
  159. package/src/kernel/journal.ts +202 -27
  160. package/src/plugins/auth-methods.security.test.ts +10 -7
  161. package/src/plugins/phone-number.ts +67 -10
  162. package/src/plugins/taqnyat.live.test.ts +174 -0
  163. package/src/runtime/dev-request-log.test.ts +20 -1
  164. package/src/runtime/dev-request-log.ts +38 -0
  165. package/src/term.test.ts +15 -0
  166. package/src/term.ts +8 -3
  167. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -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,8 +117,23 @@ 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;
129
+ /**
130
+ * Test-only client injection for binders that open redis / signal redis.
131
+ * Production apps leave this unset.
132
+ */
133
+ readonly clients?: {
134
+ readonly kv?: import("../drivers/types.ts").KvClientLike;
135
+ readonly signalRedis?: import("../drivers/signal-types.ts").SignalRedisClientLike;
136
+ };
117
137
  /** Instance id for leader election. */
118
138
  readonly instanceId?: string;
119
139
  /**
@@ -136,6 +156,8 @@ export interface BootResult {
136
156
  readonly channel?: ChannelRuntime;
137
157
  readonly ai?: AiRuntime;
138
158
  readonly runs?: RunsRuntime;
159
+ /** Durable-run journal (present when any flow declares `durable: true`). */
160
+ readonly journal?: JournalRuntime;
139
161
  /** Per-flow capability tokens minted from declared effects. */
140
162
  readonly capabilities: ReadonlyMap<string, CapabilityToken>;
141
163
  /** Stop the background scheduler (if started). */
@@ -154,6 +176,8 @@ export interface ElementNeeds {
154
176
  readonly channel: boolean;
155
177
  readonly ai: boolean;
156
178
  readonly runs: boolean;
179
+ /** Durable-run journal — any flow with `durable: true`. */
180
+ readonly journal: boolean;
157
181
  }
158
182
 
159
183
  /**
@@ -174,9 +198,11 @@ export function resolveElementNeeds(options: BootOptions): ElementNeeds {
174
198
  let channel = pre.channel !== undefined || options.channel !== undefined;
175
199
  let ai = pre.ai !== undefined || options.ai !== undefined;
176
200
  let runs = pre.runs !== undefined || options.runs !== undefined;
201
+ let journal = pre.journal !== undefined;
177
202
 
178
203
  const considerFlow = (f: AnyFlowDef): void => {
179
204
  const e = f.effects;
205
+ if (f.durable === true) journal = true;
180
206
  if ((e?.reads?.length ?? 0) > 0 || (e?.writes?.length ?? 0) > 0) {
181
207
  store = true;
182
208
  }
@@ -208,7 +234,7 @@ export function resolveElementNeeds(options: BootOptions): ElementNeeds {
208
234
  signal = true;
209
235
  }
210
236
 
211
- return { vault, store, signal, clock, gate, channel, ai, runs };
237
+ return { vault, store, signal, clock, gate, channel, ai, runs, journal };
212
238
  }
213
239
 
214
240
  /**
@@ -264,6 +290,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
264
290
  type StoreBind = typeof import("./boot-bind/store.ts");
265
291
  type SignalBind = typeof import("./boot-bind/signal.ts");
266
292
  type ClockBind = typeof import("./boot-bind/clock.ts");
293
+ type JournalBind = typeof import("./boot-bind/journal.ts");
267
294
  type GateBind = typeof import("./boot-bind/gate.ts");
268
295
  type ChannelBind = typeof import("./boot-bind/channel.ts");
269
296
  type AiBind = typeof import("./boot-bind/ai.ts");
@@ -272,6 +299,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
272
299
  let storeBind: StoreBind | undefined;
273
300
  let signalBind: SignalBind | undefined;
274
301
  let clockBind: ClockBind | undefined;
302
+ let journalBind: JournalBind | undefined;
275
303
  let gateBind: GateBind | undefined;
276
304
  let channelBind: ChannelBind | undefined;
277
305
  let aiBind: AiBind | undefined;
@@ -298,6 +326,13 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
298
326
  }),
299
327
  );
300
328
  }
329
+ if (needs.journal && !pre.journal) {
330
+ binderLoads.push(
331
+ loadBind<JournalBind>("journal").then((m) => {
332
+ journalBind = m;
333
+ }),
334
+ );
335
+ }
301
336
  if (needs.gate && !pre.gate) {
302
337
  binderLoads.push(
303
338
  loadBind<GateBind>("gate").then((m) => {
@@ -344,7 +379,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
344
379
  let signal = pre.signal;
345
380
  if (needs.signal) {
346
381
  if (!signal) {
347
- signal = await signalBind!.bindSignal(options, env, now);
382
+ signal = await signalBind!.bindSignal(options, env, now, docker);
348
383
  } else {
349
384
  // Pre-built: still register decls / start if needed.
350
385
  for (const decl of options.signals ?? []) {
@@ -356,27 +391,37 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
356
391
  }
357
392
  }
358
393
 
359
- // 4. Clocks + optional scheduler
394
+ // 4. Clocks
360
395
  let clock = pre.clock;
361
- let schedulerTimer: ReturnType<typeof setInterval> | undefined;
362
396
  if (needs.clock) {
363
397
  const bound = await clockBind!.bindClock(options, env, now, clock);
364
398
  clock = bound.clock;
365
- const startScheduler = options.startScheduler ?? env !== "test";
366
- if (startScheduler) {
367
- const period = options.schedulerIntervalMs ?? 1000;
368
- const clockRt = clock;
369
- schedulerTimer = setInterval(() => {
370
- void clockRt.tick();
371
- }, period);
372
- schedulerTimer.unref?.();
373
- }
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?.();
374
419
  }
375
420
 
376
421
  // Gate (before AI)
377
422
  let gate = pre.gate;
378
423
  if (needs.gate && !gate) {
379
- gate = await gateBind!.bindGate(options, now);
424
+ gate = await gateBind!.bindGate(options, now, env, docker);
380
425
  }
381
426
 
382
427
  // 5. Channel
@@ -419,6 +464,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
419
464
  channel,
420
465
  ai,
421
466
  runs,
467
+ journal,
422
468
  capabilities,
423
469
  stopScheduler() {
424
470
  if (schedulerTimer !== undefined) {
@@ -434,6 +480,8 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
434
480
  await signal?.close();
435
481
  await vault?.close();
436
482
  await runs?.flush();
483
+ const journalStore = journal?.store as { close?: () => Promise<void> } | undefined;
484
+ await journalStore?.close?.();
437
485
  },
438
486
  };
439
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>;
@@ -143,14 +143,20 @@ export const OKE_ERRORS = {
143
143
  fix: 'Add "{resource}" to this flow\'s effects.calls.',
144
144
  },
145
145
  /**
146
- * Emit target has no subscriber (unified-theory §21 example).
147
- * Reserved now so the number stays permanent.
146
+ * Emit target has no subscriber (unified-theory §21).
147
+ * Thrown at emit when `optional` is false and nobody is subscribed.
148
148
  */
149
149
  ORPHAN_EMIT: {
150
150
  code: 1042,
151
151
  cause: 'Flow "{flow}" emits signal "{resource}" with no subscriber.',
152
152
  fix: "Add `on({resource}, …)` or mark the signal `{ optional: true }`.",
153
153
  },
154
+ /** Emit payload failed the signal's declared Standard Schema. */
155
+ SIGNAL_SCHEMA: {
156
+ code: 1043,
157
+ cause: '"{resource}": {detail}',
158
+ fix: "Fix schema payload.",
159
+ },
154
160
  /**
155
161
  * Domain table/column missing under docker/prod (migrations not applied).
156
162
  * Store/DDL band starts at 1100.
@@ -141,6 +141,12 @@ 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
+ },
144
150
  async ask() {
145
151
  return {};
146
152
  },
@@ -258,6 +264,19 @@ describe("errors — registry", () => {
258
264
  expect(err.message).toContain("→");
259
265
  });
260
266
 
267
+ test("OKE1043 is reserved for signal schema emit failures", () => {
268
+ const def = OKE_ERRORS.SIGNAL_SCHEMA;
269
+ expect(def.code).toBe(1043);
270
+ expect(lookupOkeError(1043)).toEqual(def);
271
+ const err = new OkeError(def, {
272
+ resource: "order-placed",
273
+ detail: "total: Expected number, received string",
274
+ });
275
+ expect(err.message).toContain("OKE1043");
276
+ expect(err.message).toContain("order-placed");
277
+ expect(err.message).toContain("https://oke.omqkhafi.dev/e/1043");
278
+ });
279
+
261
280
  test("fx.fail returns a value, not an exception", () => {
262
281
  const fx = createFx({ flow: "x", effects: {} });
263
282
  const result = fx.fail(
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,
@@ -19,6 +26,7 @@ import type {
19
26
  } from "../elements/store.ts";
20
27
  import type { SqlRow } from "../drivers/types.ts";
21
28
  import type { SignalRuntime } from "../elements/signal.ts";
29
+ import type { SignalEmitOptions } from "../drivers/signal-types.ts";
22
30
  import type { VaultRuntime } from "../elements/vault.ts";
23
31
  import type { ChannelRuntime } from "../elements/channel.ts";
24
32
  import type { AiRuntime } from "../elements/ai.ts";
@@ -227,6 +235,36 @@ export interface FxSendOptions {
227
235
  readonly acceptLanguage?: string;
228
236
  }
229
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
+
230
268
  /** Options for {@link Fx.ask}. */
231
269
  export interface FxAskOptions {
232
270
  readonly via?: readonly NamedRef[];
@@ -285,18 +323,24 @@ export interface Fx {
285
323
  * Open a store handle for `ref` (capability checked on each op).
286
324
  *
287
325
  * When a {@link CreateFxOptions.storeRuntime} is bound and `ref` is a
288
- * registered store declaration, returns the driver-backed handle.
326
+ * facet declaration, returns the driver-backed handle for that facet.
327
+ * String / `{ ref }` forms return the in-memory stub (tests).
289
328
  *
290
329
  * @param ref - Store resource ref, named handle, or store declaration
291
330
  */
331
+ store(ref: SqlStoreDecl): SqlStoreHandle;
332
+ store(ref: KvStoreDecl): KvStoreFxHandle;
333
+ store(ref: FilesStoreDecl): FilesStoreFxHandle;
334
+ store(ref: IndexStoreDecl): IndexStoreFxHandle;
292
335
  store(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle;
293
336
  /**
294
337
  * Emit a signal (records `emit`).
295
338
  *
296
339
  * @param signal - Signal name or handle
297
340
  * @param payload - Payload
341
+ * @param options - Optional emit options (`key` for per-key once ordering)
298
342
  */
299
- emit(signal: NamedRef, payload?: unknown): Promise<void>;
343
+ emit(signal: NamedRef, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
300
344
  /**
301
345
  * Call another flow (records `call`). Stub returns `undefined`.
302
346
  *
@@ -325,6 +369,22 @@ export interface Fx {
325
369
  * @param opts - Recipient / data
326
370
  */
327
371
  send(template: NamedRef, opts?: FxSendOptions): Promise<{ ok: true }>;
372
+ /**
373
+ * Send a provider-managed SMS OTP (records `send` on `sms-otp`).
374
+ *
375
+ * Vendor extra (Taqnyat Verify API) — requires a bound SMS driver that
376
+ * supports provider-managed OTP. Dry-run records would-have-fired without
377
+ * contacting the provider.
378
+ *
379
+ * @param opts - Recipient + requestId (+ lang / note / from)
380
+ */
381
+ sendOtp(opts: FxSendOtpOptions): Promise<{ ok: true }>;
382
+ /**
383
+ * Verify a provider-managed SMS OTP code (records `send` on `sms-otp`).
384
+ *
385
+ * @param opts - Recipient + requestId + code (+ lang / note / from)
386
+ */
387
+ verifyOtp(opts: FxVerifyOtpOptions): Promise<{ ok: true }>;
328
388
  /**
329
389
  * Ask an AI prompt (records `ask`). Stub returns `{}`.
330
390
  *
@@ -835,6 +895,13 @@ export function createFxContext(options: CreateFxOptions): FxContext {
835
895
  return h.exists(table, idOrWhere);
836
896
  });
837
897
  },
898
+ upsert(table, matchOn, values, upsertOptions) {
899
+ return gated("write", ref, async () => {
900
+ refuseDryRunWrite();
901
+ const h = await ensure();
902
+ return h.upsert(table, matchOn, values, upsertOptions);
903
+ });
904
+ },
838
905
  increment(table, id, column, by) {
839
906
  return gated("write", ref, async () => {
840
907
  refuseDryRunWrite();
@@ -870,10 +937,25 @@ export function createFxContext(options: CreateFxOptions): FxContext {
870
937
  } as SqlStoreHandle;
871
938
  }
872
939
 
940
+ function storeHandle(ref: SqlStoreDecl): SqlStoreHandle;
941
+ function storeHandle(ref: KvStoreDecl): KvStoreFxHandle;
942
+ function storeHandle(ref: FilesStoreDecl): FilesStoreFxHandle;
943
+ function storeHandle(ref: IndexStoreDecl): IndexStoreFxHandle;
944
+ function storeHandle(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle;
873
945
  function storeHandle(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle {
874
946
  const runtime = options.storeRuntime;
875
- if (runtime && typeof ref === "object" && ref !== null && "facet" in ref) {
947
+ if (typeof ref === "object" && ref !== null && "facet" in ref) {
876
948
  const decl = ref;
949
+ // SQL physics cannot run on the in-memory stub (insert(table).values ≠ stub insert(row)).
950
+ // Without a runtime, fail loudly — never return a stub missing upsert/select/….
951
+ if (!runtime) {
952
+ if (decl.facet === "sql") {
953
+ throw new Error(
954
+ `fx.store("${decl.ref}"): no store runtime — boot the app (stores / flow effects) before using SQL handles`,
955
+ );
956
+ }
957
+ return stubStoreHandle(decl.ref);
958
+ }
877
959
  const cache: { handle?: StoreHandle } = {};
878
960
  const open = async () => {
879
961
  if (!cache.handle) {
@@ -892,7 +974,28 @@ export function createFxContext(options: CreateFxOptions): FxContext {
892
974
  });
893
975
  }
894
976
 
895
- // KV / files / index — thin gated wrappers preserving driver methods.
977
+ if (decl.facet === "files") {
978
+ const ref = decl.ref;
979
+ return runtime.openFilesFx(
980
+ decl as Extract<StoreDecl, { facet: "files" }>,
981
+ {
982
+ effects: options.effects ?? {},
983
+ revealPii: options.revealPii,
984
+ },
985
+ {
986
+ gate: gated,
987
+ refuseDryRunWrite: () => {
988
+ if (isDryRun()) {
989
+ throw new DryRunWriteIsolationError(
990
+ `Driver-backed store "${ref}" cannot isolate writes during dry-run; dry-run refused rather than risk a double-write.`,
991
+ );
992
+ }
993
+ },
994
+ },
995
+ );
996
+ }
997
+
998
+ // KV / index — thin gated wrappers preserving driver methods.
896
999
  const baseRef = decl.ref;
897
1000
  return new Proxy({} as StoreHandle, {
898
1001
  get(_t, prop) {
@@ -1007,14 +1110,12 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1007
1110
  };
1008
1111
 
1009
1112
  const fx: Fx = {
1010
- store(ref) {
1011
- return storeHandle(ref);
1012
- },
1013
- emit(signal, payload) {
1113
+ store: storeHandle,
1114
+ emit(signal, payload, emitOptions) {
1014
1115
  const name = resolveName(signal);
1015
1116
  return gated("emit", name, async () => {
1016
1117
  if (options.signalRuntime) {
1017
- await options.signalRuntime.emit(name, payload);
1118
+ await options.signalRuntime.emit(name, payload, emitOptions);
1018
1119
  }
1019
1120
  });
1020
1121
  },
@@ -1069,6 +1170,49 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1069
1170
  return { ok: true as const };
1070
1171
  });
1071
1172
  },
1173
+ sendOtp(opts) {
1174
+ return gated("send", "sms-otp", async () => {
1175
+ if (isDryRun()) {
1176
+ recordWouldHaveFired("send", "sms-otp");
1177
+ return { ok: true as const };
1178
+ }
1179
+ if (!options.channelRuntime) {
1180
+ throw new Error(
1181
+ "fx.sendOtp needs a bound Channel — declare channel and set drivers.channel.sms (e.g. taqnyat)",
1182
+ );
1183
+ }
1184
+ await options.channelRuntime.sendOtp({
1185
+ to: opts.to,
1186
+ requestId: opts.requestId,
1187
+ ...(opts.lang ? { lang: opts.lang } : {}),
1188
+ ...(opts.note ? { note: opts.note } : {}),
1189
+ ...(opts.from ? { from: opts.from } : {}),
1190
+ });
1191
+ return { ok: true as const };
1192
+ });
1193
+ },
1194
+ verifyOtp(opts) {
1195
+ return gated("send", "sms-otp", async () => {
1196
+ if (isDryRun()) {
1197
+ recordWouldHaveFired("send", "sms-otp");
1198
+ return { ok: true as const };
1199
+ }
1200
+ if (!options.channelRuntime) {
1201
+ throw new Error(
1202
+ "fx.verifyOtp needs a bound Channel — declare channel and set drivers.channel.sms (e.g. taqnyat)",
1203
+ );
1204
+ }
1205
+ await options.channelRuntime.verifyOtp({
1206
+ to: opts.to,
1207
+ requestId: opts.requestId,
1208
+ code: opts.code,
1209
+ ...(opts.lang ? { lang: opts.lang } : {}),
1210
+ ...(opts.from ? { from: opts.from } : {}),
1211
+ ...(opts.note ? { note: opts.note } : {}),
1212
+ });
1213
+ return { ok: true as const };
1214
+ });
1215
+ },
1072
1216
  ask(prompt, input, opts) {
1073
1217
  const name = resolveName(prompt);
1074
1218
  return gated("ask", name, async () => {
@@ -167,10 +167,16 @@ export {
167
167
  createJournal,
168
168
  createMemoryJournalStore,
169
169
  createFileJournalStore,
170
+ hasJournalLease,
171
+ isJournalLeaseBusy,
170
172
  isJournalSuspend,
173
+ JournalLeaseBusy,
171
174
  JournalSuspend,
175
+ JOURNAL_DEFAULT_LEASE_MS,
172
176
  type Journal,
173
177
  type JournalEntry,
178
+ type JournalLeaseOptions,
179
+ type JournalLeaseStore,
174
180
  type JournalRun,
175
181
  type JournalRunStatus,
176
182
  type JournalSession,