@palbase/backend 23.0.0 → 24.0.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 (74) hide show
  1. package/dist/bin/palbase-backend.cjs +695 -61
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +4 -5
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/{chunk-FSGSB42K.js → chunk-7Z6MGMXQ.js} +64 -4
  6. package/dist/chunk-7Z6MGMXQ.js.map +1 -0
  7. package/dist/{chunk-OMRTHM4X.js → chunk-H3JAISUY.js} +136 -1
  8. package/dist/chunk-H3JAISUY.js.map +1 -0
  9. package/dist/{chunk-REZU6UKT.js → chunk-NXDH6VQJ.js} +549 -42
  10. package/dist/chunk-NXDH6VQJ.js.map +1 -0
  11. package/dist/{chunk-W5ODXPY3.js → chunk-P2Q27SGP.js} +32 -3
  12. package/dist/chunk-P2Q27SGP.js.map +1 -0
  13. package/dist/{chunk-ZC6Q2BRD.js → chunk-T5IOSOE5.js} +7 -2
  14. package/dist/chunk-T5IOSOE5.js.map +1 -0
  15. package/dist/{chunk-HAF67F2H.js → chunk-ZUGY7RGS.js} +86 -3
  16. package/dist/chunk-ZUGY7RGS.js.map +1 -0
  17. package/dist/db/index.cjs +115 -3
  18. package/dist/db/index.cjs.map +1 -1
  19. package/dist/db/index.d.cts +2 -2
  20. package/dist/db/index.d.ts +2 -2
  21. package/dist/db/index.js +2 -2
  22. package/dist/{endpoint-BavvbW4P.d.ts → endpoint-0_DGBajf.d.ts} +168 -9
  23. package/dist/{endpoint-i8TTCohk.d.cts → endpoint-CcQ1a36a.d.cts} +168 -9
  24. package/dist/engine/index.cjs +684 -48
  25. package/dist/engine/index.cjs.map +1 -1
  26. package/dist/engine/index.d.cts +4 -4
  27. package/dist/engine/index.d.ts +4 -4
  28. package/dist/engine/index.js +4 -4
  29. package/dist/{index-B3jmmItD.d.ts → index-CJiJU9ux.d.ts} +209 -36
  30. package/dist/{index-Bmvx1EvJ.d.cts → index-D-4-PNuQ.d.cts} +209 -36
  31. package/dist/{index-B7YBEG5w.d.ts → index-D17r-MKb.d.ts} +177 -7
  32. package/dist/{index-E7OscPJT.d.cts → index-DRFxf07H.d.cts} +177 -7
  33. package/dist/index.cjs +269 -7
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +54 -12
  36. package/dist/index.d.ts +54 -12
  37. package/dist/index.js +54 -11
  38. package/dist/index.js.map +1 -1
  39. package/dist/openapi/index.cjs +45 -5
  40. package/dist/openapi/index.cjs.map +1 -1
  41. package/dist/openapi/index.d.cts +9 -4
  42. package/dist/openapi/index.d.ts +9 -4
  43. package/dist/openapi/index.js +35 -11
  44. package/dist/openapi/index.js.map +1 -1
  45. package/dist/{registry-C3H2uPeZ.d.cts → registry-1X-skBNu.d.cts} +101 -7
  46. package/dist/{registry-DY3d9l1k.d.ts → registry-CEod_5sz.d.ts} +101 -7
  47. package/dist/test/index.cjs +509 -9
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.d.cts +35 -3
  50. package/dist/test/index.d.ts +35 -3
  51. package/dist/test/index.js +507 -8
  52. package/dist/test/index.js.map +1 -1
  53. package/docs/README.md +4 -4
  54. package/docs/database.md +115 -11
  55. package/docs/getting-started.md +5 -4
  56. package/docs/llms-full.txt +385 -89
  57. package/docs/migrations.md +81 -59
  58. package/docs/schema.md +82 -2
  59. package/docs/services.md +98 -9
  60. package/package.json +3 -2
  61. package/stager/return_types.js +23 -0
  62. package/template/AGENTS.md +121 -41
  63. package/template/controllers/notes.controller.ts +64 -0
  64. package/template/package.json +1 -1
  65. package/template/services/note.service.ts +74 -0
  66. package/template/tsconfig.json +11 -1
  67. package/dist/chunk-FSGSB42K.js.map +0 -1
  68. package/dist/chunk-HAF67F2H.js.map +0 -1
  69. package/dist/chunk-OMRTHM4X.js.map +0 -1
  70. package/dist/chunk-REZU6UKT.js.map +0 -1
  71. package/dist/chunk-W5ODXPY3.js.map +0 -1
  72. package/dist/chunk-Y5HXVUMP.js +0 -90
  73. package/dist/chunk-Y5HXVUMP.js.map +0 -1
  74. package/dist/chunk-ZC6Q2BRD.js.map +0 -1
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  __requestALS,
3
+ __runStartHooks,
3
4
  __runWithRuntime
4
- } from "./chunk-FSGSB42K.js";
5
+ } from "./chunk-7Z6MGMXQ.js";
5
6
  import {
7
+ UniqueViolation,
8
+ assertZeroArgConstructor,
6
9
  getRoutes,
7
- isHttpError
8
- } from "./chunk-OMRTHM4X.js";
10
+ isHttpError,
11
+ resolveEffectiveAuth
12
+ } from "./chunk-H3JAISUY.js";
9
13
 
10
14
  // src/engine/config.ts
11
15
  var BootRefused = class extends Error {
@@ -323,6 +327,14 @@ function makeMemoryCache(opts = {}) {
323
327
  function quoteIdent(name) {
324
328
  return `"${name.replace(/"/g, '""')}"`;
325
329
  }
330
+ function encodePgArray(value) {
331
+ const element = (x) => {
332
+ if (x === null || x === void 0) return "NULL";
333
+ if (Array.isArray(x)) return encodePgArray(x);
334
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
335
+ };
336
+ return `{${value.map(element).join(",")}}`;
337
+ }
326
338
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
327
339
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
328
340
  const { lockTimeout } = options;
@@ -405,18 +417,48 @@ function reviveVectors(row, vectorCols) {
405
417
  }
406
418
  return row;
407
419
  }
420
+ function transformsOf(schema, table) {
421
+ const out = /* @__PURE__ */ new Map();
422
+ for (const [key, def] of Object.entries(schema.tables ?? {})) {
423
+ if ((def.name ?? key) !== table) continue;
424
+ for (const [col, c] of Object.entries(def.columns ?? {})) {
425
+ const d = c !== null && typeof c === "object" && "_def" in c ? c._def : c;
426
+ const t = d?.transform;
427
+ if (t !== null && typeof t === "object") {
428
+ out.set(col, t);
429
+ }
430
+ }
431
+ }
432
+ return out;
433
+ }
434
+ function applyFromDb(row, transforms) {
435
+ if (transforms.size === 0) return row;
436
+ for (const [col, t] of transforms) {
437
+ if (t.fromDb === void 0) continue;
438
+ const v = row[col];
439
+ if (v === null || v === void 0) continue;
440
+ row[col] = t.fromDb(v);
441
+ }
442
+ return row;
443
+ }
408
444
  function asTableRow(table, row) {
409
- return reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
445
+ const revived = reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
446
+ return applyFromDb(revived, transformsOf(currentSchema, table));
410
447
  }
411
448
  function asTableRows(table, rows) {
412
449
  const vectorCols = vectorColumnsOf(currentSchema, table);
413
- return rows.map((row) => reviveVectors(asWireRow(row), vectorCols));
450
+ const transforms = transformsOf(currentSchema, table);
451
+ return rows.map((row) => applyFromDb(reviveVectors(asWireRow(row), vectorCols), transforms));
414
452
  }
415
453
  function asBindParams(table, cols, data) {
416
454
  const vectorCols = vectorColumnsOf(currentSchema, table);
455
+ const transforms = transformsOf(currentSchema, table);
417
456
  return cols.map((c) => {
418
457
  const v = data[c];
419
- return Array.isArray(v) && vectorCols.has(c) ? toVectorLiteral(v) : v;
458
+ if (Array.isArray(v) && vectorCols.has(c)) return toVectorLiteral(v);
459
+ const t = transforms.get(c);
460
+ if (t?.toDb !== void 0 && v !== null && v !== void 0) return t.toDb(v);
461
+ return v;
420
462
  });
421
463
  }
422
464
  var SELECTIVITY_EXACT_THRESHOLD = 1e4;
@@ -543,7 +585,9 @@ function expandSynonyms(query, map) {
543
585
  }).join("");
544
586
  }).join("");
545
587
  }
546
- var searchLogger = null;
588
+ var searchLogger = (evt, fields) => {
589
+ console.log(evt, JSON.stringify(fields));
590
+ };
547
591
  function logNoResults(table, query, mode, n) {
548
592
  if (n === 0) {
549
593
  searchLogger?.("palbase.search.no_results", {
@@ -584,19 +628,55 @@ function validitySql(v, add) {
584
628
  function excludeSql(pk, ids, add) {
585
629
  if (ids.length === 0) return "";
586
630
  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(", ")}])`;
631
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
632
+ }
633
+ function schemaColumns(table) {
634
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
635
+ const cols = t?.columns ? Object.keys(t.columns) : [];
636
+ return cols.length > 0 ? new Set(cols) : null;
637
+ }
638
+ function orderClause(table, known, orderBy) {
639
+ if (!orderBy) return "";
640
+ const col = orderBy.column;
641
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
642
+ if (known !== null ? !known.has(col) : !shapeOK) {
643
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
644
+ }
645
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
646
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
647
+ }
648
+ function limitClause(limit) {
649
+ if (limit === void 0) return "";
650
+ if (!Number.isInteger(limit) || limit < 0) {
651
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
652
+ }
653
+ return ` LIMIT ${limit}`;
588
654
  }
589
- function compileWhere(table, colSet, where, add) {
655
+ function offsetClause(offset, limit) {
656
+ if (offset === void 0) return "";
657
+ if (!Number.isInteger(offset) || offset < 0) {
658
+ throw new Error(
659
+ `findMany: offset bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(offset)})`
660
+ );
661
+ }
662
+ if (limit === void 0) {
663
+ throw new Error(
664
+ "findMany: offset yaln\u0131z limit ile birlikte verilir \u2014 limitsiz offset bir sayfa de\u011Fil, s\u0131n\u0131rs\u0131z bir kuyru\u011Fun kayd\u0131r\u0131lm\u0131\u015F\u0131d\u0131r"
665
+ );
666
+ }
667
+ return ` OFFSET ${offset}`;
668
+ }
669
+ function compileWhere(table, colSet, where, add, caller = "search") {
590
670
  const parts = [];
591
671
  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)`);
672
+ if (colSet !== null && !colSet.has(col)) {
673
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
594
674
  }
595
675
  const q = `t.${quoteIdent(col)}`;
596
676
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
597
677
  for (const [op, v] of Object.entries(cond)) {
598
678
  if (op === "in") {
599
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
679
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
600
680
  if (v.length === 0) {
601
681
  parts.push("false");
602
682
  continue;
@@ -605,7 +685,7 @@ function compileWhere(table, colSet, where, add) {
605
685
  } else if (op in WHERE_OPS) {
606
686
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
607
687
  } else {
608
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
688
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
609
689
  }
610
690
  }
611
691
  } else {
@@ -614,6 +694,13 @@ function compileWhere(table, colSet, where, add) {
614
694
  }
615
695
  return parts.length === 0 ? "" : ` AND ${parts.join(" AND ")}`;
616
696
  }
697
+ function onConflictTail(cols, conflict, action) {
698
+ const target = `ON CONFLICT (${conflict.map(quoteIdent).join(", ")})`;
699
+ if (action === "ignore") return `${target} DO NOTHING`;
700
+ const conflictSet = new Set(conflict);
701
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
702
+ return sets.length ? `${target} DO UPDATE SET ${sets.join(", ")}` : `${target} DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
703
+ }
617
704
  var cachedVectorSchema = null;
618
705
  var cachedTrgmSchema = null;
619
706
  async function trgmSchemaOf(live) {
@@ -685,7 +772,8 @@ function createOps(tx) {
685
772
  const at = () => resolveTx(tx);
686
773
  const ops = {
687
774
  async query(sql, params = []) {
688
- return asWireRows(await (await at()).unsafe(sql, params));
775
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
776
+ return asWireRows(await (await at()).unsafe(sql, bound));
689
777
  },
690
778
  async insert(table, data) {
691
779
  const cols = Object.keys(data);
@@ -701,6 +789,74 @@ function createOps(tx) {
701
789
  }
702
790
  return asTableRow(table, inserted);
703
791
  },
792
+ /**
793
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
794
+ *
795
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
796
+ * unique violation, update instead" does not work here: a request runs in ONE
797
+ * Postgres transaction, so the failed insert aborts it and every later
798
+ * statement answers `current transaction is aborted`. A tenant measured that
799
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
800
+ * trigger create the row instead — a workaround that needs a new trigger for
801
+ * every table with a unique row.
802
+ *
803
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
804
+ * so writing them back is at best a no-op and at worst a surprise.
805
+ */
806
+ async upsert(table, data, opts) {
807
+ const conflict = opts.onConflict;
808
+ if (conflict.length === 0) {
809
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
810
+ }
811
+ const cols = Object.keys(data);
812
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
813
+ if (searchConfigFor(table)?.validity) {
814
+ const ph = cols.map((_, i) => `$${i + 1}`).join(", ");
815
+ const insertSql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${ph}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) WHERE "valid_to" IS NULL DO NOTHING RETURNING *`;
816
+ const live = await at();
817
+ const bindData = asBindParams(table, cols, data);
818
+ const first = await live.unsafe(insertSql, bindData);
819
+ if (first[0]) return asTableRow(table, first[0]);
820
+ const condSql = conflict.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ");
821
+ const condBind = conflict.map((c) => data[c]);
822
+ const cur = await live.unsafe(
823
+ `SELECT * FROM ${quoteIdent(table)} WHERE ${condSql} AND "valid_to" IS NULL`,
824
+ condBind
825
+ );
826
+ const old = cur[0];
827
+ if (old === void 0) {
828
+ throw new Error(`upsert into ${table}: e\u015Fzamanl\u0131 supersede yar\u0131\u015F\u0131 \u2014 ge\xE7erli sat\u0131r bulunamad\u0131, iste\u011Fi yineleyin (D-020)`);
829
+ }
830
+ const pk = Object.keys(old).includes("id") ? "id" : Object.keys(old)[0];
831
+ await live.unsafe(
832
+ `UPDATE ${quoteIdent(table)} SET "valid_to" = now() WHERE ${quoteIdent(pk)} = $1 AND "valid_to" IS NULL`,
833
+ [old[pk]]
834
+ );
835
+ const second = await live.unsafe(insertSql, bindData);
836
+ if (!second[0]) {
837
+ throw new Error(`upsert into ${table}: e\u015Fzamanl\u0131 supersede yar\u0131\u015F\u0131 \u2014 yeni sat\u0131r a\xE7\u0131lamad\u0131, iste\u011Fi yineleyin (D-020)`);
838
+ }
839
+ const fresh = second[0];
840
+ await live.unsafe(
841
+ `UPDATE ${quoteIdent(table)} SET "superseded_by" = $2 WHERE ${quoteIdent(pk)} = $1`,
842
+ [old[pk], fresh[pk]]
843
+ );
844
+ return asTableRow(table, second[0]);
845
+ }
846
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
847
+ const conflictSet = new Set(conflict);
848
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
849
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
850
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
851
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
852
+ const row = rows[0];
853
+ if (!row) {
854
+ throw new Error(
855
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
856
+ );
857
+ }
858
+ return asTableRow(table, row);
859
+ },
704
860
  async update(table, id, data) {
705
861
  const cols = Object.keys(data);
706
862
  if (cols.length === 0) return ops.findById(table, id);
@@ -712,6 +868,86 @@ function createOps(tx) {
712
868
  async delete(table, id) {
713
869
  await (await at()).unsafe(`DELETE FROM ${quoteIdent(table)} WHERE id = $1`, [id]);
714
870
  },
871
+ /**
872
+ * Update every row the filter matches, in ONE statement.
873
+ *
874
+ * The capability was already here and only reachable from inside a
875
+ * transaction plan (`tx.tables.x.updateWhere`, since 11.0.0). Outside it the
876
+ * only door was `update(id, …)`, so "mark every unapproved row in this
877
+ * household" was an N+1 loop or hand-written SQL — and hand-written SQL is
878
+ * where the typed surface and RLS both stop helping.
879
+ *
880
+ * The filter language is `findMany`'s, compiled by the same `compileWhere`:
881
+ * one filter language, or the two spellings drift.
882
+ *
883
+ * AN EMPTY FILTER IS REFUSED. `UPDATE … WHERE true` is a whole-table write,
884
+ * and the shape that produces it by accident — a filter object built from
885
+ * request input that happened to come back empty — is exactly the shape that
886
+ * should not silently succeed. Callers who mean every row say so with a
887
+ * predicate that is true for every row.
888
+ */
889
+ async updateMany(table, where, set) {
890
+ const cols = Object.keys(set);
891
+ if (cols.length === 0) return [];
892
+ if (Object.keys(where).length === 0) {
893
+ throw new Error(
894
+ `updateMany(${table}): bo\u015F filtre T\xDCM sat\u0131rlar\u0131 g\xFCncellerdi \u2014 bir filtre verin, ya da her sat\u0131ra uyan bir ko\u015Ful yaz\u0131n`
895
+ );
896
+ }
897
+ const params = [];
898
+ const add = (v) => {
899
+ params.push(v);
900
+ return `$${params.length}`;
901
+ };
902
+ const assignments = cols.map((c) => `${quoteIdent(c)} = ${add(asBindParams(table, [c], set)[0])}`).join(", ");
903
+ const known = schemaColumns(table);
904
+ const whereSql = compileWhere(table, known, where, add, "updateMany");
905
+ const sql = `UPDATE ${quoteIdent(table)} AS t SET ${assignments} WHERE true${whereSql} RETURNING *`;
906
+ return asTableRows(table, await (await at()).unsafe(sql, params));
907
+ },
908
+ /**
909
+ * Delete every row the filter matches, in ONE statement; resolves to how
910
+ * many went. Same filter language, same empty-filter refusal as
911
+ * {@link updateMany} — and here the accident is worse.
912
+ */
913
+ async deleteMany(table, where) {
914
+ if (Object.keys(where).length === 0) {
915
+ throw new Error(
916
+ `deleteMany(${table}): bo\u015F filtre T\xDCM sat\u0131rlar\u0131 silerdi \u2014 bir filtre verin, ya da her sat\u0131ra uyan bir ko\u015Ful yaz\u0131n`
917
+ );
918
+ }
919
+ const params = [];
920
+ const add = (v) => {
921
+ params.push(v);
922
+ return `$${params.length}`;
923
+ };
924
+ const known = schemaColumns(table);
925
+ const whereSql = compileWhere(table, known, where, add, "deleteMany");
926
+ const sql = `DELETE FROM ${quoteIdent(table)} AS t WHERE true${whereSql} RETURNING 1`;
927
+ const rows = await (await at()).unsafe(sql, params);
928
+ return rows.length;
929
+ },
930
+ /**
931
+ * How many rows match — the half of pagination `limit`/`offset` cannot
932
+ * supply. Without it a page count is either a guess or "fetch everything and
933
+ * read .length", and the second one is the scale risk this surface exists to
934
+ * remove.
935
+ *
936
+ * An empty filter is legitimate HERE: counting a whole table is a read, and
937
+ * reads do not destroy anything.
938
+ */
939
+ async count(table, where = {}) {
940
+ const params = [];
941
+ const add = (v) => {
942
+ params.push(v);
943
+ return `$${params.length}`;
944
+ };
945
+ const known = schemaColumns(table);
946
+ const whereSql = compileWhere(table, known, where, add, "count");
947
+ const sql = `SELECT count(*) AS n FROM ${quoteIdent(table)} t WHERE true${whereSql}`;
948
+ const rows = await (await at()).unsafe(sql, params);
949
+ return Number(rows[0]?.n ?? 0);
950
+ },
715
951
  async findById(table, id) {
716
952
  const rows = await (await at()).unsafe(
717
953
  `SELECT * FROM ${quoteIdent(table)} WHERE id = $1`,
@@ -719,13 +955,19 @@ function createOps(tx) {
719
955
  );
720
956
  return rows[0] ? asTableRow(table, rows[0]) : null;
721
957
  },
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
- ));
958
+ async findMany(table, query = {}, opts = {}) {
959
+ const params = [];
960
+ const add = (v) => {
961
+ params.push(v);
962
+ return `$${params.length}`;
963
+ };
964
+ const known = schemaColumns(table);
965
+ const whereSql = compileWhere(table, known, query, add, "findMany");
966
+ const order = orderClause(table, known, opts.orderBy);
967
+ const limit = limitClause(opts.limit);
968
+ const offset = offsetClause(opts.offset, opts.limit);
969
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}${offset}`;
970
+ return asTableRows(table, await (await at()).unsafe(sql, params));
729
971
  },
730
972
  /**
731
973
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -807,7 +1049,9 @@ function createOps(tx) {
807
1049
  bindC.push(v);
808
1050
  return `$${bindC.length}`;
809
1051
  };
810
- const whereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC) + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1052
+ const userWhereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC);
1053
+ const whereC = userWhereC + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1054
+ const parentJoinC = ` JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = c.${pidQ} WHERE true${whereC} AND `;
811
1055
  const liveC = await at();
812
1056
  const K2 = 50;
813
1057
  const cpool = Math.max(limit * 9, 90);
@@ -816,8 +1060,19 @@ function createOps(tx) {
816
1060
  if (qvC !== null) {
817
1061
  const sch = await vectorSchemaWithGuc(liveC);
818
1062
  const op = METRIC_OPERATOR[ck.metric] ?? METRIC_OPERATOR.cosine;
1063
+ let exactOrderC = "";
1064
+ if (userWhereC !== "") {
1065
+ const probeRows = await liveC.unsafe(
1066
+ `SELECT count(*)::int AS n FROM (SELECT 1 FROM ${quoteIdent(ck.table)} c${parentJoinC}c.embedding IS NOT NULL LIMIT ${SELECTIVITY_EXACT_THRESHOLD + 1}) s`,
1067
+ bindC.slice()
1068
+ );
1069
+ const n = probeRows?.[0]?.n;
1070
+ if (typeof n === "number" && n <= SELECTIVITY_EXACT_THRESHOLD) {
1071
+ exactOrderC = " + 0.0";
1072
+ }
1073
+ }
819
1074
  const vp = addC(toVectorLiteral(qvC));
820
- semC = `SELECT c.${pidQ} AS pid, c.chunk_seq, ROW_NUMBER() OVER (ORDER BY (c.embedding OPERATOR(${quoteIdent(sch)}.${op}) ${vp}::${quoteIdent(sch)}.vector)) AS r FROM ${quoteIdent(ck.table)} c WHERE c.embedding IS NOT NULL ORDER BY r LIMIT ${cpool}`;
1075
+ semC = `SELECT c.${pidQ} AS pid, c.chunk_seq, ROW_NUMBER() OVER (ORDER BY (c.embedding OPERATOR(${quoteIdent(sch)}.${op}) ${vp}::${quoteIdent(sch)}.vector)${exactOrderC}) AS r FROM ${quoteIdent(ck.table)} c${parentJoinC}c.embedding IS NOT NULL ORDER BY r LIMIT ${cpool}`;
821
1076
  }
822
1077
  let qpC = "";
823
1078
  let qpCIdx = -1;
@@ -825,7 +1080,7 @@ function createOps(tx) {
825
1080
  const qTextC = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
826
1081
  qpC = addC(qTextC);
827
1082
  qpCIdx = bindC.length - 1;
828
- kwC = `SELECT c.${pidQ} AS pid, c.chunk_seq, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(c.palbase_fts, websearch_to_tsquery('simple', ${qpC})) DESC) AS r FROM ${quoteIdent(ck.table)} c WHERE c.palbase_fts @@ websearch_to_tsquery('simple', ${qpC}) ORDER BY r LIMIT ${cpool}`;
1083
+ kwC = `SELECT c.${pidQ} AS pid, c.chunk_seq, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(c.palbase_fts, websearch_to_tsquery('simple', ${qpC})) DESC) AS r FROM ${quoteIdent(ck.table)} c${parentJoinC}c.palbase_fts @@ websearch_to_tsquery('simple', ${qpC}) ORDER BY r LIMIT ${cpool}`;
829
1084
  }
830
1085
  const msC = params.minScore === void 0 ? "" : addC(params.minScore);
831
1086
  const colListC = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
@@ -840,13 +1095,13 @@ function createOps(tx) {
840
1095
  } else {
841
1096
  fusedSrc = `WITH kw AS (${kwIn}), fused AS (SELECT kw.pid, kw.chunk_seq, (1.0/(${K2} + kw.r))::float8 AS cscore FROM kw)`;
842
1097
  }
843
- const inner = `${fusedSrc}, ranked AS (SELECT f.pid, f.chunk_seq, f.cscore, ROW_NUMBER() OVER (PARTITION BY f.pid ORDER BY f.cscore DESC, f.chunk_seq) AS rn FROM fused f), grouped AS (SELECT r.pid, MAX(r.cscore) AS _score, json_agg(json_build_object('content', c.content, 'score', r.cscore, 'chunkSeq', r.chunk_seq, 'charStart', c.char_start) ORDER BY r.cscore DESC, r.chunk_seq) FILTER (WHERE r.rn <= ${bpr}) AS blocks FROM ranked r JOIN ${quoteIdent(ck.table)} c ON c.${pidQ} = r.pid AND c.chunk_seq = r.chunk_seq GROUP BY r.pid) SELECT ${colListC}, ${scoreC}::float8 AS _score, g.blocks AS blocks${kwnCol} FROM grouped g JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = g.pid WHERE true${whereC}`;
1098
+ const inner = `${fusedSrc}, ranked AS (SELECT f.pid, f.chunk_seq, f.cscore, ROW_NUMBER() OVER (PARTITION BY f.pid ORDER BY f.cscore DESC, f.chunk_seq) AS rn FROM fused f), grouped AS (SELECT r.pid, MAX(r.cscore) AS _score, json_agg(json_build_object('content', c.content, 'score', r.cscore, 'chunkSeq', r.chunk_seq, 'charStart', c.char_start) ORDER BY r.cscore DESC, r.chunk_seq) FILTER (WHERE r.rn <= ${bpr}) AS blocks FROM ranked r JOIN ${quoteIdent(ck.table)} c ON c.${pidQ} = r.pid AND c.chunk_seq = r.chunk_seq GROUP BY r.pid) SELECT ${colListC}, ${scoreC}::float8 AS _score, g.blocks AS blocks${kwnCol} FROM grouped g JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = g.pid`;
844
1099
  return msC === "" ? `SELECT * FROM (${inner}) q ORDER BY q._score DESC, q.${quoteIdent(cfg.pk)} LIMIT ${limit}` : `SELECT * FROM (${inner}) q WHERE q._score >= ${msC} ORDER BY q._score DESC, q.${quoteIdent(cfg.pk)} LIMIT ${limit}`;
845
1100
  };
846
1101
  let rowsC = await liveC.unsafe(assembleC(kwC, kwC !== ""), bindC);
847
1102
  if (wantTextC && kwC !== "" && (rowsC.length === 0 || rowsC[0]._kwn === 0)) {
848
1103
  const tsch = await trgmSchemaOf(liveC);
849
- const trgmKwC = `SELECT c.${pidQ} AS pid, c.chunk_seq, ROW_NUMBER() OVER (ORDER BY ${quoteIdent(tsch)}.word_similarity(${qpC}, coalesce(c.content,'')) DESC) AS r FROM ${quoteIdent(ck.table)} c WHERE ${qpC} OPERATOR(${quoteIdent(tsch)}.<%) coalesce(c.content,'') ORDER BY r LIMIT ${cpool}`;
1104
+ const trgmKwC = `SELECT c.${pidQ} AS pid, c.chunk_seq, ROW_NUMBER() OVER (ORDER BY ${quoteIdent(tsch)}.word_similarity(${qpC}, coalesce(c.content,'')) DESC) AS r FROM ${quoteIdent(ck.table)} c${parentJoinC}${quoteIdent(tsch)}.word_similarity(${qpC}, coalesce(c.content,'')) > 0.3 ORDER BY r LIMIT ${cpool}`;
850
1105
  const retryBindC = bindC.slice();
851
1106
  if (qpCIdx >= 0) retryBindC[qpCIdx] = params.query;
852
1107
  rowsC = await liveC.unsafe(assembleC(trgmKwC, false), retryBindC);
@@ -942,7 +1197,7 @@ function createOps(tx) {
942
1197
  if (wantText && kwSql !== "" && (rows.length === 0 || rows[0]._kwn === 0)) {
943
1198
  const tsch = await trgmSchemaOf(live);
944
1199
  const expr = ftsExprOf(cfg.ftsCols);
945
- const trgmKw = `SELECT t.${quoteIdent(cfg.pk)} AS id, ROW_NUMBER() OVER (ORDER BY ${quoteIdent(tsch)}.word_similarity(${qpRef}, ${expr}) DESC) AS r FROM ${quoteIdent(table)} t WHERE ${qpRef} OPERATOR(${quoteIdent(tsch)}.<%) ${expr}${whereSql} ORDER BY r LIMIT ${pool}`;
1200
+ const trgmKw = `SELECT t.${quoteIdent(cfg.pk)} AS id, ROW_NUMBER() OVER (ORDER BY ${quoteIdent(tsch)}.word_similarity(${qpRef}, ${expr}) DESC) AS r FROM ${quoteIdent(table)} t WHERE ${quoteIdent(tsch)}.word_similarity(${qpRef}, ${expr}) > 0.3${whereSql} ORDER BY r LIMIT ${pool}`;
946
1201
  const retryBind = bind.slice();
947
1202
  if (qpIdx >= 0) retryBind[qpIdx] = params.query;
948
1203
  rows = await live.unsafe(assemble(trgmKw, false), retryBind);
@@ -965,6 +1220,25 @@ function createOps(tx) {
965
1220
  * tek SQL'de "id yok" ile "0 komşu" ayrılamazdı; +1 küçük turla id-yokluğu
966
1221
  * adlandırılmış hataya çevrilir. Sonuç şekli search ile aynı (FR-015).
967
1222
  */
1223
+ /** D-021 (FR-027 DX): sayaçlar BAĞIMSIZ op'la — search'ün dizi-üstü
1224
+ * `_facets` özelliği JSON.stringify'da kaybolur (dizi özelliği), tenant
1225
+ * yanıtına koyunca sessizce yok olurdu. Ayrı dönüş ciddi bir sözleşmedir;
1226
+ * search'teki alan geriye-uyum için DURUR. where + validity default'u
1227
+ * sayaçlara da uygulanır (sayaç, kullanıcının gördüğü kümeyi anlatır). */
1228
+ async facets(table, params) {
1229
+ const cfg = searchConfigFor(table);
1230
+ if (!cfg) {
1231
+ throw new Error(`facets(${table}): tablo aranabilir de\u011Fil \u2014 search beyan\u0131 yok (FR-027)`);
1232
+ }
1233
+ for (const col of params.facets) {
1234
+ if (!cfg.colSet.has(col)) {
1235
+ throw new Error(`facets(${table}): facets kolonu "${col}" tabloda yok (FR-027)`);
1236
+ }
1237
+ }
1238
+ const live = await at();
1239
+ const extra = cfg.validity ? (add) => validitySql(params.validity, add) : () => "";
1240
+ return fetchFacets(live, table, cfg.colSet, params.facets, params.where ?? {}, extra);
1241
+ },
968
1242
  async similar(table, id, opts = {}) {
969
1243
  const cfg = searchConfigFor(table);
970
1244
  if (!cfg) {
@@ -1116,6 +1390,27 @@ function createOps(tx) {
1116
1390
  const live = await at();
1117
1391
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1118
1392
  },
1393
+ /**
1394
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
1395
+ * rolls back only what that handle wrote and the request can keep writing.
1396
+ *
1397
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
1398
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
1399
+ * the ambient `Database` at the savepoint for the duration, and a request is
1400
+ * concurrent with itself: `Promise.all([Database.insert(a),
1401
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
1402
+ * back with it. Silent data loss, and the same interleaving this file already
1403
+ * refuses for `asService()`. Passing the handle makes the boundary something
1404
+ * you can see in the code that crosses it.
1405
+ *
1406
+ * Postgres, not us: the savepoint is released on success and rolled back on
1407
+ * failure by the driver, so an aborted statement inside `fn` does not poison
1408
+ * the surrounding transaction.
1409
+ */
1410
+ async attempt(fn) {
1411
+ const live = await at();
1412
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
1413
+ },
1119
1414
  /**
1120
1415
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1121
1416
  *
@@ -1166,22 +1461,68 @@ function withTables(ops, schema = currentSchema) {
1166
1461
  update: (id, data) => ops.update(name, id, data),
1167
1462
  delete: (id) => ops.delete(name, id),
1168
1463
  findById: (id) => ops.findById(name, id),
1169
- findMany: (query) => ops.findMany(name, query ?? {})
1464
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
1465
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1170
1466
  };
1171
1467
  }
1172
1468
  const base = /* @__PURE__ */ Object.create(null);
1173
1469
  return Object.assign(base, ops, { tables });
1174
1470
  }
1175
1471
  var SERVICE_LOCK_TIMEOUT = "5s";
1472
+ function isAbortedTransaction(e) {
1473
+ const message = String(e?.message ?? "");
1474
+ return sqlstateOf(e) === "25P02" || /current transaction is aborted/i.test(message);
1475
+ }
1476
+ function sqlstateOf(e) {
1477
+ const err = e;
1478
+ const isState = (v) => typeof v === "string" && /^[0-9A-Z]{5}$/.test(v);
1479
+ if (isState(err?.code)) return err.code;
1480
+ if (isState(err?.errno)) return err.errno;
1481
+ if (typeof err?.errno === "number") {
1482
+ const asText = String(err.errno);
1483
+ if (isState(asText)) return asText;
1484
+ }
1485
+ return void 0;
1486
+ }
1487
+ function isForeignKeyViolation(e) {
1488
+ return sqlstateOf(e) === "23503";
1489
+ }
1490
+ function isUniqueViolation(e) {
1491
+ return sqlstateOf(e) === "23505";
1492
+ }
1493
+ function constraintOf(e) {
1494
+ const named = e?.constraint;
1495
+ if (typeof named === "string" && named.length > 0) return named;
1496
+ const message = String(e?.message ?? "");
1497
+ return /violates unique constraint "([^"]+)"/.exec(message)?.[1] ?? "";
1498
+ }
1176
1499
  function isLockTimeout(e) {
1177
- const code = e?.code;
1178
1500
  const message = String(e?.message ?? "");
1179
- return code === "55P03" || /lock timeout/i.test(message);
1501
+ return sqlstateOf(e) === "55P03" || /lock timeout/i.test(message);
1180
1502
  }
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;
1503
+ function diagnosingDriver(sql, surface = "service") {
1504
+ const original = (e) => String(e?.message ?? e);
1505
+ const explain = (e) => {
1506
+ if (isAbortedTransaction(e)) {
1507
+ return new Error(
1508
+ `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)})`
1509
+ );
1510
+ }
1511
+ if (isUniqueViolation(e)) {
1512
+ return new UniqueViolation(constraintOf(e));
1513
+ }
1514
+ if (surface === "service" && isForeignKeyViolation(e)) {
1515
+ return new Error(
1516
+ `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)})`
1517
+ );
1518
+ }
1519
+ if (isLockTimeout(e)) {
1520
+ return new Error(
1521
+ `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)})`
1522
+ );
1523
+ }
1524
+ return e;
1525
+ };
1185
1526
  const wrapTx = (tx) => ({
1186
1527
  async unsafe(text, params) {
1187
1528
  try {
@@ -1200,13 +1541,13 @@ function diagnosingDriver(sql) {
1200
1541
  };
1201
1542
  }
1202
1543
  function createRequestDatabase(sql, identity) {
1203
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
1544
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1204
1545
  let serviceTx = null;
1205
1546
  let serviceClient = null;
1206
1547
  const asService = () => {
1207
1548
  if (serviceClient === null) {
1208
1549
  serviceTx = createLazyTransaction(
1209
- diagnosingDriver(sql),
1550
+ diagnosingDriver(sql, "service"),
1210
1551
  identity.serviceRole,
1211
1552
  identity.claimsJson,
1212
1553
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1287,6 +1628,20 @@ async function runPlanOp(sp, op, results) {
1287
1628
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1288
1629
  break;
1289
1630
  }
1631
+ case "upsert": {
1632
+ const cols = Object.keys(op.values ?? {});
1633
+ const conflict = op.onConflict ?? [];
1634
+ if (cols.length === 0 || conflict.length === 0) {
1635
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
1636
+ error_code: "tx_bad_upsert"
1637
+ });
1638
+ }
1639
+ const rendered = cols.map(
1640
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
1641
+ );
1642
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ${onConflictTail(cols, conflict, "update")} RETURNING *`;
1643
+ break;
1644
+ }
1290
1645
  case "insertMany": {
1291
1646
  const rows = op.rows ?? [];
1292
1647
  if (rows.length === 0 || !rows[0]) return [];
@@ -1294,7 +1649,9 @@ async function runPlanOp(sp, op, results) {
1294
1649
  const tuples = rows.map(
1295
1650
  (r) => `(${cols.map((c) => renderValue(r[c], c, args, results, opVectorCols)).join(", ")})`
1296
1651
  );
1297
- sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")} RETURNING *`;
1652
+ const conflictCols = op.onConflict ?? [];
1653
+ const tail = op.action !== void 0 && conflictCols.length > 0 ? ` ${onConflictTail(cols, conflictCols, op.action)}` : "";
1654
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}${tail} RETURNING *`;
1298
1655
  break;
1299
1656
  }
1300
1657
  case "update": {
@@ -1351,6 +1708,7 @@ function buildRouteTable(controllers) {
1351
1708
  `controller ${name} collected zero routes. Either it declares no @Get/@Post/\u2026 , or its decorator metadata was erased at build time \u2014 check that the bundle was compiled with experimentalDecorators enabled.`
1352
1709
  );
1353
1710
  }
1711
+ assertZeroArgConstructor(Ctrl, "controller");
1354
1712
  const instance = new ctor();
1355
1713
  for (const r of routes) {
1356
1714
  const full = `${basePath}${r.subpath ?? ""}` || "/";
@@ -1360,7 +1718,10 @@ function buildRouteTable(controllers) {
1360
1718
  meta: r,
1361
1719
  instance,
1362
1720
  id: `${r.method} ${full}`,
1363
- controllerAuth: meta?.defaultAuth
1721
+ // The route's OWN spec is deliberately not folded in here: this field
1722
+ // is what applies when the route is silent, and `effectiveAuth` puts
1723
+ // the route level back on top per request.
1724
+ controllerAuth: resolveEffectiveAuth(void 0, meta?.defaultAuth)
1364
1725
  });
1365
1726
  }
1366
1727
  }
@@ -1447,6 +1808,41 @@ function verifySignature(presented, expected) {
1447
1808
  return diff === 0;
1448
1809
  }
1449
1810
 
1811
+ // src/engine/sse.ts
1812
+ var SSE_CONTENT_TYPE = "text/event-stream";
1813
+ function encodeFrame(value) {
1814
+ return `data: ${JSON.stringify(value)}
1815
+
1816
+ `;
1817
+ }
1818
+ function encodeErrorFrame(requestId) {
1819
+ return `event: error
1820
+ data: ${JSON.stringify({ requestId })}
1821
+
1822
+ `;
1823
+ }
1824
+ function makeSseWriter(opts) {
1825
+ let begun = false;
1826
+ let chain = Promise.resolve();
1827
+ return {
1828
+ write(value) {
1829
+ const frame = encodeFrame(value);
1830
+ if (!begun) {
1831
+ begun = true;
1832
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
1833
+ return;
1834
+ }
1835
+ chain = chain.then(() => opts.enqueue(frame));
1836
+ },
1837
+ started() {
1838
+ return begun;
1839
+ },
1840
+ drained() {
1841
+ return chain;
1842
+ }
1843
+ };
1844
+ }
1845
+
1450
1846
  // src/engine/fence.ts
1451
1847
  var SECRET_ENV = [
1452
1848
  "DATABASE_URL",
@@ -1682,6 +2078,13 @@ async function createApp(opts) {
1682
2078
  const args = [];
1683
2079
  let parsedBody;
1684
2080
  let bodyRead = false;
2081
+ let sseEnqueue = null;
2082
+ let sseSettle = async () => {
2083
+ };
2084
+ const sseWriter = makeSseWriter({
2085
+ enqueue: (chunk) => sseEnqueue?.(chunk),
2086
+ onFirstWrite: () => sseSettle()
2087
+ });
1685
2088
  for (const p of meta.params ?? []) {
1686
2089
  switch (p.kind) {
1687
2090
  case "body": {
@@ -1785,6 +2188,21 @@ async function createApp(opts) {
1785
2188
  case "req":
1786
2189
  args[p.index] = req;
1787
2190
  break;
2191
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
2192
+ // state when the client disconnects. Measured on Bun: an infinite
2193
+ // producer guarded by it stops within a few frames of the client dying.
2194
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
2195
+ // request-scoped data and no signal.
2196
+ case "signal":
2197
+ args[p.index] = req.signal;
2198
+ break;
2199
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
2200
+ // on any other route it is an inert writer whose frames go nowhere,
2201
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
2202
+ // route.
2203
+ case "sseOut":
2204
+ args[p.index] = sseWriter;
2205
+ break;
1788
2206
  default:
1789
2207
  args[p.index] = void 0;
1790
2208
  }
@@ -1796,6 +2214,84 @@ async function createApp(opts) {
1796
2214
  });
1797
2215
  try {
1798
2216
  const services = buildServices(db);
2217
+ if (meta.options?.sseConfig !== void 0) {
2218
+ let sseController;
2219
+ const sseEncoder = new TextEncoder();
2220
+ const stream = new ReadableStream({
2221
+ start(c) {
2222
+ sseController = c;
2223
+ }
2224
+ });
2225
+ sseEnqueue = (chunk) => {
2226
+ try {
2227
+ sseController.enqueue(sseEncoder.encode(chunk));
2228
+ } catch {
2229
+ }
2230
+ };
2231
+ let sawFirstFrame;
2232
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
2233
+ sseSettle = async () => {
2234
+ await db.commit();
2235
+ sawFirstFrame();
2236
+ };
2237
+ const guarded = {
2238
+ ...services,
2239
+ Database: new Proxy(services.Database, {
2240
+ get(target, prop, recv) {
2241
+ if (sseWriter.started()) {
2242
+ throw new Error(
2243
+ `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.`
2244
+ );
2245
+ }
2246
+ return Reflect.get(target, prop, recv);
2247
+ }
2248
+ })
2249
+ };
2250
+ let thrown = null;
2251
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
2252
+ const box = requestALS.getStore();
2253
+ if (box) {
2254
+ box.userId = userId ?? null;
2255
+ box.requestId = requestId;
2256
+ box.idempotencyKey = req.headers.get("idempotency-key");
2257
+ }
2258
+ const method = hit.entry.instance[meta.fnName];
2259
+ if (typeof method !== "function") {
2260
+ throw new Error(
2261
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
2262
+ );
2263
+ }
2264
+ return method.apply(hit.entry.instance, args);
2265
+ })).then(
2266
+ () => void 0,
2267
+ (err) => {
2268
+ thrown = err;
2269
+ }
2270
+ );
2271
+ await Promise.race([firstFrame, ran]);
2272
+ if (!sseWriter.started()) {
2273
+ await ran;
2274
+ await db.commit();
2275
+ if (thrown !== null) throw thrown;
2276
+ return new Response(null, { status: 204 });
2277
+ }
2278
+ void (async () => {
2279
+ await ran;
2280
+ await sseWriter.drained();
2281
+ try {
2282
+ if (thrown !== null) {
2283
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
2284
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
2285
+ }
2286
+ sseController.close();
2287
+ } catch {
2288
+ }
2289
+ })();
2290
+ return new Response(stream, {
2291
+ status: 200,
2292
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
2293
+ });
2294
+ }
1799
2295
  const result = await runWithRuntime(services, () => {
1800
2296
  const box = requestALS.getStore();
1801
2297
  if (box) {
@@ -1854,18 +2350,29 @@ async function createApp(opts) {
1854
2350
  return envelope("internal_error", "The request could not be completed", 500, requestId);
1855
2351
  }
1856
2352
  }
2353
+ let runShutdownHooks;
2354
+ try {
2355
+ runShutdownHooks = await __runStartHooks();
2356
+ } catch (err) {
2357
+ await closeDriver(sql);
2358
+ throw err;
2359
+ }
1857
2360
  return {
1858
2361
  handle,
1859
2362
  routes,
1860
2363
  config,
1861
2364
  runInServiceScope,
1862
2365
  async shutdown() {
1863
- const closable = sql;
1864
- await closable.close?.();
1865
- await closable.end?.();
2366
+ await runShutdownHooks();
2367
+ await closeDriver(sql);
1866
2368
  }
1867
2369
  };
1868
2370
  }
2371
+ async function closeDriver(sql) {
2372
+ const closable = sql;
2373
+ await closable.close?.();
2374
+ await closable.end?.();
2375
+ }
1869
2376
 
1870
2377
  export {
1871
2378
  BootRefused,
@@ -1886,4 +2393,4 @@ export {
1886
2393
  installEgressFence,
1887
2394
  createApp
1888
2395
  };
1889
- //# sourceMappingURL=chunk-REZU6UKT.js.map
2396
+ //# sourceMappingURL=chunk-NXDH6VQJ.js.map