okengine 0.23.0 → 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 +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- 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/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- 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/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.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 +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -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 +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- 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.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- 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/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -3,9 +3,23 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { encodeExecuteResult } from "../../compiler/response.ts";
|
|
7
|
+
import { connectPostgres } from "../../drivers/postgres.ts";
|
|
6
8
|
import { isFlowFailure } from "../../kernel/hooks.ts";
|
|
7
9
|
import { isRetryableSqlError, sqlErrorToFailure } from "./sql-errors.ts";
|
|
8
10
|
|
|
11
|
+
const LIVE_POSTGRES_URL =
|
|
12
|
+
process.env.OKE_TEST_POSTGRES_URL?.trim() ||
|
|
13
|
+
(process.env.OKE_TEST_POSTGRES === "1"
|
|
14
|
+
? (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.trim()
|
|
15
|
+
: undefined);
|
|
16
|
+
|
|
17
|
+
if (!LIVE_POSTGRES_URL) {
|
|
18
|
+
console.log(
|
|
19
|
+
"skip: bun duplicate-key SQLSTATE (set OKE_TEST_POSTGRES_URL or OKE_TEST_POSTGRES=1 + DATABASE_URL/OKE_STORE_SQL_URL)",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
describe("sqlErrorToFailure", () => {
|
|
10
24
|
test("unique / exclusion → Conflict without copying message", () => {
|
|
11
25
|
const unique = sqlErrorToFailure({
|
|
@@ -194,4 +208,121 @@ describe("sqlErrorToFailure", () => {
|
|
|
194
208
|
expect(sqlErrorToFailure(new Error("boom"))).toBeUndefined();
|
|
195
209
|
expect(isFlowFailure(sqlErrorToFailure({ code: "23505" }))).toBe(true);
|
|
196
210
|
});
|
|
211
|
+
|
|
212
|
+
test("Bun ERR_POSTGRES_SERVER_ERROR + string errno 23505 → Conflict 409", async () => {
|
|
213
|
+
const driver = {
|
|
214
|
+
code: "ERR_POSTGRES_SERVER_ERROR",
|
|
215
|
+
errno: "23505",
|
|
216
|
+
constraint: "users_email_key",
|
|
217
|
+
table: "users",
|
|
218
|
+
message: 'duplicate key value violates unique constraint "users_email_key"',
|
|
219
|
+
};
|
|
220
|
+
const mapped = sqlErrorToFailure(driver);
|
|
221
|
+
expect(mapped?.error.code).toBe("Conflict");
|
|
222
|
+
expect(mapped?.error.data).toMatchObject({
|
|
223
|
+
sqlstate: "23505",
|
|
224
|
+
constraint: "users_email_key",
|
|
225
|
+
table: "users",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const res = await encodeExecuteResult({ error: driver });
|
|
229
|
+
expect(res.status).toBe(409);
|
|
230
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
231
|
+
expect(body.error.code).toBe("Conflict");
|
|
232
|
+
expect(JSON.stringify(body)).not.toContain("duplicate key");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("string errno 40P01 is retryable; exhausted 40001 stays ServiceUnavailable", async () => {
|
|
236
|
+
expect(isRetryableSqlError({ errno: "40P01" })).toBe(true);
|
|
237
|
+
expect(
|
|
238
|
+
sqlErrorToFailure(
|
|
239
|
+
{ code: "ERR_POSTGRES_SERVER_ERROR", errno: "40P01" },
|
|
240
|
+
{ retryable: "leave" },
|
|
241
|
+
),
|
|
242
|
+
).toBeUndefined();
|
|
243
|
+
expect(
|
|
244
|
+
sqlErrorToFailure(
|
|
245
|
+
{ code: "ERR_POSTGRES_SERVER_ERROR", errno: "40001" },
|
|
246
|
+
{ retryable: "unavailable" },
|
|
247
|
+
)?.error.code,
|
|
248
|
+
).toBe("ServiceUnavailable");
|
|
249
|
+
|
|
250
|
+
const exhausted = await encodeExecuteResult({
|
|
251
|
+
error: {
|
|
252
|
+
code: "ERR_POSTGRES_SERVER_ERROR",
|
|
253
|
+
errno: "40001",
|
|
254
|
+
message: "could not serialize access",
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
expect(exhausted.status).toBe(503);
|
|
258
|
+
const body = (await exhausted.json()) as { error: { code: string } };
|
|
259
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
260
|
+
expect(JSON.stringify(body)).not.toContain("serialize");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("42804 stays 422; other 42xxx are DatabaseError unknown", async () => {
|
|
264
|
+
const datatype = await encodeExecuteResult({ error: { code: "42804" } });
|
|
265
|
+
expect(datatype.status).toBe(422);
|
|
266
|
+
|
|
267
|
+
const syntax = await encodeExecuteResult({
|
|
268
|
+
error: { code: "42601", message: "syntax error at or near" },
|
|
269
|
+
});
|
|
270
|
+
expect(syntax.status).toBe(500);
|
|
271
|
+
const body = (await syntax.json()) as {
|
|
272
|
+
error: { code: string; data?: { reason?: string; sqlstate?: string } };
|
|
273
|
+
};
|
|
274
|
+
expect(body.error.code).toBe("DatabaseError");
|
|
275
|
+
expect(body.error.data).toMatchObject({ reason: "unknown", sqlstate: "42601" });
|
|
276
|
+
expect(JSON.stringify(body)).not.toContain("syntax error");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("ERR_POSTGRES_SERVER_ERROR without a SQLSTATE is not unavailable", () => {
|
|
280
|
+
const mapped = sqlErrorToFailure({
|
|
281
|
+
code: "ERR_POSTGRES_SERVER_ERROR",
|
|
282
|
+
message: "syntax error at or near select",
|
|
283
|
+
});
|
|
284
|
+
expect(mapped?.error.code).toBe("DatabaseError");
|
|
285
|
+
expect(mapped?.error.code).not.toBe("ServiceUnavailable");
|
|
286
|
+
expect(sqlErrorToFailure({ code: "ERR_POSTGRES_CONNECTION_CLOSED" })?.error.code).toBe(
|
|
287
|
+
"ServiceUnavailable",
|
|
288
|
+
);
|
|
289
|
+
expect(
|
|
290
|
+
sqlErrorToFailure({ code: "ERR_POSTGRES_SERVER_ERROR", errno: "53300" })?.error.code,
|
|
291
|
+
).toBe("ServiceUnavailable");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe.skipIf(!LIVE_POSTGRES_URL)("bun postgres duplicate key", () => {
|
|
296
|
+
test("a real duplicate-key error is HTTP 409", async () => {
|
|
297
|
+
const url = LIVE_POSTGRES_URL;
|
|
298
|
+
if (!url) throw new Error("postgres url missing");
|
|
299
|
+
const conn = await connectPostgres({ url, pool: { max: 1 } });
|
|
300
|
+
const table = `oke_sqlstate_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
301
|
+
try {
|
|
302
|
+
await conn.exec(`CREATE TABLE "${table}" (id text PRIMARY KEY)`);
|
|
303
|
+
await conn.exec(`INSERT INTO "${table}" (id) VALUES ('a')`);
|
|
304
|
+
let caught: unknown;
|
|
305
|
+
try {
|
|
306
|
+
await conn.exec(`INSERT INTO "${table}" (id) VALUES ('a')`);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
caught = err;
|
|
309
|
+
}
|
|
310
|
+
expect(caught).toBeDefined();
|
|
311
|
+
const shape =
|
|
312
|
+
caught !== null && typeof caught === "object"
|
|
313
|
+
? {
|
|
314
|
+
name: "name" in caught ? caught.name : undefined,
|
|
315
|
+
code: "code" in caught ? caught.code : undefined,
|
|
316
|
+
errno: "errno" in caught ? caught.errno : undefined,
|
|
317
|
+
}
|
|
318
|
+
: { caught };
|
|
319
|
+
const res = await encodeExecuteResult({ error: caught });
|
|
320
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
321
|
+
expect(res.status, JSON.stringify(shape)).toBe(409);
|
|
322
|
+
expect(body.error.code).toBe("Conflict");
|
|
323
|
+
} finally {
|
|
324
|
+
await conn.exec(`DROP TABLE IF EXISTS "${table}"`);
|
|
325
|
+
await conn.close();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
197
328
|
});
|
|
@@ -113,16 +113,16 @@ export function sqlErrorToFailure(
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function mapConstraint(err: unknown): SqlFailure | undefined {
|
|
116
|
-
const
|
|
116
|
+
const state = sqlState(err);
|
|
117
117
|
const names = publicNames(err);
|
|
118
118
|
|
|
119
|
-
if (CONFLICT_STATE.has(
|
|
120
|
-
if (FOREIGN_KEY_STATE.has(
|
|
121
|
-
if (NOT_NULL_STATE.has(
|
|
122
|
-
if (CHECK_STATE.has(
|
|
123
|
-
if (TOO_LONG_STATE.has(
|
|
124
|
-
if (OUT_OF_RANGE_STATE.has(
|
|
125
|
-
if (INVALID_STATE.has(
|
|
119
|
+
if (CONFLICT_STATE.has(state)) return fail.conflict(names);
|
|
120
|
+
if (FOREIGN_KEY_STATE.has(state)) return fail.foreignKey(names);
|
|
121
|
+
if (NOT_NULL_STATE.has(state)) return fail.database({ reason: "not_null", ...names });
|
|
122
|
+
if (CHECK_STATE.has(state)) return fail.database({ reason: "check", ...names });
|
|
123
|
+
if (TOO_LONG_STATE.has(state)) return fail.database({ reason: "too_long", ...names });
|
|
124
|
+
if (OUT_OF_RANGE_STATE.has(state)) return fail.database({ reason: "out_of_range", ...names });
|
|
125
|
+
if (INVALID_STATE.has(state)) return fail.database({ reason: "invalid", ...names });
|
|
126
126
|
|
|
127
127
|
const sqlite = sqliteConstraint(err);
|
|
128
128
|
if (sqlite === "unique") return fail.conflict(names);
|
|
@@ -134,8 +134,8 @@ function mapConstraint(err: unknown): SqlFailure | undefined {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
function retrySignal(err: unknown): boolean {
|
|
137
|
-
const
|
|
138
|
-
if (RETRY_STATE.has(
|
|
137
|
+
const state = sqlState(err);
|
|
138
|
+
if (RETRY_STATE.has(state)) return true;
|
|
139
139
|
const errno = sqlErrno(err);
|
|
140
140
|
if (errno !== undefined && SQLITE_RETRY_ERRNO.has(errno)) return true;
|
|
141
141
|
const named = sqlCode(err);
|
|
@@ -147,11 +147,14 @@ function retrySignal(err: unknown): boolean {
|
|
|
147
147
|
|
|
148
148
|
function isConnectionUnavailable(err: unknown): boolean {
|
|
149
149
|
const e = err as SqlErrShape;
|
|
150
|
+
const state = sqlState(err);
|
|
151
|
+
if (state.length > 0) {
|
|
152
|
+
// A SQLSTATE decides. `ERR_POSTGRES_SERVER_ERROR` is not unavailable on its own.
|
|
153
|
+
if (state.startsWith("08") || state.startsWith("53")) return true;
|
|
154
|
+
return UNAVAILABLE_STATE.has(state);
|
|
155
|
+
}
|
|
150
156
|
const code = sqlCode(err);
|
|
151
|
-
if (code.startsWith("
|
|
152
|
-
if (code.startsWith("53")) return true;
|
|
153
|
-
if (UNAVAILABLE_STATE.has(code)) return true;
|
|
154
|
-
if (code.startsWith("ERR_POSTGRES_")) return true;
|
|
157
|
+
if (code.startsWith("ERR_POSTGRES_CONNECTION_")) return true;
|
|
155
158
|
if (code === "ERR_OKE_POSTGRES_PAUSED") return true;
|
|
156
159
|
if (typeof e.name === "string" && e.name === "SharedPostgresPausedError") return true;
|
|
157
160
|
const errno = sqlErrno(err);
|
|
@@ -166,20 +169,40 @@ function isConnectionUnavailable(err: unknown): boolean {
|
|
|
166
169
|
|
|
167
170
|
function looksLikeSqlError(err: unknown): boolean {
|
|
168
171
|
const e = err as SqlErrShape;
|
|
172
|
+
if (sqlState(err).length > 0) return true;
|
|
169
173
|
const code = sqlCode(err);
|
|
170
|
-
if (/^[0-9A-Z]{5}$/.test(code)) return true;
|
|
171
174
|
if (code.startsWith("SQLITE_")) return true;
|
|
172
175
|
if (typeof e.name === "string" && /postgres|sqlite|pglite|libsql/i.test(e.name)) return true;
|
|
173
176
|
const message = typeof e.message === "string" ? e.message : "";
|
|
174
177
|
return /sqlstate|constraint|syntax error/i.test(message);
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
/** Five-character Postgres SQLSTATE (`23505`). */
|
|
181
|
+
const SQLSTATE_RE = /^[0-9A-Z]{5}$/;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Postgres SQLSTATE on a driver error.
|
|
185
|
+
*
|
|
186
|
+
* A five-character `code` wins. Otherwise a five-character string `errno`
|
|
187
|
+
* (Bun puts `ERR_POSTGRES_SERVER_ERROR` on `code` and the SQLSTATE on `errno`).
|
|
188
|
+
*
|
|
189
|
+
* @param err - Caught driver value
|
|
190
|
+
*/
|
|
191
|
+
function sqlState(err: unknown): string {
|
|
192
|
+
if (!err || typeof err !== "object") return "";
|
|
193
|
+
const e = err as SqlErrShape;
|
|
194
|
+
if (typeof e.code === "string" && SQLSTATE_RE.test(e.code)) return e.code;
|
|
195
|
+
if (typeof e.errno === "string" && SQLSTATE_RE.test(e.errno)) return e.errno;
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
|
|
177
199
|
function sqlCode(err: unknown): string {
|
|
178
200
|
if (!err || typeof err !== "object") return "";
|
|
179
201
|
const code = (err as SqlErrShape).code;
|
|
180
202
|
return typeof code === "string" ? code : "";
|
|
181
203
|
}
|
|
182
204
|
|
|
205
|
+
/** Numeric errno only (bun:sqlite). String errno is a SQLSTATE — see {@link sqlState}. */
|
|
183
206
|
function sqlErrno(err: unknown): number | undefined {
|
|
184
207
|
if (!err || typeof err !== "object") return undefined;
|
|
185
208
|
const errno = (err as SqlErrShape).errno;
|
|
@@ -193,7 +216,7 @@ function publicNames(err: unknown): {
|
|
|
193
216
|
readonly column?: string;
|
|
194
217
|
} {
|
|
195
218
|
const e = err as SqlErrShape;
|
|
196
|
-
const sqlstate =
|
|
219
|
+
const sqlstate = sqlState(err);
|
|
197
220
|
const constraint = firstString(e.constraint, e.constraint_name);
|
|
198
221
|
const table = firstString(e.table, e.table_name);
|
|
199
222
|
const column = firstString(e.column, e.column_name);
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested `transaction()` uses SAVEPOINT on the pinned connection.
|
|
3
|
+
* Live Postgres cases need `OKE_TEST_POSTGRES_URL` or `OKE_TEST_POSTGRES=1`
|
|
4
|
+
* plus `DATABASE_URL` / `OKE_STORE_SQL_URL`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { SignalBus } from "../../drivers/signal-types.ts";
|
|
9
|
+
import { memorySignalDriver } from "../../drivers/signal-memory.ts";
|
|
10
|
+
import { connectPostgres } from "../../drivers/postgres.ts";
|
|
11
|
+
import type { SqlConnection, SqlRow } from "../../drivers/types.ts";
|
|
12
|
+
import { createFxContext } from "../../kernel/fx.ts";
|
|
13
|
+
import type { SignalRuntime } from "../signal/runtime.ts";
|
|
14
|
+
import { signal } from "../signal.ts";
|
|
15
|
+
import type { StoreRuntime } from "./runtime.ts";
|
|
16
|
+
import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
|
|
17
|
+
import { store } from "./declare.ts";
|
|
18
|
+
|
|
19
|
+
const LIVE_POSTGRES_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
|
+
if (!LIVE_POSTGRES_URL) {
|
|
26
|
+
console.log(
|
|
27
|
+
"skip: nested transaction live postgres (set OKE_TEST_POSTGRES_URL or OKE_TEST_POSTGRES=1 + DATABASE_URL/OKE_STORE_SQL_URL)",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scriptedConnection(): { readonly connection: SqlConnection; readonly log: string[] } {
|
|
32
|
+
const log: string[] = [];
|
|
33
|
+
const pinned: SqlConnection = {
|
|
34
|
+
driverId: "postgres",
|
|
35
|
+
role: "primary",
|
|
36
|
+
async query() {
|
|
37
|
+
return [];
|
|
38
|
+
},
|
|
39
|
+
async exec(sql) {
|
|
40
|
+
log.push(sql);
|
|
41
|
+
return { changes: 0 };
|
|
42
|
+
},
|
|
43
|
+
async transaction() {
|
|
44
|
+
throw new Error("nested transaction reserved a second connection");
|
|
45
|
+
},
|
|
46
|
+
async close() {},
|
|
47
|
+
};
|
|
48
|
+
const connection: SqlConnection = {
|
|
49
|
+
driverId: "postgres",
|
|
50
|
+
role: "primary",
|
|
51
|
+
async query() {
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
async exec(sql) {
|
|
55
|
+
log.push(sql);
|
|
56
|
+
return { changes: 0 };
|
|
57
|
+
},
|
|
58
|
+
async transaction(fn) {
|
|
59
|
+
log.push("BEGIN");
|
|
60
|
+
try {
|
|
61
|
+
const result = await fn(pinned);
|
|
62
|
+
log.push("COMMIT");
|
|
63
|
+
return result;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log.push("ROLLBACK");
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async close() {},
|
|
70
|
+
};
|
|
71
|
+
return { connection, log };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleFor(connection: SqlConnection): SqlStoreHandle {
|
|
75
|
+
return createSqlStoreHandle("sql:db", {
|
|
76
|
+
connection,
|
|
77
|
+
classifications: new Map(),
|
|
78
|
+
routedRole: "primary",
|
|
79
|
+
domainDdl: "off",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("nested transaction (fake connection)", () => {
|
|
84
|
+
test("nested calls SAVEPOINT and RELEASE on the pinned connection", async () => {
|
|
85
|
+
const { connection, log } = scriptedConnection();
|
|
86
|
+
const handle = handleFor(connection);
|
|
87
|
+
await handle.transaction(async (tx) => {
|
|
88
|
+
await tx.transaction(async (inner) => {
|
|
89
|
+
await inner.transaction(async () => undefined);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
expect(log).toEqual([
|
|
93
|
+
"BEGIN",
|
|
94
|
+
'SAVEPOINT "oke_sp_1_1"',
|
|
95
|
+
'SAVEPOINT "oke_sp_2_2"',
|
|
96
|
+
'RELEASE SAVEPOINT "oke_sp_2_2"',
|
|
97
|
+
'RELEASE SAVEPOINT "oke_sp_1_1"',
|
|
98
|
+
"COMMIT",
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("sibling savepoints get distinct names", async () => {
|
|
103
|
+
const { connection, log } = scriptedConnection();
|
|
104
|
+
const handle = handleFor(connection);
|
|
105
|
+
await handle.transaction(async (tx) => {
|
|
106
|
+
await tx.transaction(async () => undefined);
|
|
107
|
+
await tx.transaction(async () => undefined);
|
|
108
|
+
});
|
|
109
|
+
expect(log).toEqual([
|
|
110
|
+
"BEGIN",
|
|
111
|
+
'SAVEPOINT "oke_sp_1_1"',
|
|
112
|
+
'RELEASE SAVEPOINT "oke_sp_1_1"',
|
|
113
|
+
'SAVEPOINT "oke_sp_1_2"',
|
|
114
|
+
'RELEASE SAVEPOINT "oke_sp_1_2"',
|
|
115
|
+
"COMMIT",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("inner throw rolls back to the savepoint even when the outer catch swallows it", async () => {
|
|
120
|
+
const { connection, log } = scriptedConnection();
|
|
121
|
+
const handle = handleFor(connection);
|
|
122
|
+
await handle.transaction(async (tx) => {
|
|
123
|
+
await tx.raw("INSERT INTO notes (id) VALUES ('outer')");
|
|
124
|
+
try {
|
|
125
|
+
await tx.transaction(async (inner) => {
|
|
126
|
+
await inner.raw("INSERT INTO notes (id) VALUES ('inner')");
|
|
127
|
+
throw new Error("inner");
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
expect(err).toBeInstanceOf(Error);
|
|
131
|
+
expect((err as Error).message).toBe("inner");
|
|
132
|
+
}
|
|
133
|
+
await tx.raw("INSERT INTO notes (id) VALUES ('after')");
|
|
134
|
+
});
|
|
135
|
+
expect(log).toEqual([
|
|
136
|
+
"BEGIN",
|
|
137
|
+
"INSERT INTO notes (id) VALUES ('outer')",
|
|
138
|
+
'SAVEPOINT "oke_sp_1_1"',
|
|
139
|
+
"INSERT INTO notes (id) VALUES ('inner')",
|
|
140
|
+
'ROLLBACK TO SAVEPOINT "oke_sp_1_1"',
|
|
141
|
+
"INSERT INTO notes (id) VALUES ('after')",
|
|
142
|
+
"COMMIT",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("concurrent inner transactions on one connection do not interleave", async () => {
|
|
147
|
+
const { connection } = scriptedConnection();
|
|
148
|
+
const handle = handleFor(connection);
|
|
149
|
+
const order: string[] = [];
|
|
150
|
+
await handle.transaction(async (tx) => {
|
|
151
|
+
await Promise.all([
|
|
152
|
+
tx.transaction(async () => {
|
|
153
|
+
order.push("a1");
|
|
154
|
+
await Bun.sleep(30);
|
|
155
|
+
order.push("a2");
|
|
156
|
+
}),
|
|
157
|
+
tx.transaction(async () => {
|
|
158
|
+
order.push("b1");
|
|
159
|
+
await Bun.sleep(30);
|
|
160
|
+
order.push("b2");
|
|
161
|
+
}),
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
expect(order).toEqual(order[0] === "a1" ? ["a1", "a2", "b1", "b2"] : ["b1", "b2", "a1", "a2"]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("nested transaction signals", () => {
|
|
169
|
+
async function openFx(connection: SqlConnection): Promise<{
|
|
170
|
+
readonly fx: ReturnType<typeof createFxContext>["fx"];
|
|
171
|
+
readonly bus: SignalBus;
|
|
172
|
+
readonly ping: ReturnType<typeof signal.once>;
|
|
173
|
+
readonly close: () => Promise<void>;
|
|
174
|
+
}> {
|
|
175
|
+
const ping = signal.once("ping");
|
|
176
|
+
const signals = new Map([[ping.name, ping]]);
|
|
177
|
+
const bus = await memorySignalDriver.open({ signals });
|
|
178
|
+
const signalRuntime: SignalRuntime = {
|
|
179
|
+
driverId: "memory",
|
|
180
|
+
get bus() {
|
|
181
|
+
return bus;
|
|
182
|
+
},
|
|
183
|
+
declarations: signals,
|
|
184
|
+
register(decl) {
|
|
185
|
+
signals.set(decl.name, decl);
|
|
186
|
+
},
|
|
187
|
+
async start() {
|
|
188
|
+
return bus;
|
|
189
|
+
},
|
|
190
|
+
emit: (name, payload, options) => bus.emit(name, payload, options),
|
|
191
|
+
deadLetters: (name) => bus.deadLetters(name),
|
|
192
|
+
live: (name, opts) => bus.live(name, opts),
|
|
193
|
+
checkLiveResume: (name, afterId) => bus.checkLiveResume(name, afterId),
|
|
194
|
+
async close() {
|
|
195
|
+
await bus.close();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
const storeRuntime = {
|
|
199
|
+
async open() {
|
|
200
|
+
return handleFor(connection);
|
|
201
|
+
},
|
|
202
|
+
} as unknown as StoreRuntime;
|
|
203
|
+
const { fx } = createFxContext({
|
|
204
|
+
flow: "notes.write",
|
|
205
|
+
effects: { writes: ["sql:db"], emits: ["ping"] },
|
|
206
|
+
storeRuntime,
|
|
207
|
+
signalRuntime,
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
fx,
|
|
211
|
+
bus,
|
|
212
|
+
ping,
|
|
213
|
+
async close() {
|
|
214
|
+
await bus.close();
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
test("nested emits merge and publish only on the outermost commit", async () => {
|
|
220
|
+
const { connection } = scriptedConnection();
|
|
221
|
+
const { fx, bus, ping, close } = await openFx(connection);
|
|
222
|
+
const got: unknown[] = [];
|
|
223
|
+
await bus.subscribe("ping", "c", async (message) => {
|
|
224
|
+
got.push(message.payload);
|
|
225
|
+
});
|
|
226
|
+
const db = store.sql("db");
|
|
227
|
+
try {
|
|
228
|
+
await fx.store(db).transaction(async (tx) => {
|
|
229
|
+
await fx.emit(ping, { id: "outer" });
|
|
230
|
+
await tx.transaction(async () => {
|
|
231
|
+
await fx.emit(ping, { id: "inner" });
|
|
232
|
+
});
|
|
233
|
+
await bus.drain();
|
|
234
|
+
expect(got).toEqual([]);
|
|
235
|
+
});
|
|
236
|
+
await bus.drain();
|
|
237
|
+
expect(got).toEqual([{ id: "outer" }, { id: "inner" }]);
|
|
238
|
+
} finally {
|
|
239
|
+
await close();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a thrown nested transaction discards its emits", async () => {
|
|
244
|
+
const { connection } = scriptedConnection();
|
|
245
|
+
const { fx, bus, ping, close } = await openFx(connection);
|
|
246
|
+
const got: unknown[] = [];
|
|
247
|
+
await bus.subscribe("ping", "c", async (message) => {
|
|
248
|
+
got.push(message.payload);
|
|
249
|
+
});
|
|
250
|
+
const db = store.sql("db");
|
|
251
|
+
try {
|
|
252
|
+
await fx.store(db).transaction(async (tx) => {
|
|
253
|
+
await fx.emit(ping, { id: "outer" });
|
|
254
|
+
try {
|
|
255
|
+
await tx.transaction(async () => {
|
|
256
|
+
await fx.emit(ping, { id: "no" });
|
|
257
|
+
throw new Error("nope");
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
expect((err as Error).message).toBe("nope");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
await bus.drain();
|
|
264
|
+
expect(got).toEqual([{ id: "outer" }]);
|
|
265
|
+
} finally {
|
|
266
|
+
await close();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
function scratchTable(prefix: string): string {
|
|
272
|
+
const name = `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
273
|
+
if (!/^[a-z][a-z0-9_]*$/.test(name)) {
|
|
274
|
+
throw new Error(`unsafe table name: ${name}`);
|
|
275
|
+
}
|
|
276
|
+
return name;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
describe.skipIf(!LIVE_POSTGRES_URL)("nested transaction (postgres)", () => {
|
|
280
|
+
test("inner throw rolls back only the inner write; outer commit keeps the outer write", async () => {
|
|
281
|
+
const url = LIVE_POSTGRES_URL;
|
|
282
|
+
if (!url) throw new Error("postgres url missing");
|
|
283
|
+
const conn = await connectPostgres({ url, pool: { max: 1 } });
|
|
284
|
+
const table = scratchTable("oke_nested");
|
|
285
|
+
const handle = handleFor(conn);
|
|
286
|
+
try {
|
|
287
|
+
await conn.exec(`CREATE TABLE "${table}" (id text PRIMARY KEY, note text)`);
|
|
288
|
+
await handle.transaction(async (tx) => {
|
|
289
|
+
await tx.raw(`INSERT INTO "${table}" (id, note) VALUES (?, ?)`, ["outer", "keep"]);
|
|
290
|
+
try {
|
|
291
|
+
await tx.transaction(async (inner) => {
|
|
292
|
+
await inner.raw(`INSERT INTO "${table}" (id, note) VALUES (?, ?)`, ["inner", "drop"]);
|
|
293
|
+
throw new Error("inner");
|
|
294
|
+
});
|
|
295
|
+
} catch (err) {
|
|
296
|
+
expect((err as Error).message).toBe("inner");
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const rows = await conn.query(`SELECT id FROM "${table}" ORDER BY id`);
|
|
300
|
+
expect(rows.map((row) => row.id)).toEqual(["outer"]);
|
|
301
|
+
} finally {
|
|
302
|
+
await conn.exec(`DROP TABLE IF EXISTS "${table}"`);
|
|
303
|
+
await conn.close();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("two concurrent inner transactions on one connection do not interleave", async () => {
|
|
308
|
+
const url = LIVE_POSTGRES_URL;
|
|
309
|
+
if (!url) throw new Error("postgres url missing");
|
|
310
|
+
const conn = await connectPostgres({ url, pool: { max: 1 } });
|
|
311
|
+
const table = scratchTable("oke_nested_race");
|
|
312
|
+
const handle = handleFor(conn);
|
|
313
|
+
const order: string[] = [];
|
|
314
|
+
try {
|
|
315
|
+
await conn.exec(`CREATE TABLE "${table}" (id text PRIMARY KEY)`);
|
|
316
|
+
await handle.transaction(async (tx) => {
|
|
317
|
+
await Promise.all([
|
|
318
|
+
tx.transaction(async (inner) => {
|
|
319
|
+
await inner.raw(`INSERT INTO "${table}" (id) VALUES (?)`, ["a1"]);
|
|
320
|
+
order.push("a1");
|
|
321
|
+
await Bun.sleep(40);
|
|
322
|
+
await inner.raw(`INSERT INTO "${table}" (id) VALUES (?)`, ["a2"]);
|
|
323
|
+
order.push("a2");
|
|
324
|
+
}),
|
|
325
|
+
tx.transaction(async (inner) => {
|
|
326
|
+
await inner.raw(`INSERT INTO "${table}" (id) VALUES (?)`, ["b1"]);
|
|
327
|
+
order.push("b1");
|
|
328
|
+
await Bun.sleep(40);
|
|
329
|
+
await inner.raw(`INSERT INTO "${table}" (id) VALUES (?)`, ["b2"]);
|
|
330
|
+
order.push("b2");
|
|
331
|
+
}),
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
expect(order).toEqual(
|
|
335
|
+
order[0] === "a1" ? ["a1", "a2", "b1", "b2"] : ["b1", "b2", "a1", "a2"],
|
|
336
|
+
);
|
|
337
|
+
const rows = await conn.query(`SELECT id FROM "${table}" ORDER BY id`);
|
|
338
|
+
expect(rows.map((row: SqlRow) => row.id)).toEqual(["a1", "a2", "b1", "b2"]);
|
|
339
|
+
} finally {
|
|
340
|
+
await conn.exec(`DROP TABLE IF EXISTS "${table}"`);
|
|
341
|
+
await conn.close();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|