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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke db seed` — load `defineSeed`, boot the app store, run essential +
|
|
3
|
+
* env-selected category (`dev` | `prod`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { isCancel, text } from "@clack/prompts";
|
|
8
|
+
import type { ConfigEnv, OkeConfig } from "../config/index.ts";
|
|
9
|
+
import {
|
|
10
|
+
normalizeSeedFns,
|
|
11
|
+
resolveSeedCategory,
|
|
12
|
+
type SeedDef,
|
|
13
|
+
type SeedFn,
|
|
14
|
+
type UpsertStatus,
|
|
15
|
+
} from "../elements/store.ts";
|
|
16
|
+
import {
|
|
17
|
+
findAppWithPlugins,
|
|
18
|
+
resolveAppEntryForPluginTables,
|
|
19
|
+
} from "../elements/store/load-plugin-tables.ts";
|
|
20
|
+
import { createFx, type Fx } from "../kernel/fx.ts";
|
|
21
|
+
import type { OkeApp } from "../kernel/app.ts";
|
|
22
|
+
import { resolveDrizzleKitEnv } from "./drizzle-env.ts";
|
|
23
|
+
import { EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from "./exit.ts";
|
|
24
|
+
import { loadOkeConfig } from "./load-config.ts";
|
|
25
|
+
import { resolveDevSqlEnv } from "./resolve-dev-sql-env.ts";
|
|
26
|
+
|
|
27
|
+
/** Per-function upsert outcome counts. */
|
|
28
|
+
export interface SeedTally {
|
|
29
|
+
upserted: number;
|
|
30
|
+
changed: number;
|
|
31
|
+
alreadyExisted: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Options for {@link runSeed}. */
|
|
35
|
+
export interface SeedOptions {
|
|
36
|
+
readonly cwd?: string;
|
|
37
|
+
readonly write?: (text: string) => void;
|
|
38
|
+
readonly env?: ConfigEnv;
|
|
39
|
+
/** Skip interactive confirm (`--force`). */
|
|
40
|
+
readonly force?: boolean;
|
|
41
|
+
/** Override seed module path (default `src/seed/index.ts`). */
|
|
42
|
+
readonly seedPath?: string;
|
|
43
|
+
/** Override app entry for boot. */
|
|
44
|
+
readonly entry?: string;
|
|
45
|
+
/** Injectable seed def (tests) — skips loading `seedPath`. */
|
|
46
|
+
readonly seedDef?: SeedDef;
|
|
47
|
+
/** Injectable fx factory (tests) — skips app boot. */
|
|
48
|
+
readonly createFx?: () => Promise<{
|
|
49
|
+
readonly fx: Fx;
|
|
50
|
+
readonly stop: () => Promise<void>;
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Confirm seeding a docker/prod target. Tests inject this.
|
|
54
|
+
* Default: prompt operator to type the literal env name.
|
|
55
|
+
*/
|
|
56
|
+
readonly confirmEnv?: (env: ConfigEnv, target: string) => Promise<boolean>;
|
|
57
|
+
readonly stdinIsTTY?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether `value` looks like a {@link SeedDef}.
|
|
62
|
+
*
|
|
63
|
+
* @param value - Candidate export
|
|
64
|
+
*/
|
|
65
|
+
export function isSeedDef(value: unknown): value is SeedDef {
|
|
66
|
+
if (!value || typeof value !== "object") return false;
|
|
67
|
+
const o = value as Record<string, unknown>;
|
|
68
|
+
for (const key of ["essential", "dev", "prod"] as const) {
|
|
69
|
+
if (o[key] === undefined) continue;
|
|
70
|
+
if (typeof o[key] === "function") continue;
|
|
71
|
+
if (Array.isArray(o[key]) && o[key].every((f) => typeof f === "function")) continue;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return "essential" in o || "dev" in o || "prod" in o;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Redact password from a connection URL for display.
|
|
79
|
+
*
|
|
80
|
+
* @param raw - Connection URL or file path
|
|
81
|
+
*/
|
|
82
|
+
export function redactConnectionTarget(raw: string): string {
|
|
83
|
+
try {
|
|
84
|
+
const u = new URL(raw);
|
|
85
|
+
if (u.password) u.password = "••••";
|
|
86
|
+
return u.toString();
|
|
87
|
+
} catch {
|
|
88
|
+
return raw;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Human-readable DB target from drizzle overlay.
|
|
94
|
+
*
|
|
95
|
+
* @param overlay - Resolved drizzle-kit env overlay
|
|
96
|
+
* @param dialect - sqlite | postgresql
|
|
97
|
+
*/
|
|
98
|
+
export function formatSeedTarget(
|
|
99
|
+
overlay: Readonly<Record<string, string>>,
|
|
100
|
+
dialect: string,
|
|
101
|
+
): string {
|
|
102
|
+
if (dialect === "postgresql" || overlay.DATABASE_URL) {
|
|
103
|
+
const url = overlay.DATABASE_URL ?? "(DATABASE_URL unset)";
|
|
104
|
+
return `postgresql ${redactConnectionTarget(url)}`;
|
|
105
|
+
}
|
|
106
|
+
const url = overlay.OKE_SQLITE_URL ?? "file:.oke/app.sqlite";
|
|
107
|
+
return `sqlite ${redactConnectionTarget(url)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run one seed category's functions in order, reporting tallies.
|
|
112
|
+
*
|
|
113
|
+
* @param category - Label (`essential` / `dev` / `prod`)
|
|
114
|
+
* @param fns - Ordered functions
|
|
115
|
+
* @param fx - Privileged fx
|
|
116
|
+
* @param write - Output
|
|
117
|
+
*/
|
|
118
|
+
export async function runSeedFns(
|
|
119
|
+
category: string,
|
|
120
|
+
fns: readonly SeedFn[],
|
|
121
|
+
fx: Fx,
|
|
122
|
+
write: (text: string) => void,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
for (let i = 0; i < fns.length; i++) {
|
|
125
|
+
const fn = fns[i]!;
|
|
126
|
+
const tally: SeedTally = { upserted: 0, changed: 0, alreadyExisted: 0 };
|
|
127
|
+
const instrumented = instrumentFxUpserts(fx, tally);
|
|
128
|
+
await fn(instrumented);
|
|
129
|
+
const label = fn.name.length > 0 ? fn.name : String(i);
|
|
130
|
+
write(
|
|
131
|
+
`oke db seed: ${category} ${label} — upserted ${tally.upserted} · changed ${tally.changed} · already-existed ${tally.alreadyExisted}\n`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Execute a seed def for `env` — essential always, then category block.
|
|
138
|
+
*
|
|
139
|
+
* @param def - Seed declaration
|
|
140
|
+
* @param env - Resolved env
|
|
141
|
+
* @param fx - Privileged fx
|
|
142
|
+
* @param write - Output
|
|
143
|
+
*/
|
|
144
|
+
export async function executeSeedDef(
|
|
145
|
+
def: SeedDef,
|
|
146
|
+
env: ConfigEnv,
|
|
147
|
+
fx: Fx,
|
|
148
|
+
write: (text: string) => void,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
await runSeedFns("essential", normalizeSeedFns(def.essential), fx, write);
|
|
151
|
+
const category = resolveSeedCategory(env);
|
|
152
|
+
if (category === "dev") {
|
|
153
|
+
await runSeedFns("dev", normalizeSeedFns(def.dev), fx, write);
|
|
154
|
+
} else if (category === "prod") {
|
|
155
|
+
await runSeedFns("prod", normalizeSeedFns(def.prod), fx, write);
|
|
156
|
+
} else {
|
|
157
|
+
write(`oke db seed: skip category (env ${env} runs essential only)\n`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Load `src/seed/index.ts` (or override) and return its SeedDef.
|
|
163
|
+
*
|
|
164
|
+
* @param cwd - Project root
|
|
165
|
+
* @param seedPath - Relative or absolute path
|
|
166
|
+
*/
|
|
167
|
+
export async function loadSeedDef(cwd: string, seedPath?: string): Promise<SeedDef> {
|
|
168
|
+
const abs = resolve(cwd, seedPath ?? "src/seed/index.ts");
|
|
169
|
+
if (!(await Bun.file(abs).exists())) {
|
|
170
|
+
throw new Error(`oke db seed: no seed module at ${abs}`);
|
|
171
|
+
}
|
|
172
|
+
const mod = (await import(abs)) as Record<string, unknown>;
|
|
173
|
+
const candidate = mod.default ?? mod.seed;
|
|
174
|
+
if (!isSeedDef(candidate)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`oke db seed: ${abs} must default-export defineSeed({ essential, dev?, prod? })`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return candidate;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Boot the app entry and return an open-capability fx over its store.
|
|
184
|
+
*
|
|
185
|
+
* @param cwd - Project root
|
|
186
|
+
* @param env - Active env
|
|
187
|
+
* @param entry - Optional entry override
|
|
188
|
+
* @param config - Loaded config
|
|
189
|
+
*/
|
|
190
|
+
export async function bootSeedFx(
|
|
191
|
+
cwd: string,
|
|
192
|
+
env: ConfigEnv,
|
|
193
|
+
entry?: string,
|
|
194
|
+
config?: OkeConfig | null,
|
|
195
|
+
): Promise<{ readonly fx: Fx; readonly stop: () => Promise<void> }> {
|
|
196
|
+
const entryAbs = await resolveAppEntryForPluginTables(cwd, entry ?? config?.db?.entry);
|
|
197
|
+
if (!entryAbs) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
"oke db seed: no app entry found — pass --entry or set package.json okengine.entry",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const mod = (await import(entryAbs)) as Record<string, unknown>;
|
|
203
|
+
const app = findBootableApp(mod);
|
|
204
|
+
if (!app) {
|
|
205
|
+
throw new Error(`oke db seed: no oke() app export in ${entryAbs}`);
|
|
206
|
+
}
|
|
207
|
+
await app.boot({ env, startScheduler: false, config: config ?? undefined });
|
|
208
|
+
const storeRuntime = app.elements?.store ?? app.bootResult?.store;
|
|
209
|
+
if (!storeRuntime) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"oke db seed: app booted without a store runtime — ensure stores are registered on the app (e.g. Object.assign(app.$options, { stores: [db] }))",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const fx = createFx({
|
|
215
|
+
flow: "oke.db.seed",
|
|
216
|
+
storeRuntime,
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
fx,
|
|
220
|
+
stop: async () => {
|
|
221
|
+
await app.stop();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Run `oke db seed`.
|
|
228
|
+
*
|
|
229
|
+
* @param options - Paths / injectables
|
|
230
|
+
*/
|
|
231
|
+
export async function runSeed(options: SeedOptions = {}): Promise<number> {
|
|
232
|
+
const write = options.write ?? ((t) => process.stdout.write(t));
|
|
233
|
+
const cwd = options.cwd ?? process.cwd();
|
|
234
|
+
const loaded = await loadOkeConfig(cwd).catch(() => null);
|
|
235
|
+
const env = options.env ?? (await resolveDevSqlEnv(cwd));
|
|
236
|
+
|
|
237
|
+
let targetLabel = String(env);
|
|
238
|
+
try {
|
|
239
|
+
const { dialect, overlay } = await resolveDrizzleKitEnv(cwd, loaded?.config, env);
|
|
240
|
+
targetLabel = formatSeedTarget(overlay, dialect);
|
|
241
|
+
} catch {
|
|
242
|
+
/* target display is best-effort */
|
|
243
|
+
}
|
|
244
|
+
write(`oke db seed: env ${env} → ${targetLabel}\n`);
|
|
245
|
+
|
|
246
|
+
if (env === "docker" || env === "prod") {
|
|
247
|
+
if (options.force !== true) {
|
|
248
|
+
const tty = options.stdinIsTTY ?? Boolean(process.stdin.isTTY);
|
|
249
|
+
const confirm =
|
|
250
|
+
options.confirmEnv ??
|
|
251
|
+
(tty
|
|
252
|
+
? (e: ConfigEnv, t: string) => promptSeedConfirm(e, t, write)
|
|
253
|
+
: async () => {
|
|
254
|
+
write(
|
|
255
|
+
`oke db seed: non-interactive ${env} requires --force (target ${targetLabel})\n`,
|
|
256
|
+
);
|
|
257
|
+
return false;
|
|
258
|
+
});
|
|
259
|
+
const ok = await confirm(env, targetLabel);
|
|
260
|
+
if (!ok) {
|
|
261
|
+
write("oke db seed: cancelled\n");
|
|
262
|
+
return EXIT_USAGE;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let stop: (() => Promise<void>) | undefined;
|
|
268
|
+
try {
|
|
269
|
+
const def = options.seedDef ?? (await loadSeedDef(cwd, options.seedPath));
|
|
270
|
+
const session = options.createFx
|
|
271
|
+
? await options.createFx()
|
|
272
|
+
: await bootSeedFx(cwd, env, options.entry, loaded?.config);
|
|
273
|
+
stop = session.stop;
|
|
274
|
+
await executeSeedDef(def, env, session.fx, write);
|
|
275
|
+
write("oke db seed: ok\n");
|
|
276
|
+
return EXIT_OK;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
write(`oke db seed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
279
|
+
return EXIT_RUNTIME;
|
|
280
|
+
} finally {
|
|
281
|
+
if (stop) await stop().catch(() => {});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Interactive confirm — operator must type the literal env name.
|
|
287
|
+
*
|
|
288
|
+
* @param env - Env name to type
|
|
289
|
+
* @param target - Displayed connection target
|
|
290
|
+
* @param write - Output
|
|
291
|
+
*/
|
|
292
|
+
async function promptSeedConfirm(
|
|
293
|
+
env: ConfigEnv,
|
|
294
|
+
target: string,
|
|
295
|
+
write: (text: string) => void,
|
|
296
|
+
): Promise<boolean> {
|
|
297
|
+
write(`oke db seed: confirm target ${target}\n`);
|
|
298
|
+
const answer = await text({
|
|
299
|
+
message: `Type "${env}" to seed this database`,
|
|
300
|
+
validate(value) {
|
|
301
|
+
if (value === env) return undefined;
|
|
302
|
+
return `Type ${JSON.stringify(env)} exactly to confirm`;
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
if (isCancel(answer)) return false;
|
|
306
|
+
return answer === env;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function findBootableApp(mod: Record<string, unknown>): OkeApp | undefined {
|
|
310
|
+
const plugged = findAppWithPlugins(mod);
|
|
311
|
+
if (plugged && isBootableApp(plugged)) return plugged as OkeApp;
|
|
312
|
+
for (const key of ["app", "default", ...Object.keys(mod)]) {
|
|
313
|
+
const value = mod[key];
|
|
314
|
+
if (isBootableApp(value)) return value as OkeApp;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isBootableApp(value: unknown): value is OkeApp {
|
|
320
|
+
if (!value || typeof value !== "object") return false;
|
|
321
|
+
const o = value as { boot?: unknown; stop?: unknown };
|
|
322
|
+
return typeof o.boot === "function" && typeof o.stop === "function";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function instrumentFxUpserts(fx: Fx, tally: SeedTally): Fx {
|
|
326
|
+
const originalStore = fx.store.bind(fx);
|
|
327
|
+
return new Proxy(fx, {
|
|
328
|
+
get(target, prop, receiver) {
|
|
329
|
+
if (prop === "store") {
|
|
330
|
+
return (ref: Parameters<Fx["store"]>[0]) => {
|
|
331
|
+
const handle = originalStore(ref);
|
|
332
|
+
return instrumentUpsertHandle(handle, tally);
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return Reflect.get(target, prop, receiver);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function instrumentUpsertHandle<T>(handle: T, tally: SeedTally): T {
|
|
341
|
+
if (!handle || typeof handle !== "object") return handle;
|
|
342
|
+
const h = handle as { upsert?: (...args: unknown[]) => Promise<{ status: UpsertStatus }> };
|
|
343
|
+
if (typeof h.upsert !== "function") return handle;
|
|
344
|
+
const orig = h.upsert.bind(h);
|
|
345
|
+
return new Proxy(handle as object, {
|
|
346
|
+
get(target, prop, receiver) {
|
|
347
|
+
if (prop === "upsert") {
|
|
348
|
+
return async (...args: unknown[]) => {
|
|
349
|
+
const result = await orig(...args);
|
|
350
|
+
if (result.status === "upserted") tally.upserted += 1;
|
|
351
|
+
else if (result.status === "changed") tally.changed += 1;
|
|
352
|
+
else if (result.status === "already-existed") tally.alreadyExisted += 1;
|
|
353
|
+
return result;
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return Reflect.get(target, prop, receiver);
|
|
357
|
+
},
|
|
358
|
+
}) as T;
|
|
359
|
+
}
|