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
|
@@ -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
|
|
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
|
-
|
|
235
|
+
flows: [flow({ name: "charge", durable: true, do: () => ({ ok: true }) })],
|
|
135
236
|
config: {
|
|
136
237
|
drivers: {
|
|
137
|
-
|
|
238
|
+
journal: { local: "neon" },
|
|
138
239
|
},
|
|
139
240
|
},
|
|
140
241
|
}),
|
|
141
|
-
).rejects.toThrow(/
|
|
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
|
+
}
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
120
|
-
//
|
|
121
|
-
|
|
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
|
}
|
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
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
|
-
*
|
|
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 (
|
|
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
|
|
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 () => {
|
package/src/kernel/index.ts
CHANGED
|
@@ -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,
|