okengine 0.7.0 → 0.9.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 (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -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
+ }
@@ -0,0 +1,410 @@
1
+ /**
2
+ * `postgres` clock driver — multi-host CronStore via SKIP LOCKED + lease reclaim.
3
+ *
4
+ * Same concurrency physics as Signal's `once` delivery (`signal-postgres.ts`):
5
+ * claim with `FOR UPDATE SKIP LOCKED`; a crashed holder's lease is reclaimed
6
+ * lazily on the next claim attempt (no sweeper).
7
+ */
8
+
9
+ import type { CronRow, CronStatus, CronStore } from "../elements/clock/reconcile.ts";
10
+ import { toPostgresParams } from "./postgres.ts";
11
+
12
+ /** Row shape in `oke_crons` (lease columns mirror `oke_signal_messages`). */
13
+ interface CronDbRow {
14
+ name: string;
15
+ declared_cron: string | null;
16
+ declared_every: string | null;
17
+ override_cron: string | null;
18
+ override_every: string | null;
19
+ effective_cron: string | null;
20
+ effective_every: string | null;
21
+ timezone: string;
22
+ overridable: boolean | number;
23
+ status: string;
24
+ locked_by: string | null;
25
+ lease_expires_at: number | null;
26
+ last_run_at: number | null;
27
+ next_run_at: number | null;
28
+ dst_ambiguity: string | null;
29
+ }
30
+
31
+ /** Minimal SQL + transaction surface for the postgres cron store. */
32
+ export interface PostgresCronSql {
33
+ query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
34
+ exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
35
+ /**
36
+ * Run `fn` inside a transaction. Nested calls join the outer txn.
37
+ *
38
+ * @param fn - Body
39
+ */
40
+ begin<T>(fn: (sql: PostgresCronSql) => Promise<T>): Promise<T>;
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Claim a cron row for lease acquire / renew / reclaim.
46
+ *
47
+ * Claimable when unlocked, same holder renewing, or lease expired
48
+ * (lazy reclaim — matches Signal's once-claim predicate).
49
+ */
50
+ const CLAIM_LEASE_SQL = `SELECT * FROM oke_crons WHERE name=? AND ((locked_by IS NULL) OR (locked_by=?) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?)) FOR UPDATE SKIP LOCKED`;
51
+
52
+ /** Options for {@link createPostgresCronStore}. */
53
+ export interface CreatePostgresCronStoreOptions {
54
+ /** Postgres connection URL (Bun.SQL). Ignored when `sql` is injected. */
55
+ readonly url?: string;
56
+ /** Injected SQL surface (tests / fakes). */
57
+ readonly sql?: PostgresCronSql;
58
+ /** Injected Bun.SQL-compatible client. */
59
+ readonly client?: BunCronClient;
60
+ }
61
+
62
+ /** Minimal Bun.SQL surface used by the real driver. */
63
+ export interface BunCronClient {
64
+ unsafe(
65
+ sql: string,
66
+ values?: unknown[],
67
+ ): PromiseLike<Record<string, unknown>[] | { length: number; changes?: number }>;
68
+ begin<T>(fn: (tx: BunCronClient) => Promise<T> | T): Promise<T>;
69
+ close?(options?: { timeout?: number }): Promise<void>;
70
+ }
71
+
72
+ function wrapBunClient(client: BunCronClient): PostgresCronSql {
73
+ const api: PostgresCronSql = {
74
+ async query(sql, params = []) {
75
+ const pg = toPostgresParams(sql);
76
+ const result = await client.unsafe(pg, [...params]);
77
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
78
+ return Array.from(result as ArrayLike<Record<string, unknown>>);
79
+ },
80
+ async exec(sql, params = []) {
81
+ const pg = toPostgresParams(sql);
82
+ const result = await client.unsafe(pg, [...params]);
83
+ if (
84
+ result &&
85
+ typeof result === "object" &&
86
+ "changes" in result &&
87
+ typeof (result as { changes: unknown }).changes === "number"
88
+ ) {
89
+ return { changes: (result as { changes: number }).changes };
90
+ }
91
+ if (Array.isArray(result)) return { changes: result.length };
92
+ return { changes: 0 };
93
+ },
94
+ async begin(fn) {
95
+ return client.begin(async (tx) => fn(wrapBunClient(tx)));
96
+ },
97
+ async close() {
98
+ await client.close?.();
99
+ },
100
+ };
101
+ return api;
102
+ }
103
+
104
+ function strOrUndef(v: unknown): string | undefined {
105
+ if (v === undefined || v === null || v === "") return undefined;
106
+ return String(v);
107
+ }
108
+
109
+ function numOrUndef(v: unknown): number | undefined {
110
+ if (v === undefined || v === null || v === "") return undefined;
111
+ const n = Number(v);
112
+ return Number.isFinite(n) ? n : undefined;
113
+ }
114
+
115
+ function rowToCron(row: Record<string, unknown>): CronRow {
116
+ const dstRaw = row.dst_ambiguity;
117
+ let dstAmbiguity: CronRow["dstAmbiguity"];
118
+ if (typeof dstRaw === "string" && dstRaw.trim()) {
119
+ dstAmbiguity = JSON.parse(dstRaw) as CronRow["dstAmbiguity"];
120
+ }
121
+ return {
122
+ name: String(row.name),
123
+ declaredCron: strOrUndef(row.declared_cron),
124
+ declaredEvery: strOrUndef(row.declared_every),
125
+ overrideCron: strOrUndef(row.override_cron),
126
+ overrideEvery: strOrUndef(row.override_every),
127
+ effectiveCron: strOrUndef(row.effective_cron),
128
+ effectiveEvery: strOrUndef(row.effective_every),
129
+ timezone: String(row.timezone ?? "UTC"),
130
+ overridable: Boolean(row.overridable),
131
+ status: String(row.status) as CronStatus,
132
+ leaderInstanceId: strOrUndef(row.locked_by),
133
+ leaderLeaseUntil: numOrUndef(row.lease_expires_at),
134
+ lastRunAt: numOrUndef(row.last_run_at),
135
+ nextRunAt: numOrUndef(row.next_run_at),
136
+ dstAmbiguity,
137
+ };
138
+ }
139
+
140
+ function cronToParams(row: CronRow): unknown[] {
141
+ return [
142
+ row.name,
143
+ row.declaredCron ?? null,
144
+ row.declaredEvery ?? null,
145
+ row.overrideCron ?? null,
146
+ row.overrideEvery ?? null,
147
+ row.effectiveCron ?? null,
148
+ row.effectiveEvery ?? null,
149
+ row.timezone,
150
+ row.overridable,
151
+ row.status,
152
+ row.leaderInstanceId ?? null,
153
+ row.leaderLeaseUntil ?? null,
154
+ row.lastRunAt ?? null,
155
+ row.nextRunAt ?? null,
156
+ row.dstAmbiguity ? JSON.stringify(row.dstAmbiguity) : null,
157
+ ];
158
+ }
159
+
160
+ async function ensureSchema(sql: PostgresCronSql): Promise<void> {
161
+ await sql.exec(`CREATE TABLE IF NOT EXISTS oke_crons (
162
+ name TEXT PRIMARY KEY,
163
+ declared_cron TEXT,
164
+ declared_every TEXT,
165
+ override_cron TEXT,
166
+ override_every TEXT,
167
+ effective_cron TEXT,
168
+ effective_every TEXT,
169
+ timezone TEXT NOT NULL,
170
+ overridable BOOLEAN NOT NULL DEFAULT FALSE,
171
+ status TEXT NOT NULL,
172
+ locked_by TEXT,
173
+ lease_expires_at BIGINT,
174
+ last_run_at BIGINT,
175
+ next_run_at BIGINT,
176
+ dst_ambiguity TEXT
177
+ )`);
178
+ }
179
+
180
+ /**
181
+ * In-memory Postgres-protocol fake with transactions + SKIP LOCKED for tests.
182
+ */
183
+ export function createPostgresCronFake(): PostgresCronSql & {
184
+ /** Force-kill mid-transaction (drops uncommitted state). */
185
+ killActiveTransaction(): void;
186
+ } {
187
+ type State = { rows: CronDbRow[] };
188
+
189
+ let committed: State = { rows: [] };
190
+ let active: { state: State; locked: Set<string>; done: boolean } | null = null;
191
+ /** Names held by other active transactions (SKIP LOCKED). */
192
+ const heldByTxn = new Set<string>();
193
+ /** Serialize top-level begins so concurrent acquires cannot join one txn. */
194
+ let beginGate: Promise<void> = Promise.resolve();
195
+
196
+ function view(): State {
197
+ return active?.state ?? committed;
198
+ }
199
+
200
+ function cloneState(s: State): State {
201
+ return { rows: s.rows.map((r) => ({ ...r })) };
202
+ }
203
+
204
+ const api: PostgresCronSql & { killActiveTransaction(): void } = {
205
+ killActiveTransaction() {
206
+ if (active) {
207
+ for (const name of active.locked) heldByTxn.delete(name);
208
+ active = null;
209
+ }
210
+ },
211
+ async query(sql, params = []) {
212
+ const text = sql.trim();
213
+ const state = view();
214
+
215
+ const isClaim = /FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) && /oke_crons/i.test(text);
216
+ if (isClaim) {
217
+ const name = String(params[0]);
218
+ const holder = String(params[1]);
219
+ const leaseCutoff = Number(params[2]);
220
+ if (heldByTxn.has(name) && !(active?.locked.has(name) ?? false)) {
221
+ return [];
222
+ }
223
+ const row = state.rows.find((r) => {
224
+ if (r.name !== name) return false;
225
+ const unlocked = r.locked_by === null;
226
+ const renew = r.locked_by === holder;
227
+ const expired = r.lease_expires_at !== null && r.lease_expires_at <= leaseCutoff;
228
+ return unlocked || renew || expired;
229
+ });
230
+ if (!row) return [];
231
+ if (active) {
232
+ active.locked.add(name);
233
+ heldByTxn.add(name);
234
+ }
235
+ return [{ ...row }];
236
+ }
237
+
238
+ const byName = /^SELECT\s+\*\s+FROM\s+oke_crons\s+WHERE\s+name\s*=\s*\?\s*$/i.exec(text);
239
+ if (byName) {
240
+ return state.rows.filter((r) => r.name === params[0]).map((r) => ({ ...r }));
241
+ }
242
+
243
+ const all = /^SELECT\s+\*\s+FROM\s+oke_crons\s*$/i.exec(text);
244
+ if (all) {
245
+ return state.rows.map((r) => ({ ...r }));
246
+ }
247
+
248
+ throw new Error(`postgres cron fake: unsupported query: ${sql}`);
249
+ },
250
+ async exec(sql, params = []) {
251
+ const text = sql.trim();
252
+ const state = view();
253
+
254
+ if (/^CREATE\s+TABLE/i.test(text)) return { changes: 0 };
255
+
256
+ const upsert =
257
+ /^INSERT\s+INTO\s+oke_crons\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*ON\s+CONFLICT\s*\(\s*name\s*\)\s*DO\s+UPDATE\s+SET\s+.+$/i.exec(
258
+ text,
259
+ );
260
+ if (upsert) {
261
+ const cols = upsert[1]!.split(",").map((c) => c.trim());
262
+ const record: Record<string, unknown> = {};
263
+ cols.forEach((c, i) => {
264
+ record[c] = params[i];
265
+ });
266
+ const next: CronDbRow = {
267
+ name: String(record.name),
268
+ declared_cron: (record.declared_cron as string | null) ?? null,
269
+ declared_every: (record.declared_every as string | null) ?? null,
270
+ override_cron: (record.override_cron as string | null) ?? null,
271
+ override_every: (record.override_every as string | null) ?? null,
272
+ effective_cron: (record.effective_cron as string | null) ?? null,
273
+ effective_every: (record.effective_every as string | null) ?? null,
274
+ timezone: String(record.timezone ?? "UTC"),
275
+ overridable: Boolean(record.overridable),
276
+ status: String(record.status),
277
+ locked_by: (record.locked_by as string | null) ?? null,
278
+ lease_expires_at:
279
+ record.lease_expires_at === undefined || record.lease_expires_at === null
280
+ ? null
281
+ : Number(record.lease_expires_at),
282
+ last_run_at:
283
+ record.last_run_at === undefined || record.last_run_at === null
284
+ ? null
285
+ : Number(record.last_run_at),
286
+ next_run_at:
287
+ record.next_run_at === undefined || record.next_run_at === null
288
+ ? null
289
+ : Number(record.next_run_at),
290
+ dst_ambiguity: (record.dst_ambiguity as string | null) ?? null,
291
+ };
292
+ const idx = state.rows.findIndex((r) => r.name === next.name);
293
+ if (idx >= 0) state.rows[idx] = next;
294
+ else state.rows.push(next);
295
+ return { changes: 1 };
296
+ }
297
+
298
+ const updLease =
299
+ /^UPDATE\s+oke_crons\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?\s+WHERE\s+name\s*=\s*\?\s*$/i.exec(
300
+ text,
301
+ );
302
+ if (updLease) {
303
+ const row = state.rows.find((r) => r.name === params[2]);
304
+ if (!row) return { changes: 0 };
305
+ row.locked_by = params[0] === null ? null : String(params[0]);
306
+ row.lease_expires_at =
307
+ params[1] === null || params[1] === undefined ? null : Number(params[1]);
308
+ return { changes: 1 };
309
+ }
310
+
311
+ throw new Error(`postgres cron fake: unsupported exec: ${sql}`);
312
+ },
313
+ async begin(fn) {
314
+ // Nested begin (same call stack) joins the open txn.
315
+ if (active && !active.done) {
316
+ return fn(api);
317
+ }
318
+ // Top-level begins serialize so Promise.all racing acquires stay exclusive.
319
+ let release!: () => void;
320
+ const slot = new Promise<void>((resolve) => {
321
+ release = resolve;
322
+ });
323
+ const prev = beginGate;
324
+ beginGate = slot;
325
+ await prev;
326
+ active = { state: cloneState(committed), locked: new Set(), done: false };
327
+ try {
328
+ const result = await fn(api);
329
+ if (active) {
330
+ for (const name of active.locked) heldByTxn.delete(name);
331
+ committed = active.state;
332
+ active.done = true;
333
+ active = null;
334
+ }
335
+ return result;
336
+ } catch (err) {
337
+ if (active) {
338
+ for (const name of active.locked) heldByTxn.delete(name);
339
+ }
340
+ active = null;
341
+ throw err;
342
+ } finally {
343
+ release();
344
+ }
345
+ },
346
+ async close() {
347
+ active = null;
348
+ heldByTxn.clear();
349
+ },
350
+ };
351
+
352
+ return api;
353
+ }
354
+
355
+ const UPSERT_SQL = `INSERT INTO oke_crons (name, declared_cron, declared_every, override_cron, override_every, effective_cron, effective_every, timezone, overridable, status, locked_by, lease_expires_at, last_run_at, next_run_at, dst_ambiguity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET declared_cron = EXCLUDED.declared_cron, declared_every = EXCLUDED.declared_every, override_cron = EXCLUDED.override_cron, override_every = EXCLUDED.override_every, effective_cron = EXCLUDED.effective_cron, effective_every = EXCLUDED.effective_every, timezone = EXCLUDED.timezone, overridable = EXCLUDED.overridable, status = EXCLUDED.status, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, last_run_at = EXCLUDED.last_run_at, next_run_at = EXCLUDED.next_run_at, dst_ambiguity = EXCLUDED.dst_ambiguity`;
356
+
357
+ /**
358
+ * Open a postgres-backed CronStore (multi-host leader election).
359
+ *
360
+ * @param options - URL / injected sql / Bun.SQL client
361
+ */
362
+ export async function createPostgresCronStore(
363
+ options: CreatePostgresCronStoreOptions = {},
364
+ ): Promise<CronStore & { readonly sql: PostgresCronSql; close(): Promise<void> }> {
365
+ const sql =
366
+ options.sql ??
367
+ wrapBunClient(
368
+ options.client ??
369
+ (new Bun.SQL(
370
+ options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke",
371
+ ) as unknown as BunCronClient),
372
+ );
373
+
374
+ await ensureSchema(sql);
375
+
376
+ const store: CronStore & { readonly sql: PostgresCronSql; close(): Promise<void> } = {
377
+ kind: "postgres",
378
+ sql,
379
+ async get(name) {
380
+ const rows = await sql.query(`SELECT * FROM oke_crons WHERE name = ?`, [name]);
381
+ if (!rows[0]) return undefined;
382
+ return rowToCron(rows[0]);
383
+ },
384
+ async put(row) {
385
+ await sql.exec(UPSERT_SQL, cronToParams(row));
386
+ },
387
+ async list() {
388
+ const rows = await sql.query(`SELECT * FROM oke_crons`);
389
+ return rows.map((r) => rowToCron(r));
390
+ },
391
+ async acquireLease(name, instanceId, now, leaseMs) {
392
+ return sql.begin(async (tx) => {
393
+ const claimed = await tx.query(CLAIM_LEASE_SQL, [name, instanceId, now]);
394
+ if (!claimed[0]) return false;
395
+ const until = now + leaseMs;
396
+ await tx.exec(`UPDATE oke_crons SET locked_by = ?, lease_expires_at = ? WHERE name = ?`, [
397
+ instanceId,
398
+ until,
399
+ name,
400
+ ]);
401
+ return true;
402
+ });
403
+ },
404
+ async close() {
405
+ await sql.close();
406
+ },
407
+ };
408
+
409
+ return store;
410
+ }
@@ -106,6 +106,23 @@ export {
106
106
  type PostgresSignalSql,
107
107
  } from "./signal-postgres.ts";
108
108
 
109
+ export {
110
+ createPostgresCronStore,
111
+ createPostgresCronFake,
112
+ type PostgresCronSql,
113
+ type CreatePostgresCronStoreOptions,
114
+ type BunCronClient,
115
+ } from "./clock-postgres.ts";
116
+
117
+ export {
118
+ createPostgresJournalStore,
119
+ createPostgresJournalFake,
120
+ type PostgresJournalSql,
121
+ type PostgresJournalStore,
122
+ type CreatePostgresJournalStoreOptions,
123
+ type BunJournalClient,
124
+ } from "./journal-postgres.ts";
125
+
109
126
  export {
110
127
  redisSignalDriver,
111
128
  openRedisSignal,
@@ -144,6 +161,7 @@ export { smtpChannelDriver, openSmtpChannel } from "./channel-smtp.ts";
144
161
  export { resendChannelDriver, openResendChannel } from "./channel-resend.ts";
145
162
  export { sndrChannelDriver, openSndrChannel } from "./channel-sndr.ts";
146
163
  export { taqnyatChannelDriver, openTaqnyatChannel } from "./channel-taqnyat.ts";
164
+ export { taqnyatMailChannelDriver, openTaqnyatMailChannel } from "./channel-taqnyat-mail.ts";
147
165
  export { msegatChannelDriver, openMsegatChannel } from "./channel-msegat.ts";
148
166
  export { unifonicChannelDriver, openUnifonicChannel } from "./channel-unifonic.ts";
149
167
  export { waCloudChannelDriver, openWaCloudChannel } from "./channel-wa-cloud.ts";