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.
- package/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +44 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +20 -27
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +172 -0
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
package/src/kernel/boot.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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>;
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 (
|
|
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
|
|
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
|
+
}
|