@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
@@ -218,7 +218,20 @@ var TxPlanBuilder = class {
218
218
  }
219
219
  return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
220
220
  },
221
- insertMany: (rows) => {
221
+ upsert: (values, options) => {
222
+ const encoded = encodeMap(values, false);
223
+ if (Object.keys(encoded).length === 0) {
224
+ throw new TxPlanError(`${name}.upsert() needs at least one column`);
225
+ }
226
+ if (options.onConflict.length === 0) {
227
+ throw new TxPlanError(`${name}.upsert() needs at least one onConflict column`);
228
+ }
229
+ return this.push(
230
+ { op: "upsert", table: name, values: encoded, onConflict: options.onConflict },
231
+ `${name}.upsert()`
232
+ );
233
+ },
234
+ insertMany: (rows, opts) => {
222
235
  if (rows.length === 0) {
223
236
  return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
224
237
  }
@@ -229,7 +242,23 @@ var TxPlanBuilder = class {
229
242
  }
230
243
  const encoded = rows.map((row) => encodeMap(row, false));
231
244
  assertUniformRows(encoded, name);
232
- return this.push({ op: "insertMany", table: name, rows: encoded }, `${name}.insertMany()`);
245
+ if (opts !== void 0 && opts.onConflict.length === 0) {
246
+ throw new TxPlanError(
247
+ `${name}.insertMany() was given a conflict action with no onConflict columns. Postgres matches a collision on columns, so name them.`
248
+ );
249
+ }
250
+ return this.push(
251
+ {
252
+ op: "insertMany",
253
+ table: name,
254
+ rows: encoded,
255
+ // Omitted entirely when no options were given, so the op a plain
256
+ // insertMany produces is byte-identical to the one it produced
257
+ // before this option existed.
258
+ ...opts !== void 0 ? { onConflict: opts.onConflict, action: opts.action ?? "ignore" } : {}
259
+ },
260
+ `${name}.insertMany()`
261
+ );
233
262
  },
234
263
  updateWhere: (where, set) => {
235
264
  const encodedWhere = encodeMap(where, false);
@@ -403,6 +432,42 @@ function __getRuntime() {
403
432
  }
404
433
  return runtime;
405
434
  }
435
+ var LIFECYCLE = /* @__PURE__ */ Symbol.for("palbase.backend.lifecycleHooks");
436
+ function declaredLifecycle() {
437
+ const g = globalThis;
438
+ return g[LIFECYCLE] ??= { start: [], shutdown: [] };
439
+ }
440
+ function reason(err) {
441
+ return err instanceof Error ? err.message : String(err);
442
+ }
443
+ async function drain(hooks) {
444
+ for (const h of [...hooks].reverse()) {
445
+ try {
446
+ await h.run();
447
+ } catch (err) {
448
+ console.error(`[palbase] shutdown hook "${h.name}" failed: ${reason(err)}`, err);
449
+ }
450
+ }
451
+ }
452
+ async function __runStartHooks() {
453
+ const slot = declaredLifecycle();
454
+ const start = slot.start.splice(0);
455
+ const shutdown = slot.shutdown.splice(0);
456
+ for (const h of start) {
457
+ try {
458
+ await h.run();
459
+ } catch (err) {
460
+ await drain(shutdown);
461
+ throw new Error(`[palbase] start hook "${h.name}" failed: ${reason(err)}`, { cause: err });
462
+ }
463
+ }
464
+ let drained = false;
465
+ return async () => {
466
+ if (drained) return;
467
+ drained = true;
468
+ await drain(shutdown);
469
+ };
470
+ }
406
471
  function makeServiceProxy(key) {
407
472
  const handler = {
408
473
  get(_target, prop, receiver) {
@@ -425,10 +490,12 @@ function makeTablesAccessor(ops) {
425
490
  update: (id, data) => ops().update(name, id, data),
426
491
  delete: (id) => ops().delete(name, id),
427
492
  findById: (id) => ops().findById(name, id),
428
- findMany: (query) => ops().findMany(name, query),
493
+ findMany: (query, opts) => ops().findMany(name, query, opts),
494
+ upsert: (data, opts) => ops().upsert(name, data, opts),
429
495
  search: (params) => ops().search(name, params),
430
496
  similar: (id, params) => ops().similar(name, id, params),
431
497
  recommend: (params) => ops().recommend(name, params),
498
+ facets: (params) => ops().facets(name, params),
432
499
  supersede: (id, row) => ops().supersede(name, id, row)
433
500
  };
434
501
  }
@@ -445,13 +512,21 @@ function makeTypedSurface(raw) {
445
512
  update: (table, id, data) => raw.update(table, id, data),
446
513
  delete: (table, id) => raw.delete(table, id),
447
514
  findById: (table, id) => raw.findById(table, id),
448
- findMany: (table, query) => raw.findMany(table, query),
515
+ findMany: (table, query, opts) => raw.findMany(table, query, opts),
516
+ upsert: (table, data, opts) => raw.upsert(table, data, opts),
517
+ updateMany: (table, where, set) => raw.updateMany(table, where, set),
518
+ deleteMany: (table, where) => raw.deleteMany(table, where),
519
+ count: (table, where) => raw.count(table, where),
449
520
  search: (table, params) => raw.search(table, params),
450
521
  similar: (table, id, params) => reco.similar(table, id, params),
451
522
  recommend: (table, params) => reco.recommend(table, params),
523
+ facets: (table, params) => reco.facets(table, params),
452
524
  supersede: (table, id, row) => raw.supersede(table, id, row)
453
525
  };
454
526
  return Object.assign(ops, {
527
+ // Both surfaces get it: a savepoint on the service transaction is as useful
528
+ // as one on the request's, and each is bound to its own connection.
529
+ attempt: (fn) => raw.attempt(fn),
455
530
  tables: makeTablesAccessor(() => reco),
456
531
  transaction(fn) {
457
532
  const builder = new TxPlanBuilder();
@@ -603,6 +678,44 @@ var HttpError = class extends Error {
603
678
  return result;
604
679
  }
605
680
  };
681
+ var NamedHttpError = class extends HttpError {
682
+ constructor(status, defaultCode, name, message, code, data) {
683
+ super(status, code ?? defaultCode, message ?? defaultMessage(name), data);
684
+ this.name = name;
685
+ }
686
+ };
687
+ function defaultMessage(name) {
688
+ const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
689
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
690
+ }
691
+ var Conflict = class extends NamedHttpError {
692
+ constructor(message, code, data) {
693
+ super(409, "conflict", "Conflict", message, code, data);
694
+ }
695
+ };
696
+ var UniqueViolation = class extends Conflict {
697
+ /**
698
+ * Whether `e` is a unique violation — REGARDLESS of which copy of this SDK
699
+ * constructed it.
700
+ *
701
+ * Use this instead of `instanceof`. Measured on a live stack: a controller
702
+ * bundle INLINES its own copy of `@palbase/backend`, and the engine that
703
+ * raises this error is the runtime's copy. Two copies, two class identities,
704
+ * and `e instanceof UniqueViolation` is false in the one place a caller
705
+ * writes it — a check that reads as correct and silently never matches.
706
+ */
707
+ static is(e) {
708
+ return typeof e === "object" && e !== null && e.name === "UniqueViolation" && typeof e.constraint === "string";
709
+ }
710
+ /** The unique constraint the statement violated, as Postgres named it.
711
+ * `""` when the driver did not say which — see `engine/db.ts`. */
712
+ constraint;
713
+ constructor(constraint, message, code, data) {
714
+ super(message ?? "Unique constraint violated", code ?? "unique_violation", data);
715
+ this.name = "UniqueViolation";
716
+ this.constraint = constraint;
717
+ }
718
+ };
606
719
 
607
720
  // src/engine/config.ts
608
721
  var BootRefused = class extends Error {
@@ -920,6 +1033,14 @@ function makeMemoryCache(opts = {}) {
920
1033
  function quoteIdent(name) {
921
1034
  return `"${name.replace(/"/g, '""')}"`;
922
1035
  }
1036
+ function encodePgArray(value) {
1037
+ const element = (x) => {
1038
+ if (x === null || x === void 0) return "NULL";
1039
+ if (Array.isArray(x)) return encodePgArray(x);
1040
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
1041
+ };
1042
+ return `{${value.map(element).join(",")}}`;
1043
+ }
923
1044
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
924
1045
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
925
1046
  const { lockTimeout } = options;
@@ -957,9 +1078,9 @@ function createLazyTransaction(sql, role, claimsJson, options = {}) {
957
1078
  release();
958
1079
  await settled;
959
1080
  },
960
- async rollback(reason) {
1081
+ async rollback(reason2) {
961
1082
  if (!opening) return;
962
- fail(reason);
1083
+ fail(reason2);
963
1084
  await settled?.catch(() => void 0);
964
1085
  }
965
1086
  };
@@ -1002,18 +1123,48 @@ function reviveVectors(row, vectorCols) {
1002
1123
  }
1003
1124
  return row;
1004
1125
  }
1126
+ function transformsOf(schema, table) {
1127
+ const out = /* @__PURE__ */ new Map();
1128
+ for (const [key, def] of Object.entries(schema.tables ?? {})) {
1129
+ if ((def.name ?? key) !== table) continue;
1130
+ for (const [col, c] of Object.entries(def.columns ?? {})) {
1131
+ const d = c !== null && typeof c === "object" && "_def" in c ? c._def : c;
1132
+ const t = d?.transform;
1133
+ if (t !== null && typeof t === "object") {
1134
+ out.set(col, t);
1135
+ }
1136
+ }
1137
+ }
1138
+ return out;
1139
+ }
1140
+ function applyFromDb(row, transforms) {
1141
+ if (transforms.size === 0) return row;
1142
+ for (const [col, t] of transforms) {
1143
+ if (t.fromDb === void 0) continue;
1144
+ const v = row[col];
1145
+ if (v === null || v === void 0) continue;
1146
+ row[col] = t.fromDb(v);
1147
+ }
1148
+ return row;
1149
+ }
1005
1150
  function asTableRow(table, row) {
1006
- return reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
1151
+ const revived = reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
1152
+ return applyFromDb(revived, transformsOf(currentSchema, table));
1007
1153
  }
1008
1154
  function asTableRows(table, rows) {
1009
1155
  const vectorCols = vectorColumnsOf(currentSchema, table);
1010
- return rows.map((row) => reviveVectors(asWireRow(row), vectorCols));
1156
+ const transforms = transformsOf(currentSchema, table);
1157
+ return rows.map((row) => applyFromDb(reviveVectors(asWireRow(row), vectorCols), transforms));
1011
1158
  }
1012
1159
  function asBindParams(table, cols, data) {
1013
1160
  const vectorCols = vectorColumnsOf(currentSchema, table);
1161
+ const transforms = transformsOf(currentSchema, table);
1014
1162
  return cols.map((c) => {
1015
1163
  const v = data[c];
1016
- return Array.isArray(v) && vectorCols.has(c) ? toVectorLiteral(v) : v;
1164
+ if (Array.isArray(v) && vectorCols.has(c)) return toVectorLiteral(v);
1165
+ const t = transforms.get(c);
1166
+ if (t?.toDb !== void 0 && v !== null && v !== void 0) return t.toDb(v);
1167
+ return v;
1017
1168
  });
1018
1169
  }
1019
1170
  var SELECTIVITY_EXACT_THRESHOLD = 1e4;
@@ -1140,7 +1291,9 @@ function expandSynonyms(query, map) {
1140
1291
  }).join("");
1141
1292
  }).join("");
1142
1293
  }
1143
- var searchLogger = null;
1294
+ var searchLogger = (evt, fields) => {
1295
+ console.log(evt, JSON.stringify(fields));
1296
+ };
1144
1297
  function logNoResults(table, query, mode, n) {
1145
1298
  if (n === 0) {
1146
1299
  searchLogger?.("palbase.search.no_results", {
@@ -1181,19 +1334,55 @@ function validitySql(v, add) {
1181
1334
  function excludeSql(pk, ids, add) {
1182
1335
  if (ids.length === 0) return "";
1183
1336
  if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
1184
- return ` AND t.${quoteIdent(pk)} <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1337
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1338
+ }
1339
+ function schemaColumns(table) {
1340
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
1341
+ const cols = t?.columns ? Object.keys(t.columns) : [];
1342
+ return cols.length > 0 ? new Set(cols) : null;
1343
+ }
1344
+ function orderClause(table, known, orderBy) {
1345
+ if (!orderBy) return "";
1346
+ const col = orderBy.column;
1347
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
1348
+ if (known !== null ? !known.has(col) : !shapeOK) {
1349
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
1350
+ }
1351
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
1352
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
1353
+ }
1354
+ function limitClause(limit) {
1355
+ if (limit === void 0) return "";
1356
+ if (!Number.isInteger(limit) || limit < 0) {
1357
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
1358
+ }
1359
+ return ` LIMIT ${limit}`;
1185
1360
  }
1186
- function compileWhere(table, colSet, where, add) {
1361
+ function offsetClause(offset, limit) {
1362
+ if (offset === void 0) return "";
1363
+ if (!Number.isInteger(offset) || offset < 0) {
1364
+ throw new Error(
1365
+ `findMany: offset bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(offset)})`
1366
+ );
1367
+ }
1368
+ if (limit === void 0) {
1369
+ throw new Error(
1370
+ "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"
1371
+ );
1372
+ }
1373
+ return ` OFFSET ${offset}`;
1374
+ }
1375
+ function compileWhere(table, colSet, where, add, caller = "search") {
1187
1376
  const parts = [];
1188
1377
  for (const [col, cond] of Object.entries(where)) {
1189
- if (!colSet.has(col)) {
1190
- throw new Error(`search(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1378
+ if (colSet !== null && !colSet.has(col)) {
1379
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1191
1380
  }
1192
1381
  const q = `t.${quoteIdent(col)}`;
1193
1382
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1194
1383
  for (const [op, v] of Object.entries(cond)) {
1195
1384
  if (op === "in") {
1196
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
1385
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
1197
1386
  if (v.length === 0) {
1198
1387
  parts.push("false");
1199
1388
  continue;
@@ -1202,7 +1391,7 @@ function compileWhere(table, colSet, where, add) {
1202
1391
  } else if (op in WHERE_OPS) {
1203
1392
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
1204
1393
  } else {
1205
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1394
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1206
1395
  }
1207
1396
  }
1208
1397
  } else {
@@ -1211,6 +1400,13 @@ function compileWhere(table, colSet, where, add) {
1211
1400
  }
1212
1401
  return parts.length === 0 ? "" : ` AND ${parts.join(" AND ")}`;
1213
1402
  }
1403
+ function onConflictTail(cols, conflict, action) {
1404
+ const target = `ON CONFLICT (${conflict.map(quoteIdent).join(", ")})`;
1405
+ if (action === "ignore") return `${target} DO NOTHING`;
1406
+ const conflictSet = new Set(conflict);
1407
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1408
+ return sets.length ? `${target} DO UPDATE SET ${sets.join(", ")}` : `${target} DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1409
+ }
1214
1410
  var cachedVectorSchema = null;
1215
1411
  var cachedTrgmSchema = null;
1216
1412
  async function trgmSchemaOf(live) {
@@ -1282,7 +1478,8 @@ function createOps(tx) {
1282
1478
  const at = () => resolveTx(tx);
1283
1479
  const ops = {
1284
1480
  async query(sql, params = []) {
1285
- return asWireRows(await (await at()).unsafe(sql, params));
1481
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
1482
+ return asWireRows(await (await at()).unsafe(sql, bound));
1286
1483
  },
1287
1484
  async insert(table, data) {
1288
1485
  const cols = Object.keys(data);
@@ -1298,6 +1495,74 @@ function createOps(tx) {
1298
1495
  }
1299
1496
  return asTableRow(table, inserted);
1300
1497
  },
1498
+ /**
1499
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
1500
+ *
1501
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
1502
+ * unique violation, update instead" does not work here: a request runs in ONE
1503
+ * Postgres transaction, so the failed insert aborts it and every later
1504
+ * statement answers `current transaction is aborted`. A tenant measured that
1505
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
1506
+ * trigger create the row instead — a workaround that needs a new trigger for
1507
+ * every table with a unique row.
1508
+ *
1509
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
1510
+ * so writing them back is at best a no-op and at worst a surprise.
1511
+ */
1512
+ async upsert(table, data, opts) {
1513
+ const conflict = opts.onConflict;
1514
+ if (conflict.length === 0) {
1515
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
1516
+ }
1517
+ const cols = Object.keys(data);
1518
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
1519
+ if (searchConfigFor(table)?.validity) {
1520
+ const ph = cols.map((_, i) => `$${i + 1}`).join(", ");
1521
+ 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 *`;
1522
+ const live = await at();
1523
+ const bindData = asBindParams(table, cols, data);
1524
+ const first = await live.unsafe(insertSql, bindData);
1525
+ if (first[0]) return asTableRow(table, first[0]);
1526
+ const condSql = conflict.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ");
1527
+ const condBind = conflict.map((c) => data[c]);
1528
+ const cur = await live.unsafe(
1529
+ `SELECT * FROM ${quoteIdent(table)} WHERE ${condSql} AND "valid_to" IS NULL`,
1530
+ condBind
1531
+ );
1532
+ const old = cur[0];
1533
+ if (old === void 0) {
1534
+ 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)`);
1535
+ }
1536
+ const pk = Object.keys(old).includes("id") ? "id" : Object.keys(old)[0];
1537
+ await live.unsafe(
1538
+ `UPDATE ${quoteIdent(table)} SET "valid_to" = now() WHERE ${quoteIdent(pk)} = $1 AND "valid_to" IS NULL`,
1539
+ [old[pk]]
1540
+ );
1541
+ const second = await live.unsafe(insertSql, bindData);
1542
+ if (!second[0]) {
1543
+ 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)`);
1544
+ }
1545
+ const fresh = second[0];
1546
+ await live.unsafe(
1547
+ `UPDATE ${quoteIdent(table)} SET "superseded_by" = $2 WHERE ${quoteIdent(pk)} = $1`,
1548
+ [old[pk], fresh[pk]]
1549
+ );
1550
+ return asTableRow(table, second[0]);
1551
+ }
1552
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1553
+ const conflictSet = new Set(conflict);
1554
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1555
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1556
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
1557
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
1558
+ const row = rows[0];
1559
+ if (!row) {
1560
+ throw new Error(
1561
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
1562
+ );
1563
+ }
1564
+ return asTableRow(table, row);
1565
+ },
1301
1566
  async update(table, id, data) {
1302
1567
  const cols = Object.keys(data);
1303
1568
  if (cols.length === 0) return ops.findById(table, id);
@@ -1309,6 +1574,86 @@ function createOps(tx) {
1309
1574
  async delete(table, id) {
1310
1575
  await (await at()).unsafe(`DELETE FROM ${quoteIdent(table)} WHERE id = $1`, [id]);
1311
1576
  },
1577
+ /**
1578
+ * Update every row the filter matches, in ONE statement.
1579
+ *
1580
+ * The capability was already here and only reachable from inside a
1581
+ * transaction plan (`tx.tables.x.updateWhere`, since 11.0.0). Outside it the
1582
+ * only door was `update(id, …)`, so "mark every unapproved row in this
1583
+ * household" was an N+1 loop or hand-written SQL — and hand-written SQL is
1584
+ * where the typed surface and RLS both stop helping.
1585
+ *
1586
+ * The filter language is `findMany`'s, compiled by the same `compileWhere`:
1587
+ * one filter language, or the two spellings drift.
1588
+ *
1589
+ * AN EMPTY FILTER IS REFUSED. `UPDATE … WHERE true` is a whole-table write,
1590
+ * and the shape that produces it by accident — a filter object built from
1591
+ * request input that happened to come back empty — is exactly the shape that
1592
+ * should not silently succeed. Callers who mean every row say so with a
1593
+ * predicate that is true for every row.
1594
+ */
1595
+ async updateMany(table, where, set) {
1596
+ const cols = Object.keys(set);
1597
+ if (cols.length === 0) return [];
1598
+ if (Object.keys(where).length === 0) {
1599
+ throw new Error(
1600
+ `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`
1601
+ );
1602
+ }
1603
+ const params = [];
1604
+ const add = (v) => {
1605
+ params.push(v);
1606
+ return `$${params.length}`;
1607
+ };
1608
+ const assignments = cols.map((c) => `${quoteIdent(c)} = ${add(asBindParams(table, [c], set)[0])}`).join(", ");
1609
+ const known = schemaColumns(table);
1610
+ const whereSql = compileWhere(table, known, where, add, "updateMany");
1611
+ const sql = `UPDATE ${quoteIdent(table)} AS t SET ${assignments} WHERE true${whereSql} RETURNING *`;
1612
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1613
+ },
1614
+ /**
1615
+ * Delete every row the filter matches, in ONE statement; resolves to how
1616
+ * many went. Same filter language, same empty-filter refusal as
1617
+ * {@link updateMany} — and here the accident is worse.
1618
+ */
1619
+ async deleteMany(table, where) {
1620
+ if (Object.keys(where).length === 0) {
1621
+ throw new Error(
1622
+ `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`
1623
+ );
1624
+ }
1625
+ const params = [];
1626
+ const add = (v) => {
1627
+ params.push(v);
1628
+ return `$${params.length}`;
1629
+ };
1630
+ const known = schemaColumns(table);
1631
+ const whereSql = compileWhere(table, known, where, add, "deleteMany");
1632
+ const sql = `DELETE FROM ${quoteIdent(table)} AS t WHERE true${whereSql} RETURNING 1`;
1633
+ const rows = await (await at()).unsafe(sql, params);
1634
+ return rows.length;
1635
+ },
1636
+ /**
1637
+ * How many rows match — the half of pagination `limit`/`offset` cannot
1638
+ * supply. Without it a page count is either a guess or "fetch everything and
1639
+ * read .length", and the second one is the scale risk this surface exists to
1640
+ * remove.
1641
+ *
1642
+ * An empty filter is legitimate HERE: counting a whole table is a read, and
1643
+ * reads do not destroy anything.
1644
+ */
1645
+ async count(table, where = {}) {
1646
+ const params = [];
1647
+ const add = (v) => {
1648
+ params.push(v);
1649
+ return `$${params.length}`;
1650
+ };
1651
+ const known = schemaColumns(table);
1652
+ const whereSql = compileWhere(table, known, where, add, "count");
1653
+ const sql = `SELECT count(*) AS n FROM ${quoteIdent(table)} t WHERE true${whereSql}`;
1654
+ const rows = await (await at()).unsafe(sql, params);
1655
+ return Number(rows[0]?.n ?? 0);
1656
+ },
1312
1657
  async findById(table, id) {
1313
1658
  const rows = await (await at()).unsafe(
1314
1659
  `SELECT * FROM ${quoteIdent(table)} WHERE id = $1`,
@@ -1316,13 +1661,19 @@ function createOps(tx) {
1316
1661
  );
1317
1662
  return rows[0] ? asTableRow(table, rows[0]) : null;
1318
1663
  },
1319
- async findMany(table, query = {}) {
1320
- const cols = Object.keys(query);
1321
- const where = cols.length ? ` WHERE ${cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ")}` : "";
1322
- return asTableRows(table, await (await at()).unsafe(
1323
- `SELECT * FROM ${quoteIdent(table)}${where}`,
1324
- cols.map((c) => query[c])
1325
- ));
1664
+ async findMany(table, query = {}, opts = {}) {
1665
+ const params = [];
1666
+ const add = (v) => {
1667
+ params.push(v);
1668
+ return `$${params.length}`;
1669
+ };
1670
+ const known = schemaColumns(table);
1671
+ const whereSql = compileWhere(table, known, query, add, "findMany");
1672
+ const order = orderClause(table, known, opts.orderBy);
1673
+ const limit = limitClause(opts.limit);
1674
+ const offset = offsetClause(opts.offset, opts.limit);
1675
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}${offset}`;
1676
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1326
1677
  },
1327
1678
  /**
1328
1679
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -1404,7 +1755,9 @@ function createOps(tx) {
1404
1755
  bindC.push(v);
1405
1756
  return `$${bindC.length}`;
1406
1757
  };
1407
- const whereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC) + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1758
+ const userWhereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC);
1759
+ const whereC = userWhereC + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1760
+ const parentJoinC = ` JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = c.${pidQ} WHERE true${whereC} AND `;
1408
1761
  const liveC = await at();
1409
1762
  const K2 = 50;
1410
1763
  const cpool = Math.max(limit * 9, 90);
@@ -1413,8 +1766,19 @@ function createOps(tx) {
1413
1766
  if (qvC !== null) {
1414
1767
  const sch = await vectorSchemaWithGuc(liveC);
1415
1768
  const op = METRIC_OPERATOR[ck.metric] ?? METRIC_OPERATOR.cosine;
1769
+ let exactOrderC = "";
1770
+ if (userWhereC !== "") {
1771
+ const probeRows = await liveC.unsafe(
1772
+ `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`,
1773
+ bindC.slice()
1774
+ );
1775
+ const n = probeRows?.[0]?.n;
1776
+ if (typeof n === "number" && n <= SELECTIVITY_EXACT_THRESHOLD) {
1777
+ exactOrderC = " + 0.0";
1778
+ }
1779
+ }
1416
1780
  const vp = addC(toVectorLiteral(qvC));
1417
- 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}`;
1781
+ 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}`;
1418
1782
  }
1419
1783
  let qpC = "";
1420
1784
  let qpCIdx = -1;
@@ -1422,7 +1786,7 @@ function createOps(tx) {
1422
1786
  const qTextC = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
1423
1787
  qpC = addC(qTextC);
1424
1788
  qpCIdx = bindC.length - 1;
1425
- 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}`;
1789
+ 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}`;
1426
1790
  }
1427
1791
  const msC = params.minScore === void 0 ? "" : addC(params.minScore);
1428
1792
  const colListC = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
@@ -1437,13 +1801,13 @@ function createOps(tx) {
1437
1801
  } else {
1438
1802
  fusedSrc = `WITH kw AS (${kwIn}), fused AS (SELECT kw.pid, kw.chunk_seq, (1.0/(${K2} + kw.r))::float8 AS cscore FROM kw)`;
1439
1803
  }
1440
- 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}`;
1804
+ 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`;
1441
1805
  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}`;
1442
1806
  };
1443
1807
  let rowsC = await liveC.unsafe(assembleC(kwC, kwC !== ""), bindC);
1444
1808
  if (wantTextC && kwC !== "" && (rowsC.length === 0 || rowsC[0]._kwn === 0)) {
1445
1809
  const tsch = await trgmSchemaOf(liveC);
1446
- 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}`;
1810
+ 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}`;
1447
1811
  const retryBindC = bindC.slice();
1448
1812
  if (qpCIdx >= 0) retryBindC[qpCIdx] = params.query;
1449
1813
  rowsC = await liveC.unsafe(assembleC(trgmKwC, false), retryBindC);
@@ -1539,7 +1903,7 @@ function createOps(tx) {
1539
1903
  if (wantText && kwSql !== "" && (rows.length === 0 || rows[0]._kwn === 0)) {
1540
1904
  const tsch = await trgmSchemaOf(live);
1541
1905
  const expr = ftsExprOf(cfg.ftsCols);
1542
- 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}`;
1906
+ 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}`;
1543
1907
  const retryBind = bind.slice();
1544
1908
  if (qpIdx >= 0) retryBind[qpIdx] = params.query;
1545
1909
  rows = await live.unsafe(assemble(trgmKw, false), retryBind);
@@ -1562,6 +1926,25 @@ function createOps(tx) {
1562
1926
  * tek SQL'de "id yok" ile "0 komşu" ayrılamazdı; +1 küçük turla id-yokluğu
1563
1927
  * adlandırılmış hataya çevrilir. Sonuç şekli search ile aynı (FR-015).
1564
1928
  */
1929
+ /** D-021 (FR-027 DX): sayaçlar BAĞIMSIZ op'la — search'ün dizi-üstü
1930
+ * `_facets` özelliği JSON.stringify'da kaybolur (dizi özelliği), tenant
1931
+ * yanıtına koyunca sessizce yok olurdu. Ayrı dönüş ciddi bir sözleşmedir;
1932
+ * search'teki alan geriye-uyum için DURUR. where + validity default'u
1933
+ * sayaçlara da uygulanır (sayaç, kullanıcının gördüğü kümeyi anlatır). */
1934
+ async facets(table, params) {
1935
+ const cfg = searchConfigFor(table);
1936
+ if (!cfg) {
1937
+ throw new Error(`facets(${table}): tablo aranabilir de\u011Fil \u2014 search beyan\u0131 yok (FR-027)`);
1938
+ }
1939
+ for (const col of params.facets) {
1940
+ if (!cfg.colSet.has(col)) {
1941
+ throw new Error(`facets(${table}): facets kolonu "${col}" tabloda yok (FR-027)`);
1942
+ }
1943
+ }
1944
+ const live = await at();
1945
+ const extra = cfg.validity ? (add) => validitySql(params.validity, add) : () => "";
1946
+ return fetchFacets(live, table, cfg.colSet, params.facets, params.where ?? {}, extra);
1947
+ },
1565
1948
  async similar(table, id, opts = {}) {
1566
1949
  const cfg = searchConfigFor(table);
1567
1950
  if (!cfg) {
@@ -1713,6 +2096,27 @@ function createOps(tx) {
1713
2096
  const live = await at();
1714
2097
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1715
2098
  },
2099
+ /**
2100
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
2101
+ * rolls back only what that handle wrote and the request can keep writing.
2102
+ *
2103
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
2104
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
2105
+ * the ambient `Database` at the savepoint for the duration, and a request is
2106
+ * concurrent with itself: `Promise.all([Database.insert(a),
2107
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
2108
+ * back with it. Silent data loss, and the same interleaving this file already
2109
+ * refuses for `asService()`. Passing the handle makes the boundary something
2110
+ * you can see in the code that crosses it.
2111
+ *
2112
+ * Postgres, not us: the savepoint is released on success and rolled back on
2113
+ * failure by the driver, so an aborted statement inside `fn` does not poison
2114
+ * the surrounding transaction.
2115
+ */
2116
+ async attempt(fn) {
2117
+ const live = await at();
2118
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
2119
+ },
1716
2120
  /**
1717
2121
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1718
2122
  *
@@ -1763,22 +2167,68 @@ function withTables(ops, schema = currentSchema) {
1763
2167
  update: (id, data) => ops.update(name, id, data),
1764
2168
  delete: (id) => ops.delete(name, id),
1765
2169
  findById: (id) => ops.findById(name, id),
1766
- findMany: (query) => ops.findMany(name, query ?? {})
2170
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
2171
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1767
2172
  };
1768
2173
  }
1769
2174
  const base = /* @__PURE__ */ Object.create(null);
1770
2175
  return Object.assign(base, ops, { tables });
1771
2176
  }
1772
2177
  var SERVICE_LOCK_TIMEOUT = "5s";
1773
- function isLockTimeout(e) {
1774
- const code = e?.code;
2178
+ function isAbortedTransaction(e) {
1775
2179
  const message = String(e?.message ?? "");
1776
- return code === "55P03" || /lock timeout/i.test(message);
2180
+ return sqlstateOf(e) === "25P02" || /current transaction is aborted/i.test(message);
1777
2181
  }
1778
- function diagnosingDriver(sql) {
1779
- const explain = (e) => isLockTimeout(e) ? new Error(
1780
- `Database.asService() waited too long for a row lock. It runs in its OWN transaction, so a row this request already wrote through Database.* is locked against it until the request commits \u2014 a wait that cannot end. Do that row's work on one surface or the other. (${String(e?.message ?? e)})`
1781
- ) : e;
2182
+ function sqlstateOf(e) {
2183
+ const err = e;
2184
+ const isState = (v) => typeof v === "string" && /^[0-9A-Z]{5}$/.test(v);
2185
+ if (isState(err?.code)) return err.code;
2186
+ if (isState(err?.errno)) return err.errno;
2187
+ if (typeof err?.errno === "number") {
2188
+ const asText = String(err.errno);
2189
+ if (isState(asText)) return asText;
2190
+ }
2191
+ return void 0;
2192
+ }
2193
+ function isForeignKeyViolation(e) {
2194
+ return sqlstateOf(e) === "23503";
2195
+ }
2196
+ function isUniqueViolation(e) {
2197
+ return sqlstateOf(e) === "23505";
2198
+ }
2199
+ function constraintOf(e) {
2200
+ const named = e?.constraint;
2201
+ if (typeof named === "string" && named.length > 0) return named;
2202
+ const message = String(e?.message ?? "");
2203
+ return /violates unique constraint "([^"]+)"/.exec(message)?.[1] ?? "";
2204
+ }
2205
+ function isLockTimeout(e) {
2206
+ const message = String(e?.message ?? "");
2207
+ return sqlstateOf(e) === "55P03" || /lock timeout/i.test(message);
2208
+ }
2209
+ function diagnosingDriver(sql, surface = "service") {
2210
+ const original = (e) => String(e?.message ?? e);
2211
+ const explain = (e) => {
2212
+ if (isAbortedTransaction(e)) {
2213
+ return new Error(
2214
+ `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)})`
2215
+ );
2216
+ }
2217
+ if (isUniqueViolation(e)) {
2218
+ return new UniqueViolation(constraintOf(e));
2219
+ }
2220
+ if (surface === "service" && isForeignKeyViolation(e)) {
2221
+ return new Error(
2222
+ `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)})`
2223
+ );
2224
+ }
2225
+ if (isLockTimeout(e)) {
2226
+ return new Error(
2227
+ `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)})`
2228
+ );
2229
+ }
2230
+ return e;
2231
+ };
1782
2232
  const wrapTx = (tx) => ({
1783
2233
  async unsafe(text, params) {
1784
2234
  try {
@@ -1797,13 +2247,13 @@ function diagnosingDriver(sql) {
1797
2247
  };
1798
2248
  }
1799
2249
  function createRequestDatabase(sql, identity) {
1800
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
2250
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1801
2251
  let serviceTx = null;
1802
2252
  let serviceClient = null;
1803
2253
  const asService = () => {
1804
2254
  if (serviceClient === null) {
1805
2255
  serviceTx = createLazyTransaction(
1806
- diagnosingDriver(sql),
2256
+ diagnosingDriver(sql, "service"),
1807
2257
  identity.serviceRole,
1808
2258
  identity.claimsJson,
1809
2259
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1818,9 +2268,9 @@ function createRequestDatabase(sql, identity) {
1818
2268
  await tx.commit();
1819
2269
  await serviceTx?.commit();
1820
2270
  },
1821
- async rollback(reason) {
1822
- await tx.rollback(reason);
1823
- await serviceTx?.rollback(reason);
2271
+ async rollback(reason2) {
2272
+ await tx.rollback(reason2);
2273
+ await serviceTx?.rollback(reason2);
1824
2274
  }
1825
2275
  };
1826
2276
  }
@@ -1884,6 +2334,20 @@ async function runPlanOp(sp, op, results) {
1884
2334
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1885
2335
  break;
1886
2336
  }
2337
+ case "upsert": {
2338
+ const cols = Object.keys(op.values ?? {});
2339
+ const conflict = op.onConflict ?? [];
2340
+ if (cols.length === 0 || conflict.length === 0) {
2341
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
2342
+ error_code: "tx_bad_upsert"
2343
+ });
2344
+ }
2345
+ const rendered = cols.map(
2346
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
2347
+ );
2348
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ${onConflictTail(cols, conflict, "update")} RETURNING *`;
2349
+ break;
2350
+ }
1887
2351
  case "insertMany": {
1888
2352
  const rows = op.rows ?? [];
1889
2353
  if (rows.length === 0 || !rows[0]) return [];
@@ -1891,7 +2355,9 @@ async function runPlanOp(sp, op, results) {
1891
2355
  const tuples = rows.map(
1892
2356
  (r) => `(${cols.map((c) => renderValue(r[c], c, args, results, opVectorCols)).join(", ")})`
1893
2357
  );
1894
- sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")} RETURNING *`;
2358
+ const conflictCols = op.onConflict ?? [];
2359
+ const tail = op.action !== void 0 && conflictCols.length > 0 ? ` ${onConflictTail(cols, conflictCols, op.action)}` : "";
2360
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}${tail} RETURNING *`;
1895
2361
  break;
1896
2362
  }
1897
2363
  case "update": {
@@ -1966,6 +2432,38 @@ function getRoutes(ctor) {
1966
2432
  }));
1967
2433
  }
1968
2434
 
2435
+ // src/decorators/controller.ts
2436
+ var REGISTRY = /* @__PURE__ */ Symbol.for("palbase.backend.allControllers");
2437
+ function registry() {
2438
+ const g = globalThis;
2439
+ const existing = g[REGISTRY];
2440
+ if (existing) return existing;
2441
+ const fresh = [];
2442
+ g[REGISTRY] = fresh;
2443
+ return fresh;
2444
+ }
2445
+ function getRegisteredControllers() {
2446
+ return registry().slice();
2447
+ }
2448
+ var APP_DEFAULT_AUTH = /* @__PURE__ */ Symbol.for("palbase.backend.appDefaultAuth");
2449
+ function appAuthSlot() {
2450
+ return globalThis;
2451
+ }
2452
+ function getDefaultAuth() {
2453
+ return appAuthSlot()[APP_DEFAULT_AUTH];
2454
+ }
2455
+ function resolveEffectiveAuth(routeAuth, controllerAuth) {
2456
+ return routeAuth ?? controllerAuth ?? getDefaultAuth() ?? true;
2457
+ }
2458
+ function assertZeroArgConstructor(Ctrl, kind) {
2459
+ const arity = Ctrl.length ?? 0;
2460
+ if (arity === 0) return;
2461
+ const name = Ctrl.name ?? "<anonymous>";
2462
+ throw new Error(
2463
+ `${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)\`).`
2464
+ );
2465
+ }
2466
+
1969
2467
  // src/engine/router.ts
1970
2468
  var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
1971
2469
  function toSegments(path) {
@@ -1984,6 +2482,7 @@ function buildRouteTable(controllers) {
1984
2482
  `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.`
1985
2483
  );
1986
2484
  }
2485
+ assertZeroArgConstructor(Ctrl, "controller");
1987
2486
  const instance = new ctor();
1988
2487
  for (const r of routes) {
1989
2488
  const full = `${basePath}${r.subpath ?? ""}` || "/";
@@ -1993,7 +2492,10 @@ function buildRouteTable(controllers) {
1993
2492
  meta: r,
1994
2493
  instance,
1995
2494
  id: `${r.method} ${full}`,
1996
- controllerAuth: meta?.defaultAuth
2495
+ // The route's OWN spec is deliberately not folded in here: this field
2496
+ // is what applies when the route is silent, and `effectiveAuth` puts
2497
+ // the route level back on top per request.
2498
+ controllerAuth: resolveEffectiveAuth(void 0, meta?.defaultAuth)
1997
2499
  });
1998
2500
  }
1999
2501
  }
@@ -2080,6 +2582,41 @@ function verifySignature(presented, expected) {
2080
2582
  return diff === 0;
2081
2583
  }
2082
2584
 
2585
+ // src/engine/sse.ts
2586
+ var SSE_CONTENT_TYPE = "text/event-stream";
2587
+ function encodeFrame(value) {
2588
+ return `data: ${JSON.stringify(value)}
2589
+
2590
+ `;
2591
+ }
2592
+ function encodeErrorFrame(requestId) {
2593
+ return `event: error
2594
+ data: ${JSON.stringify({ requestId })}
2595
+
2596
+ `;
2597
+ }
2598
+ function makeSseWriter(opts) {
2599
+ let begun = false;
2600
+ let chain = Promise.resolve();
2601
+ return {
2602
+ write(value) {
2603
+ const frame = encodeFrame(value);
2604
+ if (!begun) {
2605
+ begun = true;
2606
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
2607
+ return;
2608
+ }
2609
+ chain = chain.then(() => opts.enqueue(frame));
2610
+ },
2611
+ started() {
2612
+ return begun;
2613
+ },
2614
+ drained() {
2615
+ return chain;
2616
+ }
2617
+ };
2618
+ }
2619
+
2083
2620
  // src/engine/index.ts
2084
2621
  var JSON_HEADERS = { "content-type": "application/json" };
2085
2622
  function fieldErrors(err) {
@@ -2254,6 +2791,13 @@ async function createApp(opts) {
2254
2791
  const args = [];
2255
2792
  let parsedBody;
2256
2793
  let bodyRead = false;
2794
+ let sseEnqueue = null;
2795
+ let sseSettle = async () => {
2796
+ };
2797
+ const sseWriter = makeSseWriter({
2798
+ enqueue: (chunk) => sseEnqueue?.(chunk),
2799
+ onFirstWrite: () => sseSettle()
2800
+ });
2257
2801
  for (const p of meta.params ?? []) {
2258
2802
  switch (p.kind) {
2259
2803
  case "body": {
@@ -2357,6 +2901,21 @@ async function createApp(opts) {
2357
2901
  case "req":
2358
2902
  args[p.index] = req;
2359
2903
  break;
2904
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
2905
+ // state when the client disconnects. Measured on Bun: an infinite
2906
+ // producer guarded by it stops within a few frames of the client dying.
2907
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
2908
+ // request-scoped data and no signal.
2909
+ case "signal":
2910
+ args[p.index] = req.signal;
2911
+ break;
2912
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
2913
+ // on any other route it is an inert writer whose frames go nowhere,
2914
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
2915
+ // route.
2916
+ case "sseOut":
2917
+ args[p.index] = sseWriter;
2918
+ break;
2360
2919
  default:
2361
2920
  args[p.index] = void 0;
2362
2921
  }
@@ -2368,6 +2927,84 @@ async function createApp(opts) {
2368
2927
  });
2369
2928
  try {
2370
2929
  const services = buildServices(db);
2930
+ if (meta.options?.sseConfig !== void 0) {
2931
+ let sseController;
2932
+ const sseEncoder = new TextEncoder();
2933
+ const stream = new ReadableStream({
2934
+ start(c) {
2935
+ sseController = c;
2936
+ }
2937
+ });
2938
+ sseEnqueue = (chunk) => {
2939
+ try {
2940
+ sseController.enqueue(sseEncoder.encode(chunk));
2941
+ } catch {
2942
+ }
2943
+ };
2944
+ let sawFirstFrame;
2945
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
2946
+ sseSettle = async () => {
2947
+ await db.commit();
2948
+ sawFirstFrame();
2949
+ };
2950
+ const guarded = {
2951
+ ...services,
2952
+ Database: new Proxy(services.Database, {
2953
+ get(target, prop, recv) {
2954
+ if (sseWriter.started()) {
2955
+ throw new Error(
2956
+ `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.`
2957
+ );
2958
+ }
2959
+ return Reflect.get(target, prop, recv);
2960
+ }
2961
+ })
2962
+ };
2963
+ let thrown = null;
2964
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
2965
+ const box = requestALS.getStore();
2966
+ if (box) {
2967
+ box.userId = userId ?? null;
2968
+ box.requestId = requestId;
2969
+ box.idempotencyKey = req.headers.get("idempotency-key");
2970
+ }
2971
+ const method = hit.entry.instance[meta.fnName];
2972
+ if (typeof method !== "function") {
2973
+ throw new Error(
2974
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
2975
+ );
2976
+ }
2977
+ return method.apply(hit.entry.instance, args);
2978
+ })).then(
2979
+ () => void 0,
2980
+ (err) => {
2981
+ thrown = err;
2982
+ }
2983
+ );
2984
+ await Promise.race([firstFrame, ran]);
2985
+ if (!sseWriter.started()) {
2986
+ await ran;
2987
+ await db.commit();
2988
+ if (thrown !== null) throw thrown;
2989
+ return new Response(null, { status: 204 });
2990
+ }
2991
+ void (async () => {
2992
+ await ran;
2993
+ await sseWriter.drained();
2994
+ try {
2995
+ if (thrown !== null) {
2996
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
2997
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
2998
+ }
2999
+ sseController.close();
3000
+ } catch {
3001
+ }
3002
+ })();
3003
+ return new Response(stream, {
3004
+ status: 200,
3005
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
3006
+ });
3007
+ }
2371
3008
  const result = await runWithRuntime(services, () => {
2372
3009
  const box = requestALS.getStore();
2373
3010
  if (box) {
@@ -2426,31 +3063,28 @@ async function createApp(opts) {
2426
3063
  return envelope("internal_error", "The request could not be completed", 500, requestId);
2427
3064
  }
2428
3065
  }
3066
+ let runShutdownHooks;
3067
+ try {
3068
+ runShutdownHooks = await __runStartHooks();
3069
+ } catch (err) {
3070
+ await closeDriver(sql);
3071
+ throw err;
3072
+ }
2429
3073
  return {
2430
3074
  handle,
2431
3075
  routes,
2432
3076
  config,
2433
3077
  runInServiceScope,
2434
3078
  async shutdown() {
2435
- const closable = sql;
2436
- await closable.close?.();
2437
- await closable.end?.();
3079
+ await runShutdownHooks();
3080
+ await closeDriver(sql);
2438
3081
  }
2439
3082
  };
2440
3083
  }
2441
-
2442
- // src/decorators/controller.ts
2443
- var REGISTRY = /* @__PURE__ */ Symbol.for("palbase.backend.allControllers");
2444
- function registry() {
2445
- const g = globalThis;
2446
- const existing = g[REGISTRY];
2447
- if (existing) return existing;
2448
- const fresh = [];
2449
- g[REGISTRY] = fresh;
2450
- return fresh;
2451
- }
2452
- function getRegisteredControllers() {
2453
- return registry().slice();
3084
+ async function closeDriver(sql) {
3085
+ const closable = sql;
3086
+ await closable.close?.();
3087
+ await closable.end?.();
2454
3088
  }
2455
3089
 
2456
3090
  // src/bin/palbase-backend.ts