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
@@ -15,6 +15,7 @@ import { gate } from "../../elements/gate.ts";
15
15
  import { signal } from "../../elements/signal.ts";
16
16
  import { vault } from "../../elements/vault.ts";
17
17
  import { buildVaultBootChain } from "../../elements/vault/boot-chain.ts";
18
+ import { flow } from "../flow.ts";
18
19
  import { bootApplication } from "../boot.ts";
19
20
 
20
21
  describe("boot binders honour drivers.* config", () => {
@@ -126,19 +127,119 @@ describe("boot binders honour drivers.* config", () => {
126
127
  }
127
128
  });
128
129
 
129
- test("clock: drivers.clock postgres fails loud", async () => {
130
+ test("clock: drivers.clock postgres fails without DATABASE_URL", async () => {
131
+ const prevDb = process.env.DATABASE_URL;
132
+ const prevStore = process.env.OKE_STORE_SQL_URL;
133
+ delete process.env.DATABASE_URL;
134
+ delete process.env.OKE_STORE_SQL_URL;
135
+ try {
136
+ await expect(
137
+ bootApplication({
138
+ env: "local",
139
+ startScheduler: false,
140
+ clocks: [clock("tick", { every: "1h" })],
141
+ config: {
142
+ drivers: {
143
+ clock: { local: "postgres" },
144
+ },
145
+ },
146
+ }),
147
+ ).rejects.toThrow(/clock driver "postgres" needs DATABASE_URL/);
148
+ } finally {
149
+ if (prevDb !== undefined) process.env.DATABASE_URL = prevDb;
150
+ else delete process.env.DATABASE_URL;
151
+ if (prevStore !== undefined) process.env.OKE_STORE_SQL_URL = prevStore;
152
+ else delete process.env.OKE_STORE_SQL_URL;
153
+ }
154
+ });
155
+
156
+ test("journal: durable flow binds memory journal by default", async () => {
157
+ const result = await bootApplication({
158
+ env: "local",
159
+ startScheduler: false,
160
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
161
+ });
162
+ try {
163
+ expect(result.journal?.driverId).toBe("memory");
164
+ expect(typeof result.journal?.instanceId).toBe("string");
165
+ expect(result.journal?.leaseMs).toBeGreaterThan(0);
166
+ } finally {
167
+ await result.close();
168
+ }
169
+ });
170
+
171
+ test("journal: no durable flow → no journal runtime", async () => {
172
+ const result = await bootApplication({
173
+ env: "local",
174
+ startScheduler: false,
175
+ flows: [flow({ name: "plain", do: () => ({ ok: true }) })],
176
+ });
177
+ try {
178
+ expect(result.journal).toBeUndefined();
179
+ } finally {
180
+ await result.close();
181
+ }
182
+ });
183
+
184
+ test("journal: drivers.journal file binds file store", async () => {
185
+ tmp = await mkdtemp(join(tmpdir(), "oke-journal-file-"));
186
+ process.chdir(tmp);
187
+ const result = await bootApplication({
188
+ env: "local",
189
+ startScheduler: false,
190
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
191
+ config: {
192
+ drivers: {
193
+ journal: { local: "file", docker: "postgres", test: "memory", prod: "postgres" },
194
+ },
195
+ },
196
+ });
197
+ try {
198
+ expect(result.journal?.driverId).toBe("file");
199
+ } finally {
200
+ await result.close();
201
+ }
202
+ });
203
+
204
+ test("journal: drivers.journal postgres fails without DATABASE_URL", async () => {
205
+ const prevDb = process.env.DATABASE_URL;
206
+ const prevStore = process.env.OKE_STORE_SQL_URL;
207
+ delete process.env.DATABASE_URL;
208
+ delete process.env.OKE_STORE_SQL_URL;
209
+ try {
210
+ await expect(
211
+ bootApplication({
212
+ env: "local",
213
+ startScheduler: false,
214
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
215
+ config: {
216
+ drivers: {
217
+ journal: { local: "postgres" },
218
+ },
219
+ },
220
+ }),
221
+ ).rejects.toThrow(/journal driver "postgres" needs DATABASE_URL/);
222
+ } finally {
223
+ if (prevDb !== undefined) process.env.DATABASE_URL = prevDb;
224
+ else delete process.env.DATABASE_URL;
225
+ if (prevStore !== undefined) process.env.OKE_STORE_SQL_URL = prevStore;
226
+ else delete process.env.OKE_STORE_SQL_URL;
227
+ }
228
+ });
229
+
230
+ test("journal: unknown driver fails loud", async () => {
130
231
  await expect(
131
232
  bootApplication({
132
233
  env: "local",
133
234
  startScheduler: false,
134
- clocks: [clock("tick", { every: "1h" })],
235
+ flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
135
236
  config: {
136
237
  drivers: {
137
- clock: { local: "postgres" },
238
+ journal: { local: "neon" },
138
239
  },
139
240
  },
140
241
  }),
141
- ).rejects.toThrow(/clock driver "postgres" is not implemented/);
242
+ ).rejects.toThrow(/unknown journal driver "neon"/);
142
243
  });
143
244
 
144
245
  test("gate: drivers.store.kv redis opens redis-backed oke:gates", async () => {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Lazy journal binder — loaded only when a flow declares `durable: true`.
3
+ */
4
+
5
+ import { mkdirSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import {
8
+ createFileJournalStore,
9
+ createMemoryJournalStore,
10
+ JOURNAL_DEFAULT_LEASE_MS,
11
+ type JournalStore,
12
+ } from "../journal.ts";
13
+ import { createPostgresJournalStore } from "../../drivers/journal-postgres.ts";
14
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
15
+ import type { BootOptions } from "../boot.ts";
16
+
17
+ /** Bound journal runtime — store + this instance's lease identity. */
18
+ export interface JournalRuntime {
19
+ readonly store: JournalStore;
20
+ readonly instanceId: string;
21
+ readonly leaseMs: number;
22
+ readonly driverId: string;
23
+ }
24
+
25
+ /** Result of binding a journal runtime. */
26
+ export interface BindJournalResult {
27
+ readonly journal: JournalRuntime;
28
+ }
29
+
30
+ /** Default on-disk journal path (single-host file driver). */
31
+ export const DEFAULT_FILE_JOURNAL_PATH = ".oke/journal.json";
32
+
33
+ /**
34
+ * Resolve `drivers.journal` for the active env (default `memory`).
35
+ *
36
+ * @param options - Boot options
37
+ * @param env - Active environment
38
+ */
39
+ export function resolveJournalDriverId(options: BootOptions, env: ConfigEnv): string {
40
+ const resolved = resolveDriverId(options.config?.drivers?.journal, env);
41
+ if (resolved) return resolved;
42
+ return "memory";
43
+ }
44
+
45
+ /**
46
+ * Construct a journal store. Supported ids: `memory` · `file` · `postgres`.
47
+ *
48
+ * @param options - Boot options
49
+ * @param env - Active environment
50
+ */
51
+ export async function bindJournal(
52
+ options: BootOptions,
53
+ env: ConfigEnv,
54
+ ): Promise<BindJournalResult> {
55
+ const driver = resolveJournalDriverId(options, env);
56
+ const instanceId = options.instanceId ?? `inst-${crypto.randomUUID()}`;
57
+
58
+ let store: JournalStore;
59
+ if (driver === "memory") {
60
+ store = createMemoryJournalStore();
61
+ } else if (driver === "file") {
62
+ const path = resolve(process.cwd(), DEFAULT_FILE_JOURNAL_PATH);
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ store = createFileJournalStore(path);
65
+ } else if (driver === "postgres") {
66
+ const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
67
+ if (!url) {
68
+ throw new Error(
69
+ env === "docker"
70
+ ? 'oke boot: journal driver "postgres" needs DATABASE_URL (did `oke dev -d` write docker/.env.docker?)'
71
+ : 'oke boot: journal driver "postgres" needs DATABASE_URL',
72
+ );
73
+ }
74
+ store = await createPostgresJournalStore({ url });
75
+ } else {
76
+ throw new Error(
77
+ `oke boot: unknown journal driver "${driver}" (expected memory · file · postgres)`,
78
+ );
79
+ }
80
+
81
+ return {
82
+ journal: {
83
+ store,
84
+ instanceId,
85
+ leaseMs: options.journalLeaseMs ?? JOURNAL_DEFAULT_LEASE_MS,
86
+ driverId: driver,
87
+ },
88
+ };
89
+ }
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 51 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -116,9 +116,11 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Rebased after AI stream/tool-loop + fx.t/i18n on the shared fx surface
120
- // (~49.5 kB gzip). Still far below an eager full-element bind.
121
- expect(total).toBeLessThan(50_000);
119
+ // Rebased after fx.sendOtp/verifyOtp on the shared fx surface (~50.1 kB
120
+ // gzip), then again after the durable-journal lease surface (SKIP LOCKED
121
+ // claim/release/orphan-scan on journal.ts + app.ts wiring, ~51.2 kB).
122
+ // Clock/channel/journal drivers stay lazy-bound; far below eager bind.
123
+ expect(total).toBeLessThan(51_500);
122
124
  } finally {
123
125
  await rm(dir, { recursive: true, force: true });
124
126
  }
@@ -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,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
  },
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,36 @@ 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
+
231
268
  /** Options for {@link Fx.ask}. */
232
269
  export interface FxAskOptions {
233
270
  readonly via?: readonly NamedRef[];
@@ -286,10 +323,15 @@ export interface Fx {
286
323
  * Open a store handle for `ref` (capability checked on each op).
287
324
  *
288
325
  * When a {@link CreateFxOptions.storeRuntime} is bound and `ref` is a
289
- * 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).
290
328
  *
291
329
  * @param ref - Store resource ref, named handle, or store declaration
292
330
  */
331
+ store(ref: SqlStoreDecl): SqlStoreHandle;
332
+ store(ref: KvStoreDecl): KvStoreFxHandle;
333
+ store(ref: FilesStoreDecl): FilesStoreFxHandle;
334
+ store(ref: IndexStoreDecl): IndexStoreFxHandle;
293
335
  store(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle;
294
336
  /**
295
337
  * Emit a signal (records `emit`).
@@ -327,6 +369,22 @@ export interface Fx {
327
369
  * @param opts - Recipient / data
328
370
  */
329
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 }>;
330
388
  /**
331
389
  * Ask an AI prompt (records `ask`). Stub returns `{}`.
332
390
  *
@@ -837,6 +895,13 @@ export function createFxContext(options: CreateFxOptions): FxContext {
837
895
  return h.exists(table, idOrWhere);
838
896
  });
839
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
+ },
840
905
  increment(table, id, column, by) {
841
906
  return gated("write", ref, async () => {
842
907
  refuseDryRunWrite();
@@ -872,10 +937,25 @@ export function createFxContext(options: CreateFxOptions): FxContext {
872
937
  } as SqlStoreHandle;
873
938
  }
874
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;
875
945
  function storeHandle(ref: NamedRef | { readonly ref: ResourceRef } | StoreDecl): FxStoreHandle {
876
946
  const runtime = options.storeRuntime;
877
- if (runtime && typeof ref === "object" && ref !== null && "facet" in ref) {
947
+ if (typeof ref === "object" && ref !== null && "facet" in ref) {
878
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
+ }
879
959
  const cache: { handle?: StoreHandle } = {};
880
960
  const open = async () => {
881
961
  if (!cache.handle) {
@@ -1030,9 +1110,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1030
1110
  };
1031
1111
 
1032
1112
  const fx: Fx = {
1033
- store(ref) {
1034
- return storeHandle(ref);
1035
- },
1113
+ store: storeHandle,
1036
1114
  emit(signal, payload, emitOptions) {
1037
1115
  const name = resolveName(signal);
1038
1116
  return gated("emit", name, async () => {
@@ -1092,6 +1170,49 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1092
1170
  return { ok: true as const };
1093
1171
  });
1094
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
+ },
1095
1216
  ask(prompt, input, opts) {
1096
1217
  const name = resolveName(prompt);
1097
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,