@palbase/backend 23.0.0 → 23.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/bin/palbase-backend.cjs +313 -24
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-Y5HXVUMP.js → chunk-HQRJDARQ.js} +2 -2
  5. package/dist/{chunk-REZU6UKT.js → chunk-M5MCBWJI.js} +296 -25
  6. package/dist/chunk-M5MCBWJI.js.map +1 -0
  7. package/dist/{chunk-W5ODXPY3.js → chunk-NS5V43YQ.js} +14 -1
  8. package/dist/chunk-NS5V43YQ.js.map +1 -0
  9. package/dist/{chunk-FSGSB42K.js → chunk-OHALWEOG.js} +9 -4
  10. package/dist/chunk-OHALWEOG.js.map +1 -0
  11. package/dist/{chunk-HAF67F2H.js → chunk-PY7YJDCT.js} +57 -3
  12. package/dist/chunk-PY7YJDCT.js.map +1 -0
  13. package/dist/{chunk-ZC6Q2BRD.js → chunk-R3KN6RHD.js} +2 -2
  14. package/dist/{chunk-OMRTHM4X.js → chunk-RCLNBJCM.js} +1 -1
  15. package/dist/chunk-RCLNBJCM.js.map +1 -0
  16. package/dist/db/index.cjs +68 -1
  17. package/dist/db/index.cjs.map +1 -1
  18. package/dist/db/index.d.cts +2 -2
  19. package/dist/db/index.d.ts +2 -2
  20. package/dist/db/index.js +2 -2
  21. package/dist/{endpoint-BavvbW4P.d.ts → endpoint-CVWXh6oG.d.ts} +70 -8
  22. package/dist/{endpoint-i8TTCohk.d.cts → endpoint-c9h5jriX.d.cts} +70 -8
  23. package/dist/engine/index.cjs +313 -24
  24. package/dist/engine/index.cjs.map +1 -1
  25. package/dist/engine/index.d.cts +4 -4
  26. package/dist/engine/index.d.ts +4 -4
  27. package/dist/engine/index.js +4 -4
  28. package/dist/{index-B7YBEG5w.d.ts → index-BZrJXnVh.d.ts} +49 -4
  29. package/dist/{index-Bmvx1EvJ.d.cts → index-By8Dle5U.d.cts} +86 -17
  30. package/dist/{index-B3jmmItD.d.ts → index-CwAJ7HEe.d.ts} +86 -17
  31. package/dist/{index-E7OscPJT.d.cts → index-CxeQSfJP.d.cts} +49 -4
  32. package/dist/index.cjs +104 -3
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +8 -8
  35. package/dist/index.d.ts +8 -8
  36. package/dist/index.js +32 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/openapi/index.cjs +29 -4
  39. package/dist/openapi/index.cjs.map +1 -1
  40. package/dist/openapi/index.d.cts +5 -2
  41. package/dist/openapi/index.d.ts +5 -2
  42. package/dist/openapi/index.js +32 -7
  43. package/dist/openapi/index.js.map +1 -1
  44. package/dist/{registry-DY3d9l1k.d.ts → registry-B3niOVYp.d.ts} +101 -7
  45. package/dist/{registry-C3H2uPeZ.d.cts → registry-CqPK2Qby.d.cts} +101 -7
  46. package/dist/test/index.cjs +482 -9
  47. package/dist/test/index.cjs.map +1 -1
  48. package/dist/test/index.d.cts +35 -3
  49. package/dist/test/index.d.ts +35 -3
  50. package/dist/test/index.js +480 -8
  51. package/dist/test/index.js.map +1 -1
  52. package/package.json +4 -3
  53. package/stager/return_types.js +23 -0
  54. package/dist/chunk-FSGSB42K.js.map +0 -1
  55. package/dist/chunk-HAF67F2H.js.map +0 -1
  56. package/dist/chunk-OMRTHM4X.js.map +0 -1
  57. package/dist/chunk-REZU6UKT.js.map +0 -1
  58. package/dist/chunk-W5ODXPY3.js.map +0 -1
  59. /package/dist/{chunk-Y5HXVUMP.js.map → chunk-HQRJDARQ.js.map} +0 -0
  60. /package/dist/{chunk-ZC6Q2BRD.js.map → chunk-R3KN6RHD.js.map} +0 -0
@@ -218,6 +218,19 @@ var TxPlanBuilder = class {
218
218
  }
219
219
  return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
220
220
  },
221
+ upsert: (values, options) => {
222
+ const encoded = encodeMap(values, false);
223
+ if (Object.keys(encoded).length === 0) {
224
+ throw new TxPlanError(`${name}.upsert() needs at least one column`);
225
+ }
226
+ if (options.onConflict.length === 0) {
227
+ throw new TxPlanError(`${name}.upsert() needs at least one onConflict column`);
228
+ }
229
+ return this.push(
230
+ { op: "upsert", table: name, values: encoded, onConflict: options.onConflict },
231
+ `${name}.upsert()`
232
+ );
233
+ },
221
234
  insertMany: (rows) => {
222
235
  if (rows.length === 0) {
223
236
  return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
@@ -425,7 +438,8 @@ function makeTablesAccessor(ops) {
425
438
  update: (id, data) => ops().update(name, id, data),
426
439
  delete: (id) => ops().delete(name, id),
427
440
  findById: (id) => ops().findById(name, id),
428
- findMany: (query) => ops().findMany(name, query),
441
+ findMany: (query, opts) => ops().findMany(name, query, opts),
442
+ upsert: (data, opts) => ops().upsert(name, data, opts),
429
443
  search: (params) => ops().search(name, params),
430
444
  similar: (id, params) => ops().similar(name, id, params),
431
445
  recommend: (params) => ops().recommend(name, params),
@@ -445,13 +459,17 @@ function makeTypedSurface(raw) {
445
459
  update: (table, id, data) => raw.update(table, id, data),
446
460
  delete: (table, id) => raw.delete(table, id),
447
461
  findById: (table, id) => raw.findById(table, id),
448
- findMany: (table, query) => raw.findMany(table, query),
462
+ findMany: (table, query, opts) => raw.findMany(table, query, opts),
463
+ upsert: (table, data, opts) => raw.upsert(table, data, opts),
449
464
  search: (table, params) => raw.search(table, params),
450
465
  similar: (table, id, params) => reco.similar(table, id, params),
451
466
  recommend: (table, params) => reco.recommend(table, params),
452
467
  supersede: (table, id, row) => raw.supersede(table, id, row)
453
468
  };
454
469
  return Object.assign(ops, {
470
+ // Both surfaces get it: a savepoint on the service transaction is as useful
471
+ // as one on the request's, and each is bound to its own connection.
472
+ attempt: (fn) => raw.attempt(fn),
455
473
  tables: makeTablesAccessor(() => reco),
456
474
  transaction(fn) {
457
475
  const builder = new TxPlanBuilder();
@@ -920,6 +938,14 @@ function makeMemoryCache(opts = {}) {
920
938
  function quoteIdent(name) {
921
939
  return `"${name.replace(/"/g, '""')}"`;
922
940
  }
941
+ function encodePgArray(value) {
942
+ const element = (x) => {
943
+ if (x === null || x === void 0) return "NULL";
944
+ if (Array.isArray(x)) return encodePgArray(x);
945
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
946
+ };
947
+ return `{${value.map(element).join(",")}}`;
948
+ }
923
949
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
924
950
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
925
951
  const { lockTimeout } = options;
@@ -1140,7 +1166,9 @@ function expandSynonyms(query, map) {
1140
1166
  }).join("");
1141
1167
  }).join("");
1142
1168
  }
1143
- var searchLogger = null;
1169
+ var searchLogger = (evt, fields) => {
1170
+ console.log(evt, JSON.stringify(fields));
1171
+ };
1144
1172
  function logNoResults(table, query, mode, n) {
1145
1173
  if (n === 0) {
1146
1174
  searchLogger?.("palbase.search.no_results", {
@@ -1181,19 +1209,41 @@ function validitySql(v, add) {
1181
1209
  function excludeSql(pk, ids, add) {
1182
1210
  if (ids.length === 0) return "";
1183
1211
  if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
1184
- return ` AND t.${quoteIdent(pk)} <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1212
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1213
+ }
1214
+ function schemaColumns(table) {
1215
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
1216
+ const cols = t?.columns ? Object.keys(t.columns) : [];
1217
+ return cols.length > 0 ? new Set(cols) : null;
1218
+ }
1219
+ function orderClause(table, known, orderBy) {
1220
+ if (!orderBy) return "";
1221
+ const col = orderBy.column;
1222
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
1223
+ if (known !== null ? !known.has(col) : !shapeOK) {
1224
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
1225
+ }
1226
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
1227
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
1228
+ }
1229
+ function limitClause(limit) {
1230
+ if (limit === void 0) return "";
1231
+ if (!Number.isInteger(limit) || limit < 0) {
1232
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
1233
+ }
1234
+ return ` LIMIT ${limit}`;
1185
1235
  }
1186
- function compileWhere(table, colSet, where, add) {
1236
+ function compileWhere(table, colSet, where, add, caller = "search") {
1187
1237
  const parts = [];
1188
1238
  for (const [col, cond] of Object.entries(where)) {
1189
- if (!colSet.has(col)) {
1190
- throw new Error(`search(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1239
+ if (colSet !== null && !colSet.has(col)) {
1240
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1191
1241
  }
1192
1242
  const q = `t.${quoteIdent(col)}`;
1193
1243
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1194
1244
  for (const [op, v] of Object.entries(cond)) {
1195
1245
  if (op === "in") {
1196
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
1246
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
1197
1247
  if (v.length === 0) {
1198
1248
  parts.push("false");
1199
1249
  continue;
@@ -1202,7 +1252,7 @@ function compileWhere(table, colSet, where, add) {
1202
1252
  } else if (op in WHERE_OPS) {
1203
1253
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
1204
1254
  } else {
1205
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1255
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1206
1256
  }
1207
1257
  }
1208
1258
  } else {
@@ -1282,7 +1332,8 @@ function createOps(tx) {
1282
1332
  const at = () => resolveTx(tx);
1283
1333
  const ops = {
1284
1334
  async query(sql, params = []) {
1285
- return asWireRows(await (await at()).unsafe(sql, params));
1335
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
1336
+ return asWireRows(await (await at()).unsafe(sql, bound));
1286
1337
  },
1287
1338
  async insert(table, data) {
1288
1339
  const cols = Object.keys(data);
@@ -1298,6 +1349,41 @@ function createOps(tx) {
1298
1349
  }
1299
1350
  return asTableRow(table, inserted);
1300
1351
  },
1352
+ /**
1353
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
1354
+ *
1355
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
1356
+ * unique violation, update instead" does not work here: a request runs in ONE
1357
+ * Postgres transaction, so the failed insert aborts it and every later
1358
+ * statement answers `current transaction is aborted`. A tenant measured that
1359
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
1360
+ * trigger create the row instead — a workaround that needs a new trigger for
1361
+ * every table with a unique row.
1362
+ *
1363
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
1364
+ * so writing them back is at best a no-op and at worst a surprise.
1365
+ */
1366
+ async upsert(table, data, opts) {
1367
+ const conflict = opts.onConflict;
1368
+ if (conflict.length === 0) {
1369
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
1370
+ }
1371
+ const cols = Object.keys(data);
1372
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
1373
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1374
+ const conflictSet = new Set(conflict);
1375
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1376
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1377
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
1378
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
1379
+ const row = rows[0];
1380
+ if (!row) {
1381
+ throw new Error(
1382
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
1383
+ );
1384
+ }
1385
+ return asTableRow(table, row);
1386
+ },
1301
1387
  async update(table, id, data) {
1302
1388
  const cols = Object.keys(data);
1303
1389
  if (cols.length === 0) return ops.findById(table, id);
@@ -1316,13 +1402,18 @@ function createOps(tx) {
1316
1402
  );
1317
1403
  return rows[0] ? asTableRow(table, rows[0]) : null;
1318
1404
  },
1319
- async findMany(table, query = {}) {
1320
- const cols = Object.keys(query);
1321
- const where = cols.length ? ` WHERE ${cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ")}` : "";
1322
- return asTableRows(table, await (await at()).unsafe(
1323
- `SELECT * FROM ${quoteIdent(table)}${where}`,
1324
- cols.map((c) => query[c])
1325
- ));
1405
+ async findMany(table, query = {}, opts = {}) {
1406
+ const params = [];
1407
+ const add = (v) => {
1408
+ params.push(v);
1409
+ return `$${params.length}`;
1410
+ };
1411
+ const known = schemaColumns(table);
1412
+ const whereSql = compileWhere(table, known, query, add, "findMany");
1413
+ const order = orderClause(table, known, opts.orderBy);
1414
+ const limit = limitClause(opts.limit);
1415
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}`;
1416
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1326
1417
  },
1327
1418
  /**
1328
1419
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -1713,6 +1804,27 @@ function createOps(tx) {
1713
1804
  const live = await at();
1714
1805
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1715
1806
  },
1807
+ /**
1808
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
1809
+ * rolls back only what that handle wrote and the request can keep writing.
1810
+ *
1811
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
1812
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
1813
+ * the ambient `Database` at the savepoint for the duration, and a request is
1814
+ * concurrent with itself: `Promise.all([Database.insert(a),
1815
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
1816
+ * back with it. Silent data loss, and the same interleaving this file already
1817
+ * refuses for `asService()`. Passing the handle makes the boundary something
1818
+ * you can see in the code that crosses it.
1819
+ *
1820
+ * Postgres, not us: the savepoint is released on success and rolled back on
1821
+ * failure by the driver, so an aborted statement inside `fn` does not poison
1822
+ * the surrounding transaction.
1823
+ */
1824
+ async attempt(fn) {
1825
+ const live = await at();
1826
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
1827
+ },
1716
1828
  /**
1717
1829
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1718
1830
  *
@@ -1763,22 +1875,47 @@ function withTables(ops, schema = currentSchema) {
1763
1875
  update: (id, data) => ops.update(name, id, data),
1764
1876
  delete: (id) => ops.delete(name, id),
1765
1877
  findById: (id) => ops.findById(name, id),
1766
- findMany: (query) => ops.findMany(name, query ?? {})
1878
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
1879
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1767
1880
  };
1768
1881
  }
1769
1882
  const base = /* @__PURE__ */ Object.create(null);
1770
1883
  return Object.assign(base, ops, { tables });
1771
1884
  }
1772
1885
  var SERVICE_LOCK_TIMEOUT = "5s";
1886
+ function isAbortedTransaction(e) {
1887
+ const code = e?.code;
1888
+ const message = String(e?.message ?? "");
1889
+ return code === "25P02" || /current transaction is aborted/i.test(message);
1890
+ }
1891
+ function isForeignKeyViolation(e) {
1892
+ return e?.code === "23503";
1893
+ }
1773
1894
  function isLockTimeout(e) {
1774
1895
  const code = e?.code;
1775
1896
  const message = String(e?.message ?? "");
1776
1897
  return code === "55P03" || /lock timeout/i.test(message);
1777
1898
  }
1778
- function diagnosingDriver(sql) {
1779
- const explain = (e) => isLockTimeout(e) ? new Error(
1780
- `Database.asService() waited too long for a row lock. It runs in its OWN transaction, so a row this request already wrote through Database.* is locked against it until the request commits \u2014 a wait that cannot end. Do that row's work on one surface or the other. (${String(e?.message ?? e)})`
1781
- ) : e;
1899
+ function diagnosingDriver(sql, surface = "service") {
1900
+ const original = (e) => String(e?.message ?? e);
1901
+ const explain = (e) => {
1902
+ if (isAbortedTransaction(e)) {
1903
+ return new Error(
1904
+ `This request cannot write any more: an earlier write in it failed, and a request runs in ONE Postgres transaction, so every statement after the failure is refused. Nothing here is retryable in place. Wrap a write you expect to fail in \`Database.attempt(async (tx) => \u2026)\` \u2014 it takes a SAVEPOINT, so only that write rolls back \u2014 or use \`Database.tables.<t>.upsert(data, { onConflict: [...] })\` when the failure you were bracing for is a duplicate row. (${original(e)})`
1905
+ );
1906
+ }
1907
+ if (surface === "service" && isForeignKeyViolation(e)) {
1908
+ return new Error(
1909
+ `Database.asService() hit a foreign key with nothing to point at. It runs in its OWN transaction on its OWN connection, so a row this request wrote through \`Database.*\` is not visible to it until the request commits \u2014 and the request cannot commit until the handler returns. If the row it needs is one this request just created, do both writes on ONE surface. (${original(e)})`
1910
+ );
1911
+ }
1912
+ if (isLockTimeout(e)) {
1913
+ return new Error(
1914
+ `Database.asService() waited too long for a row lock. It runs in its OWN transaction, so a row this request already wrote through Database.* is locked against it until the request commits \u2014 a wait that cannot end. Do that row's work on one surface or the other. (${original(e)})`
1915
+ );
1916
+ }
1917
+ return e;
1918
+ };
1782
1919
  const wrapTx = (tx) => ({
1783
1920
  async unsafe(text, params) {
1784
1921
  try {
@@ -1797,13 +1934,13 @@ function diagnosingDriver(sql) {
1797
1934
  };
1798
1935
  }
1799
1936
  function createRequestDatabase(sql, identity) {
1800
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
1937
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1801
1938
  let serviceTx = null;
1802
1939
  let serviceClient = null;
1803
1940
  const asService = () => {
1804
1941
  if (serviceClient === null) {
1805
1942
  serviceTx = createLazyTransaction(
1806
- diagnosingDriver(sql),
1943
+ diagnosingDriver(sql, "service"),
1807
1944
  identity.serviceRole,
1808
1945
  identity.claimsJson,
1809
1946
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1884,6 +2021,23 @@ async function runPlanOp(sp, op, results) {
1884
2021
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1885
2022
  break;
1886
2023
  }
2024
+ case "upsert": {
2025
+ const cols = Object.keys(op.values ?? {});
2026
+ const conflict = op.onConflict ?? [];
2027
+ if (cols.length === 0 || conflict.length === 0) {
2028
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
2029
+ error_code: "tx_bad_upsert"
2030
+ });
2031
+ }
2032
+ const rendered = cols.map(
2033
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
2034
+ );
2035
+ const conflictSet = new Set(conflict);
2036
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
2037
+ const action = sets.length ? `DO UPDATE SET ${sets.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
2038
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
2039
+ break;
2040
+ }
1887
2041
  case "insertMany": {
1888
2042
  const rows = op.rows ?? [];
1889
2043
  if (rows.length === 0 || !rows[0]) return [];
@@ -2080,6 +2234,41 @@ function verifySignature(presented, expected) {
2080
2234
  return diff === 0;
2081
2235
  }
2082
2236
 
2237
+ // src/engine/sse.ts
2238
+ var SSE_CONTENT_TYPE = "text/event-stream";
2239
+ function encodeFrame(value) {
2240
+ return `data: ${JSON.stringify(value)}
2241
+
2242
+ `;
2243
+ }
2244
+ function encodeErrorFrame(requestId) {
2245
+ return `event: error
2246
+ data: ${JSON.stringify({ requestId })}
2247
+
2248
+ `;
2249
+ }
2250
+ function makeSseWriter(opts) {
2251
+ let begun = false;
2252
+ let chain = Promise.resolve();
2253
+ return {
2254
+ write(value) {
2255
+ const frame = encodeFrame(value);
2256
+ if (!begun) {
2257
+ begun = true;
2258
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
2259
+ return;
2260
+ }
2261
+ chain = chain.then(() => opts.enqueue(frame));
2262
+ },
2263
+ started() {
2264
+ return begun;
2265
+ },
2266
+ drained() {
2267
+ return chain;
2268
+ }
2269
+ };
2270
+ }
2271
+
2083
2272
  // src/engine/index.ts
2084
2273
  var JSON_HEADERS = { "content-type": "application/json" };
2085
2274
  function fieldErrors(err) {
@@ -2254,6 +2443,13 @@ async function createApp(opts) {
2254
2443
  const args = [];
2255
2444
  let parsedBody;
2256
2445
  let bodyRead = false;
2446
+ let sseEnqueue = null;
2447
+ let sseSettle = async () => {
2448
+ };
2449
+ const sseWriter = makeSseWriter({
2450
+ enqueue: (chunk) => sseEnqueue?.(chunk),
2451
+ onFirstWrite: () => sseSettle()
2452
+ });
2257
2453
  for (const p of meta.params ?? []) {
2258
2454
  switch (p.kind) {
2259
2455
  case "body": {
@@ -2357,6 +2553,21 @@ async function createApp(opts) {
2357
2553
  case "req":
2358
2554
  args[p.index] = req;
2359
2555
  break;
2556
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
2557
+ // state when the client disconnects. Measured on Bun: an infinite
2558
+ // producer guarded by it stops within a few frames of the client dying.
2559
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
2560
+ // request-scoped data and no signal.
2561
+ case "signal":
2562
+ args[p.index] = req.signal;
2563
+ break;
2564
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
2565
+ // on any other route it is an inert writer whose frames go nowhere,
2566
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
2567
+ // route.
2568
+ case "sseOut":
2569
+ args[p.index] = sseWriter;
2570
+ break;
2360
2571
  default:
2361
2572
  args[p.index] = void 0;
2362
2573
  }
@@ -2368,6 +2579,84 @@ async function createApp(opts) {
2368
2579
  });
2369
2580
  try {
2370
2581
  const services = buildServices(db);
2582
+ if (meta.options?.sseConfig !== void 0) {
2583
+ let sseController;
2584
+ const sseEncoder = new TextEncoder();
2585
+ const stream = new ReadableStream({
2586
+ start(c) {
2587
+ sseController = c;
2588
+ }
2589
+ });
2590
+ sseEnqueue = (chunk) => {
2591
+ try {
2592
+ sseController.enqueue(sseEncoder.encode(chunk));
2593
+ } catch {
2594
+ }
2595
+ };
2596
+ let sawFirstFrame;
2597
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
2598
+ sseSettle = async () => {
2599
+ await db.commit();
2600
+ sawFirstFrame();
2601
+ };
2602
+ const guarded = {
2603
+ ...services,
2604
+ Database: new Proxy(services.Database, {
2605
+ get(target, prop, recv) {
2606
+ if (sseWriter.started()) {
2607
+ throw new Error(
2608
+ `sse_db_after_write: ${hit.entry.id} touched the database after its first write(). A streaming response settles its transaction with the first frame \u2014 do the database work before the first write.`
2609
+ );
2610
+ }
2611
+ return Reflect.get(target, prop, recv);
2612
+ }
2613
+ })
2614
+ };
2615
+ let thrown = null;
2616
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
2617
+ const box = requestALS.getStore();
2618
+ if (box) {
2619
+ box.userId = userId ?? null;
2620
+ box.requestId = requestId;
2621
+ box.idempotencyKey = req.headers.get("idempotency-key");
2622
+ }
2623
+ const method = hit.entry.instance[meta.fnName];
2624
+ if (typeof method !== "function") {
2625
+ throw new Error(
2626
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
2627
+ );
2628
+ }
2629
+ return method.apply(hit.entry.instance, args);
2630
+ })).then(
2631
+ () => void 0,
2632
+ (err) => {
2633
+ thrown = err;
2634
+ }
2635
+ );
2636
+ await Promise.race([firstFrame, ran]);
2637
+ if (!sseWriter.started()) {
2638
+ await ran;
2639
+ await db.commit();
2640
+ if (thrown !== null) throw thrown;
2641
+ return new Response(null, { status: 204 });
2642
+ }
2643
+ void (async () => {
2644
+ await ran;
2645
+ await sseWriter.drained();
2646
+ try {
2647
+ if (thrown !== null) {
2648
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
2649
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
2650
+ }
2651
+ sseController.close();
2652
+ } catch {
2653
+ }
2654
+ })();
2655
+ return new Response(stream, {
2656
+ status: 200,
2657
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
2658
+ });
2659
+ }
2371
2660
  const result = await runWithRuntime(services, () => {
2372
2661
  const box = requestALS.getStore();
2373
2662
  if (box) {