okengine 0.23.1 → 0.23.2
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 +1 -1
- package/site/content/docs/ai/mcp.mdx +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +59 -0
- package/src/elements/store/cache.ts +157 -12
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
|
|
8
|
+
import { OkeError } from "../kernel/errors.ts";
|
|
8
9
|
import { createJournal, type JournalRun } from "../kernel/journal.ts";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createPostgresJournalFake,
|
|
12
|
+
createPostgresJournalStore,
|
|
13
|
+
postgresJournalStatements,
|
|
14
|
+
} from "./journal-postgres.ts";
|
|
10
15
|
|
|
11
16
|
function seedRun(patch: Partial<JournalRun> & { id: string }): JournalRun {
|
|
12
17
|
return {
|
|
@@ -195,4 +200,82 @@ describe("postgres JournalStore (fake)", () => {
|
|
|
195
200
|
expect(row?.entries).toHaveLength(steps);
|
|
196
201
|
await store.close();
|
|
197
202
|
});
|
|
203
|
+
|
|
204
|
+
test("a stale fence cannot append, and an identical append is a no-op", async () => {
|
|
205
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
206
|
+
const journal = createJournal({
|
|
207
|
+
store,
|
|
208
|
+
now: () => 1_000,
|
|
209
|
+
codeVersion: "v1",
|
|
210
|
+
lease: { instanceId: "A", leaseMs: 5_000 },
|
|
211
|
+
});
|
|
212
|
+
const session = await journal.start("charge");
|
|
213
|
+
await session.step("one", () => 1);
|
|
214
|
+
const entry = { kind: "step" as const, name: "one", value: 1, at: 1_000 };
|
|
215
|
+
const append = store.appendEntry;
|
|
216
|
+
if (!append) throw new Error("appendEntry missing");
|
|
217
|
+
await append(session.runId, 0, entry, {
|
|
218
|
+
lockedBy: "A",
|
|
219
|
+
leaseToken: 1,
|
|
220
|
+
now: 1_000,
|
|
221
|
+
});
|
|
222
|
+
await expect(
|
|
223
|
+
append(
|
|
224
|
+
session.runId,
|
|
225
|
+
1,
|
|
226
|
+
{ kind: "step", name: "two", value: 2, at: 1_000 },
|
|
227
|
+
{ lockedBy: "A", leaseToken: 99, now: 1_000 },
|
|
228
|
+
),
|
|
229
|
+
).rejects.toBeInstanceOf(OkeError);
|
|
230
|
+
expect((await store.get(session.runId))?.entries).toHaveLength(1);
|
|
231
|
+
expect((await store.get(session.runId))?.leaseToken).toBe(1);
|
|
232
|
+
await store.close();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("cas rewrites only the entries that changed", async () => {
|
|
236
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
237
|
+
await store.put(
|
|
238
|
+
seedRun({
|
|
239
|
+
id: "cas",
|
|
240
|
+
lockedBy: "A",
|
|
241
|
+
leaseExpiresAt: 5_000,
|
|
242
|
+
leaseToken: 1,
|
|
243
|
+
entries: [
|
|
244
|
+
{ kind: "step", name: "a", value: 1, at: 1 },
|
|
245
|
+
{ kind: "step", name: "b", value: 2, at: 1 },
|
|
246
|
+
{ kind: "step", name: "c", value: 3, at: 1 },
|
|
247
|
+
],
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
const before = store.writeBytes;
|
|
251
|
+
const result = await store.cas("cas", "A", 1_000, 5_000, (run) => ({
|
|
252
|
+
...run,
|
|
253
|
+
entries: run.entries.map((item) =>
|
|
254
|
+
item.kind === "step" && item.name === "b" ? { ...item, value: 9 } : item,
|
|
255
|
+
),
|
|
256
|
+
}));
|
|
257
|
+
expect(result).toBe("ok");
|
|
258
|
+
const row = await store.get("cas");
|
|
259
|
+
expect(row?.entries[1]).toMatchObject({ value: 9 });
|
|
260
|
+
expect(row?.entries[0]).toMatchObject({ value: 1 });
|
|
261
|
+
expect(store.writeBytes - before).toBeLessThan(JSON.stringify(row?.entries).length);
|
|
262
|
+
await store.close();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("every journal statement matches the fake, and an unknown statement does not", async () => {
|
|
266
|
+
const sql = createPostgresJournalFake();
|
|
267
|
+
const pad = Array.from({ length: 20 }, () => 0);
|
|
268
|
+
for (const statement of postgresJournalStatements()) {
|
|
269
|
+
const run = /^\s*SELECT/i.test(statement)
|
|
270
|
+
? sql.query(statement, pad)
|
|
271
|
+
: sql.exec(statement, pad);
|
|
272
|
+
try {
|
|
273
|
+
await run;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
expect(String(err)).not.toContain("unsupported");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
await expect(sql.exec("UPDATE oke_journal_runs SET nope = 1")).rejects.toThrow(/unsupported/);
|
|
279
|
+
await sql.close();
|
|
280
|
+
});
|
|
198
281
|
});
|
|
@@ -103,7 +103,41 @@ const ORPHANS_SQL = `SELECT * FROM oke_journal_runs WHERE (status='running' OR s
|
|
|
103
103
|
|
|
104
104
|
const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ?, lease_token = COALESCE(lease_token, 0) + 1 WHERE id = ?`;
|
|
105
105
|
|
|
106
|
-
const
|
|
106
|
+
const HEARTBEAT_SQL = `UPDATE oke_journal_runs SET lease_expires_at = GREATEST(COALESCE(lease_expires_at, 0), ?) WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ?`;
|
|
107
|
+
|
|
108
|
+
const INSERT_ENTRY_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`;
|
|
109
|
+
|
|
110
|
+
/** One entry row, inserted only while the caller's fence still holds. */
|
|
111
|
+
const APPEND_ENTRY_FENCED_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) SELECT ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM oke_journal_runs WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ? FOR SHARE) ON CONFLICT (run_id, seq) DO NOTHING`;
|
|
112
|
+
|
|
113
|
+
const UPDATE_ENTRY_FENCED_SQL = `UPDATE oke_journal_entries SET kind = ?, name = ?, resource = ?, value = ?, at = ? WHERE run_id = ? AND seq = ? AND EXISTS (SELECT 1 FROM oke_journal_runs WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ? FOR SHARE)`;
|
|
114
|
+
|
|
115
|
+
const FENCED_HEADER_SQL = `UPDATE oke_journal_runs SET flow = ?, input = ?, status = ?, entries = ?, wake_at = ?, error = ?, output = ?, locked_by = ?, lease_expires_at = ?, updated_at = ?, tenant = ?, lease_token = GREATEST(COALESCE(lease_token, 0), ?), code_version = ? WHERE id = ? AND locked_by = ? AND lease_token = ? AND lease_expires_at > ?`;
|
|
116
|
+
|
|
117
|
+
/** Header write inside `cas`, which already holds the row `FOR UPDATE`. */
|
|
118
|
+
const CAS_HEADER_SQL = `UPDATE oke_journal_runs SET flow = ?, input = ?, status = ?, entries = ?, wake_at = ?, error = ?, output = ?, locked_by = ?, lease_expires_at = ?, updated_at = ?, tenant = ?, lease_token = GREATEST(COALESCE(lease_token, 0), ?), code_version = ? WHERE id = ? AND COALESCE(lease_token, 0) = ?`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every statement the journal driver sends. The in-memory fake must match
|
|
122
|
+
* each one; a new statement that misses every branch fails the driver tests.
|
|
123
|
+
*/
|
|
124
|
+
export function postgresJournalStatements(): readonly string[] {
|
|
125
|
+
return [
|
|
126
|
+
CLAIM_LEASE_SQL,
|
|
127
|
+
CLAIM_DUE_SQL,
|
|
128
|
+
ORPHANS_SQL,
|
|
129
|
+
UPDATE_LEASE_SQL,
|
|
130
|
+
HEARTBEAT_SQL,
|
|
131
|
+
INSERT_ENTRY_SQL,
|
|
132
|
+
APPEND_ENTRY_FENCED_SQL,
|
|
133
|
+
UPDATE_ENTRY_FENCED_SQL,
|
|
134
|
+
FENCED_HEADER_SQL,
|
|
135
|
+
CAS_HEADER_SQL,
|
|
136
|
+
SELECT_ENTRIES_SQL,
|
|
137
|
+
RELEASE_LEASE_SQL,
|
|
138
|
+
UPSERT_SQL,
|
|
139
|
+
];
|
|
140
|
+
}
|
|
107
141
|
|
|
108
142
|
const SELECT_ENTRIES_SQL = `SELECT * FROM oke_journal_entries WHERE run_id = ? ORDER BY seq ASC`;
|
|
109
143
|
|
|
@@ -203,6 +237,54 @@ function rowToRun(row: Record<string, unknown>): JournalRun {
|
|
|
203
237
|
return run;
|
|
204
238
|
}
|
|
205
239
|
|
|
240
|
+
function sameEntry(a: JournalEntry, b: JournalEntry): boolean {
|
|
241
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fencedHeaderParams(run: JournalRun, fence: JournalWriteFence): unknown[] {
|
|
245
|
+
const base = runToParams(run);
|
|
246
|
+
return [
|
|
247
|
+
base[1],
|
|
248
|
+
base[2],
|
|
249
|
+
base[3],
|
|
250
|
+
JSON.stringify([]),
|
|
251
|
+
base[5],
|
|
252
|
+
base[6],
|
|
253
|
+
base[7],
|
|
254
|
+
base[8],
|
|
255
|
+
base[9],
|
|
256
|
+
base[11],
|
|
257
|
+
base[12],
|
|
258
|
+
fence.leaseToken,
|
|
259
|
+
base[14],
|
|
260
|
+
run.id,
|
|
261
|
+
fence.lockedBy,
|
|
262
|
+
fence.leaseToken,
|
|
263
|
+
fence.now,
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function casHeaderParams(run: JournalRun, heldToken: number): unknown[] {
|
|
268
|
+
const base = runToParams(run);
|
|
269
|
+
return [
|
|
270
|
+
base[1],
|
|
271
|
+
base[2],
|
|
272
|
+
base[3],
|
|
273
|
+
JSON.stringify([]),
|
|
274
|
+
base[5],
|
|
275
|
+
base[6],
|
|
276
|
+
base[7],
|
|
277
|
+
base[8],
|
|
278
|
+
base[9],
|
|
279
|
+
base[11],
|
|
280
|
+
base[12],
|
|
281
|
+
run.leaseToken ?? heldToken,
|
|
282
|
+
base[14],
|
|
283
|
+
run.id,
|
|
284
|
+
heldToken,
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
206
288
|
function entryToParams(runId: string, seq: number, entry: JournalEntry): unknown[] {
|
|
207
289
|
if (entry.kind === "step") {
|
|
208
290
|
return [runId, seq, "step", entry.name, null, JSON.stringify(entry.value ?? null), entry.at];
|
|
@@ -440,6 +522,50 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
440
522
|
return r.locked_by === null || (r.lease_expires_at !== null && r.lease_expires_at <= cutoff);
|
|
441
523
|
}
|
|
442
524
|
|
|
525
|
+
function fenceHolds(
|
|
526
|
+
state: { rows: JournalDbRow[] },
|
|
527
|
+
id: string,
|
|
528
|
+
lockedBy: string,
|
|
529
|
+
token: number,
|
|
530
|
+
now: number,
|
|
531
|
+
): boolean {
|
|
532
|
+
const row = state.rows.find((r) => r.id === id);
|
|
533
|
+
if (!row || row.locked_by !== lockedBy) return false;
|
|
534
|
+
if ((row.lease_token ?? 0) !== token) return false;
|
|
535
|
+
return row.lease_expires_at !== null && row.lease_expires_at > now;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function applyFencedHeader(
|
|
539
|
+
state: { rows: JournalDbRow[] },
|
|
540
|
+
text: string,
|
|
541
|
+
params: readonly unknown[],
|
|
542
|
+
): { changes: number } {
|
|
543
|
+
const tokenOnly = /COALESCE\(lease_token,\s*0\)\s*=\s*\?/i.test(text);
|
|
544
|
+
const id = String(params[13]);
|
|
545
|
+
const row = state.rows.find((r) => r.id === id);
|
|
546
|
+
if (!row) return { changes: 0 };
|
|
547
|
+
if (tokenOnly) {
|
|
548
|
+
if ((row.lease_token ?? 0) !== Number(params[14])) return { changes: 0 };
|
|
549
|
+
} else if (!fenceHolds(state, id, String(params[14]), Number(params[15]), Number(params[16]))) {
|
|
550
|
+
return { changes: 0 };
|
|
551
|
+
}
|
|
552
|
+
row.flow = String(params[0]);
|
|
553
|
+
row.input = params[1] === null || params[1] === undefined ? null : String(params[1]);
|
|
554
|
+
row.status = String(params[2]);
|
|
555
|
+
row.entries = String(params[3] ?? "[]");
|
|
556
|
+
row.wake_at = params[4] === null || params[4] === undefined ? null : Number(params[4]);
|
|
557
|
+
row.error = params[5] === null || params[5] === undefined ? null : String(params[5]);
|
|
558
|
+
row.output = params[6] === null || params[6] === undefined ? null : String(params[6]);
|
|
559
|
+
row.locked_by = params[7] === null || params[7] === undefined ? null : String(params[7]);
|
|
560
|
+
row.lease_expires_at = params[8] === null || params[8] === undefined ? null : Number(params[8]);
|
|
561
|
+
row.updated_at = Number(params[9] ?? 0);
|
|
562
|
+
row.tenant = params[10] === null || params[10] === undefined ? null : String(params[10]);
|
|
563
|
+
row.lease_token = Math.max(row.lease_token ?? 0, Number(params[11] ?? 0));
|
|
564
|
+
row.code_version =
|
|
565
|
+
params[12] === null || params[12] === undefined ? row.code_version : String(params[12]);
|
|
566
|
+
return { changes: 1 };
|
|
567
|
+
}
|
|
568
|
+
|
|
443
569
|
function tryLock(id: string): boolean {
|
|
444
570
|
if (heldByTxn.has(id) && !(active?.locked.has(id) ?? false)) return false;
|
|
445
571
|
if (active) {
|
|
@@ -581,8 +707,9 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
581
707
|
|
|
582
708
|
if (/FROM\s+oke_journal_entries/i.test(text)) {
|
|
583
709
|
const runId = String(params[0] ?? "");
|
|
710
|
+
const seq = /seq\s*=\s*\?/i.test(text) ? Number(params[1]) : undefined;
|
|
584
711
|
return state.entries
|
|
585
|
-
.filter((row) => row.run_id === runId)
|
|
712
|
+
.filter((row) => row.run_id === runId && (seq === undefined || row.seq === seq))
|
|
586
713
|
.sort((a, b) => a.seq - b.seq)
|
|
587
714
|
.map((row) => ({ ...row }));
|
|
588
715
|
}
|
|
@@ -624,18 +751,39 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
624
751
|
created_at: Number(record.created_at ?? 0),
|
|
625
752
|
updated_at: Number(record.updated_at ?? 0),
|
|
626
753
|
tenant: (record.tenant as string | null) ?? null,
|
|
627
|
-
lease_token:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
754
|
+
lease_token: (() => {
|
|
755
|
+
const prev = state.rows.find((r) => r.id === String(record.id))?.lease_token ?? 0;
|
|
756
|
+
const incoming =
|
|
757
|
+
record.lease_token === undefined || record.lease_token === null
|
|
758
|
+
? prev
|
|
759
|
+
: Number(record.lease_token);
|
|
760
|
+
return Math.max(prev, incoming);
|
|
761
|
+
})(),
|
|
631
762
|
code_version:
|
|
632
763
|
record.code_version === undefined || record.code_version === null
|
|
633
764
|
? (state.rows.find((r) => r.id === String(record.id))?.code_version ?? null)
|
|
634
765
|
: String(record.code_version),
|
|
635
766
|
};
|
|
636
767
|
const idx = state.rows.findIndex((r) => r.id === next.id);
|
|
637
|
-
if (idx >= 0)
|
|
638
|
-
|
|
768
|
+
if (idx >= 0) {
|
|
769
|
+
const prev = state.rows[idx]!;
|
|
770
|
+
next.created_at = prev.created_at;
|
|
771
|
+
state.rows[idx] = next;
|
|
772
|
+
} else state.rows.push(next);
|
|
773
|
+
return { changes: 1 };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (/^UPDATE\s+oke_journal_runs\s+SET\s+lease_expires_at\s*=\s*GREATEST/i.test(text)) {
|
|
777
|
+
const id = String(params[1]);
|
|
778
|
+
const row = state.rows.find((r) => r.id === id);
|
|
779
|
+
if (
|
|
780
|
+
!row ||
|
|
781
|
+
!fenceHolds(state, id, String(params[2]), Number(params[3]), Number(params[4]))
|
|
782
|
+
) {
|
|
783
|
+
return { changes: 0 };
|
|
784
|
+
}
|
|
785
|
+
const next = Number(params[0]);
|
|
786
|
+
row.lease_expires_at = Math.max(row.lease_expires_at ?? 0, next);
|
|
639
787
|
return { changes: 1 };
|
|
640
788
|
}
|
|
641
789
|
|
|
@@ -654,7 +802,31 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
654
802
|
return { changes: 1 };
|
|
655
803
|
}
|
|
656
804
|
|
|
805
|
+
if (/^UPDATE\s+oke_journal_runs\s+SET\s+flow\s*=/i.test(text)) {
|
|
806
|
+
return applyFencedHeader(state, text, params);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (/^UPDATE\s+oke_journal_entries\s+SET\s+kind\s*=/i.test(text)) {
|
|
810
|
+
const runId = String(params[5]);
|
|
811
|
+
const seq = Number(params[6]);
|
|
812
|
+
const fenceRun = String(params[7]);
|
|
813
|
+
const lockedBy = String(params[8]);
|
|
814
|
+
const token = Number(params[9]);
|
|
815
|
+
const at = Number(params[10]);
|
|
816
|
+
if (!fenceHolds(state, fenceRun, lockedBy, token, at)) return { changes: 0 };
|
|
817
|
+
const idx = state.entries.findIndex((e) => e.run_id === runId && e.seq === seq);
|
|
818
|
+
if (idx < 0) return { changes: 0 };
|
|
819
|
+
const row = state.entries[idx]!;
|
|
820
|
+
row.kind = String(params[0]);
|
|
821
|
+
row.name = params[1] === null || params[1] === undefined ? null : String(params[1]);
|
|
822
|
+
row.resource = params[2] === null || params[2] === undefined ? null : String(params[2]);
|
|
823
|
+
row.value = params[3] === null || params[3] === undefined ? null : String(params[3]);
|
|
824
|
+
row.at = Number(params[4]);
|
|
825
|
+
return { changes: 1 };
|
|
826
|
+
}
|
|
827
|
+
|
|
657
828
|
if (/^INSERT\s+INTO\s+oke_journal_entries\b/i.test(text)) {
|
|
829
|
+
const fenced = /WHERE\s+EXISTS/i.test(text);
|
|
658
830
|
const row: JournalEntryDbRow = {
|
|
659
831
|
run_id: String(params[0]),
|
|
660
832
|
seq: Number(params[1]),
|
|
@@ -664,9 +836,25 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
664
836
|
value: params[5] === null || params[5] === undefined ? null : String(params[5]),
|
|
665
837
|
at: Number(params[6]),
|
|
666
838
|
};
|
|
839
|
+
if (
|
|
840
|
+
fenced &&
|
|
841
|
+
!fenceHolds(
|
|
842
|
+
state,
|
|
843
|
+
String(params[7]),
|
|
844
|
+
String(params[8]),
|
|
845
|
+
Number(params[9]),
|
|
846
|
+
Number(params[10]),
|
|
847
|
+
)
|
|
848
|
+
) {
|
|
849
|
+
return { changes: 0 };
|
|
850
|
+
}
|
|
667
851
|
const idx = state.entries.findIndex((e) => e.run_id === row.run_id && e.seq === row.seq);
|
|
668
|
-
if (idx >= 0)
|
|
669
|
-
|
|
852
|
+
if (idx >= 0) {
|
|
853
|
+
if (/DO\s+NOTHING/i.test(text)) return { changes: 0 };
|
|
854
|
+
state.entries[idx] = row;
|
|
855
|
+
return { changes: 1 };
|
|
856
|
+
}
|
|
857
|
+
state.entries.push(row);
|
|
670
858
|
return { changes: 1 };
|
|
671
859
|
}
|
|
672
860
|
|
|
@@ -939,7 +1127,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
939
1127
|
return api;
|
|
940
1128
|
}
|
|
941
1129
|
|
|
942
|
-
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, tenant, lease_token, code_version) 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 =
|
|
1130
|
+
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, tenant, lease_token, code_version) 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 = oke_journal_runs.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant, lease_token = GREATEST(COALESCE(oke_journal_runs.lease_token, 0), COALESCE(EXCLUDED.lease_token, 0)), code_version = EXCLUDED.code_version`;
|
|
943
1131
|
|
|
944
1132
|
/** Postgres journal store with run-level lease coordination. */
|
|
945
1133
|
export type PostgresJournalStore = JournalStore &
|
|
@@ -1043,14 +1231,6 @@ export async function createPostgresJournalStore(
|
|
|
1043
1231
|
return run;
|
|
1044
1232
|
}
|
|
1045
1233
|
|
|
1046
|
-
function assertFence(run: JournalRun | undefined, fence: JournalWriteFence | undefined): void {
|
|
1047
|
-
if (!fence || !run) return;
|
|
1048
|
-
const expired = run.leaseExpiresAt !== undefined && run.leaseExpiresAt <= fence.now;
|
|
1049
|
-
if (run.lockedBy !== fence.lockedBy || run.leaseToken !== fence.leaseToken || expired) {
|
|
1050
|
-
throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
1234
|
const store: PostgresJournalStore = {
|
|
1055
1235
|
sql,
|
|
1056
1236
|
get writeBytes() {
|
|
@@ -1065,11 +1245,12 @@ export async function createPostgresJournalStore(
|
|
|
1065
1245
|
return hydrate(rows[0]);
|
|
1066
1246
|
},
|
|
1067
1247
|
async put(run, fence) {
|
|
1248
|
+
writeBytes += JSON.stringify(run.entries).length;
|
|
1068
1249
|
if (fence) {
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1250
|
+
const wrote = await sql.exec(FENCED_HEADER_SQL, fencedHeaderParams(run, fence));
|
|
1251
|
+
if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
|
|
1252
|
+
return;
|
|
1071
1253
|
}
|
|
1072
|
-
writeBytes += JSON.stringify(run.entries).length;
|
|
1073
1254
|
await sql.exec(UPSERT_SQL, runToParams(run));
|
|
1074
1255
|
for (let seq = 0; seq < run.entries.length; seq++) {
|
|
1075
1256
|
const entry = run.entries[seq];
|
|
@@ -1079,14 +1260,44 @@ export async function createPostgresJournalStore(
|
|
|
1079
1260
|
}
|
|
1080
1261
|
},
|
|
1081
1262
|
async appendEntry(runId, seq, entry, fence) {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
assertFence(current, fence);
|
|
1263
|
+
writeBytes += JSON.stringify(entry).length;
|
|
1264
|
+
if (!fence) {
|
|
1265
|
+
await sql.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
|
|
1266
|
+
return;
|
|
1087
1267
|
}
|
|
1268
|
+
const wrote = await sql.exec(APPEND_ENTRY_FENCED_SQL, [
|
|
1269
|
+
...entryToParams(runId, seq, entry),
|
|
1270
|
+
runId,
|
|
1271
|
+
fence.lockedBy,
|
|
1272
|
+
fence.leaseToken,
|
|
1273
|
+
fence.now,
|
|
1274
|
+
]);
|
|
1275
|
+
if (wrote.changes === 1) return;
|
|
1276
|
+
const stored = await sql.query(
|
|
1277
|
+
`SELECT * FROM oke_journal_entries WHERE run_id = ? AND seq = ?`,
|
|
1278
|
+
[runId, seq],
|
|
1279
|
+
);
|
|
1280
|
+
const row = stored[0];
|
|
1281
|
+
if (row && sameEntry(entryFromRow(row), entry)) return;
|
|
1282
|
+
throwOke("JOURNAL_STALE_LEASE", { runId });
|
|
1283
|
+
},
|
|
1284
|
+
async updateEntry(runId, seq, entry, fence) {
|
|
1088
1285
|
writeBytes += JSON.stringify(entry).length;
|
|
1089
|
-
|
|
1286
|
+
const params = entryToParams(runId, seq, entry);
|
|
1287
|
+
const wrote = await sql.exec(UPDATE_ENTRY_FENCED_SQL, [
|
|
1288
|
+
params[2],
|
|
1289
|
+
params[3],
|
|
1290
|
+
params[4],
|
|
1291
|
+
params[5],
|
|
1292
|
+
params[6],
|
|
1293
|
+
runId,
|
|
1294
|
+
seq,
|
|
1295
|
+
runId,
|
|
1296
|
+
fence.lockedBy,
|
|
1297
|
+
fence.leaseToken,
|
|
1298
|
+
fence.now,
|
|
1299
|
+
]);
|
|
1300
|
+
if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
|
|
1090
1301
|
},
|
|
1091
1302
|
async list() {
|
|
1092
1303
|
const rows = await sql.query(`SELECT * FROM oke_journal_runs`);
|
|
@@ -1121,6 +1332,7 @@ export async function createPostgresJournalStore(
|
|
|
1121
1332
|
);
|
|
1122
1333
|
}
|
|
1123
1334
|
const next = update(current) ?? current;
|
|
1335
|
+
const heldToken = current.leaseToken ?? 0;
|
|
1124
1336
|
const renew =
|
|
1125
1337
|
current.lockedBy === instanceId &&
|
|
1126
1338
|
current.leaseExpiresAt !== undefined &&
|
|
@@ -1128,17 +1340,55 @@ export async function createPostgresJournalStore(
|
|
|
1128
1340
|
current.leaseToken !== undefined;
|
|
1129
1341
|
next.lockedBy = instanceId;
|
|
1130
1342
|
next.leaseExpiresAt = now + leaseMs;
|
|
1131
|
-
next.leaseToken = renew ?
|
|
1343
|
+
next.leaseToken = renew ? heldToken : heldToken + 1;
|
|
1344
|
+
const header = { ...next, entries: [] as JournalEntry[] };
|
|
1345
|
+
const wrote = await tx.exec(CAS_HEADER_SQL, casHeaderParams(header, heldToken));
|
|
1346
|
+
if (wrote.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
|
|
1347
|
+
const fence = { lockedBy: instanceId, leaseToken: next.leaseToken, now };
|
|
1132
1348
|
for (let seq = 0; seq < next.entries.length; seq++) {
|
|
1133
1349
|
const entry = next.entries[seq];
|
|
1134
1350
|
if (!entry) continue;
|
|
1135
|
-
|
|
1351
|
+
const prev = current.entries[seq];
|
|
1352
|
+
if (!prev) {
|
|
1353
|
+
const inserted = await tx.exec(APPEND_ENTRY_FENCED_SQL, [
|
|
1354
|
+
...entryToParams(runId, seq, entry),
|
|
1355
|
+
runId,
|
|
1356
|
+
fence.lockedBy,
|
|
1357
|
+
fence.leaseToken,
|
|
1358
|
+
fence.now,
|
|
1359
|
+
]);
|
|
1360
|
+
if (inserted.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
|
|
1361
|
+
} else if (!sameEntry(prev, entry)) {
|
|
1362
|
+
const params = entryToParams(runId, seq, entry);
|
|
1363
|
+
const updated = await tx.exec(UPDATE_ENTRY_FENCED_SQL, [
|
|
1364
|
+
params[2],
|
|
1365
|
+
params[3],
|
|
1366
|
+
params[4],
|
|
1367
|
+
params[5],
|
|
1368
|
+
params[6],
|
|
1369
|
+
runId,
|
|
1370
|
+
seq,
|
|
1371
|
+
runId,
|
|
1372
|
+
fence.lockedBy,
|
|
1373
|
+
fence.leaseToken,
|
|
1374
|
+
fence.now,
|
|
1375
|
+
]);
|
|
1376
|
+
if (updated.changes === 0) throwOke("JOURNAL_STALE_LEASE", { runId });
|
|
1377
|
+
}
|
|
1136
1378
|
}
|
|
1137
|
-
const header = { ...next, entries: [] as JournalEntry[] };
|
|
1138
|
-
await tx.exec(UPSERT_SQL, runToParams(header));
|
|
1139
1379
|
return "ok";
|
|
1140
1380
|
});
|
|
1141
1381
|
},
|
|
1382
|
+
async renewLeaseExpiry(runId, expiresAt, fence) {
|
|
1383
|
+
const wrote = await sql.exec(HEARTBEAT_SQL, [
|
|
1384
|
+
expiresAt,
|
|
1385
|
+
runId,
|
|
1386
|
+
fence.lockedBy,
|
|
1387
|
+
fence.leaseToken,
|
|
1388
|
+
fence.now,
|
|
1389
|
+
]);
|
|
1390
|
+
return wrote.changes === 1;
|
|
1391
|
+
},
|
|
1142
1392
|
async releaseLease(runId, instanceId) {
|
|
1143
1393
|
await sql.exec(RELEASE_LEASE_SQL, [runId, instanceId]);
|
|
1144
1394
|
},
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isPostgresConnectionNoise,
|
|
10
10
|
isSharedPostgresPaused,
|
|
11
11
|
pauseSharedPostgresClients,
|
|
12
|
+
PostgresDriverError,
|
|
12
13
|
resumeSharedPostgresClients,
|
|
13
14
|
SharedPostgresPausedError,
|
|
14
15
|
sharedPostgresClient,
|
|
@@ -101,3 +102,62 @@ describe("pauseSharedPostgresClients", () => {
|
|
|
101
102
|
expect(isPostgresConnectionNoise(new Error("relation missing"))).toBe(false);
|
|
102
103
|
});
|
|
103
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("PostgresDriverError", () => {
|
|
107
|
+
test("a non-Error rejection copies SQLSTATE fields and sets cause", async () => {
|
|
108
|
+
const cause = {
|
|
109
|
+
code: "ERR_POSTGRES_SERVER_ERROR",
|
|
110
|
+
errno: "23505",
|
|
111
|
+
constraint: "users_pkey",
|
|
112
|
+
table: "users",
|
|
113
|
+
column: "id",
|
|
114
|
+
message: "duplicate key",
|
|
115
|
+
};
|
|
116
|
+
const conn = await connectPostgres({
|
|
117
|
+
client: {
|
|
118
|
+
async unsafe() {
|
|
119
|
+
throw cause;
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
let caught: unknown;
|
|
124
|
+
try {
|
|
125
|
+
await conn.query("SELECT 1");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
caught = err;
|
|
128
|
+
} finally {
|
|
129
|
+
await conn.close();
|
|
130
|
+
}
|
|
131
|
+
expect(caught).toBeInstanceOf(PostgresDriverError);
|
|
132
|
+
const wrapped = caught as PostgresDriverError;
|
|
133
|
+
expect(wrapped.code).toBe("ERR_POSTGRES_SERVER_ERROR");
|
|
134
|
+
expect(wrapped.errno).toBe("23505");
|
|
135
|
+
expect(wrapped.constraint).toBe("users_pkey");
|
|
136
|
+
expect(wrapped.table).toBe("users");
|
|
137
|
+
expect(wrapped.column).toBe("id");
|
|
138
|
+
expect(wrapped.cause).toBe(cause);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an Error rejection is rethrown unchanged", async () => {
|
|
142
|
+
const original = Object.assign(new Error("duplicate key"), {
|
|
143
|
+
code: "23505",
|
|
144
|
+
errno: "23505",
|
|
145
|
+
});
|
|
146
|
+
const conn = await connectPostgres({
|
|
147
|
+
client: {
|
|
148
|
+
async unsafe() {
|
|
149
|
+
throw original;
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
let caught: unknown;
|
|
154
|
+
try {
|
|
155
|
+
await conn.query("SELECT 1");
|
|
156
|
+
} catch (err) {
|
|
157
|
+
caught = err;
|
|
158
|
+
} finally {
|
|
159
|
+
await conn.close();
|
|
160
|
+
}
|
|
161
|
+
expect(caught).toBe(original);
|
|
162
|
+
});
|
|
163
|
+
});
|