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,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `postgres` journal driver — shared durable-run store via SKIP LOCKED + lease reclaim.
|
|
3
|
+
*
|
|
4
|
+
* Same concurrency physics as Signal's `once` delivery and Clock's postgres
|
|
5
|
+
* CronStore: claim with `FOR UPDATE SKIP LOCKED`; a crashed holder's lease is
|
|
6
|
+
* reclaimed lazily on the next claim attempt (no sweeper, no fencing token —
|
|
7
|
+
* completed journal steps replay instead of re-running).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
12
|
+
type JournalEntry,
|
|
13
|
+
type JournalLeaseStore,
|
|
14
|
+
type JournalRun,
|
|
15
|
+
type JournalStore,
|
|
16
|
+
} from "../kernel/journal.ts";
|
|
17
|
+
import { toPostgresParams } from "./postgres.ts";
|
|
18
|
+
|
|
19
|
+
/** Row shape in `oke_journal_runs` (lease columns mirror `oke_crons`). */
|
|
20
|
+
interface JournalDbRow {
|
|
21
|
+
id: string;
|
|
22
|
+
flow: string;
|
|
23
|
+
input: string | null;
|
|
24
|
+
status: string;
|
|
25
|
+
entries: string;
|
|
26
|
+
wake_at: number | null;
|
|
27
|
+
error: string | null;
|
|
28
|
+
output: string | null;
|
|
29
|
+
locked_by: string | null;
|
|
30
|
+
lease_expires_at: number | null;
|
|
31
|
+
created_at: number;
|
|
32
|
+
updated_at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Minimal SQL + transaction surface for the postgres journal store. */
|
|
36
|
+
export interface PostgresJournalSql {
|
|
37
|
+
query(sql: string, params?: readonly unknown[]): Promise<Record<string, unknown>[]>;
|
|
38
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
39
|
+
/**
|
|
40
|
+
* Run `fn` inside a transaction. Nested calls join the outer txn.
|
|
41
|
+
*
|
|
42
|
+
* @param fn - Body
|
|
43
|
+
*/
|
|
44
|
+
begin<T>(fn: (sql: PostgresJournalSql) => Promise<T>): Promise<T>;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Claim a run for lease acquire / renew / reclaim.
|
|
50
|
+
*
|
|
51
|
+
* Claimable when unlocked, same holder renewing, or lease expired
|
|
52
|
+
* (lazy reclaim — matches the cron lease predicate).
|
|
53
|
+
*/
|
|
54
|
+
const CLAIM_LEASE_SQL = `SELECT * FROM oke_journal_runs WHERE id=? AND ((locked_by IS NULL) OR (locked_by=?) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?)) FOR UPDATE SKIP LOCKED`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Claim the next due sleep — Signal-shaped queue drain: `sleeping`, wake time
|
|
58
|
+
* reached, no live lease; oldest wake first.
|
|
59
|
+
*/
|
|
60
|
+
const CLAIM_DUE_SQL = `SELECT * FROM oke_journal_runs WHERE status='sleeping' AND wake_at<=? AND ((locked_by IS NULL) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?)) ORDER BY wake_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED`;
|
|
61
|
+
|
|
62
|
+
/** Boot-time orphan discovery: running/sleeping runs with no live lease. */
|
|
63
|
+
const ORPHANS_SQL = `SELECT * FROM oke_journal_runs WHERE (status='running' OR status='sleeping') AND ((locked_by IS NULL) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?))`;
|
|
64
|
+
|
|
65
|
+
const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ? WHERE id = ?`;
|
|
66
|
+
|
|
67
|
+
/** Guarded release — never clears another holder's lease. */
|
|
68
|
+
const RELEASE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = NULL, lease_expires_at = NULL WHERE id = ? AND locked_by = ?`;
|
|
69
|
+
|
|
70
|
+
/** Options for {@link createPostgresJournalStore}. */
|
|
71
|
+
export interface CreatePostgresJournalStoreOptions {
|
|
72
|
+
/** Postgres connection URL (Bun.SQL). Ignored when `sql` is injected. */
|
|
73
|
+
readonly url?: string;
|
|
74
|
+
/** Injected SQL surface (tests / fakes). */
|
|
75
|
+
readonly sql?: PostgresJournalSql;
|
|
76
|
+
/** Injected Bun.SQL-compatible client. */
|
|
77
|
+
readonly client?: BunJournalClient;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Minimal Bun.SQL surface used by the real driver. */
|
|
81
|
+
export interface BunJournalClient {
|
|
82
|
+
unsafe(
|
|
83
|
+
sql: string,
|
|
84
|
+
values?: unknown[],
|
|
85
|
+
): PromiseLike<Record<string, unknown>[] | { length: number; changes?: number }>;
|
|
86
|
+
begin<T>(fn: (tx: BunJournalClient) => Promise<T> | T): Promise<T>;
|
|
87
|
+
close?(options?: { timeout?: number }): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function wrapBunClient(client: BunJournalClient): PostgresJournalSql {
|
|
91
|
+
const api: PostgresJournalSql = {
|
|
92
|
+
async query(sql, params = []) {
|
|
93
|
+
const pg = toPostgresParams(sql);
|
|
94
|
+
const result = await client.unsafe(pg, [...params]);
|
|
95
|
+
if (Array.isArray(result)) return result as Record<string, unknown>[];
|
|
96
|
+
return Array.from(result as ArrayLike<Record<string, unknown>>);
|
|
97
|
+
},
|
|
98
|
+
async exec(sql, params = []) {
|
|
99
|
+
const pg = toPostgresParams(sql);
|
|
100
|
+
const result = await client.unsafe(pg, [...params]);
|
|
101
|
+
if (
|
|
102
|
+
result &&
|
|
103
|
+
typeof result === "object" &&
|
|
104
|
+
"changes" in result &&
|
|
105
|
+
typeof (result as { changes: unknown }).changes === "number"
|
|
106
|
+
) {
|
|
107
|
+
return { changes: (result as { changes: number }).changes };
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(result)) return { changes: result.length };
|
|
110
|
+
return { changes: 0 };
|
|
111
|
+
},
|
|
112
|
+
async begin(fn) {
|
|
113
|
+
return client.begin(async (tx) => fn(wrapBunClient(tx)));
|
|
114
|
+
},
|
|
115
|
+
async close() {
|
|
116
|
+
await client.close?.();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
return api;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function numOrNull(v: unknown): number | null {
|
|
123
|
+
if (v === undefined || v === null || v === "") return null;
|
|
124
|
+
const n = Number(v);
|
|
125
|
+
return Number.isFinite(n) ? n : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function rowToRun(row: Record<string, unknown>): JournalRun {
|
|
129
|
+
const r = row as unknown as JournalDbRow;
|
|
130
|
+
const run: JournalRun = {
|
|
131
|
+
id: String(r.id),
|
|
132
|
+
flow: String(r.flow),
|
|
133
|
+
input: r.input === null ? undefined : (JSON.parse(r.input) as unknown),
|
|
134
|
+
status: String(r.status) as JournalRun["status"],
|
|
135
|
+
entries: JSON.parse(r.entries) as JournalEntry[],
|
|
136
|
+
createdAt: Number(r.created_at),
|
|
137
|
+
updatedAt: Number(r.updated_at),
|
|
138
|
+
};
|
|
139
|
+
const wakeAt = numOrNull(r.wake_at);
|
|
140
|
+
if (wakeAt !== null) run.wakeAt = wakeAt;
|
|
141
|
+
if (r.error !== null && r.error !== undefined) run.error = String(r.error);
|
|
142
|
+
if (r.output !== null && r.output !== undefined) {
|
|
143
|
+
run.output = JSON.parse(String(r.output)) as unknown;
|
|
144
|
+
}
|
|
145
|
+
if (r.locked_by !== null && r.locked_by !== undefined) run.lockedBy = String(r.locked_by);
|
|
146
|
+
const leaseExpiresAt = numOrNull(r.lease_expires_at);
|
|
147
|
+
if (leaseExpiresAt !== null) run.leaseExpiresAt = leaseExpiresAt;
|
|
148
|
+
return run;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function runToParams(run: JournalRun): unknown[] {
|
|
152
|
+
return [
|
|
153
|
+
run.id,
|
|
154
|
+
run.flow,
|
|
155
|
+
run.input === undefined ? null : JSON.stringify(run.input),
|
|
156
|
+
run.status,
|
|
157
|
+
JSON.stringify(run.entries),
|
|
158
|
+
run.wakeAt ?? null,
|
|
159
|
+
run.error ?? null,
|
|
160
|
+
run.output === undefined ? null : JSON.stringify(run.output),
|
|
161
|
+
run.lockedBy ?? null,
|
|
162
|
+
run.leaseExpiresAt ?? null,
|
|
163
|
+
run.createdAt,
|
|
164
|
+
run.updatedAt,
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function ensureSchema(sql: PostgresJournalSql): Promise<void> {
|
|
169
|
+
await sql.exec(`CREATE TABLE IF NOT EXISTS oke_journal_runs (
|
|
170
|
+
id TEXT PRIMARY KEY,
|
|
171
|
+
flow TEXT NOT NULL,
|
|
172
|
+
input TEXT,
|
|
173
|
+
status TEXT NOT NULL,
|
|
174
|
+
entries TEXT NOT NULL,
|
|
175
|
+
wake_at BIGINT,
|
|
176
|
+
error TEXT,
|
|
177
|
+
output TEXT,
|
|
178
|
+
locked_by TEXT,
|
|
179
|
+
lease_expires_at BIGINT,
|
|
180
|
+
created_at BIGINT NOT NULL,
|
|
181
|
+
updated_at BIGINT NOT NULL
|
|
182
|
+
)`);
|
|
183
|
+
await sql.exec(
|
|
184
|
+
`CREATE INDEX IF NOT EXISTS oke_journal_runs_wake ON oke_journal_runs (status, wake_at)`,
|
|
185
|
+
);
|
|
186
|
+
await sql.exec(
|
|
187
|
+
`CREATE INDEX IF NOT EXISTS oke_journal_runs_lease ON oke_journal_runs (status, lease_expires_at)`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* In-memory Postgres-protocol fake with transactions + SKIP LOCKED for tests.
|
|
193
|
+
*/
|
|
194
|
+
export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
195
|
+
/** Force-kill mid-transaction (drops uncommitted state). */
|
|
196
|
+
killActiveTransaction(): void;
|
|
197
|
+
} {
|
|
198
|
+
type State = { rows: JournalDbRow[] };
|
|
199
|
+
|
|
200
|
+
let committed: State = { rows: [] };
|
|
201
|
+
let active: { state: State; locked: Set<string>; done: boolean } | null = null;
|
|
202
|
+
/** Run ids held by other active transactions (SKIP LOCKED). */
|
|
203
|
+
const heldByTxn = new Set<string>();
|
|
204
|
+
/** Serialize top-level begins so concurrent acquires cannot join one txn. */
|
|
205
|
+
let beginGate: Promise<void> = Promise.resolve();
|
|
206
|
+
|
|
207
|
+
function view(): State {
|
|
208
|
+
return active?.state ?? committed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cloneState(s: State): State {
|
|
212
|
+
return { rows: s.rows.map((r) => ({ ...r })) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function claimable(r: JournalDbRow, holder: string, cutoff: number): boolean {
|
|
216
|
+
if (r.locked_by === null) return true;
|
|
217
|
+
if (r.locked_by === holder) return true;
|
|
218
|
+
return r.lease_expires_at !== null && r.lease_expires_at <= cutoff;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function noLiveLease(r: JournalDbRow, cutoff: number): boolean {
|
|
222
|
+
return r.locked_by === null || (r.lease_expires_at !== null && r.lease_expires_at <= cutoff);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function tryLock(id: string): boolean {
|
|
226
|
+
if (heldByTxn.has(id) && !(active?.locked.has(id) ?? false)) return false;
|
|
227
|
+
if (active) {
|
|
228
|
+
active.locked.add(id);
|
|
229
|
+
heldByTxn.add(id);
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const api: PostgresJournalSql & { killActiveTransaction(): void } = {
|
|
235
|
+
killActiveTransaction() {
|
|
236
|
+
if (active) {
|
|
237
|
+
for (const id of active.locked) heldByTxn.delete(id);
|
|
238
|
+
active = null;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async query(sql, params = []) {
|
|
242
|
+
const text = sql.trim();
|
|
243
|
+
const state = view();
|
|
244
|
+
|
|
245
|
+
const isClaim = /FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) && /oke_journal_runs/i.test(text);
|
|
246
|
+
if (isClaim) {
|
|
247
|
+
const isDue = /status\s*=\s*'sleeping'/i.test(text) && /wake_at\s*<=/i.test(text);
|
|
248
|
+
if (isDue) {
|
|
249
|
+
const wakeCutoff = Number(params[0]);
|
|
250
|
+
const leaseCutoff = Number(params[1]);
|
|
251
|
+
const row = state.rows
|
|
252
|
+
.filter(
|
|
253
|
+
(r) =>
|
|
254
|
+
r.status === "sleeping" &&
|
|
255
|
+
r.wake_at !== null &&
|
|
256
|
+
r.wake_at <= wakeCutoff &&
|
|
257
|
+
noLiveLease(r, leaseCutoff),
|
|
258
|
+
)
|
|
259
|
+
.sort((a, b) => (a.wake_at ?? 0) - (b.wake_at ?? 0))
|
|
260
|
+
.find((r) => tryLock(r.id));
|
|
261
|
+
return row ? [{ ...row }] : [];
|
|
262
|
+
}
|
|
263
|
+
const id = String(params[0]);
|
|
264
|
+
const holder = String(params[1]);
|
|
265
|
+
const leaseCutoff = Number(params[2]);
|
|
266
|
+
if (heldByTxn.has(id) && !(active?.locked.has(id) ?? false)) {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
const row = state.rows.find((r) => r.id === id && claimable(r, holder, leaseCutoff));
|
|
270
|
+
if (!row) return [];
|
|
271
|
+
tryLock(id);
|
|
272
|
+
return [{ ...row }];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const byId = /^SELECT\s+\*\s+FROM\s+oke_journal_runs\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(text);
|
|
276
|
+
if (byId) {
|
|
277
|
+
return state.rows.filter((r) => r.id === params[0]).map((r) => ({ ...r }));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const orphans =
|
|
281
|
+
/status\s*=\s*'running'\s+OR\s+status\s*=\s*'sleeping'/i.test(text) &&
|
|
282
|
+
!/FOR\s+UPDATE/i.test(text);
|
|
283
|
+
if (orphans) {
|
|
284
|
+
const cutoff = Number(params[0]);
|
|
285
|
+
return state.rows
|
|
286
|
+
.filter(
|
|
287
|
+
(r) => (r.status === "running" || r.status === "sleeping") && noLiveLease(r, cutoff),
|
|
288
|
+
)
|
|
289
|
+
.map((r) => ({ ...r }));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const all = /^SELECT\s+\*\s+FROM\s+oke_journal_runs\s*$/i.exec(text);
|
|
293
|
+
if (all) {
|
|
294
|
+
return state.rows.map((r) => ({ ...r }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
throw new Error(`postgres journal fake: unsupported query: ${sql}`);
|
|
298
|
+
},
|
|
299
|
+
async exec(sql, params = []) {
|
|
300
|
+
const text = sql.trim();
|
|
301
|
+
const state = view();
|
|
302
|
+
|
|
303
|
+
if (/^CREATE\s+(TABLE|INDEX)/i.test(text)) return { changes: 0 };
|
|
304
|
+
|
|
305
|
+
const upsert =
|
|
306
|
+
/^INSERT\s+INTO\s+oke_journal_runs\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*ON\s+CONFLICT\s*\(\s*id\s*\)\s*DO\s+UPDATE\s+SET\s+.+$/i.exec(
|
|
307
|
+
text,
|
|
308
|
+
);
|
|
309
|
+
if (upsert) {
|
|
310
|
+
const cols = upsert[1]!.split(",").map((c) => c.trim());
|
|
311
|
+
const record: Record<string, unknown> = {};
|
|
312
|
+
cols.forEach((c, i) => {
|
|
313
|
+
record[c] = params[i];
|
|
314
|
+
});
|
|
315
|
+
const next: JournalDbRow = {
|
|
316
|
+
id: String(record.id),
|
|
317
|
+
flow: String(record.flow),
|
|
318
|
+
input: (record.input as string | null) ?? null,
|
|
319
|
+
status: String(record.status),
|
|
320
|
+
entries: String(record.entries ?? "[]"),
|
|
321
|
+
wake_at:
|
|
322
|
+
record.wake_at === undefined || record.wake_at === null ? null : Number(record.wake_at),
|
|
323
|
+
error: (record.error as string | null) ?? null,
|
|
324
|
+
output: (record.output as string | null) ?? null,
|
|
325
|
+
locked_by: (record.locked_by as string | null) ?? null,
|
|
326
|
+
lease_expires_at:
|
|
327
|
+
record.lease_expires_at === undefined || record.lease_expires_at === null
|
|
328
|
+
? null
|
|
329
|
+
: Number(record.lease_expires_at),
|
|
330
|
+
created_at: Number(record.created_at ?? 0),
|
|
331
|
+
updated_at: Number(record.updated_at ?? 0),
|
|
332
|
+
};
|
|
333
|
+
const idx = state.rows.findIndex((r) => r.id === next.id);
|
|
334
|
+
if (idx >= 0) state.rows[idx] = next;
|
|
335
|
+
else state.rows.push(next);
|
|
336
|
+
return { changes: 1 };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const updLease =
|
|
340
|
+
/^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(
|
|
341
|
+
text,
|
|
342
|
+
);
|
|
343
|
+
if (updLease) {
|
|
344
|
+
const row = state.rows.find((r) => r.id === params[2]);
|
|
345
|
+
if (!row) return { changes: 0 };
|
|
346
|
+
row.locked_by = params[0] === null ? null : String(params[0]);
|
|
347
|
+
row.lease_expires_at =
|
|
348
|
+
params[1] === null || params[1] === undefined ? null : Number(params[1]);
|
|
349
|
+
return { changes: 1 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const release =
|
|
353
|
+
/^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*NULL,\s*lease_expires_at\s*=\s*NULL\s+WHERE\s+id\s*=\s*\?\s+AND\s+locked_by\s*=\s*\?\s*$/i.exec(
|
|
354
|
+
text,
|
|
355
|
+
);
|
|
356
|
+
if (release) {
|
|
357
|
+
const row = state.rows.find((r) => r.id === params[0]);
|
|
358
|
+
if (!row || row.locked_by !== String(params[1])) return { changes: 0 };
|
|
359
|
+
row.locked_by = null;
|
|
360
|
+
row.lease_expires_at = null;
|
|
361
|
+
return { changes: 1 };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const del = /^DELETE\s+FROM\s+oke_journal_runs\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(text);
|
|
365
|
+
if (del) {
|
|
366
|
+
const idx = state.rows.findIndex((r) => r.id === params[0]);
|
|
367
|
+
if (idx >= 0) {
|
|
368
|
+
state.rows.splice(idx, 1);
|
|
369
|
+
return { changes: 1 };
|
|
370
|
+
}
|
|
371
|
+
return { changes: 0 };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
throw new Error(`postgres journal fake: unsupported exec: ${sql}`);
|
|
375
|
+
},
|
|
376
|
+
async begin(fn) {
|
|
377
|
+
// Nested begin (same call stack) joins the open txn.
|
|
378
|
+
if (active && !active.done) {
|
|
379
|
+
return fn(api);
|
|
380
|
+
}
|
|
381
|
+
// Top-level begins serialize so Promise.all racing acquires stay exclusive.
|
|
382
|
+
let release!: () => void;
|
|
383
|
+
const slot = new Promise<void>((resolve) => {
|
|
384
|
+
release = resolve;
|
|
385
|
+
});
|
|
386
|
+
const prev = beginGate;
|
|
387
|
+
beginGate = slot;
|
|
388
|
+
await prev;
|
|
389
|
+
active = { state: cloneState(committed), locked: new Set(), done: false };
|
|
390
|
+
try {
|
|
391
|
+
const result = await fn(api);
|
|
392
|
+
if (active) {
|
|
393
|
+
for (const id of active.locked) heldByTxn.delete(id);
|
|
394
|
+
committed = active.state;
|
|
395
|
+
active.done = true;
|
|
396
|
+
active = null;
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (active) {
|
|
401
|
+
for (const id of active.locked) heldByTxn.delete(id);
|
|
402
|
+
}
|
|
403
|
+
active = null;
|
|
404
|
+
throw err;
|
|
405
|
+
} finally {
|
|
406
|
+
release();
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
async close() {
|
|
410
|
+
active = null;
|
|
411
|
+
heldByTxn.clear();
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
return api;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at`;
|
|
419
|
+
|
|
420
|
+
/** Postgres journal store with run-level lease coordination. */
|
|
421
|
+
export type PostgresJournalStore = JournalStore &
|
|
422
|
+
JournalLeaseStore & {
|
|
423
|
+
readonly sql: PostgresJournalSql;
|
|
424
|
+
close(): Promise<void>;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Open a postgres-backed JournalStore (multi-host durable-run coordination).
|
|
429
|
+
*
|
|
430
|
+
* @param options - URL / injected sql / Bun.SQL client
|
|
431
|
+
*/
|
|
432
|
+
export async function createPostgresJournalStore(
|
|
433
|
+
options: CreatePostgresJournalStoreOptions = {},
|
|
434
|
+
): Promise<PostgresJournalStore> {
|
|
435
|
+
const sql =
|
|
436
|
+
options.sql ??
|
|
437
|
+
wrapBunClient(
|
|
438
|
+
options.client ??
|
|
439
|
+
(new Bun.SQL(
|
|
440
|
+
options.url ?? process.env.DATABASE_URL ?? "postgres://localhost:5432/oke",
|
|
441
|
+
) as unknown as BunJournalClient),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
await ensureSchema(sql);
|
|
445
|
+
|
|
446
|
+
const store: PostgresJournalStore = {
|
|
447
|
+
sql,
|
|
448
|
+
async get(runId) {
|
|
449
|
+
const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
|
|
450
|
+
if (!rows[0]) return undefined;
|
|
451
|
+
return rowToRun(rows[0]);
|
|
452
|
+
},
|
|
453
|
+
async put(run) {
|
|
454
|
+
await sql.exec(UPSERT_SQL, runToParams(run));
|
|
455
|
+
},
|
|
456
|
+
async list() {
|
|
457
|
+
const rows = await sql.query(`SELECT * FROM oke_journal_runs`);
|
|
458
|
+
return rows.map((r) => rowToRun(r));
|
|
459
|
+
},
|
|
460
|
+
async acquireLease(runId, instanceId, now, leaseMs) {
|
|
461
|
+
return sql.begin(async (tx) => {
|
|
462
|
+
const claimed = await tx.query(CLAIM_LEASE_SQL, [runId, instanceId, now]);
|
|
463
|
+
if (!claimed[0]) return false;
|
|
464
|
+
await tx.exec(UPDATE_LEASE_SQL, [instanceId, now + leaseMs, runId]);
|
|
465
|
+
return true;
|
|
466
|
+
});
|
|
467
|
+
},
|
|
468
|
+
async releaseLease(runId, instanceId) {
|
|
469
|
+
await sql.exec(RELEASE_LEASE_SQL, [runId, instanceId]);
|
|
470
|
+
},
|
|
471
|
+
async claimDueSleep(instanceId, now, leaseMs) {
|
|
472
|
+
return sql.begin(async (tx) => {
|
|
473
|
+
const rows = await tx.query(CLAIM_DUE_SQL, [now, now]);
|
|
474
|
+
const row = rows[0];
|
|
475
|
+
if (!row) return undefined;
|
|
476
|
+
await tx.exec(UPDATE_LEASE_SQL, [instanceId, now + leaseMs, String(row.id)]);
|
|
477
|
+
return rowToRun(row);
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
async listOrphans(now) {
|
|
481
|
+
const rows = await sql.query(ORPHANS_SQL, [now]);
|
|
482
|
+
return rows.map((r) => rowToRun(r));
|
|
483
|
+
},
|
|
484
|
+
async close() {
|
|
485
|
+
await sql.close();
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
return store;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export { JOURNAL_DEFAULT_LEASE_MS };
|
|
@@ -11,7 +11,12 @@ import type {
|
|
|
11
11
|
ChannelAttempt,
|
|
12
12
|
ChannelDriver,
|
|
13
13
|
ChannelMessage,
|
|
14
|
+
ChannelOtpSendOptions,
|
|
15
|
+
ChannelOtpSendResult,
|
|
16
|
+
ChannelOtpVerifyOptions,
|
|
17
|
+
ChannelOtpVerifyResult,
|
|
14
18
|
ChannelSendResult,
|
|
19
|
+
SmsOtpTransport,
|
|
15
20
|
} from "../../drivers/channel-types.ts";
|
|
16
21
|
import type { ChannelMedium } from "../../manifest/types.ts";
|
|
17
22
|
import type { ConsentStore } from "./consent.ts";
|
|
@@ -85,6 +90,8 @@ export interface ChannelRuntime {
|
|
|
85
90
|
readonly suppression: SuppressionStore;
|
|
86
91
|
readonly receipts: ReceiptLedger;
|
|
87
92
|
readonly costs: MediumCosts;
|
|
93
|
+
/** Bound driver chain (email + SMS + …). */
|
|
94
|
+
readonly drivers: readonly ChannelDriver[];
|
|
88
95
|
/**
|
|
89
96
|
* Send a template through the driver chain.
|
|
90
97
|
*
|
|
@@ -92,6 +99,22 @@ export interface ChannelRuntime {
|
|
|
92
99
|
* @param options - Recipient / data / locale / via
|
|
93
100
|
*/
|
|
94
101
|
send(template: string, options: ChannelSendOptions): Promise<ChannelSendResult>;
|
|
102
|
+
/**
|
|
103
|
+
* Send a provider-managed OTP via the bound Taqnyat SMS driver.
|
|
104
|
+
*
|
|
105
|
+
* Vendor extra (Taqnyat Verify API) — only valid when the configured SMS
|
|
106
|
+
* driver supports it. Throws loudly when no SMS driver is bound or the
|
|
107
|
+
* bound driver does not support provider-managed OTP.
|
|
108
|
+
*
|
|
109
|
+
* @param options - Recipient + requestId (+ lang / note / from)
|
|
110
|
+
*/
|
|
111
|
+
sendOtp(options: ChannelOtpSendOptions): Promise<ChannelOtpSendResult>;
|
|
112
|
+
/**
|
|
113
|
+
* Verify a provider-managed OTP code via the bound Taqnyat SMS driver.
|
|
114
|
+
*
|
|
115
|
+
* @param options - Recipient + requestId + code (+ lang / note / from)
|
|
116
|
+
*/
|
|
117
|
+
verifyOtp(options: ChannelOtpVerifyOptions): Promise<ChannelOtpVerifyResult>;
|
|
95
118
|
/**
|
|
96
119
|
* Ingest a post-send provider outcome (bounce / complaint / …).
|
|
97
120
|
* Hard bounce auto-adds suppression. Console projects the ledger — never
|
|
@@ -391,11 +414,39 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
391
414
|
return "provider-error";
|
|
392
415
|
}
|
|
393
416
|
|
|
417
|
+
function otpSmsDriver(): { driver: ChannelDriver; otp: SmsOtpTransport } {
|
|
418
|
+
const sms = drivers.filter((d) => d.smsTransport);
|
|
419
|
+
if (sms.length === 0) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
"channel: no SMS driver bound — bind drivers.channel.sms (e.g. taqnyat) to send provider OTP",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
for (const d of sms) {
|
|
425
|
+
const t = d.smsTransport as Partial<SmsOtpTransport> | undefined;
|
|
426
|
+
if (t && typeof t.sendOtp === "function" && typeof t.verifyOtp === "function") {
|
|
427
|
+
return { driver: d, otp: t as SmsOtpTransport };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const id = sms[0]?.id ?? "unknown";
|
|
431
|
+
throw new Error(
|
|
432
|
+
`channel: SMS driver "${id}" does not support provider-managed OTP; use exposeDevOtp locally or set drivers.channel.sms to "taqnyat"`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
394
436
|
return {
|
|
395
437
|
templates,
|
|
396
438
|
suppression,
|
|
397
439
|
receipts,
|
|
398
440
|
costs,
|
|
441
|
+
drivers,
|
|
442
|
+
async sendOtp(opts) {
|
|
443
|
+
const { otp } = otpSmsDriver();
|
|
444
|
+
return otp.sendOtp(opts);
|
|
445
|
+
},
|
|
446
|
+
async verifyOtp(opts) {
|
|
447
|
+
const { otp } = otpSmsDriver();
|
|
448
|
+
return otp.verifyOtp(opts);
|
|
449
|
+
},
|
|
399
450
|
ingestOutcome(input) {
|
|
400
451
|
const at = input.at ?? now();
|
|
401
452
|
const existing = receipts.byMessageId(input.messageId);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
11
|
import type { MailOptions, SendResult, Transport } from "sently";
|
|
12
12
|
import { createChannelInbox, openConsoleChannel, type ChannelDriver } from "../drivers/index.ts";
|
|
13
|
+
import type { SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts";
|
|
13
14
|
import { channel, createChannelRuntime, createConsentStore, FallbackTransport } from "./channel.ts";
|
|
14
15
|
|
|
15
16
|
/** Create a sently-compatible transport that always fails. */
|
|
@@ -207,3 +208,73 @@ describe("console driver", () => {
|
|
|
207
208
|
expect(inbox.entries[0]!.text).toBe("code 1234");
|
|
208
209
|
});
|
|
209
210
|
});
|
|
211
|
+
|
|
212
|
+
describe("provider-managed OTP (Taqnyat Verify)", () => {
|
|
213
|
+
function taqnyatSmsDriver(): ChannelDriver {
|
|
214
|
+
const otpTransport: SmsTransport & SmsOtpTransport = {
|
|
215
|
+
provider: "taqnyat-sms",
|
|
216
|
+
async send() {
|
|
217
|
+
return { messageId: "m", to: "", status: "sent", response: "" };
|
|
218
|
+
},
|
|
219
|
+
async sendOtp(opts) {
|
|
220
|
+
return {
|
|
221
|
+
requestId: opts.requestId,
|
|
222
|
+
to: opts.to,
|
|
223
|
+
code: 5,
|
|
224
|
+
response: "sent",
|
|
225
|
+
provider: "taqnyat-sms",
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
async verifyOtp(opts) {
|
|
229
|
+
return {
|
|
230
|
+
ok: true as const,
|
|
231
|
+
code: opts.code === "0000" ? 13 : 10,
|
|
232
|
+
message: "verified",
|
|
233
|
+
response: "ok",
|
|
234
|
+
provider: "taqnyat-sms",
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
return { id: "taqnyat", smsTransport: otpTransport };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
test("sendOtp / verifyOtp dispatch to the taqnyat smsTransport", async () => {
|
|
242
|
+
const runtime = createChannelRuntime({ drivers: [taqnyatSmsDriver()] });
|
|
243
|
+
const sent = await runtime.sendOtp({ to: "+966500000000", requestId: "r1", lang: "en" });
|
|
244
|
+
expect(sent.code).toBe(5);
|
|
245
|
+
expect(sent.requestId).toBe("r1");
|
|
246
|
+
const verified = await runtime.verifyOtp({
|
|
247
|
+
to: "+966500000000",
|
|
248
|
+
requestId: "r1",
|
|
249
|
+
code: "6240",
|
|
250
|
+
});
|
|
251
|
+
expect(verified.ok).toBe(true);
|
|
252
|
+
expect(verified.code).toBe(10);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("throws loudly when no SMS driver is bound", async () => {
|
|
256
|
+
const runtime = createChannelRuntime({});
|
|
257
|
+
await expect(runtime.sendOtp({ to: "+966500000000", requestId: "r1" })).rejects.toThrow(
|
|
258
|
+
"no SMS driver bound",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("throws loudly naming the driver when SMS driver lacks OTP support", async () => {
|
|
263
|
+
const twilioLike: ChannelDriver = {
|
|
264
|
+
id: "msegat",
|
|
265
|
+
smsTransport: {
|
|
266
|
+
provider: "msegat",
|
|
267
|
+
async send() {
|
|
268
|
+
return { messageId: "m", to: "", status: "sent", response: "" };
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const runtime = createChannelRuntime({ drivers: [twilioLike] });
|
|
273
|
+
await expect(runtime.sendOtp({ to: "+966500000000", requestId: "r1" })).rejects.toThrow(
|
|
274
|
+
/SMS driver "msegat" does not support provider-managed OTP/,
|
|
275
|
+
);
|
|
276
|
+
await expect(
|
|
277
|
+
runtime.verifyOtp({ to: "+966500000000", requestId: "r1", code: "1234" }),
|
|
278
|
+
).rejects.toThrow(/does not support provider-managed OTP/);
|
|
279
|
+
});
|
|
280
|
+
});
|