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
|
@@ -4,48 +4,55 @@
|
|
|
4
4
|
* Args:
|
|
5
5
|
* tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]
|
|
6
6
|
* hold-lease <storePath> <instanceId> <markerPath> <leaseMs>
|
|
7
|
+
* tick-loop-pg <databaseUrl> <instanceId> <fireLogPath> <leaseMs> [every]
|
|
8
|
+
* hold-lease-pg <databaseUrl> <instanceId> <markerPath> <leaseMs>
|
|
7
9
|
* durable-mid-step <journalPath> <markerPath> <runIdPath>
|
|
10
|
+
* journal-pg-start <databaseUrl> <instanceId> <stepLogPath> <markerPath> <leaseMs> <blockMs>
|
|
11
|
+
* journal-pg-resume <databaseUrl> <instanceId> <stepLogPath> <donePath> <leaseMs>
|
|
12
|
+
* journal-pg-park <databaseUrl> <instanceId> <stepLogPath> <parkedPath> <wakeMs>
|
|
13
|
+
* journal-pg-claim <databaseUrl> <instanceId> <stepLogPath> <leaseMs>
|
|
8
14
|
*
|
|
9
15
|
* Modes:
|
|
10
|
-
* tick-loop
|
|
11
|
-
* hold-lease
|
|
12
|
-
* durable-mid-step
|
|
16
|
+
* tick-loop / tick-loop-pg — reconcile + tick until this instance fires once
|
|
17
|
+
* hold-lease / hold-lease-pg — acquire lease, write marker, hang (parent SIGKILLs)
|
|
18
|
+
* durable-mid-step — journal step 1, write marker + runId, hang
|
|
19
|
+
* journal-pg-start — boot oke() app, start durable run over HTTP, hang (parent SIGKILLs)
|
|
20
|
+
* journal-pg-resume — boot oke() app; orphan sweep resumes the dead run; exit when done
|
|
21
|
+
* journal-pg-park — boot oke() app, park a durable sleep over HTTP, exit
|
|
22
|
+
* journal-pg-claim — boot oke() app; race claimDueSleep; exit 0 on own execution
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
25
|
import { join } from "node:path";
|
|
16
26
|
|
|
17
|
-
import {
|
|
27
|
+
import { createPostgresCronStore } from "../../drivers/clock-postgres.ts";
|
|
28
|
+
import { oke, type OkeApp } from "../../kernel/app.ts";
|
|
29
|
+
import { flow, type AnyFlowDef } from "../../kernel/flow.ts";
|
|
18
30
|
import { createFileJournalStore } from "../../kernel/journal.ts";
|
|
31
|
+
import { on } from "../../kernel/on.ts";
|
|
32
|
+
import { http } from "../../kernel/triggers.ts";
|
|
19
33
|
import { clock } from "./declare.ts";
|
|
20
34
|
import { runDurable } from "./durable.ts";
|
|
21
|
-
import { createFileCronStore } from "./reconcile.ts";
|
|
35
|
+
import { createFileCronStore, type CronStore } from "./reconcile.ts";
|
|
22
36
|
import { createClockRuntime } from "./runtime.ts";
|
|
23
37
|
|
|
24
38
|
const mode = process.argv[2];
|
|
25
39
|
|
|
26
40
|
if (!mode) {
|
|
27
|
-
console.error(
|
|
41
|
+
console.error(
|
|
42
|
+
"usage: chaos-child <tick-loop|hold-lease|tick-loop-pg|hold-lease-pg|durable-mid-step> …",
|
|
43
|
+
);
|
|
28
44
|
process.exit(2);
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
const CRON_NAME = "chaos-job";
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const every = process.argv[7] ?? "1h";
|
|
41
|
-
if (!storePath || !instanceId || !fireLogPath) {
|
|
42
|
-
console.error(
|
|
43
|
-
"usage: chaos-child tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]",
|
|
44
|
-
);
|
|
45
|
-
process.exit(2);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const store = createFileCronStore(join(storePath));
|
|
49
|
+
async function runTickLoop(
|
|
50
|
+
store: CronStore,
|
|
51
|
+
instanceId: string,
|
|
52
|
+
fireLogPath: string,
|
|
53
|
+
leaseMs: number,
|
|
54
|
+
every: string,
|
|
55
|
+
) {
|
|
49
56
|
const rt = createClockRuntime({
|
|
50
57
|
instanceId,
|
|
51
58
|
store,
|
|
@@ -69,17 +76,12 @@ if (mode === "tick-loop") {
|
|
|
69
76
|
process.exit(3);
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.error("usage: chaos-child hold-lease <storePath> <instanceId> <markerPath> <leaseMs>");
|
|
79
|
-
process.exit(2);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const store = createFileCronStore(join(storePath));
|
|
79
|
+
async function runHoldLease(
|
|
80
|
+
store: CronStore,
|
|
81
|
+
instanceId: string,
|
|
82
|
+
markerPath: string,
|
|
83
|
+
leaseMs: number,
|
|
84
|
+
) {
|
|
83
85
|
const rt = createClockRuntime({
|
|
84
86
|
instanceId,
|
|
85
87
|
store,
|
|
@@ -88,7 +90,6 @@ if (mode === "hold-lease") {
|
|
|
88
90
|
rt.register(clock(CRON_NAME, { every: "50ms" }));
|
|
89
91
|
await rt.reconcile();
|
|
90
92
|
|
|
91
|
-
// Fire once so lastRunAt + lease are set (this instance is leader).
|
|
92
93
|
rt.onCron(CRON_NAME, () => {});
|
|
93
94
|
const ok = await rt.runNow(CRON_NAME);
|
|
94
95
|
if (!ok) {
|
|
@@ -104,12 +105,61 @@ if (mode === "hold-lease") {
|
|
|
104
105
|
leaderLeaseUntil: row?.leaderLeaseUntil,
|
|
105
106
|
}),
|
|
106
107
|
);
|
|
107
|
-
// Hang while holding the lease — parent will SIGKILL.
|
|
108
108
|
await Bun.sleep(60_000);
|
|
109
109
|
process.exit(0);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
if (mode === "
|
|
112
|
+
if (mode === "tick-loop") {
|
|
113
|
+
const storePath = process.argv[3];
|
|
114
|
+
const instanceId = process.argv[4];
|
|
115
|
+
const fireLogPath = process.argv[5];
|
|
116
|
+
const leaseMs = Number(process.argv[6] ?? 100);
|
|
117
|
+
const every = process.argv[7] ?? "1h";
|
|
118
|
+
if (!storePath || !instanceId || !fireLogPath) {
|
|
119
|
+
console.error(
|
|
120
|
+
"usage: chaos-child tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]",
|
|
121
|
+
);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
await runTickLoop(createFileCronStore(join(storePath)), instanceId, fireLogPath, leaseMs, every);
|
|
125
|
+
} else if (mode === "hold-lease") {
|
|
126
|
+
const storePath = process.argv[3];
|
|
127
|
+
const instanceId = process.argv[4];
|
|
128
|
+
const markerPath = process.argv[5];
|
|
129
|
+
const leaseMs = Number(process.argv[6] ?? 100);
|
|
130
|
+
if (!storePath || !instanceId || !markerPath) {
|
|
131
|
+
console.error("usage: chaos-child hold-lease <storePath> <instanceId> <markerPath> <leaseMs>");
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
await runHoldLease(createFileCronStore(join(storePath)), instanceId, markerPath, leaseMs);
|
|
135
|
+
} else if (mode === "tick-loop-pg") {
|
|
136
|
+
const databaseUrl = process.argv[3];
|
|
137
|
+
const instanceId = process.argv[4];
|
|
138
|
+
const fireLogPath = process.argv[5];
|
|
139
|
+
const leaseMs = Number(process.argv[6] ?? 100);
|
|
140
|
+
const every = process.argv[7] ?? "1h";
|
|
141
|
+
if (!databaseUrl || !instanceId || !fireLogPath) {
|
|
142
|
+
console.error(
|
|
143
|
+
"usage: chaos-child tick-loop-pg <databaseUrl> <instanceId> <fireLogPath> <leaseMs> [every]",
|
|
144
|
+
);
|
|
145
|
+
process.exit(2);
|
|
146
|
+
}
|
|
147
|
+
const store = await createPostgresCronStore({ url: databaseUrl });
|
|
148
|
+
await runTickLoop(store, instanceId, fireLogPath, leaseMs, every);
|
|
149
|
+
} else if (mode === "hold-lease-pg") {
|
|
150
|
+
const databaseUrl = process.argv[3];
|
|
151
|
+
const instanceId = process.argv[4];
|
|
152
|
+
const markerPath = process.argv[5];
|
|
153
|
+
const leaseMs = Number(process.argv[6] ?? 100);
|
|
154
|
+
if (!databaseUrl || !instanceId || !markerPath) {
|
|
155
|
+
console.error(
|
|
156
|
+
"usage: chaos-child hold-lease-pg <databaseUrl> <instanceId> <markerPath> <leaseMs>",
|
|
157
|
+
);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
const store = await createPostgresCronStore({ url: databaseUrl });
|
|
161
|
+
await runHoldLease(store, instanceId, markerPath, leaseMs);
|
|
162
|
+
} else if (mode === "durable-mid-step") {
|
|
113
163
|
const journalPath = process.argv[3];
|
|
114
164
|
const markerPath = process.argv[4];
|
|
115
165
|
const runIdPath = process.argv[5];
|
|
@@ -124,14 +174,12 @@ if (mode === "durable-mid-step") {
|
|
|
124
174
|
durable: true,
|
|
125
175
|
do: async (_input, fx) => {
|
|
126
176
|
const intent = await fx.step("create-intent", async () => {
|
|
127
|
-
// Persist run id as soon as the step body runs (session already started).
|
|
128
177
|
const runs = await journalStore.list();
|
|
129
178
|
const run = runs[0];
|
|
130
179
|
if (run) await Bun.write(runIdPath, run.id);
|
|
131
180
|
await Bun.write(markerPath, "step1");
|
|
132
181
|
return { id: "pi_chaos" };
|
|
133
182
|
});
|
|
134
|
-
// Hang after journaling step 1 — parent SIGKILLs before confirm.
|
|
135
183
|
await Bun.sleep(60_000);
|
|
136
184
|
return fx.step("confirm", () => intent.id === "pi_chaos");
|
|
137
185
|
},
|
|
@@ -142,9 +190,200 @@ if (mode === "durable-mid-step") {
|
|
|
142
190
|
input: { orderId: "o-chaos" },
|
|
143
191
|
journalStore,
|
|
144
192
|
});
|
|
145
|
-
// Unreachable under SIGKILL.
|
|
146
193
|
process.exit(0);
|
|
194
|
+
} else if (mode === "journal-pg-start") {
|
|
195
|
+
const [databaseUrl, instanceId, stepLogPath, markerPath] = process.argv.slice(3);
|
|
196
|
+
const leaseMs = Number(process.argv[7] ?? 300);
|
|
197
|
+
const blockMs = Number(process.argv[8] ?? 60_000);
|
|
198
|
+
if (!databaseUrl || !instanceId || !stepLogPath || !markerPath) {
|
|
199
|
+
console.error(
|
|
200
|
+
"usage: chaos-child journal-pg-start <databaseUrl> <instanceId> <stepLogPath> <markerPath> <leaseMs> <blockMs>",
|
|
201
|
+
);
|
|
202
|
+
process.exit(2);
|
|
203
|
+
}
|
|
204
|
+
const app = await bootJournalPgApp({
|
|
205
|
+
url: databaseUrl!,
|
|
206
|
+
instanceId: instanceId!,
|
|
207
|
+
leaseMs,
|
|
208
|
+
flow: chargeFlow(instanceId!, stepLogPath!, blockMs),
|
|
209
|
+
});
|
|
210
|
+
void app.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
|
|
211
|
+
// Marker once step 1 is journaled — then hang; the parent SIGKILLs mid-run.
|
|
212
|
+
const deadline = Date.now() + 15_000;
|
|
213
|
+
for (;;) {
|
|
214
|
+
const row = (await app.bootResult!.journal!.store.list())[0];
|
|
215
|
+
if (row && row.entries.length >= 1) {
|
|
216
|
+
await Bun.write(markerPath!, JSON.stringify({ runId: row.id, instanceId }));
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (Date.now() > deadline) {
|
|
220
|
+
console.error("journal-pg-start: step 1 never journaled");
|
|
221
|
+
process.exit(3);
|
|
222
|
+
}
|
|
223
|
+
await Bun.sleep(10);
|
|
224
|
+
}
|
|
225
|
+
await Bun.sleep(3_600_000);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
} else if (mode === "journal-pg-resume") {
|
|
228
|
+
const [databaseUrl, instanceId, stepLogPath, donePath] = process.argv.slice(3);
|
|
229
|
+
const leaseMs = Number(process.argv[7] ?? 300);
|
|
230
|
+
if (!databaseUrl || !instanceId || !stepLogPath || !donePath) {
|
|
231
|
+
console.error(
|
|
232
|
+
"usage: chaos-child journal-pg-resume <databaseUrl> <instanceId> <stepLogPath> <donePath> <leaseMs>",
|
|
233
|
+
);
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
// No fetch — the boot orphan scan / resume sweep must discover the dead run.
|
|
237
|
+
const app = await bootJournalPgApp({
|
|
238
|
+
url: databaseUrl!,
|
|
239
|
+
instanceId: instanceId!,
|
|
240
|
+
leaseMs,
|
|
241
|
+
flow: chargeFlow(instanceId!, stepLogPath!, 0),
|
|
242
|
+
});
|
|
243
|
+
const deadline = Date.now() + 20_000;
|
|
244
|
+
for (;;) {
|
|
245
|
+
await app.resumeDurable();
|
|
246
|
+
const row = (await app.bootResult!.journal!.store.list())[0];
|
|
247
|
+
if (row?.status === "completed") {
|
|
248
|
+
await Bun.write(donePath!, JSON.stringify({ runId: row.id, completedBy: instanceId }));
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
if (Date.now() > deadline) {
|
|
252
|
+
console.error("journal-pg-resume: orphan never resumed");
|
|
253
|
+
process.exit(3);
|
|
254
|
+
}
|
|
255
|
+
await Bun.sleep(25);
|
|
256
|
+
}
|
|
257
|
+
} else if (mode === "journal-pg-park") {
|
|
258
|
+
const [databaseUrl, instanceId, stepLogPath, parkedPath] = process.argv.slice(3);
|
|
259
|
+
const wakeMs = Number(process.argv[7] ?? 400);
|
|
260
|
+
if (!databaseUrl || !instanceId || !stepLogPath || !parkedPath) {
|
|
261
|
+
console.error(
|
|
262
|
+
"usage: chaos-child journal-pg-park <databaseUrl> <instanceId> <stepLogPath> <parkedPath> <wakeMs>",
|
|
263
|
+
);
|
|
264
|
+
process.exit(2);
|
|
265
|
+
}
|
|
266
|
+
const app = await bootJournalPgApp({
|
|
267
|
+
url: databaseUrl!,
|
|
268
|
+
instanceId: instanceId!,
|
|
269
|
+
leaseMs: 30_000,
|
|
270
|
+
flow: sleeperFlow(instanceId!, stepLogPath!, wakeMs),
|
|
271
|
+
});
|
|
272
|
+
const res = await app.fetch(
|
|
273
|
+
new Request("http://localhost/sleep", { method: "POST", body: "{}" }),
|
|
274
|
+
);
|
|
275
|
+
if (res.status !== 204) {
|
|
276
|
+
console.error(`journal-pg-park: expected 204 park, got ${res.status}`);
|
|
277
|
+
process.exit(3);
|
|
278
|
+
}
|
|
279
|
+
const row = (await app.bootResult!.journal!.store.list())[0];
|
|
280
|
+
await Bun.write(parkedPath!, JSON.stringify({ runId: row?.id, instanceId }));
|
|
281
|
+
process.exit(0);
|
|
282
|
+
} else if (mode === "journal-pg-claim") {
|
|
283
|
+
const [databaseUrl, instanceId, stepLogPath] = process.argv.slice(3);
|
|
284
|
+
const leaseMs = Number(process.argv[6] ?? 300);
|
|
285
|
+
if (!databaseUrl || !instanceId || !stepLogPath) {
|
|
286
|
+
console.error(
|
|
287
|
+
"usage: chaos-child journal-pg-claim <databaseUrl> <instanceId> <stepLogPath> <leaseMs>",
|
|
288
|
+
);
|
|
289
|
+
process.exit(2);
|
|
290
|
+
}
|
|
291
|
+
const app = await bootJournalPgApp({
|
|
292
|
+
url: databaseUrl!,
|
|
293
|
+
instanceId: instanceId!,
|
|
294
|
+
leaseMs,
|
|
295
|
+
flow: sleeperFlow(instanceId!, stepLogPath!, 400),
|
|
296
|
+
});
|
|
297
|
+
const deadline = Date.now() + 15_000;
|
|
298
|
+
for (;;) {
|
|
299
|
+
await app.resumeDurable();
|
|
300
|
+
if (await logHasStep(stepLogPath!, "confirm", instanceId!)) process.exit(0);
|
|
301
|
+
const row = (await app.bootResult!.journal!.store.list())[0];
|
|
302
|
+
if (row?.status === "completed") process.exit(3); // someone else claimed it
|
|
303
|
+
if (Date.now() > deadline) process.exit(3);
|
|
304
|
+
await Bun.sleep(25);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
console.error(`unknown mode: ${mode}`);
|
|
308
|
+
process.exit(2);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Append one JSON line to the shared step log. */
|
|
312
|
+
async function appendStepLog(
|
|
313
|
+
path: string,
|
|
314
|
+
entry: { instanceId: string; step: string; at: number },
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
|
|
317
|
+
await Bun.write(path, `${prev}${JSON.stringify(entry)}\n`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** True when the step log holds `step` executed by `instanceId`. */
|
|
321
|
+
async function logHasStep(path: string, step: string, instanceId: string): Promise<boolean> {
|
|
322
|
+
if (!(await Bun.file(path).exists())) return false;
|
|
323
|
+
const lines = (await Bun.file(path).text()).trim().split("\n").filter(Boolean);
|
|
324
|
+
return lines.some((l) => {
|
|
325
|
+
const e = JSON.parse(l) as { instanceId: string; step: string };
|
|
326
|
+
return e.step === step && e.instanceId === instanceId;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Durable charge flow: step 1 → block → step 2 (both logged). */
|
|
331
|
+
function chargeFlow(instanceId: string, stepLogPath: string, blockMs: number): AnyFlowDef {
|
|
332
|
+
return flow({
|
|
333
|
+
name: "chaos.journal.charge",
|
|
334
|
+
durable: true,
|
|
335
|
+
do: async (_input, fx) => {
|
|
336
|
+
const intent = await fx.step("create-intent", async () => {
|
|
337
|
+
await appendStepLog(stepLogPath, { instanceId, step: "create-intent", at: Date.now() });
|
|
338
|
+
return { id: "pi_chaos" };
|
|
339
|
+
});
|
|
340
|
+
if (blockMs > 0) await Bun.sleep(blockMs);
|
|
341
|
+
await fx.step("mid-flight", async () => {
|
|
342
|
+
await appendStepLog(stepLogPath, { instanceId, step: "mid-flight", at: Date.now() });
|
|
343
|
+
return intent.id === "pi_chaos";
|
|
344
|
+
});
|
|
345
|
+
return { ok: true };
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Durable sleeper flow: step 1 → sleep → confirm (steps logged). */
|
|
351
|
+
function sleeperFlow(instanceId: string, stepLogPath: string, wakeMs: number): AnyFlowDef {
|
|
352
|
+
return flow({
|
|
353
|
+
name: "chaos.journal.sleeper",
|
|
354
|
+
durable: true,
|
|
355
|
+
do: async (_input, fx) => {
|
|
356
|
+
await fx.step("create-intent", async () => {
|
|
357
|
+
await appendStepLog(stepLogPath, { instanceId, step: "create-intent", at: Date.now() });
|
|
358
|
+
return { id: "pi_chaos" };
|
|
359
|
+
});
|
|
360
|
+
await fx.clock.sleep("verify-window", `${wakeMs}ms`);
|
|
361
|
+
await fx.step("confirm", async () => {
|
|
362
|
+
await appendStepLog(stepLogPath, { instanceId, step: "confirm", at: Date.now() });
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
return { ok: true };
|
|
366
|
+
},
|
|
367
|
+
});
|
|
147
368
|
}
|
|
148
369
|
|
|
149
|
-
|
|
150
|
-
|
|
370
|
+
/** Boot a real `oke()` app against live postgres (drivers.journal). */
|
|
371
|
+
async function bootJournalPgApp(options: {
|
|
372
|
+
readonly url: string;
|
|
373
|
+
readonly instanceId: string;
|
|
374
|
+
readonly leaseMs: number;
|
|
375
|
+
readonly flow: AnyFlowDef;
|
|
376
|
+
}): Promise<OkeApp> {
|
|
377
|
+
process.env.DATABASE_URL = options.url;
|
|
378
|
+
on(http.post("/charge"), options.flow);
|
|
379
|
+
on(http.post("/sleep"), options.flow);
|
|
380
|
+
const app = oke({
|
|
381
|
+
name: `chaos-journal-${options.instanceId}`,
|
|
382
|
+
env: "test",
|
|
383
|
+
startScheduler: false,
|
|
384
|
+
gate: { unguardedHttp: "allow" },
|
|
385
|
+
config: { drivers: { journal: { test: "postgres" } } },
|
|
386
|
+
});
|
|
387
|
+
await app.boot({ instanceId: options.instanceId, journalLeaseMs: options.leaseMs });
|
|
388
|
+
return app;
|
|
389
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
createJournal,
|
|
14
14
|
isJournalSuspend,
|
|
15
15
|
type Journal,
|
|
16
|
+
type JournalLeaseOptions,
|
|
16
17
|
type JournalSession,
|
|
17
18
|
type JournalStore,
|
|
18
19
|
} from "../../kernel/journal.ts";
|
|
@@ -45,6 +46,11 @@ export interface RunDurableOptions {
|
|
|
45
46
|
readonly journalStore: JournalStore;
|
|
46
47
|
/** Resume an existing run id (crash recovery). */
|
|
47
48
|
readonly runId?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Run-level lease (SKIP LOCKED + lazy reclaim when the store supports it).
|
|
51
|
+
* Resume throws `JournalLeaseBusy` when another live instance holds the run.
|
|
52
|
+
*/
|
|
53
|
+
readonly lease?: JournalLeaseOptions;
|
|
48
54
|
/** Injectable clock. */
|
|
49
55
|
readonly now?: () => number;
|
|
50
56
|
/** Extra fx options (secrets, store runtime, …). */
|
|
@@ -63,6 +69,7 @@ export async function runDurable<O = unknown>(
|
|
|
63
69
|
const journal: Journal = createJournal({
|
|
64
70
|
store: options.journalStore,
|
|
65
71
|
now,
|
|
72
|
+
...(options.lease ? { lease: options.lease } : {}),
|
|
66
73
|
});
|
|
67
74
|
|
|
68
75
|
if (options.runId) {
|
|
@@ -52,8 +52,8 @@ export interface CronRow {
|
|
|
52
52
|
|
|
53
53
|
/** Store surface for reconciled crons. */
|
|
54
54
|
export interface CronStore {
|
|
55
|
-
/** Backing kind when known (`memory` · `file`). */
|
|
56
|
-
readonly kind?: "memory" | "file";
|
|
55
|
+
/** Backing kind when known (`memory` · `file` · `postgres`). */
|
|
56
|
+
readonly kind?: "memory" | "file" | "postgres";
|
|
57
57
|
/**
|
|
58
58
|
* @param name - Cron name
|
|
59
59
|
*/
|
|
@@ -44,9 +44,9 @@ export interface CreateClockRuntimeOptions {
|
|
|
44
44
|
export interface ClockRuntime {
|
|
45
45
|
/**
|
|
46
46
|
* Effective driver id selected at construction
|
|
47
|
-
* (`memory` · `file` · `frozen`).
|
|
47
|
+
* (`memory` · `file` · `postgres` · `frozen`).
|
|
48
48
|
*/
|
|
49
|
-
readonly driverId: "memory" | "file" | "frozen";
|
|
49
|
+
readonly driverId: "memory" | "file" | "postgres" | "frozen";
|
|
50
50
|
/** Instance id used for leases. */
|
|
51
51
|
readonly instanceId: string;
|
|
52
52
|
/** Cron store the scheduler reads. */
|
|
@@ -116,7 +116,9 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
116
116
|
? "frozen"
|
|
117
117
|
: store.kind === "file"
|
|
118
118
|
? "file"
|
|
119
|
-
: "
|
|
119
|
+
: store.kind === "postgres"
|
|
120
|
+
? "postgres"
|
|
121
|
+
: "memory";
|
|
120
122
|
|
|
121
123
|
async function fire(name: string): Promise<boolean> {
|
|
122
124
|
const row = await store.get(name);
|
package/src/elements/clock.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Clock element — time.
|
|
3
3
|
*
|
|
4
4
|
* Physics: cron · delay · timeout · durable sleep · TTL.
|
|
5
|
-
* Drivers (protocol-named): `memory` · `postgres`.
|
|
5
|
+
* Drivers (protocol-named): `memory` · `postgres` · `file` · `frozen`.
|
|
6
6
|
*
|
|
7
7
|
* Durability lives on the Flow (`durable: true`); the journal records every
|
|
8
8
|
* `fx` call. Crons leader-elect; schedules are reconciled into the Store at
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineSeed, normalizeSeedFns, resolveSeedCategory, type SeedFn } from "./seed.ts";
|
|
3
|
+
|
|
4
|
+
describe("defineSeed / resolveSeedCategory", () => {
|
|
5
|
+
test("defineSeed returns the same def", () => {
|
|
6
|
+
const fn: SeedFn = async () => {};
|
|
7
|
+
const def = defineSeed({ essential: fn, dev: [fn], prod: fn });
|
|
8
|
+
expect(def.essential).toBe(fn);
|
|
9
|
+
expect(def.dev).toEqual([fn]);
|
|
10
|
+
expect(def.prod).toBe(fn);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("normalizeSeedFns preserves array order", () => {
|
|
14
|
+
const a: SeedFn = async () => {};
|
|
15
|
+
const b: SeedFn = async () => {};
|
|
16
|
+
expect(normalizeSeedFns([a, b])).toEqual([a, b]);
|
|
17
|
+
expect(normalizeSeedFns(a)).toEqual([a]);
|
|
18
|
+
expect(normalizeSeedFns()).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("resolveSeedCategory matches the env matrix", () => {
|
|
22
|
+
expect(resolveSeedCategory("local")).toBe("dev");
|
|
23
|
+
expect(resolveSeedCategory("docker")).toBe("dev");
|
|
24
|
+
expect(resolveSeedCategory("prod")).toBe("prod");
|
|
25
|
+
expect(resolveSeedCategory("test")).toBe(null);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database seed declarations — `defineSeed({ essential, dev, prod })`.
|
|
3
|
+
*
|
|
4
|
+
* Seed never runs at boot; only via `oke db seed`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ConfigEnv } from "../../config/index.ts";
|
|
8
|
+
import type { Fx } from "../../kernel/fx.ts";
|
|
9
|
+
|
|
10
|
+
/** One seed function — receives a privileged `fx` with store access. */
|
|
11
|
+
export type SeedFn = (fx: Fx) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
/** Single function or ordered array (array order is execution order). */
|
|
14
|
+
export type SeedFns = SeedFn | readonly SeedFn[];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Seed categories for {@link defineSeed}.
|
|
18
|
+
*
|
|
19
|
+
* - `essential` — every environment, always
|
|
20
|
+
* - `dev` — `local` or `docker` only
|
|
21
|
+
* - `prod` — `prod` only
|
|
22
|
+
*/
|
|
23
|
+
export interface SeedDef {
|
|
24
|
+
readonly essential?: SeedFns;
|
|
25
|
+
readonly dev?: SeedFns;
|
|
26
|
+
readonly prod?: SeedFns;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Env-selected category that runs alongside `essential` (or neither). */
|
|
30
|
+
export type SeedCategory = "dev" | "prod";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Freeze a seed declaration for `src/seed/index.ts` default export.
|
|
34
|
+
*
|
|
35
|
+
* @param def - Essential / dev / prod function bags
|
|
36
|
+
*/
|
|
37
|
+
export function defineSeed(def: SeedDef): SeedDef {
|
|
38
|
+
return def;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a seed key to an ordered function list.
|
|
43
|
+
*
|
|
44
|
+
* @param fns - Single function, array, or omitted
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeSeedFns(fns?: SeedFns): SeedFn[] {
|
|
47
|
+
if (fns === undefined) return [];
|
|
48
|
+
if (typeof fns === "function") return [fns];
|
|
49
|
+
return [...fns];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which optional category runs for `env` (single source of truth).
|
|
54
|
+
*
|
|
55
|
+
* | env | category |
|
|
56
|
+
* | ------ | -------- |
|
|
57
|
+
* | local | dev |
|
|
58
|
+
* | docker | dev |
|
|
59
|
+
* | test | (none) |
|
|
60
|
+
* | prod | prod |
|
|
61
|
+
*
|
|
62
|
+
* @param env - Resolved {@link ConfigEnv}
|
|
63
|
+
*/
|
|
64
|
+
export function resolveSeedCategory(env: ConfigEnv): SeedCategory | null {
|
|
65
|
+
if (env === "local" || env === "docker") return "dev";
|
|
66
|
+
if (env === "prod") return "prod";
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -56,6 +56,7 @@ describe("SqlStoreHandle — no relational query surface (path b)", () => {
|
|
|
56
56
|
"findById",
|
|
57
57
|
"delete",
|
|
58
58
|
"exists",
|
|
59
|
+
"upsert",
|
|
59
60
|
"increment",
|
|
60
61
|
"raw",
|
|
61
62
|
"count",
|
|
@@ -133,3 +134,41 @@ describe("SqlStoreHandle — orderBy / limit select chain", () => {
|
|
|
133
134
|
await conn.close();
|
|
134
135
|
});
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
describe("SqlStoreHandle — upsert", () => {
|
|
139
|
+
test("default inserts once then already-existed without touching the row", async () => {
|
|
140
|
+
const { handle, conn } = await openHandle();
|
|
141
|
+
const first = await handle.upsert(
|
|
142
|
+
posts,
|
|
143
|
+
{ id: "welcome" },
|
|
144
|
+
{ id: "welcome", title: "Hello", createdAt: 1 },
|
|
145
|
+
);
|
|
146
|
+
expect(first.status).toBe("upserted");
|
|
147
|
+
|
|
148
|
+
const second = await handle.upsert(
|
|
149
|
+
posts,
|
|
150
|
+
{ id: "welcome" },
|
|
151
|
+
{ id: "welcome", title: "Changed", createdAt: 2 },
|
|
152
|
+
);
|
|
153
|
+
expect(second.status).toBe("already-existed");
|
|
154
|
+
|
|
155
|
+
const row = await handle.findById(posts, "welcome");
|
|
156
|
+
expect(row).toEqual({ id: "welcome", title: "Hello", createdAt: 1 });
|
|
157
|
+
await conn.close();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("onExisting update changes matched columns", async () => {
|
|
161
|
+
const { handle, conn } = await openHandle();
|
|
162
|
+
await handle.upsert(posts, { id: "n1" }, { id: "n1", title: "one", createdAt: 10 });
|
|
163
|
+
const updated = await handle.upsert(
|
|
164
|
+
posts,
|
|
165
|
+
{ id: "n1" },
|
|
166
|
+
{ id: "n1", title: "two", createdAt: 20 },
|
|
167
|
+
{ onExisting: "update" },
|
|
168
|
+
);
|
|
169
|
+
expect(updated.status).toBe("changed");
|
|
170
|
+
const row = await handle.findById(posts, "n1");
|
|
171
|
+
expect(row).toEqual({ id: "n1", title: "two", createdAt: 20 });
|
|
172
|
+
await conn.close();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -237,6 +237,21 @@ export interface SqlStoreHandle {
|
|
|
237
237
|
* @param idOrWhere - Primary key string, or column equality map
|
|
238
238
|
*/
|
|
239
239
|
exists(table: TableHandle | unknown, idOrWhere: string | WhereMap): Promise<boolean>;
|
|
240
|
+
/**
|
|
241
|
+
* Insert when no row matches `matchOn`; never touches an existing match
|
|
242
|
+
* unless `options.onExisting` is `"update"`.
|
|
243
|
+
*
|
|
244
|
+
* @param table - Table
|
|
245
|
+
* @param matchOn - Equality map or Drizzle condition identifying the row
|
|
246
|
+
* @param values - Row values for insert (and optional update)
|
|
247
|
+
* @param options - Per-call opt-in to update an existing match
|
|
248
|
+
*/
|
|
249
|
+
upsert(
|
|
250
|
+
table: TableHandle | unknown,
|
|
251
|
+
matchOn: WhereMap | unknown,
|
|
252
|
+
values: SqlRow,
|
|
253
|
+
options?: { readonly onExisting?: "update" },
|
|
254
|
+
): Promise<UpsertResult>;
|
|
240
255
|
/**
|
|
241
256
|
* Atomically add `by` to `column` on the PK row.
|
|
242
257
|
*
|
|
@@ -276,6 +291,14 @@ export interface SqlStoreHandle {
|
|
|
276
291
|
ensureTable(table: TableHandle): Promise<void>;
|
|
277
292
|
}
|
|
278
293
|
|
|
294
|
+
/** Outcome of {@link SqlStoreHandle.upsert}. */
|
|
295
|
+
export type UpsertStatus = "upserted" | "changed" | "already-existed";
|
|
296
|
+
|
|
297
|
+
/** Result envelope for {@link SqlStoreHandle.upsert}. */
|
|
298
|
+
export interface UpsertResult {
|
|
299
|
+
readonly status: UpsertStatus;
|
|
300
|
+
}
|
|
301
|
+
|
|
279
302
|
/** Options for {@link SqlStoreHandle.page}. */
|
|
280
303
|
export interface SqlPageOptions {
|
|
281
304
|
/** Filter condition (equality map or Drizzle SQL). */
|
|
@@ -617,6 +640,38 @@ export function createSqlStoreHandle(
|
|
|
617
640
|
return rows.length > 0;
|
|
618
641
|
},
|
|
619
642
|
|
|
643
|
+
async upsert(table, matchOn, values, upsertOptions) {
|
|
644
|
+
await ensureFromMeta(table);
|
|
645
|
+
const name = resolveTableName(table);
|
|
646
|
+
const compiled = compileTableWhere(table, matchOn);
|
|
647
|
+
if (!compiled.clause) {
|
|
648
|
+
throw new Error("upsert() requires at least one matchOn predicate");
|
|
649
|
+
}
|
|
650
|
+
const found = await query(
|
|
651
|
+
`SELECT 1 AS "ok" FROM ${quoteIdent(name)} WHERE ${compiled.clause} LIMIT 1`,
|
|
652
|
+
compiled.params,
|
|
653
|
+
);
|
|
654
|
+
if (found.length === 0) {
|
|
655
|
+
const prepared = prepareInsertRow(table, values);
|
|
656
|
+
const cols = Object.keys(prepared);
|
|
657
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
658
|
+
const colList = cols.map(quoteIdent).join(", ");
|
|
659
|
+
const params = cols.map((c) => prepared[c]);
|
|
660
|
+
await exec(`INSERT INTO ${quoteIdent(name)} (${colList}) VALUES (${placeholders})`, params);
|
|
661
|
+
return { status: "upserted" as const };
|
|
662
|
+
}
|
|
663
|
+
if (upsertOptions?.onExisting !== "update") {
|
|
664
|
+
return { status: "already-existed" as const };
|
|
665
|
+
}
|
|
666
|
+
const prepared = prepareUpdateRow(table, values);
|
|
667
|
+
const setEntries = Object.entries(prepared);
|
|
668
|
+
if (setEntries.length === 0) return { status: "changed" as const };
|
|
669
|
+
const setSql = setEntries.map(([col]) => `${quoteIdent(col)} = ?`).join(", ");
|
|
670
|
+
const params = [...setEntries.map(([, v]) => v), ...compiled.params];
|
|
671
|
+
await exec(`UPDATE ${quoteIdent(name)} SET ${setSql} WHERE ${compiled.clause}`, params);
|
|
672
|
+
return { status: "changed" as const };
|
|
673
|
+
},
|
|
674
|
+
|
|
620
675
|
async increment(table, idValue, column, by = 1) {
|
|
621
676
|
await ensureFromMeta(table);
|
|
622
677
|
const name = resolveTableName(table);
|