@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
@@ -252,7 +252,20 @@ var TxPlanBuilder = class {
252
252
  }
253
253
  return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
254
254
  },
255
- insertMany: (rows) => {
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
+ },
268
+ insertMany: (rows, opts) => {
256
269
  if (rows.length === 0) {
257
270
  return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
258
271
  }
@@ -263,7 +276,23 @@ var TxPlanBuilder = class {
263
276
  }
264
277
  const encoded = rows.map((row) => encodeMap(row, false));
265
278
  assertUniformRows(encoded, name);
266
- return this.push({ op: "insertMany", table: name, rows: encoded }, `${name}.insertMany()`);
279
+ if (opts !== void 0 && opts.onConflict.length === 0) {
280
+ throw new TxPlanError(
281
+ `${name}.insertMany() was given a conflict action with no onConflict columns. Postgres matches a collision on columns, so name them.`
282
+ );
283
+ }
284
+ return this.push(
285
+ {
286
+ op: "insertMany",
287
+ table: name,
288
+ rows: encoded,
289
+ // Omitted entirely when no options were given, so the op a plain
290
+ // insertMany produces is byte-identical to the one it produced
291
+ // before this option existed.
292
+ ...opts !== void 0 ? { onConflict: opts.onConflict, action: opts.action ?? "ignore" } : {}
293
+ },
294
+ `${name}.insertMany()`
295
+ );
267
296
  },
268
297
  updateWhere: (where, set) => {
269
298
  const encodedWhere = encodeMap(where, false);
@@ -437,6 +466,42 @@ function __getRuntime() {
437
466
  }
438
467
  return runtime;
439
468
  }
469
+ var LIFECYCLE = /* @__PURE__ */ Symbol.for("palbase.backend.lifecycleHooks");
470
+ function declaredLifecycle() {
471
+ const g = globalThis;
472
+ return g[LIFECYCLE] ??= { start: [], shutdown: [] };
473
+ }
474
+ function reason(err) {
475
+ return err instanceof Error ? err.message : String(err);
476
+ }
477
+ async function drain(hooks) {
478
+ for (const h of [...hooks].reverse()) {
479
+ try {
480
+ await h.run();
481
+ } catch (err) {
482
+ console.error(`[palbase] shutdown hook "${h.name}" failed: ${reason(err)}`, err);
483
+ }
484
+ }
485
+ }
486
+ async function __runStartHooks() {
487
+ const slot = declaredLifecycle();
488
+ const start = slot.start.splice(0);
489
+ const shutdown = slot.shutdown.splice(0);
490
+ for (const h of start) {
491
+ try {
492
+ await h.run();
493
+ } catch (err) {
494
+ await drain(shutdown);
495
+ throw new Error(`[palbase] start hook "${h.name}" failed: ${reason(err)}`, { cause: err });
496
+ }
497
+ }
498
+ let drained = false;
499
+ return async () => {
500
+ if (drained) return;
501
+ drained = true;
502
+ await drain(shutdown);
503
+ };
504
+ }
440
505
  function makeServiceProxy(key) {
441
506
  const handler = {
442
507
  get(_target, prop, receiver) {
@@ -459,10 +524,12 @@ function makeTablesAccessor(ops) {
459
524
  update: (id, data) => ops().update(name, id, data),
460
525
  delete: (id) => ops().delete(name, id),
461
526
  findById: (id) => ops().findById(name, id),
462
- findMany: (query) => ops().findMany(name, query),
527
+ findMany: (query, opts) => ops().findMany(name, query, opts),
528
+ upsert: (data, opts) => ops().upsert(name, data, opts),
463
529
  search: (params) => ops().search(name, params),
464
530
  similar: (id, params) => ops().similar(name, id, params),
465
531
  recommend: (params) => ops().recommend(name, params),
532
+ facets: (params) => ops().facets(name, params),
466
533
  supersede: (id, row) => ops().supersede(name, id, row)
467
534
  };
468
535
  }
@@ -479,13 +546,21 @@ function makeTypedSurface(raw) {
479
546
  update: (table, id, data) => raw.update(table, id, data),
480
547
  delete: (table, id) => raw.delete(table, id),
481
548
  findById: (table, id) => raw.findById(table, id),
482
- findMany: (table, query) => raw.findMany(table, query),
549
+ findMany: (table, query, opts) => raw.findMany(table, query, opts),
550
+ upsert: (table, data, opts) => raw.upsert(table, data, opts),
551
+ updateMany: (table, where, set) => raw.updateMany(table, where, set),
552
+ deleteMany: (table, where) => raw.deleteMany(table, where),
553
+ count: (table, where) => raw.count(table, where),
483
554
  search: (table, params) => raw.search(table, params),
484
555
  similar: (table, id, params) => reco.similar(table, id, params),
485
556
  recommend: (table, params) => reco.recommend(table, params),
557
+ facets: (table, params) => reco.facets(table, params),
486
558
  supersede: (table, id, row) => raw.supersede(table, id, row)
487
559
  };
488
560
  return Object.assign(ops, {
561
+ // Both surfaces get it: a savepoint on the service transaction is as useful
562
+ // as one on the request's, and each is bound to its own connection.
563
+ attempt: (fn) => raw.attempt(fn),
489
564
  tables: makeTablesAccessor(() => reco),
490
565
  transaction(fn) {
491
566
  const builder = new TxPlanBuilder();
@@ -637,6 +712,44 @@ var HttpError = class extends Error {
637
712
  return result;
638
713
  }
639
714
  };
715
+ var NamedHttpError = class extends HttpError {
716
+ constructor(status, defaultCode, name, message, code, data) {
717
+ super(status, code ?? defaultCode, message ?? defaultMessage(name), data);
718
+ this.name = name;
719
+ }
720
+ };
721
+ function defaultMessage(name) {
722
+ const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
723
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
724
+ }
725
+ var Conflict = class extends NamedHttpError {
726
+ constructor(message, code, data) {
727
+ super(409, "conflict", "Conflict", message, code, data);
728
+ }
729
+ };
730
+ var UniqueViolation = class extends Conflict {
731
+ /**
732
+ * Whether `e` is a unique violation — REGARDLESS of which copy of this SDK
733
+ * constructed it.
734
+ *
735
+ * Use this instead of `instanceof`. Measured on a live stack: a controller
736
+ * bundle INLINES its own copy of `@palbase/backend`, and the engine that
737
+ * raises this error is the runtime's copy. Two copies, two class identities,
738
+ * and `e instanceof UniqueViolation` is false in the one place a caller
739
+ * writes it — a check that reads as correct and silently never matches.
740
+ */
741
+ static is(e) {
742
+ return typeof e === "object" && e !== null && e.name === "UniqueViolation" && typeof e.constraint === "string";
743
+ }
744
+ /** The unique constraint the statement violated, as Postgres named it.
745
+ * `""` when the driver did not say which — see `engine/db.ts`. */
746
+ constraint;
747
+ constructor(constraint, message, code, data) {
748
+ super(message ?? "Unique constraint violated", code ?? "unique_violation", data);
749
+ this.name = "UniqueViolation";
750
+ this.constraint = constraint;
751
+ }
752
+ };
640
753
 
641
754
  // src/engine/config.ts
642
755
  var BootRefused = class extends Error {
@@ -954,6 +1067,14 @@ function makeMemoryCache(opts = {}) {
954
1067
  function quoteIdent(name) {
955
1068
  return `"${name.replace(/"/g, '""')}"`;
956
1069
  }
1070
+ function encodePgArray(value) {
1071
+ const element = (x) => {
1072
+ if (x === null || x === void 0) return "NULL";
1073
+ if (Array.isArray(x)) return encodePgArray(x);
1074
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
1075
+ };
1076
+ return `{${value.map(element).join(",")}}`;
1077
+ }
957
1078
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
958
1079
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
959
1080
  const { lockTimeout } = options;
@@ -991,9 +1112,9 @@ function createLazyTransaction(sql, role, claimsJson, options = {}) {
991
1112
  release();
992
1113
  await settled;
993
1114
  },
994
- async rollback(reason) {
1115
+ async rollback(reason2) {
995
1116
  if (!opening) return;
996
- fail(reason);
1117
+ fail(reason2);
997
1118
  await settled?.catch(() => void 0);
998
1119
  }
999
1120
  };
@@ -1036,18 +1157,48 @@ function reviveVectors(row, vectorCols) {
1036
1157
  }
1037
1158
  return row;
1038
1159
  }
1160
+ function transformsOf(schema, table) {
1161
+ const out = /* @__PURE__ */ new Map();
1162
+ for (const [key, def] of Object.entries(schema.tables ?? {})) {
1163
+ if ((def.name ?? key) !== table) continue;
1164
+ for (const [col, c] of Object.entries(def.columns ?? {})) {
1165
+ const d = c !== null && typeof c === "object" && "_def" in c ? c._def : c;
1166
+ const t = d?.transform;
1167
+ if (t !== null && typeof t === "object") {
1168
+ out.set(col, t);
1169
+ }
1170
+ }
1171
+ }
1172
+ return out;
1173
+ }
1174
+ function applyFromDb(row, transforms) {
1175
+ if (transforms.size === 0) return row;
1176
+ for (const [col, t] of transforms) {
1177
+ if (t.fromDb === void 0) continue;
1178
+ const v = row[col];
1179
+ if (v === null || v === void 0) continue;
1180
+ row[col] = t.fromDb(v);
1181
+ }
1182
+ return row;
1183
+ }
1039
1184
  function asTableRow(table, row) {
1040
- return reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
1185
+ const revived = reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
1186
+ return applyFromDb(revived, transformsOf(currentSchema, table));
1041
1187
  }
1042
1188
  function asTableRows(table, rows) {
1043
1189
  const vectorCols = vectorColumnsOf(currentSchema, table);
1044
- return rows.map((row) => reviveVectors(asWireRow(row), vectorCols));
1190
+ const transforms = transformsOf(currentSchema, table);
1191
+ return rows.map((row) => applyFromDb(reviveVectors(asWireRow(row), vectorCols), transforms));
1045
1192
  }
1046
1193
  function asBindParams(table, cols, data) {
1047
1194
  const vectorCols = vectorColumnsOf(currentSchema, table);
1195
+ const transforms = transformsOf(currentSchema, table);
1048
1196
  return cols.map((c) => {
1049
1197
  const v = data[c];
1050
- return Array.isArray(v) && vectorCols.has(c) ? toVectorLiteral(v) : v;
1198
+ if (Array.isArray(v) && vectorCols.has(c)) return toVectorLiteral(v);
1199
+ const t = transforms.get(c);
1200
+ if (t?.toDb !== void 0 && v !== null && v !== void 0) return t.toDb(v);
1201
+ return v;
1051
1202
  });
1052
1203
  }
1053
1204
  var SELECTIVITY_EXACT_THRESHOLD = 1e4;
@@ -1174,7 +1325,9 @@ function expandSynonyms(query, map) {
1174
1325
  }).join("");
1175
1326
  }).join("");
1176
1327
  }
1177
- var searchLogger = null;
1328
+ var searchLogger = (evt, fields) => {
1329
+ console.log(evt, JSON.stringify(fields));
1330
+ };
1178
1331
  function logNoResults(table, query, mode, n) {
1179
1332
  if (n === 0) {
1180
1333
  searchLogger?.("palbase.search.no_results", {
@@ -1215,19 +1368,55 @@ function validitySql(v, add) {
1215
1368
  function excludeSql(pk, ids, add) {
1216
1369
  if (ids.length === 0) return "";
1217
1370
  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(", ")}])`;
1371
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1372
+ }
1373
+ function schemaColumns(table) {
1374
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
1375
+ const cols = t?.columns ? Object.keys(t.columns) : [];
1376
+ return cols.length > 0 ? new Set(cols) : null;
1377
+ }
1378
+ function orderClause(table, known, orderBy) {
1379
+ if (!orderBy) return "";
1380
+ const col = orderBy.column;
1381
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
1382
+ if (known !== null ? !known.has(col) : !shapeOK) {
1383
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
1384
+ }
1385
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
1386
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
1387
+ }
1388
+ function limitClause(limit) {
1389
+ if (limit === void 0) return "";
1390
+ if (!Number.isInteger(limit) || limit < 0) {
1391
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
1392
+ }
1393
+ return ` LIMIT ${limit}`;
1219
1394
  }
1220
- function compileWhere(table, colSet, where, add) {
1395
+ function offsetClause(offset, limit) {
1396
+ if (offset === void 0) return "";
1397
+ if (!Number.isInteger(offset) || offset < 0) {
1398
+ throw new Error(
1399
+ `findMany: offset bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(offset)})`
1400
+ );
1401
+ }
1402
+ if (limit === void 0) {
1403
+ throw new Error(
1404
+ "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"
1405
+ );
1406
+ }
1407
+ return ` OFFSET ${offset}`;
1408
+ }
1409
+ function compileWhere(table, colSet, where, add, caller = "search") {
1221
1410
  const parts = [];
1222
1411
  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)`);
1412
+ if (colSet !== null && !colSet.has(col)) {
1413
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1225
1414
  }
1226
1415
  const q = `t.${quoteIdent(col)}`;
1227
1416
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1228
1417
  for (const [op, v] of Object.entries(cond)) {
1229
1418
  if (op === "in") {
1230
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
1419
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
1231
1420
  if (v.length === 0) {
1232
1421
  parts.push("false");
1233
1422
  continue;
@@ -1236,7 +1425,7 @@ function compileWhere(table, colSet, where, add) {
1236
1425
  } else if (op in WHERE_OPS) {
1237
1426
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
1238
1427
  } else {
1239
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1428
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1240
1429
  }
1241
1430
  }
1242
1431
  } else {
@@ -1245,6 +1434,13 @@ function compileWhere(table, colSet, where, add) {
1245
1434
  }
1246
1435
  return parts.length === 0 ? "" : ` AND ${parts.join(" AND ")}`;
1247
1436
  }
1437
+ function onConflictTail(cols, conflict, action) {
1438
+ const target = `ON CONFLICT (${conflict.map(quoteIdent).join(", ")})`;
1439
+ if (action === "ignore") return `${target} DO NOTHING`;
1440
+ const conflictSet = new Set(conflict);
1441
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1442
+ return sets.length ? `${target} DO UPDATE SET ${sets.join(", ")}` : `${target} DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1443
+ }
1248
1444
  var cachedVectorSchema = null;
1249
1445
  var cachedTrgmSchema = null;
1250
1446
  async function trgmSchemaOf(live) {
@@ -1316,7 +1512,8 @@ function createOps(tx) {
1316
1512
  const at = () => resolveTx(tx);
1317
1513
  const ops = {
1318
1514
  async query(sql, params = []) {
1319
- return asWireRows(await (await at()).unsafe(sql, params));
1515
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
1516
+ return asWireRows(await (await at()).unsafe(sql, bound));
1320
1517
  },
1321
1518
  async insert(table, data) {
1322
1519
  const cols = Object.keys(data);
@@ -1332,6 +1529,74 @@ function createOps(tx) {
1332
1529
  }
1333
1530
  return asTableRow(table, inserted);
1334
1531
  },
1532
+ /**
1533
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
1534
+ *
1535
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
1536
+ * unique violation, update instead" does not work here: a request runs in ONE
1537
+ * Postgres transaction, so the failed insert aborts it and every later
1538
+ * statement answers `current transaction is aborted`. A tenant measured that
1539
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
1540
+ * trigger create the row instead — a workaround that needs a new trigger for
1541
+ * every table with a unique row.
1542
+ *
1543
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
1544
+ * so writing them back is at best a no-op and at worst a surprise.
1545
+ */
1546
+ async upsert(table, data, opts) {
1547
+ const conflict = opts.onConflict;
1548
+ if (conflict.length === 0) {
1549
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
1550
+ }
1551
+ const cols = Object.keys(data);
1552
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
1553
+ if (searchConfigFor(table)?.validity) {
1554
+ const ph = cols.map((_, i) => `$${i + 1}`).join(", ");
1555
+ 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 *`;
1556
+ const live = await at();
1557
+ const bindData = asBindParams(table, cols, data);
1558
+ const first = await live.unsafe(insertSql, bindData);
1559
+ if (first[0]) return asTableRow(table, first[0]);
1560
+ const condSql = conflict.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ");
1561
+ const condBind = conflict.map((c) => data[c]);
1562
+ const cur = await live.unsafe(
1563
+ `SELECT * FROM ${quoteIdent(table)} WHERE ${condSql} AND "valid_to" IS NULL`,
1564
+ condBind
1565
+ );
1566
+ const old = cur[0];
1567
+ if (old === void 0) {
1568
+ 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)`);
1569
+ }
1570
+ const pk = Object.keys(old).includes("id") ? "id" : Object.keys(old)[0];
1571
+ await live.unsafe(
1572
+ `UPDATE ${quoteIdent(table)} SET "valid_to" = now() WHERE ${quoteIdent(pk)} = $1 AND "valid_to" IS NULL`,
1573
+ [old[pk]]
1574
+ );
1575
+ const second = await live.unsafe(insertSql, bindData);
1576
+ if (!second[0]) {
1577
+ 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)`);
1578
+ }
1579
+ const fresh = second[0];
1580
+ await live.unsafe(
1581
+ `UPDATE ${quoteIdent(table)} SET "superseded_by" = $2 WHERE ${quoteIdent(pk)} = $1`,
1582
+ [old[pk], fresh[pk]]
1583
+ );
1584
+ return asTableRow(table, second[0]);
1585
+ }
1586
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1587
+ const conflictSet = new Set(conflict);
1588
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1589
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1590
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
1591
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
1592
+ const row = rows[0];
1593
+ if (!row) {
1594
+ throw new Error(
1595
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
1596
+ );
1597
+ }
1598
+ return asTableRow(table, row);
1599
+ },
1335
1600
  async update(table, id, data) {
1336
1601
  const cols = Object.keys(data);
1337
1602
  if (cols.length === 0) return ops.findById(table, id);
@@ -1343,6 +1608,86 @@ function createOps(tx) {
1343
1608
  async delete(table, id) {
1344
1609
  await (await at()).unsafe(`DELETE FROM ${quoteIdent(table)} WHERE id = $1`, [id]);
1345
1610
  },
1611
+ /**
1612
+ * Update every row the filter matches, in ONE statement.
1613
+ *
1614
+ * The capability was already here and only reachable from inside a
1615
+ * transaction plan (`tx.tables.x.updateWhere`, since 11.0.0). Outside it the
1616
+ * only door was `update(id, …)`, so "mark every unapproved row in this
1617
+ * household" was an N+1 loop or hand-written SQL — and hand-written SQL is
1618
+ * where the typed surface and RLS both stop helping.
1619
+ *
1620
+ * The filter language is `findMany`'s, compiled by the same `compileWhere`:
1621
+ * one filter language, or the two spellings drift.
1622
+ *
1623
+ * AN EMPTY FILTER IS REFUSED. `UPDATE … WHERE true` is a whole-table write,
1624
+ * and the shape that produces it by accident — a filter object built from
1625
+ * request input that happened to come back empty — is exactly the shape that
1626
+ * should not silently succeed. Callers who mean every row say so with a
1627
+ * predicate that is true for every row.
1628
+ */
1629
+ async updateMany(table, where, set) {
1630
+ const cols = Object.keys(set);
1631
+ if (cols.length === 0) return [];
1632
+ if (Object.keys(where).length === 0) {
1633
+ throw new Error(
1634
+ `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`
1635
+ );
1636
+ }
1637
+ const params = [];
1638
+ const add = (v) => {
1639
+ params.push(v);
1640
+ return `$${params.length}`;
1641
+ };
1642
+ const assignments = cols.map((c) => `${quoteIdent(c)} = ${add(asBindParams(table, [c], set)[0])}`).join(", ");
1643
+ const known = schemaColumns(table);
1644
+ const whereSql = compileWhere(table, known, where, add, "updateMany");
1645
+ const sql = `UPDATE ${quoteIdent(table)} AS t SET ${assignments} WHERE true${whereSql} RETURNING *`;
1646
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1647
+ },
1648
+ /**
1649
+ * Delete every row the filter matches, in ONE statement; resolves to how
1650
+ * many went. Same filter language, same empty-filter refusal as
1651
+ * {@link updateMany} — and here the accident is worse.
1652
+ */
1653
+ async deleteMany(table, where) {
1654
+ if (Object.keys(where).length === 0) {
1655
+ throw new Error(
1656
+ `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`
1657
+ );
1658
+ }
1659
+ const params = [];
1660
+ const add = (v) => {
1661
+ params.push(v);
1662
+ return `$${params.length}`;
1663
+ };
1664
+ const known = schemaColumns(table);
1665
+ const whereSql = compileWhere(table, known, where, add, "deleteMany");
1666
+ const sql = `DELETE FROM ${quoteIdent(table)} AS t WHERE true${whereSql} RETURNING 1`;
1667
+ const rows = await (await at()).unsafe(sql, params);
1668
+ return rows.length;
1669
+ },
1670
+ /**
1671
+ * How many rows match — the half of pagination `limit`/`offset` cannot
1672
+ * supply. Without it a page count is either a guess or "fetch everything and
1673
+ * read .length", and the second one is the scale risk this surface exists to
1674
+ * remove.
1675
+ *
1676
+ * An empty filter is legitimate HERE: counting a whole table is a read, and
1677
+ * reads do not destroy anything.
1678
+ */
1679
+ async count(table, where = {}) {
1680
+ const params = [];
1681
+ const add = (v) => {
1682
+ params.push(v);
1683
+ return `$${params.length}`;
1684
+ };
1685
+ const known = schemaColumns(table);
1686
+ const whereSql = compileWhere(table, known, where, add, "count");
1687
+ const sql = `SELECT count(*) AS n FROM ${quoteIdent(table)} t WHERE true${whereSql}`;
1688
+ const rows = await (await at()).unsafe(sql, params);
1689
+ return Number(rows[0]?.n ?? 0);
1690
+ },
1346
1691
  async findById(table, id) {
1347
1692
  const rows = await (await at()).unsafe(
1348
1693
  `SELECT * FROM ${quoteIdent(table)} WHERE id = $1`,
@@ -1350,13 +1695,19 @@ function createOps(tx) {
1350
1695
  );
1351
1696
  return rows[0] ? asTableRow(table, rows[0]) : null;
1352
1697
  },
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
- ));
1698
+ async findMany(table, query = {}, opts = {}) {
1699
+ const params = [];
1700
+ const add = (v) => {
1701
+ params.push(v);
1702
+ return `$${params.length}`;
1703
+ };
1704
+ const known = schemaColumns(table);
1705
+ const whereSql = compileWhere(table, known, query, add, "findMany");
1706
+ const order = orderClause(table, known, opts.orderBy);
1707
+ const limit = limitClause(opts.limit);
1708
+ const offset = offsetClause(opts.offset, opts.limit);
1709
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}${offset}`;
1710
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1360
1711
  },
1361
1712
  /**
1362
1713
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -1438,7 +1789,9 @@ function createOps(tx) {
1438
1789
  bindC.push(v);
1439
1790
  return `$${bindC.length}`;
1440
1791
  };
1441
- const whereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC) + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1792
+ const userWhereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC);
1793
+ const whereC = userWhereC + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1794
+ const parentJoinC = ` JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = c.${pidQ} WHERE true${whereC} AND `;
1442
1795
  const liveC = await at();
1443
1796
  const K2 = 50;
1444
1797
  const cpool = Math.max(limit * 9, 90);
@@ -1447,8 +1800,19 @@ function createOps(tx) {
1447
1800
  if (qvC !== null) {
1448
1801
  const sch = await vectorSchemaWithGuc(liveC);
1449
1802
  const op = METRIC_OPERATOR[ck.metric] ?? METRIC_OPERATOR.cosine;
1803
+ let exactOrderC = "";
1804
+ if (userWhereC !== "") {
1805
+ const probeRows = await liveC.unsafe(
1806
+ `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`,
1807
+ bindC.slice()
1808
+ );
1809
+ const n = probeRows?.[0]?.n;
1810
+ if (typeof n === "number" && n <= SELECTIVITY_EXACT_THRESHOLD) {
1811
+ exactOrderC = " + 0.0";
1812
+ }
1813
+ }
1450
1814
  const vp = addC(toVectorLiteral(qvC));
1451
- 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}`;
1815
+ 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}`;
1452
1816
  }
1453
1817
  let qpC = "";
1454
1818
  let qpCIdx = -1;
@@ -1456,7 +1820,7 @@ function createOps(tx) {
1456
1820
  const qTextC = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
1457
1821
  qpC = addC(qTextC);
1458
1822
  qpCIdx = bindC.length - 1;
1459
- 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}`;
1823
+ 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}`;
1460
1824
  }
1461
1825
  const msC = params.minScore === void 0 ? "" : addC(params.minScore);
1462
1826
  const colListC = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
@@ -1471,13 +1835,13 @@ function createOps(tx) {
1471
1835
  } else {
1472
1836
  fusedSrc = `WITH kw AS (${kwIn}), fused AS (SELECT kw.pid, kw.chunk_seq, (1.0/(${K2} + kw.r))::float8 AS cscore FROM kw)`;
1473
1837
  }
1474
- 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}`;
1838
+ 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`;
1475
1839
  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}`;
1476
1840
  };
1477
1841
  let rowsC = await liveC.unsafe(assembleC(kwC, kwC !== ""), bindC);
1478
1842
  if (wantTextC && kwC !== "" && (rowsC.length === 0 || rowsC[0]._kwn === 0)) {
1479
1843
  const tsch = await trgmSchemaOf(liveC);
1480
- 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}`;
1844
+ 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}`;
1481
1845
  const retryBindC = bindC.slice();
1482
1846
  if (qpCIdx >= 0) retryBindC[qpCIdx] = params.query;
1483
1847
  rowsC = await liveC.unsafe(assembleC(trgmKwC, false), retryBindC);
@@ -1573,7 +1937,7 @@ function createOps(tx) {
1573
1937
  if (wantText && kwSql !== "" && (rows.length === 0 || rows[0]._kwn === 0)) {
1574
1938
  const tsch = await trgmSchemaOf(live);
1575
1939
  const expr = ftsExprOf(cfg.ftsCols);
1576
- 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}`;
1940
+ 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}`;
1577
1941
  const retryBind = bind.slice();
1578
1942
  if (qpIdx >= 0) retryBind[qpIdx] = params.query;
1579
1943
  rows = await live.unsafe(assemble(trgmKw, false), retryBind);
@@ -1596,6 +1960,25 @@ function createOps(tx) {
1596
1960
  * tek SQL'de "id yok" ile "0 komşu" ayrılamazdı; +1 küçük turla id-yokluğu
1597
1961
  * adlandırılmış hataya çevrilir. Sonuç şekli search ile aynı (FR-015).
1598
1962
  */
1963
+ /** D-021 (FR-027 DX): sayaçlar BAĞIMSIZ op'la — search'ün dizi-üstü
1964
+ * `_facets` özelliği JSON.stringify'da kaybolur (dizi özelliği), tenant
1965
+ * yanıtına koyunca sessizce yok olurdu. Ayrı dönüş ciddi bir sözleşmedir;
1966
+ * search'teki alan geriye-uyum için DURUR. where + validity default'u
1967
+ * sayaçlara da uygulanır (sayaç, kullanıcının gördüğü kümeyi anlatır). */
1968
+ async facets(table, params) {
1969
+ const cfg = searchConfigFor(table);
1970
+ if (!cfg) {
1971
+ throw new Error(`facets(${table}): tablo aranabilir de\u011Fil \u2014 search beyan\u0131 yok (FR-027)`);
1972
+ }
1973
+ for (const col of params.facets) {
1974
+ if (!cfg.colSet.has(col)) {
1975
+ throw new Error(`facets(${table}): facets kolonu "${col}" tabloda yok (FR-027)`);
1976
+ }
1977
+ }
1978
+ const live = await at();
1979
+ const extra = cfg.validity ? (add) => validitySql(params.validity, add) : () => "";
1980
+ return fetchFacets(live, table, cfg.colSet, params.facets, params.where ?? {}, extra);
1981
+ },
1599
1982
  async similar(table, id, opts = {}) {
1600
1983
  const cfg = searchConfigFor(table);
1601
1984
  if (!cfg) {
@@ -1747,6 +2130,27 @@ function createOps(tx) {
1747
2130
  const live = await at();
1748
2131
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1749
2132
  },
2133
+ /**
2134
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
2135
+ * rolls back only what that handle wrote and the request can keep writing.
2136
+ *
2137
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
2138
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
2139
+ * the ambient `Database` at the savepoint for the duration, and a request is
2140
+ * concurrent with itself: `Promise.all([Database.insert(a),
2141
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
2142
+ * back with it. Silent data loss, and the same interleaving this file already
2143
+ * refuses for `asService()`. Passing the handle makes the boundary something
2144
+ * you can see in the code that crosses it.
2145
+ *
2146
+ * Postgres, not us: the savepoint is released on success and rolled back on
2147
+ * failure by the driver, so an aborted statement inside `fn` does not poison
2148
+ * the surrounding transaction.
2149
+ */
2150
+ async attempt(fn) {
2151
+ const live = await at();
2152
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
2153
+ },
1750
2154
  /**
1751
2155
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1752
2156
  *
@@ -1797,22 +2201,68 @@ function withTables(ops, schema = currentSchema) {
1797
2201
  update: (id, data) => ops.update(name, id, data),
1798
2202
  delete: (id) => ops.delete(name, id),
1799
2203
  findById: (id) => ops.findById(name, id),
1800
- findMany: (query) => ops.findMany(name, query ?? {})
2204
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
2205
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1801
2206
  };
1802
2207
  }
1803
2208
  const base = /* @__PURE__ */ Object.create(null);
1804
2209
  return Object.assign(base, ops, { tables });
1805
2210
  }
1806
2211
  var SERVICE_LOCK_TIMEOUT = "5s";
1807
- function isLockTimeout(e) {
1808
- const code = e?.code;
2212
+ function isAbortedTransaction(e) {
1809
2213
  const message = String(e?.message ?? "");
1810
- return code === "55P03" || /lock timeout/i.test(message);
2214
+ return sqlstateOf(e) === "25P02" || /current transaction is aborted/i.test(message);
2215
+ }
2216
+ function sqlstateOf(e) {
2217
+ const err = e;
2218
+ const isState = (v) => typeof v === "string" && /^[0-9A-Z]{5}$/.test(v);
2219
+ if (isState(err?.code)) return err.code;
2220
+ if (isState(err?.errno)) return err.errno;
2221
+ if (typeof err?.errno === "number") {
2222
+ const asText = String(err.errno);
2223
+ if (isState(asText)) return asText;
2224
+ }
2225
+ return void 0;
1811
2226
  }
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;
2227
+ function isForeignKeyViolation(e) {
2228
+ return sqlstateOf(e) === "23503";
2229
+ }
2230
+ function isUniqueViolation(e) {
2231
+ return sqlstateOf(e) === "23505";
2232
+ }
2233
+ function constraintOf(e) {
2234
+ const named = e?.constraint;
2235
+ if (typeof named === "string" && named.length > 0) return named;
2236
+ const message = String(e?.message ?? "");
2237
+ return /violates unique constraint "([^"]+)"/.exec(message)?.[1] ?? "";
2238
+ }
2239
+ function isLockTimeout(e) {
2240
+ const message = String(e?.message ?? "");
2241
+ return sqlstateOf(e) === "55P03" || /lock timeout/i.test(message);
2242
+ }
2243
+ function diagnosingDriver(sql, surface = "service") {
2244
+ const original = (e) => String(e?.message ?? e);
2245
+ const explain = (e) => {
2246
+ if (isAbortedTransaction(e)) {
2247
+ return new Error(
2248
+ `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)})`
2249
+ );
2250
+ }
2251
+ if (isUniqueViolation(e)) {
2252
+ return new UniqueViolation(constraintOf(e));
2253
+ }
2254
+ if (surface === "service" && isForeignKeyViolation(e)) {
2255
+ return new Error(
2256
+ `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)})`
2257
+ );
2258
+ }
2259
+ if (isLockTimeout(e)) {
2260
+ return new Error(
2261
+ `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)})`
2262
+ );
2263
+ }
2264
+ return e;
2265
+ };
1816
2266
  const wrapTx = (tx) => ({
1817
2267
  async unsafe(text, params) {
1818
2268
  try {
@@ -1831,13 +2281,13 @@ function diagnosingDriver(sql) {
1831
2281
  };
1832
2282
  }
1833
2283
  function createRequestDatabase(sql, identity) {
1834
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
2284
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1835
2285
  let serviceTx = null;
1836
2286
  let serviceClient = null;
1837
2287
  const asService = () => {
1838
2288
  if (serviceClient === null) {
1839
2289
  serviceTx = createLazyTransaction(
1840
- diagnosingDriver(sql),
2290
+ diagnosingDriver(sql, "service"),
1841
2291
  identity.serviceRole,
1842
2292
  identity.claimsJson,
1843
2293
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1852,9 +2302,9 @@ function createRequestDatabase(sql, identity) {
1852
2302
  await tx.commit();
1853
2303
  await serviceTx?.commit();
1854
2304
  },
1855
- async rollback(reason) {
1856
- await tx.rollback(reason);
1857
- await serviceTx?.rollback(reason);
2305
+ async rollback(reason2) {
2306
+ await tx.rollback(reason2);
2307
+ await serviceTx?.rollback(reason2);
1858
2308
  }
1859
2309
  };
1860
2310
  }
@@ -1918,6 +2368,20 @@ async function runPlanOp(sp, op, results) {
1918
2368
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1919
2369
  break;
1920
2370
  }
2371
+ case "upsert": {
2372
+ const cols = Object.keys(op.values ?? {});
2373
+ const conflict = op.onConflict ?? [];
2374
+ if (cols.length === 0 || conflict.length === 0) {
2375
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
2376
+ error_code: "tx_bad_upsert"
2377
+ });
2378
+ }
2379
+ const rendered = cols.map(
2380
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
2381
+ );
2382
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ${onConflictTail(cols, conflict, "update")} RETURNING *`;
2383
+ break;
2384
+ }
1921
2385
  case "insertMany": {
1922
2386
  const rows = op.rows ?? [];
1923
2387
  if (rows.length === 0 || !rows[0]) return [];
@@ -1925,7 +2389,9 @@ async function runPlanOp(sp, op, results) {
1925
2389
  const tuples = rows.map(
1926
2390
  (r) => `(${cols.map((c) => renderValue(r[c], c, args, results, opVectorCols)).join(", ")})`
1927
2391
  );
1928
- sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")} RETURNING *`;
2392
+ const conflictCols = op.onConflict ?? [];
2393
+ const tail = op.action !== void 0 && conflictCols.length > 0 ? ` ${onConflictTail(cols, conflictCols, op.action)}` : "";
2394
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}${tail} RETURNING *`;
1929
2395
  break;
1930
2396
  }
1931
2397
  case "update": {
@@ -2000,6 +2466,26 @@ function getRoutes(ctor) {
2000
2466
  }));
2001
2467
  }
2002
2468
 
2469
+ // src/decorators/controller.ts
2470
+ var APP_DEFAULT_AUTH = /* @__PURE__ */ Symbol.for("palbase.backend.appDefaultAuth");
2471
+ function appAuthSlot() {
2472
+ return globalThis;
2473
+ }
2474
+ function getDefaultAuth() {
2475
+ return appAuthSlot()[APP_DEFAULT_AUTH];
2476
+ }
2477
+ function resolveEffectiveAuth(routeAuth, controllerAuth) {
2478
+ return routeAuth ?? controllerAuth ?? getDefaultAuth() ?? true;
2479
+ }
2480
+ function assertZeroArgConstructor(Ctrl, kind) {
2481
+ const arity = Ctrl.length ?? 0;
2482
+ if (arity === 0) return;
2483
+ const name = Ctrl.name ?? "<anonymous>";
2484
+ throw new Error(
2485
+ `${kind} ${name} declares a constructor with ${arity} parameter(s). A ${kind} is constructed by the runtime with a zero-argument constructor \u2014 there is no injector to supply them, so every parameter would arrive as undefined. Hold the dependency as a module-level singleton the ${kind} imports (\`const repo = makeRepo()\` beside the class), and construct the service directly in tests (e.g. \`new TodoService(fakeDatabase().db)\`).`
2486
+ );
2487
+ }
2488
+
2003
2489
  // src/engine/router.ts
2004
2490
  var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
2005
2491
  function toSegments(path) {
@@ -2018,6 +2504,7 @@ function buildRouteTable(controllers) {
2018
2504
  `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.`
2019
2505
  );
2020
2506
  }
2507
+ assertZeroArgConstructor(Ctrl, "controller");
2021
2508
  const instance = new ctor();
2022
2509
  for (const r of routes) {
2023
2510
  const full = `${basePath}${r.subpath ?? ""}` || "/";
@@ -2027,7 +2514,10 @@ function buildRouteTable(controllers) {
2027
2514
  meta: r,
2028
2515
  instance,
2029
2516
  id: `${r.method} ${full}`,
2030
- controllerAuth: meta?.defaultAuth
2517
+ // The route's OWN spec is deliberately not folded in here: this field
2518
+ // is what applies when the route is silent, and `effectiveAuth` puts
2519
+ // the route level back on top per request.
2520
+ controllerAuth: resolveEffectiveAuth(void 0, meta?.defaultAuth)
2031
2521
  });
2032
2522
  }
2033
2523
  }
@@ -2114,6 +2604,41 @@ function verifySignature(presented, expected) {
2114
2604
  return diff === 0;
2115
2605
  }
2116
2606
 
2607
+ // src/engine/sse.ts
2608
+ var SSE_CONTENT_TYPE = "text/event-stream";
2609
+ function encodeFrame(value) {
2610
+ return `data: ${JSON.stringify(value)}
2611
+
2612
+ `;
2613
+ }
2614
+ function encodeErrorFrame(requestId) {
2615
+ return `event: error
2616
+ data: ${JSON.stringify({ requestId })}
2617
+
2618
+ `;
2619
+ }
2620
+ function makeSseWriter(opts) {
2621
+ let begun = false;
2622
+ let chain = Promise.resolve();
2623
+ return {
2624
+ write(value) {
2625
+ const frame = encodeFrame(value);
2626
+ if (!begun) {
2627
+ begun = true;
2628
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
2629
+ return;
2630
+ }
2631
+ chain = chain.then(() => opts.enqueue(frame));
2632
+ },
2633
+ started() {
2634
+ return begun;
2635
+ },
2636
+ drained() {
2637
+ return chain;
2638
+ }
2639
+ };
2640
+ }
2641
+
2117
2642
  // src/engine/fence.ts
2118
2643
  var SECRET_ENV = [
2119
2644
  "DATABASE_URL",
@@ -2349,6 +2874,13 @@ async function createApp(opts) {
2349
2874
  const args = [];
2350
2875
  let parsedBody;
2351
2876
  let bodyRead = false;
2877
+ let sseEnqueue = null;
2878
+ let sseSettle = async () => {
2879
+ };
2880
+ const sseWriter = makeSseWriter({
2881
+ enqueue: (chunk) => sseEnqueue?.(chunk),
2882
+ onFirstWrite: () => sseSettle()
2883
+ });
2352
2884
  for (const p of meta.params ?? []) {
2353
2885
  switch (p.kind) {
2354
2886
  case "body": {
@@ -2452,6 +2984,21 @@ async function createApp(opts) {
2452
2984
  case "req":
2453
2985
  args[p.index] = req;
2454
2986
  break;
2987
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
2988
+ // state when the client disconnects. Measured on Bun: an infinite
2989
+ // producer guarded by it stops within a few frames of the client dying.
2990
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
2991
+ // request-scoped data and no signal.
2992
+ case "signal":
2993
+ args[p.index] = req.signal;
2994
+ break;
2995
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
2996
+ // on any other route it is an inert writer whose frames go nowhere,
2997
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
2998
+ // route.
2999
+ case "sseOut":
3000
+ args[p.index] = sseWriter;
3001
+ break;
2455
3002
  default:
2456
3003
  args[p.index] = void 0;
2457
3004
  }
@@ -2463,6 +3010,84 @@ async function createApp(opts) {
2463
3010
  });
2464
3011
  try {
2465
3012
  const services = buildServices(db);
3013
+ if (meta.options?.sseConfig !== void 0) {
3014
+ let sseController;
3015
+ const sseEncoder = new TextEncoder();
3016
+ const stream = new ReadableStream({
3017
+ start(c) {
3018
+ sseController = c;
3019
+ }
3020
+ });
3021
+ sseEnqueue = (chunk) => {
3022
+ try {
3023
+ sseController.enqueue(sseEncoder.encode(chunk));
3024
+ } catch {
3025
+ }
3026
+ };
3027
+ let sawFirstFrame;
3028
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
3029
+ sseSettle = async () => {
3030
+ await db.commit();
3031
+ sawFirstFrame();
3032
+ };
3033
+ const guarded = {
3034
+ ...services,
3035
+ Database: new Proxy(services.Database, {
3036
+ get(target, prop, recv) {
3037
+ if (sseWriter.started()) {
3038
+ throw new Error(
3039
+ `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.`
3040
+ );
3041
+ }
3042
+ return Reflect.get(target, prop, recv);
3043
+ }
3044
+ })
3045
+ };
3046
+ let thrown = null;
3047
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
3048
+ const box = requestALS.getStore();
3049
+ if (box) {
3050
+ box.userId = userId ?? null;
3051
+ box.requestId = requestId;
3052
+ box.idempotencyKey = req.headers.get("idempotency-key");
3053
+ }
3054
+ const method = hit.entry.instance[meta.fnName];
3055
+ if (typeof method !== "function") {
3056
+ throw new Error(
3057
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
3058
+ );
3059
+ }
3060
+ return method.apply(hit.entry.instance, args);
3061
+ })).then(
3062
+ () => void 0,
3063
+ (err) => {
3064
+ thrown = err;
3065
+ }
3066
+ );
3067
+ await Promise.race([firstFrame, ran]);
3068
+ if (!sseWriter.started()) {
3069
+ await ran;
3070
+ await db.commit();
3071
+ if (thrown !== null) throw thrown;
3072
+ return new Response(null, { status: 204 });
3073
+ }
3074
+ void (async () => {
3075
+ await ran;
3076
+ await sseWriter.drained();
3077
+ try {
3078
+ if (thrown !== null) {
3079
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
3080
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
3081
+ }
3082
+ sseController.close();
3083
+ } catch {
3084
+ }
3085
+ })();
3086
+ return new Response(stream, {
3087
+ status: 200,
3088
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
3089
+ });
3090
+ }
2466
3091
  const result = await runWithRuntime(services, () => {
2467
3092
  const box = requestALS.getStore();
2468
3093
  if (box) {
@@ -2521,18 +3146,29 @@ async function createApp(opts) {
2521
3146
  return envelope("internal_error", "The request could not be completed", 500, requestId);
2522
3147
  }
2523
3148
  }
3149
+ let runShutdownHooks;
3150
+ try {
3151
+ runShutdownHooks = await __runStartHooks();
3152
+ } catch (err) {
3153
+ await closeDriver(sql);
3154
+ throw err;
3155
+ }
2524
3156
  return {
2525
3157
  handle,
2526
3158
  routes,
2527
3159
  config,
2528
3160
  runInServiceScope,
2529
3161
  async shutdown() {
2530
- const closable = sql;
2531
- await closable.close?.();
2532
- await closable.end?.();
3162
+ await runShutdownHooks();
3163
+ await closeDriver(sql);
2533
3164
  }
2534
3165
  };
2535
3166
  }
3167
+ async function closeDriver(sql) {
3168
+ const closable = sql;
3169
+ await closable.close?.();
3170
+ await closable.end?.();
3171
+ }
2536
3172
  // Annotate the CommonJS export names for ESM import in node:
2537
3173
  0 && (module.exports = {
2538
3174
  AuthVerifier,