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.
- package/package.json +2 -2
- package/site/content/docs/elements/channel.mdx +23 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/store.mdx +131 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/plugins/magic-link.mdx +42 -0
- package/site/content/docs/plugins/phone-number.mdx +78 -17
- package/site/content/docs/plugins/two-factor.mdx +1 -0
- package/site/content/docs/reference/cli.md +2 -0
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +20 -8
- 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/load-config.images.test.ts +22 -0
- package/src/cli/load-config.ts +7 -2
- package/src/cli/registry.ts +37 -1
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-types.ts +71 -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/runtime.ts +51 -0
- package/src/elements/channel.test.ts +71 -0
- 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 +15 -0
- package/src/kernel/app.ts +165 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +13 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/honor-config.test.ts +105 -4
- package/src/kernel/boot-bind/journal.ts +89 -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 +6 -0
- package/src/kernel/fx.ts +126 -5
- package/src/kernel/index.ts +6 -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/plugins/auth-methods.security.test.ts +10 -7
- package/src/plugins/phone-number.ts +67 -10
- package/src/plugins/taqnyat.live.test.ts +174 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real app-boot path for fx.store(db).upsert — must work through
|
|
3
|
+
* oke() → createTestApp → app.fetch(), not only the isolated SqlStoreHandle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { gate } from "../gate.ts";
|
|
9
|
+
import { oke } from "../../kernel/app.ts";
|
|
10
|
+
import { createFx } from "../../kernel/fx.ts";
|
|
11
|
+
import { flow, resetFlowSeq } from "../../kernel/flow.ts";
|
|
12
|
+
import { on, resetBindings } from "../../kernel/on.ts";
|
|
13
|
+
import { http } from "../../kernel/triggers.ts";
|
|
14
|
+
import { createTestApp } from "../../test/create-test-app.ts";
|
|
15
|
+
import { field, id, now, store } from "../store.ts";
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetBindings();
|
|
19
|
+
resetFlowSeq();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const notes = store.schema.table("notes", {
|
|
23
|
+
id: field.text().primaryKey().defaultFn(id),
|
|
24
|
+
title: field.text().notNull(),
|
|
25
|
+
body: field.text().notNull(),
|
|
26
|
+
createdAt: field.integer().notNull().defaultFn(now),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const UpsertIn = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
title: z.string(),
|
|
32
|
+
body: z.string(),
|
|
33
|
+
onExisting: z.enum(["update"]).optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const UpsertOut = z.object({
|
|
37
|
+
status: z.enum(["upserted", "changed", "already-existed"]),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("fx.store(db).upsert via app.fetch (real boot)", () => {
|
|
41
|
+
test("abstract schema table: upserted → already-existed → changed", async () => {
|
|
42
|
+
resetBindings();
|
|
43
|
+
resetFlowSeq();
|
|
44
|
+
|
|
45
|
+
const db = store.sql("app", { schema: { notes } });
|
|
46
|
+
|
|
47
|
+
const seed = on(
|
|
48
|
+
http.post("/seed").gate(gate.public),
|
|
49
|
+
flow({
|
|
50
|
+
name: "notes.seed",
|
|
51
|
+
in: UpsertIn,
|
|
52
|
+
out: UpsertOut,
|
|
53
|
+
effects: { writes: ["sql:app"], reads: ["sql:app"] },
|
|
54
|
+
do: async (input, fx) =>
|
|
55
|
+
fx
|
|
56
|
+
.store(db)
|
|
57
|
+
.upsert(
|
|
58
|
+
notes,
|
|
59
|
+
{ id: input.id },
|
|
60
|
+
{ id: input.id, title: input.title, body: input.body, createdAt: 1 },
|
|
61
|
+
input.onExisting ? { onExisting: input.onExisting } : undefined,
|
|
62
|
+
),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const app = oke({
|
|
67
|
+
name: "upsert-app-boot",
|
|
68
|
+
gate: { policies: [gate.public] },
|
|
69
|
+
}).adopt({ seed });
|
|
70
|
+
Object.assign(app.$options, { env: "test", stores: [db], unguardedHttp: "allow" });
|
|
71
|
+
await createTestApp(app);
|
|
72
|
+
|
|
73
|
+
async function post(body: unknown) {
|
|
74
|
+
const res = await app.fetch(
|
|
75
|
+
new Request("http://localhost/seed", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
const json = (await res.json()) as { data: { status: string } };
|
|
83
|
+
return json.data.status;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expect(await post({ id: "welcome", title: "Hello", body: "one" })).toBe("upserted");
|
|
87
|
+
expect(await post({ id: "welcome", title: "Changed", body: "two" })).toBe("already-existed");
|
|
88
|
+
expect(
|
|
89
|
+
await post({
|
|
90
|
+
id: "welcome",
|
|
91
|
+
title: "Updated",
|
|
92
|
+
body: "three",
|
|
93
|
+
onExisting: "update",
|
|
94
|
+
}),
|
|
95
|
+
).toBe("changed");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("SQL StoreDecl without store runtime fails loudly (not stub upsert-missing)", async () => {
|
|
99
|
+
const db = store.sql("app", { schema: { notes } });
|
|
100
|
+
const fx = createFx({ flow: "orphan" });
|
|
101
|
+
expect(() => fx.store(db)).toThrow(/no store runtime/);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/elements/store.ts
CHANGED
|
@@ -91,6 +91,9 @@ export type {
|
|
|
91
91
|
ResolvedListConfig,
|
|
92
92
|
} from "./store/resource.ts";
|
|
93
93
|
|
|
94
|
+
export { defineSeed, normalizeSeedFns, resolveSeedCategory } from "./store/seed.ts";
|
|
95
|
+
export type { SeedCategory, SeedDef, SeedFn, SeedFns } from "./store/seed.ts";
|
|
96
|
+
|
|
94
97
|
export { createSqlStoreHandle, resolvePkColumn } from "./store/sql-session.ts";
|
|
95
98
|
export type {
|
|
96
99
|
SqlStoreHandle,
|
|
@@ -102,6 +105,8 @@ export type {
|
|
|
102
105
|
InsertValuesBuilder,
|
|
103
106
|
SqlSessionOptions,
|
|
104
107
|
SqlPageOptions,
|
|
108
|
+
UpsertResult,
|
|
109
|
+
UpsertStatus,
|
|
105
110
|
WhereMap,
|
|
106
111
|
} from "./store/sql-session.ts";
|
|
107
112
|
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,9 @@ export {
|
|
|
65
65
|
now,
|
|
66
66
|
defineTable,
|
|
67
67
|
field,
|
|
68
|
+
defineSeed,
|
|
69
|
+
normalizeSeedFns,
|
|
70
|
+
resolveSeedCategory,
|
|
68
71
|
createStoreRuntime,
|
|
69
72
|
type StoreDecl,
|
|
70
73
|
type StoreRuntime,
|
|
@@ -72,6 +75,12 @@ export {
|
|
|
72
75
|
type TableHandle,
|
|
73
76
|
type SchemaTableDecl,
|
|
74
77
|
type SchemaColumnDecl,
|
|
78
|
+
type SeedDef,
|
|
79
|
+
type SeedFn,
|
|
80
|
+
type SeedFns,
|
|
81
|
+
type SeedCategory,
|
|
82
|
+
type UpsertResult,
|
|
83
|
+
type UpsertStatus,
|
|
75
84
|
} from "./elements/store.ts";
|
|
76
85
|
|
|
77
86
|
export {
|
|
@@ -152,7 +161,13 @@ export {
|
|
|
152
161
|
createJournal,
|
|
153
162
|
createMemoryJournalStore,
|
|
154
163
|
createFileJournalStore,
|
|
164
|
+
hasJournalLease,
|
|
165
|
+
isJournalLeaseBusy,
|
|
166
|
+
JournalLeaseBusy,
|
|
167
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
155
168
|
type Journal,
|
|
169
|
+
type JournalLeaseOptions,
|
|
170
|
+
type JournalLeaseStore,
|
|
156
171
|
type JournalStore,
|
|
157
172
|
type JournalRun,
|
|
158
173
|
} from "./kernel/journal.ts";
|
package/src/kernel/app.ts
CHANGED
|
@@ -71,9 +71,14 @@ import {
|
|
|
71
71
|
import {
|
|
72
72
|
createJournal,
|
|
73
73
|
createMemoryJournalStore,
|
|
74
|
+
hasJournalLease,
|
|
75
|
+
isJournalLeaseBusy,
|
|
74
76
|
isJournalSuspend,
|
|
77
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
75
78
|
type JournalSession,
|
|
79
|
+
type JournalStore,
|
|
76
80
|
} from "./journal.ts";
|
|
81
|
+
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
77
82
|
import { listBindings, resetBindings, type Binding } from "./on.ts";
|
|
78
83
|
import {
|
|
79
84
|
applyPrincipal,
|
|
@@ -190,6 +195,8 @@ export interface OkeOptions {
|
|
|
190
195
|
readonly startScheduler?: boolean;
|
|
191
196
|
/** Scheduler tick period ms (default 1000). Forwarded to boot. */
|
|
192
197
|
readonly schedulerIntervalMs?: number;
|
|
198
|
+
/** Durable-run lease duration ms (default 30_000). Forwarded to boot. */
|
|
199
|
+
readonly journalLeaseMs?: number;
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
/** Payload for a CDC invocation. */
|
|
@@ -579,13 +586,37 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
579
586
|
} else {
|
|
580
587
|
setActiveGateAuthContext(undefined);
|
|
581
588
|
}
|
|
582
|
-
|
|
589
|
+
// Fallback journal for pre-boot / `autoBoot: false` unit tests. A booted
|
|
590
|
+
// app replaces this with the bound `drivers.journal` store (postgres etc.).
|
|
591
|
+
const fallbackJournalStore = createMemoryJournalStore();
|
|
592
|
+
const fallbackJournalInstanceId = `app-${crypto.randomUUID()}`;
|
|
583
593
|
const sleepingRuns = new Map<
|
|
584
594
|
string,
|
|
585
595
|
{ readonly flow: AnyFlowDef; readonly input: unknown; readonly wakeAt: number }
|
|
586
596
|
>();
|
|
597
|
+
// Same-process mutual exclusion per runId. The lease coordinates across
|
|
598
|
+
// processes, but same-holder renew cannot distinguish two overlapping
|
|
599
|
+
// sessions inside one process (boot orphan scan vs. scheduler tick).
|
|
600
|
+
const inflightRuns = new Set<string>();
|
|
587
601
|
const EMPTY_CAPABILITIES: ReadonlyMap<string, CapabilityToken> = new Map();
|
|
588
602
|
|
|
603
|
+
/** Active journal: the boot-bound store once available, else the fallback. */
|
|
604
|
+
function activeJournal(): {
|
|
605
|
+
readonly store: JournalStore;
|
|
606
|
+
readonly instanceId: string;
|
|
607
|
+
readonly leaseMs: number;
|
|
608
|
+
} {
|
|
609
|
+
const bound: JournalRuntime | undefined = bootResult?.journal;
|
|
610
|
+
if (bound) {
|
|
611
|
+
return { store: bound.store, instanceId: bound.instanceId, leaseMs: bound.leaseMs };
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
store: fallbackJournalStore,
|
|
615
|
+
instanceId: fallbackJournalInstanceId,
|
|
616
|
+
leaseMs: JOURNAL_DEFAULT_LEASE_MS,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
589
620
|
async function handleCronFire(name: string): Promise<void> {
|
|
590
621
|
await app.dispatchEvery(name);
|
|
591
622
|
// Named clocks (e.g. `clock("expire-stale", { every: "1h" })`) reconcile
|
|
@@ -600,6 +631,84 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
600
631
|
await app.dispatchSignal(name, payload);
|
|
601
632
|
}
|
|
602
633
|
|
|
634
|
+
async function handleDurableResume(): Promise<void> {
|
|
635
|
+
await app.resumeDurable();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Element runtimes handed to durable resumes (same bag as cron dispatch). */
|
|
639
|
+
function durableResumeFx() {
|
|
640
|
+
return {
|
|
641
|
+
storeRuntime: bootResult?.store,
|
|
642
|
+
signalRuntime: bootResult?.signal,
|
|
643
|
+
vaultRuntime: bootResult?.vault,
|
|
644
|
+
channelRuntime: bootResult?.channel,
|
|
645
|
+
aiRuntime: bootResult?.ai,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Resume one persisted run under its lease. A lost lease race is the
|
|
651
|
+
* coordination win — another live instance owns the run, so skip quietly.
|
|
652
|
+
*/
|
|
653
|
+
/** Runs whose undeclared flow was already logged (sweep ticks must not spam). */
|
|
654
|
+
const warnedOrphanRuns = new Set<string>();
|
|
655
|
+
|
|
656
|
+
async function resumeDurableRun(
|
|
657
|
+
runId: string,
|
|
658
|
+
flowName: string,
|
|
659
|
+
input: unknown,
|
|
660
|
+
now: () => number,
|
|
661
|
+
): Promise<void> {
|
|
662
|
+
// The lease only coordinates across processes — within one process,
|
|
663
|
+
// overlapping callers (orphan scan vs. scheduler tick) would both pass
|
|
664
|
+
// same-holder lease renewal and run two sessions for one runId.
|
|
665
|
+
if (inflightRuns.has(runId)) return;
|
|
666
|
+
inflightRuns.add(runId);
|
|
667
|
+
try {
|
|
668
|
+
const flowDef = flowsByName.get(flowName);
|
|
669
|
+
if (!flowDef) {
|
|
670
|
+
if (!warnedOrphanRuns.has(runId)) {
|
|
671
|
+
warnedOrphanRuns.add(runId);
|
|
672
|
+
console.warn(`oke: durable run "${runId}": undeclared flow "${flowName}" — skipped`);
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
677
|
+
try {
|
|
678
|
+
await runDurable({
|
|
679
|
+
flow: flowDef,
|
|
680
|
+
input,
|
|
681
|
+
journalStore: store,
|
|
682
|
+
runId,
|
|
683
|
+
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
684
|
+
now,
|
|
685
|
+
fx: durableResumeFx(),
|
|
686
|
+
});
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (isJournalLeaseBusy(err)) return;
|
|
689
|
+
throw err;
|
|
690
|
+
}
|
|
691
|
+
} finally {
|
|
692
|
+
inflightRuns.delete(runId);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Boot-time orphan scan — `running`/`sleeping` runs with no live lease. */
|
|
697
|
+
async function resumeOrphanedDurableRuns(): Promise<void> {
|
|
698
|
+
const { store } = activeJournal();
|
|
699
|
+
if (!hasJournalLease(store)) return;
|
|
700
|
+
const now = () => bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
701
|
+
const orphans = await store.listOrphans(now());
|
|
702
|
+
for (const orphan of orphans) {
|
|
703
|
+
// Future sleeps are claimed by the scheduler tick when they come due —
|
|
704
|
+
// the shared store is the schedule, no in-process seeding needed.
|
|
705
|
+
if (orphan.status === "sleeping" && orphan.wakeAt !== undefined && orphan.wakeAt > now()) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, now);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
603
712
|
async function doBoot(overrides?: Partial<BootOptions>): Promise<BootResult> {
|
|
604
713
|
const { bootApplication, resolveElementNeeds } = await import("./boot.ts");
|
|
605
714
|
const { assertHttpGatePosture } = await import("../elements/gate/boot.ts");
|
|
@@ -716,13 +825,23 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
716
825
|
instanceId: overrides?.instanceId,
|
|
717
826
|
startScheduler: overrides?.startScheduler ?? options.startScheduler,
|
|
718
827
|
schedulerIntervalMs: overrides?.schedulerIntervalMs ?? options.schedulerIntervalMs,
|
|
828
|
+
journalLeaseMs: overrides?.journalLeaseMs ?? options.journalLeaseMs,
|
|
719
829
|
bindings: adopted,
|
|
720
830
|
flows: [...flowsByName.values()],
|
|
721
831
|
onCronFire: overrides?.onCronFire ?? handleCronFire,
|
|
722
832
|
onSignal: overrides?.onSignal ?? handleSignalFire,
|
|
833
|
+
onDurableResume: overrides?.onDurableResume ?? handleDurableResume,
|
|
723
834
|
};
|
|
724
835
|
const result = await bootApplication(merged);
|
|
725
836
|
bootResult = result;
|
|
837
|
+
// Boot-time orphan discovery: resume/schedule any `running` / `sleeping`
|
|
838
|
+
// run left without a live lease by a crashed (or previous) instance.
|
|
839
|
+
// Fire-and-forget — boot must not block serving on a long resume.
|
|
840
|
+
if (result.journal && hasJournalLease(result.journal.store)) {
|
|
841
|
+
void resumeOrphanedDurableRuns().catch((err) => {
|
|
842
|
+
console.error("oke: durable orphan scan failed", err);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
726
845
|
// Prefer the booted clock for access-token expiry checks.
|
|
727
846
|
if (authBinding) {
|
|
728
847
|
authBinding = createAppAuthBinding({
|
|
@@ -908,8 +1027,16 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
908
1027
|
capability = booted.capabilities.get(flowDef.name);
|
|
909
1028
|
|
|
910
1029
|
if (flowDef.durable) {
|
|
911
|
-
const
|
|
1030
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
1031
|
+
const journal = createJournal({
|
|
1032
|
+
store,
|
|
1033
|
+
now,
|
|
1034
|
+
// Hold the run lease for the request's lifetime — a crash mid-run
|
|
1035
|
+
// leaves an expired lease another instance can reclaim and resume.
|
|
1036
|
+
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
1037
|
+
});
|
|
912
1038
|
journalSession = await journal.start(flowDef.name, input);
|
|
1039
|
+
inflightRuns.add(journalSession.runId);
|
|
913
1040
|
}
|
|
914
1041
|
}
|
|
915
1042
|
|
|
@@ -998,25 +1125,34 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
998
1125
|
// HTTP flows serialize before `onResponse` so the last stage can see
|
|
999
1126
|
// and replace the final response (plugin header/middleware surfaces).
|
|
1000
1127
|
trigger.kind === "http" ? encodeExecuteResult : undefined,
|
|
1001
|
-
)
|
|
1128
|
+
).catch((err: unknown) => {
|
|
1129
|
+
// Park suspensions are already absorbed above; a real pipeline failure
|
|
1130
|
+
// still leaves the run lease to expire for cross-process reclaim, but
|
|
1131
|
+
// must not pin the runId in this process's in-flight guard forever.
|
|
1132
|
+
if (journalSession) inflightRuns.delete(journalSession.runId);
|
|
1133
|
+
throw err;
|
|
1134
|
+
});
|
|
1002
1135
|
|
|
1003
1136
|
const endedAt = now();
|
|
1004
1137
|
|
|
1005
1138
|
if (journalSession) {
|
|
1139
|
+
inflightRuns.delete(journalSession.runId);
|
|
1006
1140
|
const sleeping = ctx.state.sleeping as
|
|
1007
1141
|
| { readonly wakeAt: number; readonly label: string; readonly runId: string }
|
|
1008
1142
|
| undefined;
|
|
1009
|
-
|
|
1143
|
+
// Lease-capable stores make the shared row the wake schedule; the
|
|
1144
|
+
// in-process map is only for custom stores without the lease surface.
|
|
1145
|
+
if (sleeping && !hasJournalLease(activeJournal().store)) {
|
|
1010
1146
|
sleepingRuns.set(sleeping.runId, {
|
|
1011
1147
|
flow: flowDef,
|
|
1012
1148
|
input: ctx.input,
|
|
1013
1149
|
wakeAt: sleeping.wakeAt,
|
|
1014
1150
|
});
|
|
1015
|
-
} else if (result.failure) {
|
|
1151
|
+
} else if (!sleeping && result.failure) {
|
|
1016
1152
|
await journalSession.commit("failed", {
|
|
1017
1153
|
error: result.failure.error.code,
|
|
1018
1154
|
});
|
|
1019
|
-
} else {
|
|
1155
|
+
} else if (!sleeping) {
|
|
1020
1156
|
await journalSession.commit("completed", { output: result.output });
|
|
1021
1157
|
}
|
|
1022
1158
|
}
|
|
@@ -1074,6 +1210,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1074
1210
|
channel: bootResult.channel,
|
|
1075
1211
|
ai: bootResult.ai,
|
|
1076
1212
|
runs: bootResult.runs,
|
|
1213
|
+
...(bootResult.journal ? { journal: bootResult.journal } : {}),
|
|
1077
1214
|
};
|
|
1078
1215
|
},
|
|
1079
1216
|
get capabilities() {
|
|
@@ -1099,22 +1236,36 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1099
1236
|
},
|
|
1100
1237
|
async resumeDurable(now) {
|
|
1101
1238
|
const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
1239
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
1240
|
+
if (hasJournalLease(store)) {
|
|
1241
|
+
// Shared store is the wake schedule — claim due sleeps across all
|
|
1242
|
+
// instances (SKIP LOCKED; exactly one claimant wins a raced claim).
|
|
1243
|
+
for (;;) {
|
|
1244
|
+
const due = await store.claimDueSleep(instanceId, t, leaseMs);
|
|
1245
|
+
if (!due) break;
|
|
1246
|
+
sleepingRuns.delete(due.id);
|
|
1247
|
+
await resumeDurableRun(due.id, due.flow, due.input, () => t);
|
|
1248
|
+
}
|
|
1249
|
+
// Crash sweep: the boot orphan scan is once-only, so each tick also
|
|
1250
|
+
// reclaims `running` runs whose holder's lease has expired (same
|
|
1251
|
+
// takeover physics as Clock: lease expiry + next tick).
|
|
1252
|
+
for (const orphan of await store.listOrphans(t)) {
|
|
1253
|
+
if (orphan.status !== "running") continue;
|
|
1254
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, () => t);
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// Custom store without the lease surface — in-process sleepers only.
|
|
1102
1259
|
for (const [runId, sleeper] of [...sleepingRuns.entries()]) {
|
|
1103
1260
|
if (t < sleeper.wakeAt) continue;
|
|
1104
1261
|
sleepingRuns.delete(runId);
|
|
1105
1262
|
const result = await runDurable({
|
|
1106
1263
|
flow: sleeper.flow,
|
|
1107
1264
|
input: sleeper.input,
|
|
1108
|
-
journalStore,
|
|
1265
|
+
journalStore: store,
|
|
1109
1266
|
runId,
|
|
1110
1267
|
now: () => t,
|
|
1111
|
-
fx:
|
|
1112
|
-
storeRuntime: bootResult?.store,
|
|
1113
|
-
signalRuntime: bootResult?.signal,
|
|
1114
|
-
vaultRuntime: bootResult?.vault,
|
|
1115
|
-
channelRuntime: bootResult?.channel,
|
|
1116
|
-
aiRuntime: bootResult?.ai,
|
|
1117
|
-
},
|
|
1268
|
+
fx: durableResumeFx(),
|
|
1118
1269
|
});
|
|
1119
1270
|
if (result.status === "sleeping") {
|
|
1120
1271
|
sleepingRuns.set(runId, {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveSmsDriverId,
|
|
11
11
|
smtpOptionsFromEnv,
|
|
12
12
|
sndrOptionsFromEnv,
|
|
13
|
+
taqnyatMailOptionsFromEnv,
|
|
13
14
|
taqnyatOptionsFromEnv,
|
|
14
15
|
unifonicOptionsFromEnv,
|
|
15
16
|
} from "./channel.ts";
|
|
@@ -24,6 +25,8 @@ const previous = {
|
|
|
24
25
|
sndrBase: process.env.SNDR_BASE_URL,
|
|
25
26
|
taqBearer: process.env.TAQNYAT_BEARER_TOKEN,
|
|
26
27
|
taqSender: process.env.TAQNYAT_SENDER,
|
|
28
|
+
taqMailToken: process.env.TAQNYAT_MAIL_TOKEN,
|
|
29
|
+
taqCampaign: process.env.TAQNYAT_CAMPAIGN,
|
|
27
30
|
msegatUser: process.env.MSEGAT_USERNAME,
|
|
28
31
|
msegatKey: process.env.MSEGAT_API_KEY,
|
|
29
32
|
msegatSender: process.env.MSEGAT_SENDER,
|
|
@@ -41,6 +44,8 @@ afterEach(() => {
|
|
|
41
44
|
restoreEnv("SNDR_BASE_URL", previous.sndrBase);
|
|
42
45
|
restoreEnv("TAQNYAT_BEARER_TOKEN", previous.taqBearer);
|
|
43
46
|
restoreEnv("TAQNYAT_SENDER", previous.taqSender);
|
|
47
|
+
restoreEnv("TAQNYAT_MAIL_TOKEN", previous.taqMailToken);
|
|
48
|
+
restoreEnv("TAQNYAT_CAMPAIGN", previous.taqCampaign);
|
|
44
49
|
restoreEnv("MSEGAT_USERNAME", previous.msegatUser);
|
|
45
50
|
restoreEnv("MSEGAT_API_KEY", previous.msegatKey);
|
|
46
51
|
restoreEnv("MSEGAT_SENDER", previous.msegatSender);
|
|
@@ -117,6 +122,17 @@ describe("bindChannel driver resolution", () => {
|
|
|
117
122
|
process.env.UNIFONIC_SENDER = "Brand";
|
|
118
123
|
expect(unifonicOptionsFromEnv()).toEqual({ appSid: "sid", sender: "Brand" });
|
|
119
124
|
});
|
|
125
|
+
|
|
126
|
+
test("taqnyat-mail env helper", () => {
|
|
127
|
+
process.env.TAQNYAT_MAIL_TOKEN = "bearer-mail";
|
|
128
|
+
process.env.TAQNYAT_CAMPAIGN = "auth";
|
|
129
|
+
expect(taqnyatMailOptionsFromEnv()).toEqual({
|
|
130
|
+
bearerToken: "bearer-mail",
|
|
131
|
+
campaignName: "auth",
|
|
132
|
+
});
|
|
133
|
+
delete process.env.TAQNYAT_CAMPAIGN;
|
|
134
|
+
expect(() => taqnyatMailOptionsFromEnv()).toThrow("TAQNYAT_CAMPAIGN");
|
|
135
|
+
});
|
|
120
136
|
});
|
|
121
137
|
|
|
122
138
|
function restoreEnv(key: string, value: string | undefined): void {
|
|
@@ -9,6 +9,7 @@ import { openResendChannel } from "../../drivers/channel-resend.ts";
|
|
|
9
9
|
import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
|
|
10
10
|
import { openSndrChannel } from "../../drivers/channel-sndr.ts";
|
|
11
11
|
import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
|
|
12
|
+
import { openTaqnyatMailChannel } from "../../drivers/channel-taqnyat-mail.ts";
|
|
12
13
|
import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
|
|
13
14
|
import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
|
|
14
15
|
import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
|
|
@@ -51,6 +52,7 @@ function emailDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean):
|
|
|
51
52
|
if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
|
|
52
53
|
if (id === "resend") return openResendChannel(resendOptionsFromEnv());
|
|
53
54
|
if (id === "sndr") return openSndrChannel(sndrOptionsFromEnv());
|
|
55
|
+
if (id === "taqnyat-mail") return openTaqnyatMailChannel(taqnyatMailOptionsFromEnv());
|
|
54
56
|
throw new Error(`oke boot: unknown email channel driver "${id}"`);
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -150,6 +152,17 @@ export function taqnyatOptionsFromEnv(): ChannelOpenOptions {
|
|
|
150
152
|
return { bearerToken, sender };
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Resolve Taqnyat Email options from env.
|
|
157
|
+
*/
|
|
158
|
+
export function taqnyatMailOptionsFromEnv(): ChannelOpenOptions {
|
|
159
|
+
const bearerToken = process.env.TAQNYAT_MAIL_TOKEN?.trim();
|
|
160
|
+
const campaignName = process.env.TAQNYAT_CAMPAIGN?.trim();
|
|
161
|
+
if (!bearerToken) throw new Error("oke boot: taqnyat-mail channel needs TAQNYAT_MAIL_TOKEN");
|
|
162
|
+
if (!campaignName) throw new Error("oke boot: taqnyat-mail channel needs TAQNYAT_CAMPAIGN");
|
|
163
|
+
return { bearerToken, campaignName };
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
/**
|
|
154
167
|
* Resolve Msegat SMS options from env.
|
|
155
168
|
*/
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ClockDecl,
|
|
14
14
|
type ClockRuntime,
|
|
15
15
|
} from "../../elements/clock.ts";
|
|
16
|
+
import { createPostgresCronStore } from "../../drivers/clock-postgres.ts";
|
|
16
17
|
import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
17
18
|
import type { BootOptions } from "../boot.ts";
|
|
18
19
|
|
|
@@ -40,8 +41,7 @@ export function resolveClockDriverId(options: BootOptions, env: ConfigEnv): stri
|
|
|
40
41
|
/**
|
|
41
42
|
* Construct / adopt a Clock runtime, register decls, reconcile.
|
|
42
43
|
*
|
|
43
|
-
* Supported ids: `frozen` · `memory` · `file
|
|
44
|
-
* fail loud — there is no postgres CronStore.
|
|
44
|
+
* Supported ids: `frozen` · `memory` · `file` · `postgres`.
|
|
45
45
|
*
|
|
46
46
|
* @param options - Boot options
|
|
47
47
|
* @param env - Active environment
|
|
@@ -76,12 +76,23 @@ export async function bindClock(
|
|
|
76
76
|
store: createFileCronStore(path),
|
|
77
77
|
});
|
|
78
78
|
} else if (clockDriver === "postgres") {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
|
|
80
|
+
if (!url) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
env === "docker"
|
|
83
|
+
? 'oke boot: clock driver "postgres" needs DATABASE_URL (did `oke dev -d` write docker/.env.docker?)'
|
|
84
|
+
: 'oke boot: clock driver "postgres" needs DATABASE_URL',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const store = await createPostgresCronStore({ url });
|
|
88
|
+
clock = createClockRuntime({
|
|
89
|
+
instanceId: options.instanceId,
|
|
90
|
+
now,
|
|
91
|
+
store,
|
|
92
|
+
});
|
|
82
93
|
} else {
|
|
83
94
|
throw new Error(
|
|
84
|
-
`oke boot: unknown clock driver "${clockDriver}" (expected memory · file · frozen)`,
|
|
95
|
+
`oke boot: unknown clock driver "${clockDriver}" (expected memory · file · postgres · frozen)`,
|
|
85
96
|
);
|
|
86
97
|
}
|
|
87
98
|
|