@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
@@ -3,13 +3,13 @@ import {
3
3
  BootRefused,
4
4
  createApp,
5
5
  loadConfig
6
- } from "../chunk-REZU6UKT.js";
7
- import "../chunk-FSGSB42K.js";
8
- import "../chunk-W5ODXPY3.js";
6
+ } from "../chunk-M5MCBWJI.js";
7
+ import "../chunk-OHALWEOG.js";
8
+ import "../chunk-NS5V43YQ.js";
9
9
  import {
10
10
  getRegisteredControllers
11
- } from "../chunk-Y5HXVUMP.js";
12
- import "../chunk-OMRTHM4X.js";
11
+ } from "../chunk-HQRJDARQ.js";
12
+ import "../chunk-RCLNBJCM.js";
13
13
  import "../chunk-7D4SUZUM.js";
14
14
 
15
15
  // src/bin/palbase-backend.ts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getRoutes
3
- } from "./chunk-OMRTHM4X.js";
3
+ } from "./chunk-RCLNBJCM.js";
4
4
 
5
5
  // src/decorators/controller.ts
6
6
  var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
@@ -87,4 +87,4 @@ export {
87
87
  isController,
88
88
  resolveController
89
89
  };
90
- //# sourceMappingURL=chunk-Y5HXVUMP.js.map
90
+ //# sourceMappingURL=chunk-HQRJDARQ.js.map
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  __requestALS,
3
3
  __runWithRuntime
4
- } from "./chunk-FSGSB42K.js";
4
+ } from "./chunk-OHALWEOG.js";
5
5
  import {
6
6
  getRoutes,
7
7
  isHttpError
8
- } from "./chunk-OMRTHM4X.js";
8
+ } from "./chunk-RCLNBJCM.js";
9
9
 
10
10
  // src/engine/config.ts
11
11
  var BootRefused = class extends Error {
@@ -323,6 +323,14 @@ function makeMemoryCache(opts = {}) {
323
323
  function quoteIdent(name) {
324
324
  return `"${name.replace(/"/g, '""')}"`;
325
325
  }
326
+ function encodePgArray(value) {
327
+ const element = (x) => {
328
+ if (x === null || x === void 0) return "NULL";
329
+ if (Array.isArray(x)) return encodePgArray(x);
330
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
331
+ };
332
+ return `{${value.map(element).join(",")}}`;
333
+ }
326
334
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
327
335
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
328
336
  const { lockTimeout } = options;
@@ -543,7 +551,9 @@ function expandSynonyms(query, map) {
543
551
  }).join("");
544
552
  }).join("");
545
553
  }
546
- var searchLogger = null;
554
+ var searchLogger = (evt, fields) => {
555
+ console.log(evt, JSON.stringify(fields));
556
+ };
547
557
  function logNoResults(table, query, mode, n) {
548
558
  if (n === 0) {
549
559
  searchLogger?.("palbase.search.no_results", {
@@ -584,19 +594,41 @@ function validitySql(v, add) {
584
594
  function excludeSql(pk, ids, add) {
585
595
  if (ids.length === 0) return "";
586
596
  if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
587
- return ` AND t.${quoteIdent(pk)} <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
597
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
598
+ }
599
+ function schemaColumns(table) {
600
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
601
+ const cols = t?.columns ? Object.keys(t.columns) : [];
602
+ return cols.length > 0 ? new Set(cols) : null;
603
+ }
604
+ function orderClause(table, known, orderBy) {
605
+ if (!orderBy) return "";
606
+ const col = orderBy.column;
607
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
608
+ if (known !== null ? !known.has(col) : !shapeOK) {
609
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
610
+ }
611
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
612
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
588
613
  }
589
- function compileWhere(table, colSet, where, add) {
614
+ function limitClause(limit) {
615
+ if (limit === void 0) return "";
616
+ if (!Number.isInteger(limit) || limit < 0) {
617
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
618
+ }
619
+ return ` LIMIT ${limit}`;
620
+ }
621
+ function compileWhere(table, colSet, where, add, caller = "search") {
590
622
  const parts = [];
591
623
  for (const [col, cond] of Object.entries(where)) {
592
- if (!colSet.has(col)) {
593
- throw new Error(`search(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
624
+ if (colSet !== null && !colSet.has(col)) {
625
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
594
626
  }
595
627
  const q = `t.${quoteIdent(col)}`;
596
628
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
597
629
  for (const [op, v] of Object.entries(cond)) {
598
630
  if (op === "in") {
599
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
631
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
600
632
  if (v.length === 0) {
601
633
  parts.push("false");
602
634
  continue;
@@ -605,7 +637,7 @@ function compileWhere(table, colSet, where, add) {
605
637
  } else if (op in WHERE_OPS) {
606
638
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
607
639
  } else {
608
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
640
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
609
641
  }
610
642
  }
611
643
  } else {
@@ -685,7 +717,8 @@ function createOps(tx) {
685
717
  const at = () => resolveTx(tx);
686
718
  const ops = {
687
719
  async query(sql, params = []) {
688
- return asWireRows(await (await at()).unsafe(sql, params));
720
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
721
+ return asWireRows(await (await at()).unsafe(sql, bound));
689
722
  },
690
723
  async insert(table, data) {
691
724
  const cols = Object.keys(data);
@@ -701,6 +734,41 @@ function createOps(tx) {
701
734
  }
702
735
  return asTableRow(table, inserted);
703
736
  },
737
+ /**
738
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
739
+ *
740
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
741
+ * unique violation, update instead" does not work here: a request runs in ONE
742
+ * Postgres transaction, so the failed insert aborts it and every later
743
+ * statement answers `current transaction is aborted`. A tenant measured that
744
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
745
+ * trigger create the row instead — a workaround that needs a new trigger for
746
+ * every table with a unique row.
747
+ *
748
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
749
+ * so writing them back is at best a no-op and at worst a surprise.
750
+ */
751
+ async upsert(table, data, opts) {
752
+ const conflict = opts.onConflict;
753
+ if (conflict.length === 0) {
754
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
755
+ }
756
+ const cols = Object.keys(data);
757
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
758
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
759
+ const conflictSet = new Set(conflict);
760
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
761
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
762
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
763
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
764
+ const row = rows[0];
765
+ if (!row) {
766
+ throw new Error(
767
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
768
+ );
769
+ }
770
+ return asTableRow(table, row);
771
+ },
704
772
  async update(table, id, data) {
705
773
  const cols = Object.keys(data);
706
774
  if (cols.length === 0) return ops.findById(table, id);
@@ -719,13 +787,18 @@ function createOps(tx) {
719
787
  );
720
788
  return rows[0] ? asTableRow(table, rows[0]) : null;
721
789
  },
722
- async findMany(table, query = {}) {
723
- const cols = Object.keys(query);
724
- const where = cols.length ? ` WHERE ${cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ")}` : "";
725
- return asTableRows(table, await (await at()).unsafe(
726
- `SELECT * FROM ${quoteIdent(table)}${where}`,
727
- cols.map((c) => query[c])
728
- ));
790
+ async findMany(table, query = {}, opts = {}) {
791
+ const params = [];
792
+ const add = (v) => {
793
+ params.push(v);
794
+ return `$${params.length}`;
795
+ };
796
+ const known = schemaColumns(table);
797
+ const whereSql = compileWhere(table, known, query, add, "findMany");
798
+ const order = orderClause(table, known, opts.orderBy);
799
+ const limit = limitClause(opts.limit);
800
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}`;
801
+ return asTableRows(table, await (await at()).unsafe(sql, params));
729
802
  },
730
803
  /**
731
804
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -1116,6 +1189,27 @@ function createOps(tx) {
1116
1189
  const live = await at();
1117
1190
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1118
1191
  },
1192
+ /**
1193
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
1194
+ * rolls back only what that handle wrote and the request can keep writing.
1195
+ *
1196
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
1197
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
1198
+ * the ambient `Database` at the savepoint for the duration, and a request is
1199
+ * concurrent with itself: `Promise.all([Database.insert(a),
1200
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
1201
+ * back with it. Silent data loss, and the same interleaving this file already
1202
+ * refuses for `asService()`. Passing the handle makes the boundary something
1203
+ * you can see in the code that crosses it.
1204
+ *
1205
+ * Postgres, not us: the savepoint is released on success and rolled back on
1206
+ * failure by the driver, so an aborted statement inside `fn` does not poison
1207
+ * the surrounding transaction.
1208
+ */
1209
+ async attempt(fn) {
1210
+ const live = await at();
1211
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
1212
+ },
1119
1213
  /**
1120
1214
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1121
1215
  *
@@ -1166,22 +1260,47 @@ function withTables(ops, schema = currentSchema) {
1166
1260
  update: (id, data) => ops.update(name, id, data),
1167
1261
  delete: (id) => ops.delete(name, id),
1168
1262
  findById: (id) => ops.findById(name, id),
1169
- findMany: (query) => ops.findMany(name, query ?? {})
1263
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
1264
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1170
1265
  };
1171
1266
  }
1172
1267
  const base = /* @__PURE__ */ Object.create(null);
1173
1268
  return Object.assign(base, ops, { tables });
1174
1269
  }
1175
1270
  var SERVICE_LOCK_TIMEOUT = "5s";
1271
+ function isAbortedTransaction(e) {
1272
+ const code = e?.code;
1273
+ const message = String(e?.message ?? "");
1274
+ return code === "25P02" || /current transaction is aborted/i.test(message);
1275
+ }
1276
+ function isForeignKeyViolation(e) {
1277
+ return e?.code === "23503";
1278
+ }
1176
1279
  function isLockTimeout(e) {
1177
1280
  const code = e?.code;
1178
1281
  const message = String(e?.message ?? "");
1179
1282
  return code === "55P03" || /lock timeout/i.test(message);
1180
1283
  }
1181
- function diagnosingDriver(sql) {
1182
- const explain = (e) => isLockTimeout(e) ? new Error(
1183
- `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)})`
1184
- ) : e;
1284
+ function diagnosingDriver(sql, surface = "service") {
1285
+ const original = (e) => String(e?.message ?? e);
1286
+ const explain = (e) => {
1287
+ if (isAbortedTransaction(e)) {
1288
+ return new Error(
1289
+ `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)})`
1290
+ );
1291
+ }
1292
+ if (surface === "service" && isForeignKeyViolation(e)) {
1293
+ return new Error(
1294
+ `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)})`
1295
+ );
1296
+ }
1297
+ if (isLockTimeout(e)) {
1298
+ return new Error(
1299
+ `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)})`
1300
+ );
1301
+ }
1302
+ return e;
1303
+ };
1185
1304
  const wrapTx = (tx) => ({
1186
1305
  async unsafe(text, params) {
1187
1306
  try {
@@ -1200,13 +1319,13 @@ function diagnosingDriver(sql) {
1200
1319
  };
1201
1320
  }
1202
1321
  function createRequestDatabase(sql, identity) {
1203
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
1322
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1204
1323
  let serviceTx = null;
1205
1324
  let serviceClient = null;
1206
1325
  const asService = () => {
1207
1326
  if (serviceClient === null) {
1208
1327
  serviceTx = createLazyTransaction(
1209
- diagnosingDriver(sql),
1328
+ diagnosingDriver(sql, "service"),
1210
1329
  identity.serviceRole,
1211
1330
  identity.claimsJson,
1212
1331
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1287,6 +1406,23 @@ async function runPlanOp(sp, op, results) {
1287
1406
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1288
1407
  break;
1289
1408
  }
1409
+ case "upsert": {
1410
+ const cols = Object.keys(op.values ?? {});
1411
+ const conflict = op.onConflict ?? [];
1412
+ if (cols.length === 0 || conflict.length === 0) {
1413
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
1414
+ error_code: "tx_bad_upsert"
1415
+ });
1416
+ }
1417
+ const rendered = cols.map(
1418
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
1419
+ );
1420
+ const conflictSet = new Set(conflict);
1421
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1422
+ const action = sets.length ? `DO UPDATE SET ${sets.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1423
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
1424
+ break;
1425
+ }
1290
1426
  case "insertMany": {
1291
1427
  const rows = op.rows ?? [];
1292
1428
  if (rows.length === 0 || !rows[0]) return [];
@@ -1447,6 +1583,41 @@ function verifySignature(presented, expected) {
1447
1583
  return diff === 0;
1448
1584
  }
1449
1585
 
1586
+ // src/engine/sse.ts
1587
+ var SSE_CONTENT_TYPE = "text/event-stream";
1588
+ function encodeFrame(value) {
1589
+ return `data: ${JSON.stringify(value)}
1590
+
1591
+ `;
1592
+ }
1593
+ function encodeErrorFrame(requestId) {
1594
+ return `event: error
1595
+ data: ${JSON.stringify({ requestId })}
1596
+
1597
+ `;
1598
+ }
1599
+ function makeSseWriter(opts) {
1600
+ let begun = false;
1601
+ let chain = Promise.resolve();
1602
+ return {
1603
+ write(value) {
1604
+ const frame = encodeFrame(value);
1605
+ if (!begun) {
1606
+ begun = true;
1607
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
1608
+ return;
1609
+ }
1610
+ chain = chain.then(() => opts.enqueue(frame));
1611
+ },
1612
+ started() {
1613
+ return begun;
1614
+ },
1615
+ drained() {
1616
+ return chain;
1617
+ }
1618
+ };
1619
+ }
1620
+
1450
1621
  // src/engine/fence.ts
1451
1622
  var SECRET_ENV = [
1452
1623
  "DATABASE_URL",
@@ -1682,6 +1853,13 @@ async function createApp(opts) {
1682
1853
  const args = [];
1683
1854
  let parsedBody;
1684
1855
  let bodyRead = false;
1856
+ let sseEnqueue = null;
1857
+ let sseSettle = async () => {
1858
+ };
1859
+ const sseWriter = makeSseWriter({
1860
+ enqueue: (chunk) => sseEnqueue?.(chunk),
1861
+ onFirstWrite: () => sseSettle()
1862
+ });
1685
1863
  for (const p of meta.params ?? []) {
1686
1864
  switch (p.kind) {
1687
1865
  case "body": {
@@ -1785,6 +1963,21 @@ async function createApp(opts) {
1785
1963
  case "req":
1786
1964
  args[p.index] = req;
1787
1965
  break;
1966
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
1967
+ // state when the client disconnects. Measured on Bun: an infinite
1968
+ // producer guarded by it stops within a few frames of the client dying.
1969
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
1970
+ // request-scoped data and no signal.
1971
+ case "signal":
1972
+ args[p.index] = req.signal;
1973
+ break;
1974
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
1975
+ // on any other route it is an inert writer whose frames go nowhere,
1976
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
1977
+ // route.
1978
+ case "sseOut":
1979
+ args[p.index] = sseWriter;
1980
+ break;
1788
1981
  default:
1789
1982
  args[p.index] = void 0;
1790
1983
  }
@@ -1796,6 +1989,84 @@ async function createApp(opts) {
1796
1989
  });
1797
1990
  try {
1798
1991
  const services = buildServices(db);
1992
+ if (meta.options?.sseConfig !== void 0) {
1993
+ let sseController;
1994
+ const sseEncoder = new TextEncoder();
1995
+ const stream = new ReadableStream({
1996
+ start(c) {
1997
+ sseController = c;
1998
+ }
1999
+ });
2000
+ sseEnqueue = (chunk) => {
2001
+ try {
2002
+ sseController.enqueue(sseEncoder.encode(chunk));
2003
+ } catch {
2004
+ }
2005
+ };
2006
+ let sawFirstFrame;
2007
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
2008
+ sseSettle = async () => {
2009
+ await db.commit();
2010
+ sawFirstFrame();
2011
+ };
2012
+ const guarded = {
2013
+ ...services,
2014
+ Database: new Proxy(services.Database, {
2015
+ get(target, prop, recv) {
2016
+ if (sseWriter.started()) {
2017
+ throw new Error(
2018
+ `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.`
2019
+ );
2020
+ }
2021
+ return Reflect.get(target, prop, recv);
2022
+ }
2023
+ })
2024
+ };
2025
+ let thrown = null;
2026
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
2027
+ const box = requestALS.getStore();
2028
+ if (box) {
2029
+ box.userId = userId ?? null;
2030
+ box.requestId = requestId;
2031
+ box.idempotencyKey = req.headers.get("idempotency-key");
2032
+ }
2033
+ const method = hit.entry.instance[meta.fnName];
2034
+ if (typeof method !== "function") {
2035
+ throw new Error(
2036
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
2037
+ );
2038
+ }
2039
+ return method.apply(hit.entry.instance, args);
2040
+ })).then(
2041
+ () => void 0,
2042
+ (err) => {
2043
+ thrown = err;
2044
+ }
2045
+ );
2046
+ await Promise.race([firstFrame, ran]);
2047
+ if (!sseWriter.started()) {
2048
+ await ran;
2049
+ await db.commit();
2050
+ if (thrown !== null) throw thrown;
2051
+ return new Response(null, { status: 204 });
2052
+ }
2053
+ void (async () => {
2054
+ await ran;
2055
+ await sseWriter.drained();
2056
+ try {
2057
+ if (thrown !== null) {
2058
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
2059
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
2060
+ }
2061
+ sseController.close();
2062
+ } catch {
2063
+ }
2064
+ })();
2065
+ return new Response(stream, {
2066
+ status: 200,
2067
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
2068
+ });
2069
+ }
1799
2070
  const result = await runWithRuntime(services, () => {
1800
2071
  const box = requestALS.getStore();
1801
2072
  if (box) {
@@ -1886,4 +2157,4 @@ export {
1886
2157
  installEgressFence,
1887
2158
  createApp
1888
2159
  };
1889
- //# sourceMappingURL=chunk-REZU6UKT.js.map
2160
+ //# sourceMappingURL=chunk-M5MCBWJI.js.map