@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
@@ -252,6 +252,19 @@ var TxPlanBuilder = class {
252
252
  }
253
253
  return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
254
254
  },
255
+ upsert: (values, options) => {
256
+ const encoded = encodeMap(values, false);
257
+ if (Object.keys(encoded).length === 0) {
258
+ throw new TxPlanError(`${name}.upsert() needs at least one column`);
259
+ }
260
+ if (options.onConflict.length === 0) {
261
+ throw new TxPlanError(`${name}.upsert() needs at least one onConflict column`);
262
+ }
263
+ return this.push(
264
+ { op: "upsert", table: name, values: encoded, onConflict: options.onConflict },
265
+ `${name}.upsert()`
266
+ );
267
+ },
255
268
  insertMany: (rows) => {
256
269
  if (rows.length === 0) {
257
270
  return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
@@ -459,7 +472,8 @@ function makeTablesAccessor(ops) {
459
472
  update: (id, data) => ops().update(name, id, data),
460
473
  delete: (id) => ops().delete(name, id),
461
474
  findById: (id) => ops().findById(name, id),
462
- findMany: (query) => ops().findMany(name, query),
475
+ findMany: (query, opts) => ops().findMany(name, query, opts),
476
+ upsert: (data, opts) => ops().upsert(name, data, opts),
463
477
  search: (params) => ops().search(name, params),
464
478
  similar: (id, params) => ops().similar(name, id, params),
465
479
  recommend: (params) => ops().recommend(name, params),
@@ -479,13 +493,17 @@ function makeTypedSurface(raw) {
479
493
  update: (table, id, data) => raw.update(table, id, data),
480
494
  delete: (table, id) => raw.delete(table, id),
481
495
  findById: (table, id) => raw.findById(table, id),
482
- findMany: (table, query) => raw.findMany(table, query),
496
+ findMany: (table, query, opts) => raw.findMany(table, query, opts),
497
+ upsert: (table, data, opts) => raw.upsert(table, data, opts),
483
498
  search: (table, params) => raw.search(table, params),
484
499
  similar: (table, id, params) => reco.similar(table, id, params),
485
500
  recommend: (table, params) => reco.recommend(table, params),
486
501
  supersede: (table, id, row) => raw.supersede(table, id, row)
487
502
  };
488
503
  return Object.assign(ops, {
504
+ // Both surfaces get it: a savepoint on the service transaction is as useful
505
+ // as one on the request's, and each is bound to its own connection.
506
+ attempt: (fn) => raw.attempt(fn),
489
507
  tables: makeTablesAccessor(() => reco),
490
508
  transaction(fn) {
491
509
  const builder = new TxPlanBuilder();
@@ -954,6 +972,14 @@ function makeMemoryCache(opts = {}) {
954
972
  function quoteIdent(name) {
955
973
  return `"${name.replace(/"/g, '""')}"`;
956
974
  }
975
+ function encodePgArray(value) {
976
+ const element = (x) => {
977
+ if (x === null || x === void 0) return "NULL";
978
+ if (Array.isArray(x)) return encodePgArray(x);
979
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
980
+ };
981
+ return `{${value.map(element).join(",")}}`;
982
+ }
957
983
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
958
984
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
959
985
  const { lockTimeout } = options;
@@ -1174,7 +1200,9 @@ function expandSynonyms(query, map) {
1174
1200
  }).join("");
1175
1201
  }).join("");
1176
1202
  }
1177
- var searchLogger = null;
1203
+ var searchLogger = (evt, fields) => {
1204
+ console.log(evt, JSON.stringify(fields));
1205
+ };
1178
1206
  function logNoResults(table, query, mode, n) {
1179
1207
  if (n === 0) {
1180
1208
  searchLogger?.("palbase.search.no_results", {
@@ -1215,19 +1243,41 @@ function validitySql(v, add) {
1215
1243
  function excludeSql(pk, ids, add) {
1216
1244
  if (ids.length === 0) return "";
1217
1245
  if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
1218
- return ` AND t.${quoteIdent(pk)} <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1246
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1247
+ }
1248
+ function schemaColumns(table) {
1249
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
1250
+ const cols = t?.columns ? Object.keys(t.columns) : [];
1251
+ return cols.length > 0 ? new Set(cols) : null;
1219
1252
  }
1220
- function compileWhere(table, colSet, where, add) {
1253
+ function orderClause(table, known, orderBy) {
1254
+ if (!orderBy) return "";
1255
+ const col = orderBy.column;
1256
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
1257
+ if (known !== null ? !known.has(col) : !shapeOK) {
1258
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
1259
+ }
1260
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
1261
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
1262
+ }
1263
+ function limitClause(limit) {
1264
+ if (limit === void 0) return "";
1265
+ if (!Number.isInteger(limit) || limit < 0) {
1266
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
1267
+ }
1268
+ return ` LIMIT ${limit}`;
1269
+ }
1270
+ function compileWhere(table, colSet, where, add, caller = "search") {
1221
1271
  const parts = [];
1222
1272
  for (const [col, cond] of Object.entries(where)) {
1223
- if (!colSet.has(col)) {
1224
- throw new Error(`search(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1273
+ if (colSet !== null && !colSet.has(col)) {
1274
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1225
1275
  }
1226
1276
  const q = `t.${quoteIdent(col)}`;
1227
1277
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1228
1278
  for (const [op, v] of Object.entries(cond)) {
1229
1279
  if (op === "in") {
1230
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
1280
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
1231
1281
  if (v.length === 0) {
1232
1282
  parts.push("false");
1233
1283
  continue;
@@ -1236,7 +1286,7 @@ function compileWhere(table, colSet, where, add) {
1236
1286
  } else if (op in WHERE_OPS) {
1237
1287
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
1238
1288
  } else {
1239
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1289
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1240
1290
  }
1241
1291
  }
1242
1292
  } else {
@@ -1316,7 +1366,8 @@ function createOps(tx) {
1316
1366
  const at = () => resolveTx(tx);
1317
1367
  const ops = {
1318
1368
  async query(sql, params = []) {
1319
- return asWireRows(await (await at()).unsafe(sql, params));
1369
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
1370
+ return asWireRows(await (await at()).unsafe(sql, bound));
1320
1371
  },
1321
1372
  async insert(table, data) {
1322
1373
  const cols = Object.keys(data);
@@ -1332,6 +1383,41 @@ function createOps(tx) {
1332
1383
  }
1333
1384
  return asTableRow(table, inserted);
1334
1385
  },
1386
+ /**
1387
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
1388
+ *
1389
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
1390
+ * unique violation, update instead" does not work here: a request runs in ONE
1391
+ * Postgres transaction, so the failed insert aborts it and every later
1392
+ * statement answers `current transaction is aborted`. A tenant measured that
1393
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
1394
+ * trigger create the row instead — a workaround that needs a new trigger for
1395
+ * every table with a unique row.
1396
+ *
1397
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
1398
+ * so writing them back is at best a no-op and at worst a surprise.
1399
+ */
1400
+ async upsert(table, data, opts) {
1401
+ const conflict = opts.onConflict;
1402
+ if (conflict.length === 0) {
1403
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
1404
+ }
1405
+ const cols = Object.keys(data);
1406
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
1407
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1408
+ const conflictSet = new Set(conflict);
1409
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1410
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1411
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
1412
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
1413
+ const row = rows[0];
1414
+ if (!row) {
1415
+ throw new Error(
1416
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
1417
+ );
1418
+ }
1419
+ return asTableRow(table, row);
1420
+ },
1335
1421
  async update(table, id, data) {
1336
1422
  const cols = Object.keys(data);
1337
1423
  if (cols.length === 0) return ops.findById(table, id);
@@ -1350,13 +1436,18 @@ function createOps(tx) {
1350
1436
  );
1351
1437
  return rows[0] ? asTableRow(table, rows[0]) : null;
1352
1438
  },
1353
- async findMany(table, query = {}) {
1354
- const cols = Object.keys(query);
1355
- const where = cols.length ? ` WHERE ${cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ")}` : "";
1356
- return asTableRows(table, await (await at()).unsafe(
1357
- `SELECT * FROM ${quoteIdent(table)}${where}`,
1358
- cols.map((c) => query[c])
1359
- ));
1439
+ async findMany(table, query = {}, opts = {}) {
1440
+ const params = [];
1441
+ const add = (v) => {
1442
+ params.push(v);
1443
+ return `$${params.length}`;
1444
+ };
1445
+ const known = schemaColumns(table);
1446
+ const whereSql = compileWhere(table, known, query, add, "findMany");
1447
+ const order = orderClause(table, known, opts.orderBy);
1448
+ const limit = limitClause(opts.limit);
1449
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}`;
1450
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1360
1451
  },
1361
1452
  /**
1362
1453
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -1747,6 +1838,27 @@ function createOps(tx) {
1747
1838
  const live = await at();
1748
1839
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1749
1840
  },
1841
+ /**
1842
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
1843
+ * rolls back only what that handle wrote and the request can keep writing.
1844
+ *
1845
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
1846
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
1847
+ * the ambient `Database` at the savepoint for the duration, and a request is
1848
+ * concurrent with itself: `Promise.all([Database.insert(a),
1849
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
1850
+ * back with it. Silent data loss, and the same interleaving this file already
1851
+ * refuses for `asService()`. Passing the handle makes the boundary something
1852
+ * you can see in the code that crosses it.
1853
+ *
1854
+ * Postgres, not us: the savepoint is released on success and rolled back on
1855
+ * failure by the driver, so an aborted statement inside `fn` does not poison
1856
+ * the surrounding transaction.
1857
+ */
1858
+ async attempt(fn) {
1859
+ const live = await at();
1860
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
1861
+ },
1750
1862
  /**
1751
1863
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1752
1864
  *
@@ -1797,22 +1909,47 @@ function withTables(ops, schema = currentSchema) {
1797
1909
  update: (id, data) => ops.update(name, id, data),
1798
1910
  delete: (id) => ops.delete(name, id),
1799
1911
  findById: (id) => ops.findById(name, id),
1800
- findMany: (query) => ops.findMany(name, query ?? {})
1912
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
1913
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1801
1914
  };
1802
1915
  }
1803
1916
  const base = /* @__PURE__ */ Object.create(null);
1804
1917
  return Object.assign(base, ops, { tables });
1805
1918
  }
1806
1919
  var SERVICE_LOCK_TIMEOUT = "5s";
1920
+ function isAbortedTransaction(e) {
1921
+ const code = e?.code;
1922
+ const message = String(e?.message ?? "");
1923
+ return code === "25P02" || /current transaction is aborted/i.test(message);
1924
+ }
1925
+ function isForeignKeyViolation(e) {
1926
+ return e?.code === "23503";
1927
+ }
1807
1928
  function isLockTimeout(e) {
1808
1929
  const code = e?.code;
1809
1930
  const message = String(e?.message ?? "");
1810
1931
  return code === "55P03" || /lock timeout/i.test(message);
1811
1932
  }
1812
- function diagnosingDriver(sql) {
1813
- const explain = (e) => isLockTimeout(e) ? new Error(
1814
- `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)})`
1815
- ) : e;
1933
+ function diagnosingDriver(sql, surface = "service") {
1934
+ const original = (e) => String(e?.message ?? e);
1935
+ const explain = (e) => {
1936
+ if (isAbortedTransaction(e)) {
1937
+ return new Error(
1938
+ `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)})`
1939
+ );
1940
+ }
1941
+ if (surface === "service" && isForeignKeyViolation(e)) {
1942
+ return new Error(
1943
+ `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)})`
1944
+ );
1945
+ }
1946
+ if (isLockTimeout(e)) {
1947
+ return new Error(
1948
+ `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)})`
1949
+ );
1950
+ }
1951
+ return e;
1952
+ };
1816
1953
  const wrapTx = (tx) => ({
1817
1954
  async unsafe(text, params) {
1818
1955
  try {
@@ -1831,13 +1968,13 @@ function diagnosingDriver(sql) {
1831
1968
  };
1832
1969
  }
1833
1970
  function createRequestDatabase(sql, identity) {
1834
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
1971
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1835
1972
  let serviceTx = null;
1836
1973
  let serviceClient = null;
1837
1974
  const asService = () => {
1838
1975
  if (serviceClient === null) {
1839
1976
  serviceTx = createLazyTransaction(
1840
- diagnosingDriver(sql),
1977
+ diagnosingDriver(sql, "service"),
1841
1978
  identity.serviceRole,
1842
1979
  identity.claimsJson,
1843
1980
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1918,6 +2055,23 @@ async function runPlanOp(sp, op, results) {
1918
2055
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1919
2056
  break;
1920
2057
  }
2058
+ case "upsert": {
2059
+ const cols = Object.keys(op.values ?? {});
2060
+ const conflict = op.onConflict ?? [];
2061
+ if (cols.length === 0 || conflict.length === 0) {
2062
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
2063
+ error_code: "tx_bad_upsert"
2064
+ });
2065
+ }
2066
+ const rendered = cols.map(
2067
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
2068
+ );
2069
+ const conflictSet = new Set(conflict);
2070
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
2071
+ const action = sets.length ? `DO UPDATE SET ${sets.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
2072
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
2073
+ break;
2074
+ }
1921
2075
  case "insertMany": {
1922
2076
  const rows = op.rows ?? [];
1923
2077
  if (rows.length === 0 || !rows[0]) return [];
@@ -2114,6 +2268,41 @@ function verifySignature(presented, expected) {
2114
2268
  return diff === 0;
2115
2269
  }
2116
2270
 
2271
+ // src/engine/sse.ts
2272
+ var SSE_CONTENT_TYPE = "text/event-stream";
2273
+ function encodeFrame(value) {
2274
+ return `data: ${JSON.stringify(value)}
2275
+
2276
+ `;
2277
+ }
2278
+ function encodeErrorFrame(requestId) {
2279
+ return `event: error
2280
+ data: ${JSON.stringify({ requestId })}
2281
+
2282
+ `;
2283
+ }
2284
+ function makeSseWriter(opts) {
2285
+ let begun = false;
2286
+ let chain = Promise.resolve();
2287
+ return {
2288
+ write(value) {
2289
+ const frame = encodeFrame(value);
2290
+ if (!begun) {
2291
+ begun = true;
2292
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
2293
+ return;
2294
+ }
2295
+ chain = chain.then(() => opts.enqueue(frame));
2296
+ },
2297
+ started() {
2298
+ return begun;
2299
+ },
2300
+ drained() {
2301
+ return chain;
2302
+ }
2303
+ };
2304
+ }
2305
+
2117
2306
  // src/engine/fence.ts
2118
2307
  var SECRET_ENV = [
2119
2308
  "DATABASE_URL",
@@ -2349,6 +2538,13 @@ async function createApp(opts) {
2349
2538
  const args = [];
2350
2539
  let parsedBody;
2351
2540
  let bodyRead = false;
2541
+ let sseEnqueue = null;
2542
+ let sseSettle = async () => {
2543
+ };
2544
+ const sseWriter = makeSseWriter({
2545
+ enqueue: (chunk) => sseEnqueue?.(chunk),
2546
+ onFirstWrite: () => sseSettle()
2547
+ });
2352
2548
  for (const p of meta.params ?? []) {
2353
2549
  switch (p.kind) {
2354
2550
  case "body": {
@@ -2452,6 +2648,21 @@ async function createApp(opts) {
2452
2648
  case "req":
2453
2649
  args[p.index] = req;
2454
2650
  break;
2651
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
2652
+ // state when the client disconnects. Measured on Bun: an infinite
2653
+ // producer guarded by it stops within a few frames of the client dying.
2654
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
2655
+ // request-scoped data and no signal.
2656
+ case "signal":
2657
+ args[p.index] = req.signal;
2658
+ break;
2659
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
2660
+ // on any other route it is an inert writer whose frames go nowhere,
2661
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
2662
+ // route.
2663
+ case "sseOut":
2664
+ args[p.index] = sseWriter;
2665
+ break;
2455
2666
  default:
2456
2667
  args[p.index] = void 0;
2457
2668
  }
@@ -2463,6 +2674,84 @@ async function createApp(opts) {
2463
2674
  });
2464
2675
  try {
2465
2676
  const services = buildServices(db);
2677
+ if (meta.options?.sseConfig !== void 0) {
2678
+ let sseController;
2679
+ const sseEncoder = new TextEncoder();
2680
+ const stream = new ReadableStream({
2681
+ start(c) {
2682
+ sseController = c;
2683
+ }
2684
+ });
2685
+ sseEnqueue = (chunk) => {
2686
+ try {
2687
+ sseController.enqueue(sseEncoder.encode(chunk));
2688
+ } catch {
2689
+ }
2690
+ };
2691
+ let sawFirstFrame;
2692
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
2693
+ sseSettle = async () => {
2694
+ await db.commit();
2695
+ sawFirstFrame();
2696
+ };
2697
+ const guarded = {
2698
+ ...services,
2699
+ Database: new Proxy(services.Database, {
2700
+ get(target, prop, recv) {
2701
+ if (sseWriter.started()) {
2702
+ throw new Error(
2703
+ `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.`
2704
+ );
2705
+ }
2706
+ return Reflect.get(target, prop, recv);
2707
+ }
2708
+ })
2709
+ };
2710
+ let thrown = null;
2711
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
2712
+ const box = requestALS.getStore();
2713
+ if (box) {
2714
+ box.userId = userId ?? null;
2715
+ box.requestId = requestId;
2716
+ box.idempotencyKey = req.headers.get("idempotency-key");
2717
+ }
2718
+ const method = hit.entry.instance[meta.fnName];
2719
+ if (typeof method !== "function") {
2720
+ throw new Error(
2721
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
2722
+ );
2723
+ }
2724
+ return method.apply(hit.entry.instance, args);
2725
+ })).then(
2726
+ () => void 0,
2727
+ (err) => {
2728
+ thrown = err;
2729
+ }
2730
+ );
2731
+ await Promise.race([firstFrame, ran]);
2732
+ if (!sseWriter.started()) {
2733
+ await ran;
2734
+ await db.commit();
2735
+ if (thrown !== null) throw thrown;
2736
+ return new Response(null, { status: 204 });
2737
+ }
2738
+ void (async () => {
2739
+ await ran;
2740
+ await sseWriter.drained();
2741
+ try {
2742
+ if (thrown !== null) {
2743
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
2744
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
2745
+ }
2746
+ sseController.close();
2747
+ } catch {
2748
+ }
2749
+ })();
2750
+ return new Response(stream, {
2751
+ status: 200,
2752
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
2753
+ });
2754
+ }
2466
2755
  const result = await runWithRuntime(services, () => {
2467
2756
  const box = requestALS.getStore();
2468
2757
  if (box) {