@palbase/backend 23.1.0 → 24.0.1

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 (73) hide show
  1. package/dist/bin/palbase-backend.cjs +400 -47
  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-OHALWEOG.js → chunk-7Z6MGMXQ.js} +57 -2
  6. package/dist/chunk-7Z6MGMXQ.js.map +1 -0
  7. package/dist/{chunk-RCLNBJCM.js → chunk-H3JAISUY.js} +136 -1
  8. package/dist/chunk-H3JAISUY.js.map +1 -0
  9. package/dist/{chunk-PY7YJDCT.js → chunk-LCL7TUAI.js} +32 -3
  10. package/dist/chunk-LCL7TUAI.js.map +1 -0
  11. package/dist/{chunk-NS5V43YQ.js → chunk-P2Q27SGP.js} +19 -3
  12. package/dist/chunk-P2Q27SGP.js.map +1 -0
  13. package/dist/{chunk-R3KN6RHD.js → chunk-T5IOSOE5.js} +7 -2
  14. package/dist/chunk-T5IOSOE5.js.map +1 -0
  15. package/dist/{chunk-M5MCBWJI.js → chunk-YSQBC2VL.js} +275 -31
  16. package/dist/chunk-YSQBC2VL.js.map +1 -0
  17. package/dist/db/index.cjs +48 -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-CVWXh6oG.d.ts → endpoint-0_DGBajf.d.ts} +100 -3
  23. package/dist/{endpoint-c9h5jriX.d.cts → endpoint-CcQ1a36a.d.cts} +100 -3
  24. package/dist/engine/index.cjs +389 -34
  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-CwAJ7HEe.d.ts → index-BOS_rFBO.d.ts} +125 -21
  30. package/dist/{index-CxeQSfJP.d.cts → index-C84bLgeO.d.cts} +134 -9
  31. package/dist/{index-By8Dle5U.d.cts → index-MoQ31B6M.d.cts} +125 -21
  32. package/dist/{index-BZrJXnVh.d.ts → index-dJNhDZ7j.d.ts} +134 -9
  33. package/dist/index.cjs +166 -5
  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 +28 -11
  38. package/dist/index.js.map +1 -1
  39. package/dist/openapi/index.cjs +16 -1
  40. package/dist/openapi/index.cjs.map +1 -1
  41. package/dist/openapi/index.d.cts +6 -4
  42. package/dist/openapi/index.d.ts +6 -4
  43. package/dist/openapi/index.js +6 -7
  44. package/dist/openapi/index.js.map +1 -1
  45. package/dist/{registry-CqPK2Qby.d.cts → registry-1X-skBNu.d.cts} +1 -1
  46. package/dist/{registry-B3niOVYp.d.ts → registry-CEod_5sz.d.ts} +1 -1
  47. package/dist/test/index.cjs +27 -0
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.d.cts +1 -1
  50. package/dist/test/index.d.ts +1 -1
  51. package/dist/test/index.js +27 -0
  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 +2 -2
  61. package/template/AGENTS.md +121 -41
  62. package/template/controllers/notes.controller.ts +64 -0
  63. package/template/package.json +1 -1
  64. package/template/services/note.service.ts +74 -0
  65. package/template/tsconfig.json +11 -1
  66. package/dist/chunk-HQRJDARQ.js +0 -90
  67. package/dist/chunk-HQRJDARQ.js.map +0 -1
  68. package/dist/chunk-M5MCBWJI.js.map +0 -1
  69. package/dist/chunk-NS5V43YQ.js.map +0 -1
  70. package/dist/chunk-OHALWEOG.js.map +0 -1
  71. package/dist/chunk-PY7YJDCT.js.map +0 -1
  72. package/dist/chunk-R3KN6RHD.js.map +0 -1
  73. package/dist/chunk-RCLNBJCM.js.map +0 -1
@@ -231,7 +231,7 @@ var TxPlanBuilder = class {
231
231
  `${name}.upsert()`
232
232
  );
233
233
  },
234
- insertMany: (rows) => {
234
+ insertMany: (rows, opts) => {
235
235
  if (rows.length === 0) {
236
236
  return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
237
237
  }
@@ -242,7 +242,23 @@ var TxPlanBuilder = class {
242
242
  }
243
243
  const encoded = rows.map((row) => encodeMap(row, false));
244
244
  assertUniformRows(encoded, name);
245
- 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
+ );
246
262
  },
247
263
  updateWhere: (where, set) => {
248
264
  const encodedWhere = encodeMap(where, false);
@@ -416,6 +432,42 @@ function __getRuntime() {
416
432
  }
417
433
  return runtime;
418
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
+ }
419
471
  function makeServiceProxy(key) {
420
472
  const handler = {
421
473
  get(_target, prop, receiver) {
@@ -443,6 +495,7 @@ function makeTablesAccessor(ops) {
443
495
  search: (params) => ops().search(name, params),
444
496
  similar: (id, params) => ops().similar(name, id, params),
445
497
  recommend: (params) => ops().recommend(name, params),
498
+ facets: (params) => ops().facets(name, params),
446
499
  supersede: (id, row) => ops().supersede(name, id, row)
447
500
  };
448
501
  }
@@ -461,9 +514,13 @@ function makeTypedSurface(raw) {
461
514
  findById: (table, id) => raw.findById(table, id),
462
515
  findMany: (table, query, opts) => raw.findMany(table, query, opts),
463
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),
464
520
  search: (table, params) => raw.search(table, params),
465
521
  similar: (table, id, params) => reco.similar(table, id, params),
466
522
  recommend: (table, params) => reco.recommend(table, params),
523
+ facets: (table, params) => reco.facets(table, params),
467
524
  supersede: (table, id, row) => raw.supersede(table, id, row)
468
525
  };
469
526
  return Object.assign(ops, {
@@ -621,6 +678,44 @@ var HttpError = class extends Error {
621
678
  return result;
622
679
  }
623
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
+ };
624
719
 
625
720
  // src/engine/config.ts
626
721
  var BootRefused = class extends Error {
@@ -983,9 +1078,9 @@ function createLazyTransaction(sql, role, claimsJson, options = {}) {
983
1078
  release();
984
1079
  await settled;
985
1080
  },
986
- async rollback(reason) {
1081
+ async rollback(reason2) {
987
1082
  if (!opening) return;
988
- fail(reason);
1083
+ fail(reason2);
989
1084
  await settled?.catch(() => void 0);
990
1085
  }
991
1086
  };
@@ -1028,18 +1123,48 @@ function reviveVectors(row, vectorCols) {
1028
1123
  }
1029
1124
  return row;
1030
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
+ }
1031
1150
  function asTableRow(table, row) {
1032
- return reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
1151
+ const revived = reviveVectors(asWireRow(row), vectorColumnsOf(currentSchema, table));
1152
+ return applyFromDb(revived, transformsOf(currentSchema, table));
1033
1153
  }
1034
1154
  function asTableRows(table, rows) {
1035
1155
  const vectorCols = vectorColumnsOf(currentSchema, table);
1036
- 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));
1037
1158
  }
1038
1159
  function asBindParams(table, cols, data) {
1039
1160
  const vectorCols = vectorColumnsOf(currentSchema, table);
1161
+ const transforms = transformsOf(currentSchema, table);
1040
1162
  return cols.map((c) => {
1041
1163
  const v = data[c];
1042
- 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;
1043
1168
  });
1044
1169
  }
1045
1170
  var SELECTIVITY_EXACT_THRESHOLD = 1e4;
@@ -1233,13 +1358,33 @@ function limitClause(limit) {
1233
1358
  }
1234
1359
  return ` LIMIT ${limit}`;
1235
1360
  }
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
+ }
1236
1375
  function compileWhere(table, colSet, where, add, caller = "search") {
1237
1376
  const parts = [];
1377
+ const transforms = transformsOf(currentSchema, table);
1378
+ const bindFor = (col) => {
1379
+ const t = transforms.get(col);
1380
+ return t?.toDb === void 0 ? add : (v) => add(v === null || v === void 0 ? v : t.toDb(v));
1381
+ };
1238
1382
  for (const [col, cond] of Object.entries(where)) {
1239
1383
  if (colSet !== null && !colSet.has(col)) {
1240
1384
  throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1241
1385
  }
1242
1386
  const q = `t.${quoteIdent(col)}`;
1387
+ const bind = bindFor(col);
1243
1388
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1244
1389
  for (const [op, v] of Object.entries(cond)) {
1245
1390
  if (op === "in") {
@@ -1248,19 +1393,26 @@ function compileWhere(table, colSet, where, add, caller = "search") {
1248
1393
  parts.push("false");
1249
1394
  continue;
1250
1395
  }
1251
- parts.push(`${q} IN (${v.map((x) => add(x)).join(", ")})`);
1396
+ parts.push(`${q} IN (${v.map((x) => bind(x)).join(", ")})`);
1252
1397
  } else if (op in WHERE_OPS) {
1253
- parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
1398
+ parts.push(`${q} ${WHERE_OPS[op]} ${bind(v)}`);
1254
1399
  } else {
1255
1400
  throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1256
1401
  }
1257
1402
  }
1258
1403
  } else {
1259
- parts.push(`${q} = ${add(cond)}`);
1404
+ parts.push(`${q} = ${bind(cond)}`);
1260
1405
  }
1261
1406
  }
1262
1407
  return parts.length === 0 ? "" : ` AND ${parts.join(" AND ")}`;
1263
1408
  }
1409
+ function onConflictTail(cols, conflict, action) {
1410
+ const target = `ON CONFLICT (${conflict.map(quoteIdent).join(", ")})`;
1411
+ if (action === "ignore") return `${target} DO NOTHING`;
1412
+ const conflictSet = new Set(conflict);
1413
+ const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1414
+ return sets.length ? `${target} DO UPDATE SET ${sets.join(", ")}` : `${target} DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1415
+ }
1264
1416
  var cachedVectorSchema = null;
1265
1417
  var cachedTrgmSchema = null;
1266
1418
  async function trgmSchemaOf(live) {
@@ -1370,6 +1522,39 @@ function createOps(tx) {
1370
1522
  }
1371
1523
  const cols = Object.keys(data);
1372
1524
  if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
1525
+ if (searchConfigFor(table)?.validity) {
1526
+ const ph = cols.map((_, i) => `$${i + 1}`).join(", ");
1527
+ 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 *`;
1528
+ const live = await at();
1529
+ const bindData = asBindParams(table, cols, data);
1530
+ const first = await live.unsafe(insertSql, bindData);
1531
+ if (first[0]) return asTableRow(table, first[0]);
1532
+ const condSql = conflict.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ");
1533
+ const condBind = conflict.map((c) => data[c]);
1534
+ const cur = await live.unsafe(
1535
+ `SELECT * FROM ${quoteIdent(table)} WHERE ${condSql} AND "valid_to" IS NULL`,
1536
+ condBind
1537
+ );
1538
+ const old = cur[0];
1539
+ if (old === void 0) {
1540
+ 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)`);
1541
+ }
1542
+ const pk = Object.keys(old).includes("id") ? "id" : Object.keys(old)[0];
1543
+ await live.unsafe(
1544
+ `UPDATE ${quoteIdent(table)} SET "valid_to" = now() WHERE ${quoteIdent(pk)} = $1 AND "valid_to" IS NULL`,
1545
+ [old[pk]]
1546
+ );
1547
+ const second = await live.unsafe(insertSql, bindData);
1548
+ if (!second[0]) {
1549
+ 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)`);
1550
+ }
1551
+ const fresh = second[0];
1552
+ await live.unsafe(
1553
+ `UPDATE ${quoteIdent(table)} SET "superseded_by" = $2 WHERE ${quoteIdent(pk)} = $1`,
1554
+ [old[pk], fresh[pk]]
1555
+ );
1556
+ return asTableRow(table, second[0]);
1557
+ }
1373
1558
  const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1374
1559
  const conflictSet = new Set(conflict);
1375
1560
  const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
@@ -1395,6 +1580,82 @@ function createOps(tx) {
1395
1580
  async delete(table, id) {
1396
1581
  await (await at()).unsafe(`DELETE FROM ${quoteIdent(table)} WHERE id = $1`, [id]);
1397
1582
  },
1583
+ /**
1584
+ * Update every row the filter matches, in ONE statement.
1585
+ *
1586
+ * The capability was already here and only reachable from inside a
1587
+ * transaction plan (`tx.tables.x.updateWhere`, since 11.0.0). Outside it the
1588
+ * only door was `update(id, …)`, so "mark every unapproved row in this
1589
+ * household" was an N+1 loop or hand-written SQL — and hand-written SQL is
1590
+ * where the typed surface and RLS both stop helping.
1591
+ *
1592
+ * The filter language is `findMany`'s, compiled by the same `compileWhere`:
1593
+ * one filter language, or the two spellings drift.
1594
+ *
1595
+ * AN EMPTY FILTER IS REFUSED. `UPDATE … WHERE true` is a whole-table write,
1596
+ * and the shape that produces it by accident — a filter object built from
1597
+ * request input that happened to come back empty — is exactly the shape that
1598
+ * should not silently succeed. Callers who mean every row say so with a
1599
+ * predicate that is true for every row.
1600
+ */
1601
+ async updateMany(table, where, set) {
1602
+ const cols = Object.keys(set);
1603
+ if (cols.length === 0) {
1604
+ throw new Error(
1605
+ `updateMany(${table}): nothing to set. An update with no columns is not a no-op worth pretending happened \u2014 pass the columns to write.`
1606
+ );
1607
+ }
1608
+ const params = [];
1609
+ const add = (v) => {
1610
+ params.push(v);
1611
+ return `$${params.length}`;
1612
+ };
1613
+ const assignments = cols.map((c) => `${quoteIdent(c)} = ${add(asBindParams(table, [c], set)[0])}`).join(", ");
1614
+ const known = schemaColumns(table);
1615
+ const whereSql = compileWhere(table, known, where, add, "updateMany");
1616
+ assertHasPredicate(whereSql, "updateMany", table);
1617
+ const sql = `UPDATE ${quoteIdent(table)} AS t SET ${assignments} WHERE true${whereSql} RETURNING *`;
1618
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1619
+ },
1620
+ /**
1621
+ * Delete every row the filter matches, in ONE statement; resolves to how
1622
+ * many went. Same filter language, same empty-filter refusal as
1623
+ * {@link updateMany} — and here the accident is worse.
1624
+ */
1625
+ async deleteMany(table, where) {
1626
+ const params = [];
1627
+ const add = (v) => {
1628
+ params.push(v);
1629
+ return `$${params.length}`;
1630
+ };
1631
+ const known = schemaColumns(table);
1632
+ const whereSql = compileWhere(table, known, where, add, "deleteMany");
1633
+ assertHasPredicate(whereSql, "deleteMany", table);
1634
+ const sql = `DELETE FROM ${quoteIdent(table)} AS t WHERE true${whereSql} RETURNING 1`;
1635
+ const rows = await (await at()).unsafe(sql, params);
1636
+ return rows.length;
1637
+ },
1638
+ /**
1639
+ * How many rows match — the half of pagination `limit`/`offset` cannot
1640
+ * supply. Without it a page count is either a guess or "fetch everything and
1641
+ * read .length", and the second one is the scale risk this surface exists to
1642
+ * remove.
1643
+ *
1644
+ * An empty filter is legitimate HERE: counting a whole table is a read, and
1645
+ * reads do not destroy anything.
1646
+ */
1647
+ async count(table, where = {}) {
1648
+ const params = [];
1649
+ const add = (v) => {
1650
+ params.push(v);
1651
+ return `$${params.length}`;
1652
+ };
1653
+ const known = schemaColumns(table);
1654
+ const whereSql = compileWhere(table, known, where, add, "count");
1655
+ const sql = `SELECT count(*) AS n FROM ${quoteIdent(table)} t WHERE true${whereSql}`;
1656
+ const rows = await (await at()).unsafe(sql, params);
1657
+ return Number(rows[0]?.n ?? 0);
1658
+ },
1398
1659
  async findById(table, id) {
1399
1660
  const rows = await (await at()).unsafe(
1400
1661
  `SELECT * FROM ${quoteIdent(table)} WHERE id = $1`,
@@ -1412,7 +1673,8 @@ function createOps(tx) {
1412
1673
  const whereSql = compileWhere(table, known, query, add, "findMany");
1413
1674
  const order = orderClause(table, known, opts.orderBy);
1414
1675
  const limit = limitClause(opts.limit);
1415
- const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}`;
1676
+ const offset = offsetClause(opts.offset, opts.limit);
1677
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}${offset}`;
1416
1678
  return asTableRows(table, await (await at()).unsafe(sql, params));
1417
1679
  },
1418
1680
  /**
@@ -1495,7 +1757,9 @@ function createOps(tx) {
1495
1757
  bindC.push(v);
1496
1758
  return `$${bindC.length}`;
1497
1759
  };
1498
- const whereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC) + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1760
+ const userWhereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC);
1761
+ const whereC = userWhereC + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1762
+ const parentJoinC = ` JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = c.${pidQ} WHERE true${whereC} AND `;
1499
1763
  const liveC = await at();
1500
1764
  const K2 = 50;
1501
1765
  const cpool = Math.max(limit * 9, 90);
@@ -1504,8 +1768,19 @@ function createOps(tx) {
1504
1768
  if (qvC !== null) {
1505
1769
  const sch = await vectorSchemaWithGuc(liveC);
1506
1770
  const op = METRIC_OPERATOR[ck.metric] ?? METRIC_OPERATOR.cosine;
1771
+ let exactOrderC = "";
1772
+ if (userWhereC !== "") {
1773
+ const probeRows = await liveC.unsafe(
1774
+ `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`,
1775
+ bindC.slice()
1776
+ );
1777
+ const n = probeRows?.[0]?.n;
1778
+ if (typeof n === "number" && n <= SELECTIVITY_EXACT_THRESHOLD) {
1779
+ exactOrderC = " + 0.0";
1780
+ }
1781
+ }
1507
1782
  const vp = addC(toVectorLiteral(qvC));
1508
- 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}`;
1783
+ 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}`;
1509
1784
  }
1510
1785
  let qpC = "";
1511
1786
  let qpCIdx = -1;
@@ -1513,7 +1788,7 @@ function createOps(tx) {
1513
1788
  const qTextC = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
1514
1789
  qpC = addC(qTextC);
1515
1790
  qpCIdx = bindC.length - 1;
1516
- 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}`;
1791
+ 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}`;
1517
1792
  }
1518
1793
  const msC = params.minScore === void 0 ? "" : addC(params.minScore);
1519
1794
  const colListC = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
@@ -1528,13 +1803,13 @@ function createOps(tx) {
1528
1803
  } else {
1529
1804
  fusedSrc = `WITH kw AS (${kwIn}), fused AS (SELECT kw.pid, kw.chunk_seq, (1.0/(${K2} + kw.r))::float8 AS cscore FROM kw)`;
1530
1805
  }
1531
- 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}`;
1806
+ 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`;
1532
1807
  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}`;
1533
1808
  };
1534
1809
  let rowsC = await liveC.unsafe(assembleC(kwC, kwC !== ""), bindC);
1535
1810
  if (wantTextC && kwC !== "" && (rowsC.length === 0 || rowsC[0]._kwn === 0)) {
1536
1811
  const tsch = await trgmSchemaOf(liveC);
1537
- 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}`;
1812
+ 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}`;
1538
1813
  const retryBindC = bindC.slice();
1539
1814
  if (qpCIdx >= 0) retryBindC[qpCIdx] = params.query;
1540
1815
  rowsC = await liveC.unsafe(assembleC(trgmKwC, false), retryBindC);
@@ -1630,7 +1905,7 @@ function createOps(tx) {
1630
1905
  if (wantText && kwSql !== "" && (rows.length === 0 || rows[0]._kwn === 0)) {
1631
1906
  const tsch = await trgmSchemaOf(live);
1632
1907
  const expr = ftsExprOf(cfg.ftsCols);
1633
- 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}`;
1908
+ 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}`;
1634
1909
  const retryBind = bind.slice();
1635
1910
  if (qpIdx >= 0) retryBind[qpIdx] = params.query;
1636
1911
  rows = await live.unsafe(assemble(trgmKw, false), retryBind);
@@ -1653,6 +1928,25 @@ function createOps(tx) {
1653
1928
  * tek SQL'de "id yok" ile "0 komşu" ayrılamazdı; +1 küçük turla id-yokluğu
1654
1929
  * adlandırılmış hataya çevrilir. Sonuç şekli search ile aynı (FR-015).
1655
1930
  */
1931
+ /** D-021 (FR-027 DX): sayaçlar BAĞIMSIZ op'la — search'ün dizi-üstü
1932
+ * `_facets` özelliği JSON.stringify'da kaybolur (dizi özelliği), tenant
1933
+ * yanıtına koyunca sessizce yok olurdu. Ayrı dönüş ciddi bir sözleşmedir;
1934
+ * search'teki alan geriye-uyum için DURUR. where + validity default'u
1935
+ * sayaçlara da uygulanır (sayaç, kullanıcının gördüğü kümeyi anlatır). */
1936
+ async facets(table, params) {
1937
+ const cfg = searchConfigFor(table);
1938
+ if (!cfg) {
1939
+ throw new Error(`facets(${table}): tablo aranabilir de\u011Fil \u2014 search beyan\u0131 yok (FR-027)`);
1940
+ }
1941
+ for (const col of params.facets) {
1942
+ if (!cfg.colSet.has(col)) {
1943
+ throw new Error(`facets(${table}): facets kolonu "${col}" tabloda yok (FR-027)`);
1944
+ }
1945
+ }
1946
+ const live = await at();
1947
+ const extra = cfg.validity ? (add) => validitySql(params.validity, add) : () => "";
1948
+ return fetchFacets(live, table, cfg.colSet, params.facets, params.where ?? {}, extra);
1949
+ },
1656
1950
  async similar(table, id, opts = {}) {
1657
1951
  const cfg = searchConfigFor(table);
1658
1952
  if (!cfg) {
@@ -1884,17 +2178,41 @@ function withTables(ops, schema = currentSchema) {
1884
2178
  }
1885
2179
  var SERVICE_LOCK_TIMEOUT = "5s";
1886
2180
  function isAbortedTransaction(e) {
1887
- const code = e?.code;
1888
2181
  const message = String(e?.message ?? "");
1889
- return code === "25P02" || /current transaction is aborted/i.test(message);
2182
+ return sqlstateOf(e) === "25P02" || /current transaction is aborted/i.test(message);
2183
+ }
2184
+ function assertHasPredicate(whereSql, op, table) {
2185
+ if (whereSql.trim().length > 0) return;
2186
+ throw new Error(
2187
+ `${op}(${table}): the filter compiled to no condition, so this would have written EVERY row. An empty filter \u2014 or an operator object with no operators in it, like { col: {} } \u2014 is refused. Name a condition.`
2188
+ );
2189
+ }
2190
+ function sqlstateOf(e) {
2191
+ const err = e;
2192
+ const isState = (v) => typeof v === "string" && /^[0-9A-Z]{5}$/.test(v);
2193
+ if (isState(err?.code)) return err.code;
2194
+ if (isState(err?.errno)) return err.errno;
2195
+ if (typeof err?.errno === "number") {
2196
+ const asText = String(err.errno);
2197
+ if (isState(asText)) return asText;
2198
+ }
2199
+ return void 0;
1890
2200
  }
1891
2201
  function isForeignKeyViolation(e) {
1892
- return e?.code === "23503";
2202
+ return sqlstateOf(e) === "23503";
2203
+ }
2204
+ function isUniqueViolation(e) {
2205
+ return sqlstateOf(e) === "23505";
2206
+ }
2207
+ function constraintOf(e) {
2208
+ const named = e?.constraint;
2209
+ if (typeof named === "string" && named.length > 0) return named;
2210
+ const message = String(e?.message ?? "");
2211
+ return /violates unique constraint "([^"]+)"/.exec(message)?.[1] ?? "";
1893
2212
  }
1894
2213
  function isLockTimeout(e) {
1895
- const code = e?.code;
1896
2214
  const message = String(e?.message ?? "");
1897
- return code === "55P03" || /lock timeout/i.test(message);
2215
+ return sqlstateOf(e) === "55P03" || /lock timeout/i.test(message);
1898
2216
  }
1899
2217
  function diagnosingDriver(sql, surface = "service") {
1900
2218
  const original = (e) => String(e?.message ?? e);
@@ -1904,6 +2222,9 @@ function diagnosingDriver(sql, surface = "service") {
1904
2222
  `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)})`
1905
2223
  );
1906
2224
  }
2225
+ if (isUniqueViolation(e)) {
2226
+ return new UniqueViolation(constraintOf(e));
2227
+ }
1907
2228
  if (surface === "service" && isForeignKeyViolation(e)) {
1908
2229
  return new Error(
1909
2230
  `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)})`
@@ -1955,9 +2276,9 @@ function createRequestDatabase(sql, identity) {
1955
2276
  await tx.commit();
1956
2277
  await serviceTx?.commit();
1957
2278
  },
1958
- async rollback(reason) {
1959
- await tx.rollback(reason);
1960
- await serviceTx?.rollback(reason);
2279
+ async rollback(reason2) {
2280
+ await tx.rollback(reason2);
2281
+ await serviceTx?.rollback(reason2);
1961
2282
  }
1962
2283
  };
1963
2284
  }
@@ -2032,10 +2353,7 @@ async function runPlanOp(sp, op, results) {
2032
2353
  const rendered = cols.map(
2033
2354
  (c) => renderValue(op.values[c], c, args, results, opVectorCols)
2034
2355
  );
2035
- const conflictSet = new Set(conflict);
2036
- const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
2037
- const action = sets.length ? `DO UPDATE SET ${sets.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
2038
- sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
2356
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ${onConflictTail(cols, conflict, "update")} RETURNING *`;
2039
2357
  break;
2040
2358
  }
2041
2359
  case "insertMany": {
@@ -2045,7 +2363,9 @@ async function runPlanOp(sp, op, results) {
2045
2363
  const tuples = rows.map(
2046
2364
  (r) => `(${cols.map((c) => renderValue(r[c], c, args, results, opVectorCols)).join(", ")})`
2047
2365
  );
2048
- sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")} RETURNING *`;
2366
+ const conflictCols = op.onConflict ?? [];
2367
+ const tail = op.action !== void 0 && conflictCols.length > 0 ? ` ${onConflictTail(cols, conflictCols, op.action)}` : "";
2368
+ sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}${tail} RETURNING *`;
2049
2369
  break;
2050
2370
  }
2051
2371
  case "update": {
@@ -2120,6 +2440,38 @@ function getRoutes(ctor) {
2120
2440
  }));
2121
2441
  }
2122
2442
 
2443
+ // src/decorators/controller.ts
2444
+ var REGISTRY = /* @__PURE__ */ Symbol.for("palbase.backend.allControllers");
2445
+ function registry() {
2446
+ const g = globalThis;
2447
+ const existing = g[REGISTRY];
2448
+ if (existing) return existing;
2449
+ const fresh = [];
2450
+ g[REGISTRY] = fresh;
2451
+ return fresh;
2452
+ }
2453
+ function getRegisteredControllers() {
2454
+ return registry().slice();
2455
+ }
2456
+ var APP_DEFAULT_AUTH = /* @__PURE__ */ Symbol.for("palbase.backend.appDefaultAuth");
2457
+ function appAuthSlot() {
2458
+ return globalThis;
2459
+ }
2460
+ function getDefaultAuth() {
2461
+ return appAuthSlot()[APP_DEFAULT_AUTH];
2462
+ }
2463
+ function resolveEffectiveAuth(routeAuth, controllerAuth) {
2464
+ return routeAuth ?? controllerAuth ?? getDefaultAuth() ?? true;
2465
+ }
2466
+ function assertZeroArgConstructor(Ctrl, kind) {
2467
+ const arity = Ctrl.length ?? 0;
2468
+ if (arity === 0) return;
2469
+ const name = Ctrl.name ?? "<anonymous>";
2470
+ throw new Error(
2471
+ `${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)\`).`
2472
+ );
2473
+ }
2474
+
2123
2475
  // src/engine/router.ts
2124
2476
  var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
2125
2477
  function toSegments(path) {
@@ -2138,6 +2490,7 @@ function buildRouteTable(controllers) {
2138
2490
  `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.`
2139
2491
  );
2140
2492
  }
2493
+ assertZeroArgConstructor(Ctrl, "controller");
2141
2494
  const instance = new ctor();
2142
2495
  for (const r of routes) {
2143
2496
  const full = `${basePath}${r.subpath ?? ""}` || "/";
@@ -2147,7 +2500,10 @@ function buildRouteTable(controllers) {
2147
2500
  meta: r,
2148
2501
  instance,
2149
2502
  id: `${r.method} ${full}`,
2150
- controllerAuth: meta?.defaultAuth
2503
+ // The route's OWN spec is deliberately not folded in here: this field
2504
+ // is what applies when the route is silent, and `effectiveAuth` puts
2505
+ // the route level back on top per request.
2506
+ controllerAuth: resolveEffectiveAuth(void 0, meta?.defaultAuth)
2151
2507
  });
2152
2508
  }
2153
2509
  }
@@ -2715,31 +3071,28 @@ async function createApp(opts) {
2715
3071
  return envelope("internal_error", "The request could not be completed", 500, requestId);
2716
3072
  }
2717
3073
  }
3074
+ let runShutdownHooks;
3075
+ try {
3076
+ runShutdownHooks = await __runStartHooks();
3077
+ } catch (err) {
3078
+ await closeDriver(sql);
3079
+ throw err;
3080
+ }
2718
3081
  return {
2719
3082
  handle,
2720
3083
  routes,
2721
3084
  config,
2722
3085
  runInServiceScope,
2723
3086
  async shutdown() {
2724
- const closable = sql;
2725
- await closable.close?.();
2726
- await closable.end?.();
3087
+ await runShutdownHooks();
3088
+ await closeDriver(sql);
2727
3089
  }
2728
3090
  };
2729
3091
  }
2730
-
2731
- // src/decorators/controller.ts
2732
- var REGISTRY = /* @__PURE__ */ Symbol.for("palbase.backend.allControllers");
2733
- function registry() {
2734
- const g = globalThis;
2735
- const existing = g[REGISTRY];
2736
- if (existing) return existing;
2737
- const fresh = [];
2738
- g[REGISTRY] = fresh;
2739
- return fresh;
2740
- }
2741
- function getRegisteredControllers() {
2742
- return registry().slice();
3092
+ async function closeDriver(sql) {
3093
+ const closable = sql;
3094
+ await closable.close?.();
3095
+ await closable.end?.();
2743
3096
  }
2744
3097
 
2745
3098
  // src/bin/palbase-backend.ts