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.
Files changed (61) hide show
  1. package/package.json +2 -2
  2. package/site/content/docs/elements/channel.mdx +23 -12
  3. package/site/content/docs/elements/clock.mdx +17 -15
  4. package/site/content/docs/elements/flow.mdx +6 -2
  5. package/site/content/docs/elements/store.mdx +131 -0
  6. package/site/content/docs/get-started/installation.mdx +18 -16
  7. package/site/content/docs/plugins/magic-link.mdx +42 -0
  8. package/site/content/docs/plugins/phone-number.mdx +78 -17
  9. package/site/content/docs/plugins/two-factor.mdx +1 -0
  10. package/site/content/docs/reference/cli.md +2 -0
  11. package/site/content/docs/reference/configuration.mdx +5 -3
  12. package/site/content/docs/reference/environment-variables.mdx +20 -8
  13. package/src/cli/db-seed.ts +359 -0
  14. package/src/cli/db.test.ts +341 -3
  15. package/src/cli/db.ts +75 -8
  16. package/src/cli/load-config.images.test.ts +22 -0
  17. package/src/cli/load-config.ts +7 -2
  18. package/src/cli/registry.ts +37 -1
  19. package/src/compiler/effects-infer.ts +1 -0
  20. package/src/config/index.ts +4 -0
  21. package/src/drivers/channel-sently.test.ts +8 -0
  22. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  23. package/src/drivers/channel-types.ts +71 -0
  24. package/src/drivers/clock-postgres.test.ts +258 -0
  25. package/src/drivers/clock-postgres.ts +410 -0
  26. package/src/drivers/index.ts +18 -0
  27. package/src/drivers/journal-postgres.test.ts +175 -0
  28. package/src/drivers/journal-postgres.ts +492 -0
  29. package/src/elements/channel/runtime.ts +51 -0
  30. package/src/elements/channel.test.ts +71 -0
  31. package/src/elements/clock/chaos-child.ts +280 -41
  32. package/src/elements/clock/durable.ts +7 -0
  33. package/src/elements/clock/reconcile.ts +2 -2
  34. package/src/elements/clock/runtime.ts +5 -3
  35. package/src/elements/clock.ts +1 -1
  36. package/src/elements/store/seed.test.ts +27 -0
  37. package/src/elements/store/seed.ts +68 -0
  38. package/src/elements/store/sql-session.test.ts +39 -0
  39. package/src/elements/store/sql-session.ts +55 -0
  40. package/src/elements/store/upsert-app.test.ts +103 -0
  41. package/src/elements/store.ts +5 -0
  42. package/src/index.ts +15 -0
  43. package/src/kernel/app.ts +165 -14
  44. package/src/kernel/boot-bind/channel.test.ts +16 -0
  45. package/src/kernel/boot-bind/channel.ts +13 -0
  46. package/src/kernel/boot-bind/clock.ts +17 -6
  47. package/src/kernel/boot-bind/honor-config.test.ts +105 -4
  48. package/src/kernel/boot-bind/journal.ts +89 -0
  49. package/src/kernel/boot.test.ts +6 -4
  50. package/src/kernel/boot.ts +53 -13
  51. package/src/kernel/concurrency.ts +1 -1
  52. package/src/kernel/fx.test.ts +6 -0
  53. package/src/kernel/fx.ts +126 -5
  54. package/src/kernel/index.ts +6 -0
  55. package/src/kernel/journal-boot.test.ts +397 -0
  56. package/src/kernel/journal-suspend.ts +35 -0
  57. package/src/kernel/journal.test.ts +142 -0
  58. package/src/kernel/journal.ts +202 -27
  59. package/src/plugins/auth-methods.security.test.ts +10 -7
  60. package/src/plugins/phone-number.ts +67 -10
  61. package/src/plugins/taqnyat.live.test.ts +174 -0
@@ -7,6 +7,7 @@ import { openFcmChannel } from "./channel-fcm.ts";
7
7
  import { openMsegatChannel } from "./channel-msegat.ts";
8
8
  import { openSndrChannel } from "./channel-sndr.ts";
9
9
  import { openTaqnyatChannel } from "./channel-taqnyat.ts";
10
+ import { openTaqnyatMailChannel } from "./channel-taqnyat-mail.ts";
10
11
  import { openUnifonicChannel } from "./channel-unifonic.ts";
11
12
  import { openWaCloudChannel } from "./channel-wa-cloud.ts";
12
13
  import { openWebPushChannel } from "./channel-webpush.ts";
@@ -26,6 +27,13 @@ describe("sently channel drivers", () => {
26
27
  expect(d.channel?.mediums).toContain("sms");
27
28
  });
28
29
 
30
+ test("taqnyat-mail requires bearer + campaignName", () => {
31
+ expect(() => openTaqnyatMailChannel({ bearerToken: "t" })).toThrow("campaignName");
32
+ const d = openTaqnyatMailChannel({ bearerToken: "t", campaignName: "auth" });
33
+ expect(d.id).toBe("taqnyat-mail");
34
+ expect(d.transport?.provider).toBe("taqnyat-mail");
35
+ });
36
+
29
37
  test("msegat requires userName + apiKey + sender", () => {
30
38
  expect(() => openMsegatChannel({ userName: "u", apiKey: "k" })).toThrow("sender");
31
39
  const d = openMsegatChannel({ userName: "u", apiKey: "k", sender: "Brand" });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `taqnyat-mail` channel driver — Email via sently's Taqnyat Mail transport.
3
+ *
4
+ * Additive email option (alongside smtp / resend / sndr). Taqnyat's
5
+ * `mailSend.php` requires a campaign name — set via options or
6
+ * `TAQNYAT_CAMPAIGN`.
7
+ */
8
+
9
+ import { TaqnyatMailTransport } from "sently/transports/taqnyat-mail";
10
+ import type { ChannelDriver, ChannelOpenOptions } from "./channel-types.ts";
11
+
12
+ /**
13
+ * Open a Taqnyat Email driver.
14
+ *
15
+ * @param options - `bearerToken`/`token`/`apiKey` + `campaignName`
16
+ */
17
+ export function openTaqnyatMailChannel(options: ChannelOpenOptions = {}): ChannelDriver {
18
+ const bearerToken = options.bearerToken ?? options.token ?? options.apiKey;
19
+ if (!bearerToken) {
20
+ throw new Error("taqnyat-mail channel: bearerToken (or token/apiKey) is required");
21
+ }
22
+ const campaignName = options.campaignName;
23
+ if (!campaignName) {
24
+ throw new Error("taqnyat-mail channel: campaignName is required");
25
+ }
26
+ const transport = new TaqnyatMailTransport({ bearerToken, campaignName });
27
+ return { id: "taqnyat-mail", transport };
28
+ }
29
+
30
+ /** Taqnyat Email driver factory. */
31
+ export const taqnyatMailChannelDriver = {
32
+ id: "taqnyat-mail" as const,
33
+ open: openTaqnyatMailChannel,
34
+ };
@@ -58,6 +58,74 @@ export interface WhatsAppTransport {
58
58
  close?(): Promise<void>;
59
59
  }
60
60
 
61
+ /**
62
+ * Provider-managed OTP vendor extra on an SMS transport (Taqnyat Verify).
63
+ * Kept off the base {@link SmsTransport} surface — only transports that expose
64
+ * these methods support Channel provider OTP.
65
+ */
66
+ export interface SmsOtpTransport {
67
+ sendOtp(options: ChannelOtpSendOptions): Promise<ChannelOtpSendResult>;
68
+ verifyOtp(options: ChannelOtpVerifyOptions): Promise<ChannelOtpVerifyResult>;
69
+ }
70
+
71
+ /** Options for {@link SmsOtpTransport.sendOtp} (Taqnyat Verify). */
72
+ export interface ChannelOtpSendOptions {
73
+ /** Recipient phone number (E.164). */
74
+ readonly to: string;
75
+ /** Unique id for this verification flow (required again on verify). */
76
+ readonly requestId: string;
77
+ /** Message language (`en` or `ar`). */
78
+ readonly lang?: "en" | "ar";
79
+ /** Optional note appended to the OTP SMS. */
80
+ readonly note?: string;
81
+ /** Sender id override. */
82
+ readonly from?: string;
83
+ }
84
+
85
+ /** Result of a successful provider OTP send. */
86
+ export interface ChannelOtpSendResult {
87
+ /** Echo of the {@link ChannelOtpSendOptions.requestId}. */
88
+ readonly requestId: string;
89
+ /** Recipient as passed in. */
90
+ readonly to: string;
91
+ /** Provider status code (`5` = code sent). */
92
+ readonly code: number;
93
+ /** Raw response body text. */
94
+ readonly response: string;
95
+ /** Provider identifier. */
96
+ readonly provider: string;
97
+ }
98
+
99
+ /** Options for {@link SmsOtpTransport.verifyOtp}. */
100
+ export interface ChannelOtpVerifyOptions {
101
+ /** Recipient phone number (same as send). */
102
+ readonly to: string;
103
+ /** Same {@link ChannelOtpSendOptions.requestId} used when sending. */
104
+ readonly requestId: string;
105
+ /** OTP code the user entered. */
106
+ readonly code: string;
107
+ /** Message language (`en` or `ar`). */
108
+ readonly lang?: "en" | "ar";
109
+ /** Sender id override. */
110
+ readonly from?: string;
111
+ /** Optional note. */
112
+ readonly note?: string;
113
+ }
114
+
115
+ /** Result of a successful provider OTP check (failures throw). */
116
+ export interface ChannelOtpVerifyResult {
117
+ /** Always `true` when the call resolves. */
118
+ readonly ok: true;
119
+ /** Provider status code (`10` = completed; `13`/`19` = already verified). */
120
+ readonly code: number;
121
+ /** Provider message when present. */
122
+ readonly message: string;
123
+ /** Raw response body text. */
124
+ readonly response: string;
125
+ /** Provider identifier. */
126
+ readonly provider: string;
127
+ }
128
+
61
129
  /** Sently-compatible push transport (structural). */
62
130
  export interface PushTransport {
63
131
  readonly provider?: string;
@@ -79,6 +147,7 @@ export type ChannelDriverId =
79
147
  | "resend"
80
148
  | "sndr"
81
149
  | "taqnyat"
150
+ | "taqnyat-mail"
82
151
  | "msegat"
83
152
  | "unifonic"
84
153
  | "wa-cloud"
@@ -183,6 +252,8 @@ export interface ChannelOpenOptions {
183
252
  readonly vapidPublicKey?: string;
184
253
  readonly vapidPrivateKey?: string;
185
254
  readonly vapidSubject?: string;
255
+ /** Taqnyat Email campaign name (required by `mailSend.php`). */
256
+ readonly campaignName?: string;
186
257
  /** Injected fetch for HTTP drivers. */
187
258
  readonly fetch?: typeof globalThis.fetch;
188
259
  /** Dev inbox sink for console driver. */
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Postgres CronStore — unit (fake) + live multi-process chaos.
3
+ *
4
+ * Live suite: set `OKE_TEST_POSTGRES_URL` (or `OKE_TEST_POSTGRES=1` + `DATABASE_URL`).
5
+ * Without a live Postgres the chaos describe skips visibly.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { clock } from "../elements/clock/declare.ts";
14
+ import { createClockRuntime } from "../elements/clock/runtime.ts";
15
+ import { createPostgresCronFake, createPostgresCronStore } from "./clock-postgres.ts";
16
+
17
+ const childPath = join(import.meta.dir, "../elements/clock/chaos-child.ts");
18
+
19
+ const LIVE_URL =
20
+ process.env.OKE_TEST_POSTGRES_URL?.trim() ||
21
+ (process.env.OKE_TEST_POSTGRES === "1"
22
+ ? (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.trim()
23
+ : undefined);
24
+
25
+ describe("postgres CronStore (fake)", () => {
26
+ test("put / get / list round-trip", async () => {
27
+ const store = await createPostgresCronStore({ sql: createPostgresCronFake() });
28
+ await store.put({
29
+ name: "job",
30
+ effectiveEvery: "1h",
31
+ timezone: "UTC",
32
+ overridable: false,
33
+ status: "active",
34
+ });
35
+ const row = await store.get("job");
36
+ expect(row?.name).toBe("job");
37
+ expect(row?.effectiveEvery).toBe("1h");
38
+ expect(await store.list()).toHaveLength(1);
39
+ await store.close();
40
+ });
41
+
42
+ test("acquireLease: exactly one of two racing claimants wins", async () => {
43
+ const fake = createPostgresCronFake();
44
+ const store = await createPostgresCronStore({ sql: fake });
45
+ await store.put({
46
+ name: "race",
47
+ effectiveEvery: "1h",
48
+ timezone: "UTC",
49
+ overridable: false,
50
+ status: "active",
51
+ });
52
+
53
+ const now = 1_000;
54
+ const leaseMs = 500;
55
+ // Overlap transactions: start both begins concurrently via fake's
56
+ // sequential begin — second joins only when nested; here we race
57
+ // two top-level acquires after seeding.
58
+ const a = store.acquireLease("race", "a", now, leaseMs);
59
+ const b = store.acquireLease("race", "b", now, leaseMs);
60
+ const [wa, wb] = await Promise.all([a, b]);
61
+ expect([wa, wb].filter(Boolean)).toHaveLength(1);
62
+
63
+ const row = await store.get("race");
64
+ expect(row?.leaderInstanceId).toBe(wa ? "a" : "b");
65
+ expect(row?.leaderLeaseUntil).toBe(now + leaseMs);
66
+ await store.close();
67
+ });
68
+
69
+ test("acquireLease: expired lease is reclaimed lazily (no sweeper)", async () => {
70
+ const store = await createPostgresCronStore({ sql: createPostgresCronFake() });
71
+ await store.put({
72
+ name: "reclaim",
73
+ effectiveEvery: "1h",
74
+ timezone: "UTC",
75
+ overridable: false,
76
+ status: "active",
77
+ leaderInstanceId: "dead",
78
+ leaderLeaseUntil: 100,
79
+ });
80
+
81
+ expect(await store.acquireLease("reclaim", "survivor", 100, 50)).toBe(true);
82
+ const row = await store.get("reclaim");
83
+ expect(row?.leaderInstanceId).toBe("survivor");
84
+ expect(row?.leaderLeaseUntil).toBe(150);
85
+ await store.close();
86
+ });
87
+
88
+ test("acquireLease: live lease blocks other instance", async () => {
89
+ const store = await createPostgresCronStore({ sql: createPostgresCronFake() });
90
+ await store.put({
91
+ name: "held",
92
+ effectiveEvery: "1h",
93
+ timezone: "UTC",
94
+ overridable: false,
95
+ status: "active",
96
+ });
97
+ expect(await store.acquireLease("held", "leader", 0, 1_000)).toBe(true);
98
+ expect(await store.acquireLease("held", "other", 100, 1_000)).toBe(false);
99
+ // Same holder may renew.
100
+ expect(await store.acquireLease("held", "leader", 100, 1_000)).toBe(true);
101
+ await store.close();
102
+ });
103
+
104
+ test("runtime: two instances fire a due tick once", async () => {
105
+ const store = await createPostgresCronStore({ sql: createPostgresCronFake() });
106
+ const fires: string[] = [];
107
+ const a = createClockRuntime({ instanceId: "a", store, leaseMs: 200 });
108
+ const b = createClockRuntime({ instanceId: "b", store, leaseMs: 200 });
109
+ a.register(clock("once", { every: "1h" }));
110
+ b.register(clock("once", { every: "1h" }));
111
+ await a.reconcile();
112
+ a.onCron("once", () => {
113
+ fires.push("a");
114
+ });
115
+ b.onCron("once", () => {
116
+ fires.push("b");
117
+ });
118
+ const [ra, rb] = await Promise.all([a.tick(), b.tick()]);
119
+ expect([...ra.ran, ...rb.ran].filter((n) => n === "once")).toHaveLength(1);
120
+ expect(fires).toHaveLength(1);
121
+ await store.close();
122
+ });
123
+ });
124
+
125
+ describe.skipIf(!LIVE_URL)("chaos — postgres CronStore multi-process", () => {
126
+ test("two OS processes fire exactly once (no shared filesystem)", async () => {
127
+ const url = LIVE_URL!;
128
+ const dir = await mkdtemp(join(tmpdir(), "oke-clock-pg-leader-"));
129
+ const fireLogPath = join(dir, "fires.jsonl");
130
+ const leaseMs = 200;
131
+ const schemaStore = await createPostgresCronStore({ url });
132
+ // Isolate this run from prior leftovers.
133
+ await schemaStore.sql.exec(`DELETE FROM oke_crons WHERE name = ?`, ["chaos-job"]);
134
+ await schemaStore.close();
135
+
136
+ try {
137
+ const a = Bun.spawn({
138
+ cmd: ["bun", childPath, "tick-loop-pg", url, "inst-a", fireLogPath, String(leaseMs), "1h"],
139
+ stdout: "pipe",
140
+ stderr: "pipe",
141
+ });
142
+ const b = Bun.spawn({
143
+ cmd: ["bun", childPath, "tick-loop-pg", url, "inst-b", fireLogPath, String(leaseMs), "1h"],
144
+ stdout: "pipe",
145
+ stderr: "pipe",
146
+ });
147
+
148
+ const deadline = Date.now() + 15_000;
149
+ while (Date.now() < deadline) {
150
+ if (await Bun.file(fireLogPath).exists()) {
151
+ const text = await Bun.file(fireLogPath).text();
152
+ if (text.trim().split("\n").filter(Boolean).length >= 1) break;
153
+ }
154
+ await Bun.sleep(20);
155
+ }
156
+ a.kill(9);
157
+ b.kill(9);
158
+ await Promise.all([a.exited, b.exited]);
159
+
160
+ expect(await Bun.file(fireLogPath).exists()).toBe(true);
161
+ const lines = (await Bun.file(fireLogPath).text()).trim().split("\n").filter(Boolean);
162
+ expect(lines).toHaveLength(1);
163
+ const fire = JSON.parse(lines[0]!) as { instanceId: string };
164
+ expect(["inst-a", "inst-b"]).toContain(fire.instanceId);
165
+ } finally {
166
+ await rm(dir, { recursive: true, force: true });
167
+ const cleanup = await createPostgresCronStore({ url });
168
+ await cleanup.sql.exec(`DELETE FROM oke_crons WHERE name = ?`, ["chaos-job"]);
169
+ await cleanup.close();
170
+ }
171
+ });
172
+
173
+ test("SIGKILL leader mid-lease: survivor takes over; report real latency", async () => {
174
+ const url = LIVE_URL!;
175
+ const dir = await mkdtemp(join(tmpdir(), "oke-clock-pg-takeover-"));
176
+ const markerPath = join(dir, "held.json");
177
+ const fireLogPath = join(dir, "fires.jsonl");
178
+ const leaseMs = 120;
179
+ const tickSlackMs = 400;
180
+
181
+ const schemaStore = await createPostgresCronStore({ url });
182
+ await schemaStore.sql.exec(`DELETE FROM oke_crons WHERE name = ?`, ["chaos-job"]);
183
+ await schemaStore.close();
184
+
185
+ async function waitForFile(path: string, timeoutMs = 15_000): Promise<boolean> {
186
+ const deadline = Date.now() + timeoutMs;
187
+ while (Date.now() < deadline) {
188
+ if (await Bun.file(path).exists()) return true;
189
+ await Bun.sleep(10);
190
+ }
191
+ return false;
192
+ }
193
+
194
+ try {
195
+ const leader = Bun.spawn({
196
+ cmd: ["bun", childPath, "hold-lease-pg", url, "leader", markerPath, String(leaseMs)],
197
+ stdout: "pipe",
198
+ stderr: "pipe",
199
+ });
200
+
201
+ expect(await waitForFile(markerPath)).toBe(true);
202
+ const held = (await Bun.file(markerPath).json()) as {
203
+ leaderLeaseUntil?: number;
204
+ heldAt: number;
205
+ };
206
+
207
+ const killAt = Date.now();
208
+ leader.kill(9);
209
+ await leader.exited;
210
+
211
+ const survivor = Bun.spawn({
212
+ cmd: [
213
+ "bun",
214
+ childPath,
215
+ "tick-loop-pg",
216
+ url,
217
+ "survivor",
218
+ fireLogPath,
219
+ String(leaseMs),
220
+ "50ms",
221
+ ],
222
+ stdout: "pipe",
223
+ stderr: "pipe",
224
+ });
225
+ expect(await survivor.exited).toBe(0);
226
+
227
+ expect(await Bun.file(fireLogPath).exists()).toBe(true);
228
+ const lines = (await Bun.file(fireLogPath).text()).trim().split("\n").filter(Boolean);
229
+ expect(lines).toHaveLength(1);
230
+ const fire = JSON.parse(lines[0]!) as { instanceId: string; firedAt: number };
231
+ expect(fire.instanceId).toBe("survivor");
232
+
233
+ const takeoverMs = fire.firedAt - killAt;
234
+ const leaseRemainingAtKill = Math.max(
235
+ 0,
236
+ (held.leaderLeaseUntil ?? killAt + leaseMs) - killAt,
237
+ );
238
+ expect(takeoverMs).toBeGreaterThanOrEqual(Math.max(0, leaseRemainingAtKill - 40));
239
+ expect(takeoverMs).toBeLessThanOrEqual(leaseMs + tickSlackMs);
240
+
241
+ console.log(
242
+ `[clock postgres takeover] measured=${takeoverMs}ms leaseMs=${leaseMs} leaseRemainingAtKill=${leaseRemainingAtKill}ms`,
243
+ );
244
+ } finally {
245
+ await rm(dir, { recursive: true, force: true });
246
+ const cleanup = await createPostgresCronStore({ url });
247
+ await cleanup.sql.exec(`DELETE FROM oke_crons WHERE name = ?`, ["chaos-job"]);
248
+ await cleanup.close();
249
+ }
250
+ });
251
+ });
252
+
253
+ // Visible skip reason when the live gate is off (describe.skipIf hides the body).
254
+ if (!LIVE_URL) {
255
+ console.log(
256
+ "skip: postgres CronStore chaos (set OKE_TEST_POSTGRES_URL or OKE_TEST_POSTGRES=1 + DATABASE_URL)",
257
+ );
258
+ }