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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. 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 code = sqlCode(err);
116
+ const state = sqlState(err);
117
117
  const names = publicNames(err);
118
118
 
119
- if (CONFLICT_STATE.has(code)) return fail.conflict(names);
120
- if (FOREIGN_KEY_STATE.has(code)) return fail.foreignKey(names);
121
- if (NOT_NULL_STATE.has(code)) return fail.database({ reason: "not_null", ...names });
122
- if (CHECK_STATE.has(code)) return fail.database({ reason: "check", ...names });
123
- if (TOO_LONG_STATE.has(code)) return fail.database({ reason: "too_long", ...names });
124
- if (OUT_OF_RANGE_STATE.has(code)) return fail.database({ reason: "out_of_range", ...names });
125
- if (INVALID_STATE.has(code)) return fail.database({ reason: "invalid", ...names });
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 code = sqlCode(err);
138
- if (RETRY_STATE.has(code)) return true;
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("08")) return true;
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 = sqlCode(err);
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
+ });