@palbase/backend 22.1.0 → 23.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/bin/palbase-backend.cjs +750 -40
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-YL4C5NRY.js → chunk-HQRJDARQ.js} +2 -2
  5. package/dist/{chunk-74XDEF5J.js → chunk-M5MCBWJI.js} +723 -37
  6. package/dist/chunk-M5MCBWJI.js.map +1 -0
  7. package/dist/{chunk-W5ODXPY3.js → chunk-NS5V43YQ.js} +14 -1
  8. package/dist/chunk-NS5V43YQ.js.map +1 -0
  9. package/dist/{chunk-SQC5EIWY.js → chunk-OHALWEOG.js} +19 -9
  10. package/dist/chunk-OHALWEOG.js.map +1 -0
  11. package/dist/{chunk-I3ON7MYF.js → chunk-PY7YJDCT.js} +129 -18
  12. package/dist/chunk-PY7YJDCT.js.map +1 -0
  13. package/dist/{chunk-N32VDWKH.js → chunk-R3KN6RHD.js} +4 -59
  14. package/dist/chunk-R3KN6RHD.js.map +1 -0
  15. package/dist/{chunk-QMVK4X3V.js → chunk-RCLNBJCM.js} +98 -98
  16. package/dist/chunk-RCLNBJCM.js.map +1 -0
  17. package/dist/db/env.cjs.map +1 -1
  18. package/dist/db/env.d.cts +3 -21
  19. package/dist/db/env.d.ts +3 -21
  20. package/dist/db/index.cjs +140 -16
  21. package/dist/db/index.cjs.map +1 -1
  22. package/dist/db/index.d.cts +3 -2
  23. package/dist/db/index.d.ts +3 -2
  24. package/dist/db/index.js +2 -2
  25. package/dist/{endpoint-BVT6jcVW.d.cts → endpoint-CVWXh6oG.d.ts} +147 -15
  26. package/dist/{endpoint-BVT6jcVW.d.ts → endpoint-c9h5jriX.d.cts} +147 -15
  27. package/dist/engine/index.cjs +750 -40
  28. package/dist/engine/index.cjs.map +1 -1
  29. package/dist/engine/index.d.cts +6 -5
  30. package/dist/engine/index.d.ts +6 -5
  31. package/dist/engine/index.js +4 -4
  32. package/dist/{index-BS1gW4nV.d.cts → index-BZrJXnVh.d.ts} +142 -72
  33. package/dist/{index-BqCiHao8.d.cts → index-By8Dle5U.d.cts} +196 -21
  34. package/dist/{index-vwHoS0l2.d.ts → index-CwAJ7HEe.d.ts} +196 -21
  35. package/dist/{index-CCZqzych.d.ts → index-CxeQSfJP.d.cts} +142 -72
  36. package/dist/index.cjs +388 -1101
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.d.cts +89 -1134
  39. package/dist/index.d.ts +89 -1134
  40. package/dist/index.js +133 -902
  41. package/dist/index.js.map +1 -1
  42. package/dist/openapi/index.cjs +32 -61
  43. package/dist/openapi/index.cjs.map +1 -1
  44. package/dist/openapi/index.d.cts +6 -2
  45. package/dist/openapi/index.d.ts +6 -2
  46. package/dist/openapi/index.js +34 -26
  47. package/dist/openapi/index.js.map +1 -1
  48. package/dist/{registry-Bsuf-orT.d.ts → registry-B3niOVYp.d.ts} +108 -170
  49. package/dist/{registry-BWttGlaT.d.cts → registry-CqPK2Qby.d.cts} +108 -170
  50. package/dist/{purchases/keys.cjs → stack.cjs} +4 -4
  51. package/dist/stack.cjs.map +1 -0
  52. package/dist/stack.d.cts +76 -0
  53. package/dist/stack.d.ts +76 -0
  54. package/dist/stack.js +1 -0
  55. package/dist/test/index.cjs +482 -9
  56. package/dist/test/index.cjs.map +1 -1
  57. package/dist/test/index.d.cts +35 -3
  58. package/dist/test/index.d.ts +35 -3
  59. package/dist/test/index.js +480 -8
  60. package/dist/test/index.js.map +1 -1
  61. package/docs/README.md +7 -6
  62. package/docs/llms-full.txt +7 -260
  63. package/docs/llms.txt +0 -2
  64. package/package.json +9 -8
  65. package/stager/return_types.js +23 -0
  66. package/template/package.json +1 -1
  67. package/dist/chunk-74XDEF5J.js.map +0 -1
  68. package/dist/chunk-I3ON7MYF.js.map +0 -1
  69. package/dist/chunk-N32VDWKH.js.map +0 -1
  70. package/dist/chunk-QMVK4X3V.js.map +0 -1
  71. package/dist/chunk-SQC5EIWY.js.map +0 -1
  72. package/dist/chunk-W5ODXPY3.js.map +0 -1
  73. package/dist/purchases/keys.cjs.map +0 -1
  74. package/dist/purchases/keys.d.cts +0 -42
  75. package/dist/purchases/keys.d.ts +0 -42
  76. package/dist/purchases/keys.js +0 -1
  77. package/docs/config.md +0 -147
  78. package/docs/resources.md +0 -97
  79. package/template/config/secrets.ts +0 -24
  80. /package/dist/{chunk-YL4C5NRY.js.map → chunk-HQRJDARQ.js.map} +0 -0
  81. /package/dist/{purchases/keys.js.map → stack.js.map} +0 -0
@@ -218,6 +218,19 @@ var TxPlanBuilder = class {
218
218
  }
219
219
  return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
220
220
  },
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
+ },
221
234
  insertMany: (rows) => {
222
235
  if (rows.length === 0) {
223
236
  return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
@@ -425,8 +438,12 @@ function makeTablesAccessor(ops) {
425
438
  update: (id, data) => ops().update(name, id, data),
426
439
  delete: (id) => ops().delete(name, id),
427
440
  findById: (id) => ops().findById(name, id),
428
- findMany: (query) => ops().findMany(name, query),
429
- search: (params) => ops().search(name, params)
441
+ findMany: (query, opts) => ops().findMany(name, query, opts),
442
+ upsert: (data, opts) => ops().upsert(name, data, opts),
443
+ search: (params) => ops().search(name, params),
444
+ similar: (id, params) => ops().similar(name, id, params),
445
+ recommend: (params) => ops().recommend(name, params),
446
+ supersede: (id, row) => ops().supersede(name, id, row)
430
447
  };
431
448
  }
432
449
  }
@@ -435,17 +452,25 @@ function makeTablesAccessor(ops) {
435
452
  }
436
453
  var rawDatabase = makeServiceProxy("Database");
437
454
  function makeTypedSurface(raw) {
455
+ const reco = raw;
438
456
  const ops = {
439
457
  query: (sql, params) => raw.query(sql, params),
440
458
  insert: (table, data) => raw.insert(table, data),
441
459
  update: (table, id, data) => raw.update(table, id, data),
442
460
  delete: (table, id) => raw.delete(table, id),
443
461
  findById: (table, id) => raw.findById(table, id),
444
- findMany: (table, query) => raw.findMany(table, query),
445
- search: (table, params) => raw.search(table, params)
462
+ findMany: (table, query, opts) => raw.findMany(table, query, opts),
463
+ upsert: (table, data, opts) => raw.upsert(table, data, opts),
464
+ search: (table, params) => raw.search(table, params),
465
+ similar: (table, id, params) => reco.similar(table, id, params),
466
+ recommend: (table, params) => reco.recommend(table, params),
467
+ supersede: (table, id, row) => raw.supersede(table, id, row)
446
468
  };
447
469
  return Object.assign(ops, {
448
- tables: makeTablesAccessor(() => raw),
470
+ // Both surfaces get it: a savepoint on the service transaction is as useful
471
+ // as one on the request's, and each is bound to its own connection.
472
+ attempt: (fn) => raw.attempt(fn),
473
+ tables: makeTablesAccessor(() => reco),
449
474
  transaction(fn) {
450
475
  const builder = new TxPlanBuilder();
451
476
  return runTxPlan(raw, makeTxTablesAccessor(builder), builder, fn);
@@ -508,7 +533,6 @@ var Cache = makeServiceProxy("Cache");
508
533
  var Secrets = makeServiceProxy("Secrets");
509
534
  var Log = makeServiceProxy("Log");
510
535
  var Notifications = makeServiceProxy("Notifications");
511
- var Purchases = makeServiceProxy("Purchases");
512
536
  var rawFlags = makeServiceProxy("Flags");
513
537
  var Flags = Object.assign(
514
538
  {
@@ -914,6 +938,14 @@ function makeMemoryCache(opts = {}) {
914
938
  function quoteIdent(name) {
915
939
  return `"${name.replace(/"/g, '""')}"`;
916
940
  }
941
+ function encodePgArray(value) {
942
+ const element = (x) => {
943
+ if (x === null || x === void 0) return "NULL";
944
+ if (Array.isArray(x)) return encodePgArray(x);
945
+ return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
946
+ };
947
+ return `{${value.map(element).join(",")}}`;
948
+ }
917
949
  var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
918
950
  function createLazyTransaction(sql, role, claimsJson, options = {}) {
919
951
  const { lockTimeout } = options;
@@ -1032,7 +1064,42 @@ function searchConfigFor(table) {
1032
1064
  );
1033
1065
  }
1034
1066
  const search = t.search;
1035
- const ftsCols = search?.text ?? [];
1067
+ const synonyms = search?.synonyms !== void 0 && Object.keys(search.synonyms).length > 0 ? { synonyms: search.synonyms } : {};
1068
+ const validity = search?.validity === true ? { validity: true } : {};
1069
+ if (search?.from !== void 0 && search.model !== void 0) {
1070
+ const m = search.model;
1071
+ const embed = {
1072
+ model: m.model,
1073
+ apiKeyName: m.apiKeyName ?? "OPENAI_API_KEY",
1074
+ ...m.baseURL !== void 0 ? { baseURL: m.baseURL } : {},
1075
+ ...m.dimensions !== void 0 ? { dimensions: m.dimensions } : {}
1076
+ };
1077
+ const metric = search.metric ?? "cosine";
1078
+ const ftsOn = search.text !== false;
1079
+ const ftsColsNew = ftsOn ? Array.isArray(search.text) && search.text.length > 0 ? search.text : search.from : [];
1080
+ if (vectorCols.length > 0) {
1081
+ return {
1082
+ pk,
1083
+ cols,
1084
+ colSet: new Set(cols),
1085
+ ftsCols: ftsColsNew,
1086
+ legs: [{ column: vectorCols[0], metric, embed }],
1087
+ ...synonyms,
1088
+ ...validity
1089
+ };
1090
+ }
1091
+ return {
1092
+ pk,
1093
+ cols,
1094
+ colSet: new Set(cols),
1095
+ ftsCols: ftsColsNew,
1096
+ legs: [],
1097
+ chunk: { table: `${table}__palbase_chunks`, metric, embed, fts: ftsOn },
1098
+ ...synonyms,
1099
+ ...validity
1100
+ };
1101
+ }
1102
+ const ftsCols = (Array.isArray(search?.text) ? search.text : void 0) ?? [];
1036
1103
  const rawLegs = search?.vector === void 0 ? [] : Array.isArray(search.vector) ? search.vector : [search.vector];
1037
1104
  let legs;
1038
1105
  if (rawLegs.length > 0) {
@@ -1058,7 +1125,7 @@ function searchConfigFor(table) {
1058
1125
  legs = vectorCols.map((column) => ({ column, metric: "cosine" }));
1059
1126
  }
1060
1127
  if (ftsCols.length === 0 && legs.length === 0) return null;
1061
- return { pk, cols, colSet: new Set(cols), ftsCols, legs };
1128
+ return { pk, cols, colSet: new Set(cols), ftsCols, legs, ...synonyms, ...validity };
1062
1129
  }
1063
1130
  function pickLeg(table, legs, using) {
1064
1131
  if (legs.length === 0) return null;
@@ -1074,18 +1141,109 @@ function pickLeg(table, legs, using) {
1074
1141
  if (legs.length === 1) return legs[0];
1075
1142
  throw new Error(`search(${table}): birden \xE7ok vekt\xF6r kolu var \u2014 'using' ile se\xE7in (FR-013) \u2014 salt metin ar\u0131yorsan mode:"text" kullan`);
1076
1143
  }
1144
+ var HALF_LIFE_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
1145
+ function parseHalfLife(s) {
1146
+ const m = /^(\d+)(s|m|h|d)$/.exec(s);
1147
+ const sec = m === null ? 0 : Number(m[1]) * HALF_LIFE_UNIT_SECONDS[m[2]];
1148
+ if (!Number.isFinite(sec) || sec <= 0) {
1149
+ throw new Error(`recency.halfLife "${s}" \xE7\xF6z\xFCmlenemedi \u2014 beklenen: pozitif <say\u0131>+(s|m|h|d), \xF6rn. "30d" (FR-004)`);
1150
+ }
1151
+ return sec;
1152
+ }
1077
1153
  var WHERE_OPS = { gt: ">", gte: ">=", lt: "<", lte: "<=", neq: "<>" };
1078
- function compileWhere(table, colSet, where, add) {
1154
+ function ftsExprOf(ftsCols) {
1155
+ return [...ftsCols].sort().map((c) => `coalesce(t.${quoteIdent(c)},'')`).join(" || ' ' || ");
1156
+ }
1157
+ function expandSynonyms(query, map) {
1158
+ const lower = {};
1159
+ for (const [word, alts] of Object.entries(map)) lower[word.toLowerCase()] = alts;
1160
+ return query.split(/("[^"]*")/).map((seg) => {
1161
+ if (seg.startsWith('"')) return seg;
1162
+ return seg.split(/(\s+)/).map((tok) => {
1163
+ if (tok === "" || /^\s+$/.test(tok)) return tok;
1164
+ const alts = lower[tok.toLowerCase()];
1165
+ return alts !== void 0 && alts.length > 0 ? `(${tok} OR ${alts.join(" OR ")})` : tok;
1166
+ }).join("");
1167
+ }).join("");
1168
+ }
1169
+ var searchLogger = (evt, fields) => {
1170
+ console.log(evt, JSON.stringify(fields));
1171
+ };
1172
+ function logNoResults(table, query, mode, n) {
1173
+ if (n === 0) {
1174
+ searchLogger?.("palbase.search.no_results", {
1175
+ table,
1176
+ query: (query ?? "").slice(0, 200),
1177
+ mode: mode ?? "hybrid"
1178
+ });
1179
+ }
1180
+ }
1181
+ async function fetchFacets(live, table, colSet, facets, where, extraWhere = () => "") {
1182
+ const bind = [];
1183
+ const add = (v) => {
1184
+ bind.push(v);
1185
+ return `$${bind.length}`;
1186
+ };
1187
+ const whereSql = compileWhere(table, colSet, where, add) + extraWhere(add);
1188
+ const parts = facets.map(
1189
+ (col) => `(SELECT '${col.replace(/'/g, "''")}' AS f, t.${quoteIdent(col)}::text AS v, count(*) AS n FROM ${quoteIdent(table)} t WHERE true${whereSql} GROUP BY 2 ORDER BY 3 DESC LIMIT 20)`
1190
+ );
1191
+ const rows = await live.unsafe(parts.join(" UNION ALL "), bind);
1192
+ const out = {};
1193
+ for (const col of facets) out[col] = [];
1194
+ for (const r of rows) {
1195
+ if (typeof r.f === "string" && out[r.f] !== void 0) {
1196
+ out[r.f].push({ value: r.v ?? null, count: Number(r.n) });
1197
+ }
1198
+ }
1199
+ return out;
1200
+ }
1201
+ function validitySql(v, add) {
1202
+ if (v === "all") return "";
1203
+ if (v !== void 0) {
1204
+ const p = add(v.asOf);
1205
+ return ` AND t."valid_from" <= ${p} AND (t."valid_to" IS NULL OR t."valid_to" > ${p})`;
1206
+ }
1207
+ return ` AND t."valid_to" IS NULL`;
1208
+ }
1209
+ function excludeSql(pk, ids, add) {
1210
+ if (ids.length === 0) return "";
1211
+ if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
1212
+ return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
1213
+ }
1214
+ function schemaColumns(table) {
1215
+ const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
1216
+ const cols = t?.columns ? Object.keys(t.columns) : [];
1217
+ return cols.length > 0 ? new Set(cols) : null;
1218
+ }
1219
+ function orderClause(table, known, orderBy) {
1220
+ if (!orderBy) return "";
1221
+ const col = orderBy.column;
1222
+ const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
1223
+ if (known !== null ? !known.has(col) : !shapeOK) {
1224
+ throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
1225
+ }
1226
+ const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
1227
+ return ` ORDER BY ${quoteIdent(col)} ${dir}`;
1228
+ }
1229
+ function limitClause(limit) {
1230
+ if (limit === void 0) return "";
1231
+ if (!Number.isInteger(limit) || limit < 0) {
1232
+ throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
1233
+ }
1234
+ return ` LIMIT ${limit}`;
1235
+ }
1236
+ function compileWhere(table, colSet, where, add, caller = "search") {
1079
1237
  const parts = [];
1080
1238
  for (const [col, cond] of Object.entries(where)) {
1081
- if (!colSet.has(col)) {
1082
- throw new Error(`search(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1239
+ if (colSet !== null && !colSet.has(col)) {
1240
+ throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
1083
1241
  }
1084
1242
  const q = `t.${quoteIdent(col)}`;
1085
1243
  if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
1086
1244
  for (const [op, v] of Object.entries(cond)) {
1087
1245
  if (op === "in") {
1088
- if (!Array.isArray(v)) throw new Error(`search(${table}): where.${col}.in bir dizi olmal\u0131`);
1246
+ if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
1089
1247
  if (v.length === 0) {
1090
1248
  parts.push("false");
1091
1249
  continue;
@@ -1094,7 +1252,7 @@ function compileWhere(table, colSet, where, add) {
1094
1252
  } else if (op in WHERE_OPS) {
1095
1253
  parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
1096
1254
  } else {
1097
- throw new Error(`search(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1255
+ throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
1098
1256
  }
1099
1257
  }
1100
1258
  } else {
@@ -1104,6 +1262,15 @@ function compileWhere(table, colSet, where, add) {
1104
1262
  return parts.length === 0 ? "" : ` AND ${parts.join(" AND ")}`;
1105
1263
  }
1106
1264
  var cachedVectorSchema = null;
1265
+ var cachedTrgmSchema = null;
1266
+ async function trgmSchemaOf(live) {
1267
+ if (cachedTrgmSchema !== null) return cachedTrgmSchema;
1268
+ const rows = await live.unsafe(
1269
+ "select n.nspname from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'"
1270
+ );
1271
+ cachedTrgmSchema = rows?.[0]?.nspname ?? "public";
1272
+ return cachedTrgmSchema;
1273
+ }
1107
1274
  async function vectorSchemaWithGuc(runner) {
1108
1275
  if (cachedVectorSchema !== null) {
1109
1276
  await runner.unsafe(
@@ -1165,7 +1332,8 @@ function createOps(tx) {
1165
1332
  const at = () => resolveTx(tx);
1166
1333
  const ops = {
1167
1334
  async query(sql, params = []) {
1168
- return asWireRows(await (await at()).unsafe(sql, params));
1335
+ const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
1336
+ return asWireRows(await (await at()).unsafe(sql, bound));
1169
1337
  },
1170
1338
  async insert(table, data) {
1171
1339
  const cols = Object.keys(data);
@@ -1181,6 +1349,41 @@ function createOps(tx) {
1181
1349
  }
1182
1350
  return asTableRow(table, inserted);
1183
1351
  },
1352
+ /**
1353
+ * INSERT the row, or UPDATE it when it collides on `onConflict`.
1354
+ *
1355
+ * WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
1356
+ * unique violation, update instead" does not work here: a request runs in ONE
1357
+ * Postgres transaction, so the failed insert aborts it and every later
1358
+ * statement answers `current transaction is aborted`. A tenant measured that
1359
+ * as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
1360
+ * trigger create the row instead — a workaround that needs a new trigger for
1361
+ * every table with a unique row.
1362
+ *
1363
+ * The conflict columns are excluded from the SET list: they are what MATCHED,
1364
+ * so writing them back is at best a no-op and at worst a surprise.
1365
+ */
1366
+ async upsert(table, data, opts) {
1367
+ const conflict = opts.onConflict;
1368
+ if (conflict.length === 0) {
1369
+ throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
1370
+ }
1371
+ const cols = Object.keys(data);
1372
+ if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
1373
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1374
+ const conflictSet = new Set(conflict);
1375
+ const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
1376
+ const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
1377
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
1378
+ const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
1379
+ const row = rows[0];
1380
+ if (!row) {
1381
+ throw new Error(
1382
+ `upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
1383
+ );
1384
+ }
1385
+ return asTableRow(table, row);
1386
+ },
1184
1387
  async update(table, id, data) {
1185
1388
  const cols = Object.keys(data);
1186
1389
  if (cols.length === 0) return ops.findById(table, id);
@@ -1199,13 +1402,18 @@ function createOps(tx) {
1199
1402
  );
1200
1403
  return rows[0] ? asTableRow(table, rows[0]) : null;
1201
1404
  },
1202
- async findMany(table, query = {}) {
1203
- const cols = Object.keys(query);
1204
- const where = cols.length ? ` WHERE ${cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ")}` : "";
1205
- return asTableRows(table, await (await at()).unsafe(
1206
- `SELECT * FROM ${quoteIdent(table)}${where}`,
1207
- cols.map((c) => query[c])
1208
- ));
1405
+ async findMany(table, query = {}, opts = {}) {
1406
+ const params = [];
1407
+ const add = (v) => {
1408
+ params.push(v);
1409
+ return `$${params.length}`;
1410
+ };
1411
+ const known = schemaColumns(table);
1412
+ const whereSql = compileWhere(table, known, query, add, "findMany");
1413
+ const order = orderClause(table, known, opts.orderBy);
1414
+ const limit = limitClause(opts.limit);
1415
+ const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}`;
1416
+ return asTableRows(table, await (await at()).unsafe(sql, params));
1209
1417
  },
1210
1418
  /**
1211
1419
  * Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
@@ -1219,12 +1427,128 @@ function createOps(tx) {
1219
1427
  if (!cfg) {
1220
1428
  throw new Error(`search(${table}): tablo aranabilir de\u011Fil \u2014 ne vector kolonu ne search beyan\u0131 var (FR-013)`);
1221
1429
  }
1430
+ const excl = params.__excludeIds ?? [];
1431
+ if (params.facets !== void 0) {
1432
+ for (const col of params.facets) {
1433
+ if (!cfg.colSet.has(col)) {
1434
+ throw new Error(`search(${table}): facets kolonu "${col}" tabloda yok (FR-027)`);
1435
+ }
1436
+ }
1437
+ }
1438
+ if (params.validity !== void 0 && cfg.validity !== true) {
1439
+ throw new Error(
1440
+ `search(${table}): validity parametresi verildi ama tablo validity beyan\u0131 ta\u015F\u0131m\u0131yor (FR-029)`
1441
+ );
1442
+ }
1443
+ const validityFor = (adder) => cfg.validity === true ? validitySql(params.validity, adder) : "";
1222
1444
  const rawLimit = params.limit ?? 20;
1223
1445
  if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)) {
1224
1446
  throw new Error(`search(${table}): limit sonlu bir say\u0131 olmal\u0131, ${String(rawLimit)} verildi (FR-013)`);
1225
1447
  }
1226
1448
  const limit = Math.min(Math.max(1, Math.trunc(rawLimit)), 100);
1227
1449
  const pool = Math.max(limit * 3, 30);
1450
+ if (params.minScore !== void 0 && (typeof params.minScore !== "number" || !Number.isFinite(params.minScore))) {
1451
+ throw new Error(`search(${table}): minScore sonlu bir say\u0131 olmal\u0131, ${String(params.minScore)} verildi (FR-001)`);
1452
+ }
1453
+ let recencyMul = "";
1454
+ if (params.recency !== void 0) {
1455
+ const { field, halfLife } = params.recency;
1456
+ if (!cfg.colSet.has(field)) {
1457
+ throw new Error(`search(${table}): recency.field "${field}" tabloda yok (FR-004)`);
1458
+ }
1459
+ recencyMul = ` * exp(-ln(2) * extract(epoch from (now() - t.${quoteIdent(field)})) / ${parseHalfLife(halfLife)})`;
1460
+ }
1461
+ let boostMul = "";
1462
+ if (params.boost !== void 0) {
1463
+ const { field, weight } = params.boost;
1464
+ if (!cfg.colSet.has(field)) {
1465
+ throw new Error(`search(${table}): boost.field "${field}" tabloda yok (FR-030)`);
1466
+ }
1467
+ if (typeof weight !== "number" || !Number.isFinite(weight)) {
1468
+ throw new Error(`search(${table}): boost.weight sonlu bir say\u0131 olmal\u0131, ${String(weight)} verildi (FR-030)`);
1469
+ }
1470
+ const g = `greatest(t.${quoteIdent(field)},0)`;
1471
+ boostMul = ` * (1 + ${weight} * ${g}/(1+${g}))`;
1472
+ }
1473
+ const scoreMul = boostMul + recencyMul;
1474
+ if (cfg.chunk !== void 0) {
1475
+ const ck = cfg.chunk;
1476
+ const pidQ = quoteIdent(`parent_${cfg.pk}`);
1477
+ const bpr = Math.min(Math.max(1, Math.trunc(params.blocksPerRow ?? 3)), 10);
1478
+ const wantTextC = params.mode !== "vector" && ck.fts && typeof params.query === "string" && params.query !== "";
1479
+ let qvC = Array.isArray(params.vector) ? params.vector : null;
1480
+ if (qvC === null && ck.embed !== void 0 && params.mode !== "text" && typeof params.query === "string" && params.query !== "") {
1481
+ try {
1482
+ qvC = await embedQuery(ck.embed, params.query);
1483
+ } catch (e) {
1484
+ if (!wantTextC) throw e;
1485
+ qvC = null;
1486
+ }
1487
+ }
1488
+ if (!wantTextC && qvC === null) {
1489
+ throw new Error(
1490
+ `search(${table}): ko\u015Fulabilir kol yok \u2014 metin i\xE7in 'query' (FTS beyan\u0131 gerekir), semantik i\xE7in 'vector' verin (FR-015)`
1491
+ );
1492
+ }
1493
+ const bindC = [];
1494
+ const addC = (v) => {
1495
+ bindC.push(v);
1496
+ return `$${bindC.length}`;
1497
+ };
1498
+ const whereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC) + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
1499
+ const liveC = await at();
1500
+ const K2 = 50;
1501
+ const cpool = Math.max(limit * 9, 90);
1502
+ let semC = "";
1503
+ let kwC = "";
1504
+ if (qvC !== null) {
1505
+ const sch = await vectorSchemaWithGuc(liveC);
1506
+ const op = METRIC_OPERATOR[ck.metric] ?? METRIC_OPERATOR.cosine;
1507
+ 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}`;
1509
+ }
1510
+ let qpC = "";
1511
+ let qpCIdx = -1;
1512
+ if (wantTextC) {
1513
+ const qTextC = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
1514
+ qpC = addC(qTextC);
1515
+ 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}`;
1517
+ }
1518
+ const msC = params.minScore === void 0 ? "" : addC(params.minScore);
1519
+ const colListC = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
1520
+ const scoreC = scoreMul === "" ? "g._score" : `(g._score)${scoreMul}`;
1521
+ const assembleC = (kwIn, withKwn) => {
1522
+ const kwnCol = withKwn ? `, (SELECT count(*) FROM kw)::int AS _kwn` : "";
1523
+ let fusedSrc;
1524
+ if (semC !== "" && kwIn !== "") {
1525
+ fusedSrc = `WITH sem AS (${semC}), kw AS (${kwIn}), fused AS (SELECT COALESCE(sem.pid, kw.pid) AS pid, COALESCE(sem.chunk_seq, kw.chunk_seq) AS chunk_seq, (COALESCE(1.0/(${K2} + sem.r), 0) + COALESCE(1.0/(${K2} + kw.r), 0))::float8 AS cscore FROM sem FULL OUTER JOIN kw ON sem.pid = kw.pid AND sem.chunk_seq = kw.chunk_seq)`;
1526
+ } else if (semC !== "") {
1527
+ fusedSrc = `WITH sem AS (${semC}), fused AS (SELECT sem.pid, sem.chunk_seq, (1.0/(${K2} + sem.r))::float8 AS cscore FROM sem)`;
1528
+ } else {
1529
+ fusedSrc = `WITH kw AS (${kwIn}), fused AS (SELECT kw.pid, kw.chunk_seq, (1.0/(${K2} + kw.r))::float8 AS cscore FROM kw)`;
1530
+ }
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}`;
1532
+ 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
+ };
1534
+ let rowsC = await liveC.unsafe(assembleC(kwC, kwC !== ""), bindC);
1535
+ if (wantTextC && kwC !== "" && (rowsC.length === 0 || rowsC[0]._kwn === 0)) {
1536
+ 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}`;
1538
+ const retryBindC = bindC.slice();
1539
+ if (qpCIdx >= 0) retryBindC[qpCIdx] = params.query;
1540
+ rowsC = await liveC.unsafe(assembleC(trgmKwC, false), retryBindC);
1541
+ }
1542
+ for (const r of rowsC) delete r._kwn;
1543
+ const outC = asTableRows(table, rowsC).map((r) => ({ ...r, blocks: r.blocks ?? [] }));
1544
+ if (params.facets !== void 0 && params.facets.length > 0) {
1545
+ Object.assign(outC, {
1546
+ _facets: await fetchFacets(liveC, table, cfg.colSet, params.facets, params.where ?? {}, validityFor)
1547
+ });
1548
+ }
1549
+ logNoResults(table, params.query, params.mode, outC.length);
1550
+ return outC;
1551
+ }
1228
1552
  const wantText = params.mode !== "vector" && cfg.ftsCols.length > 0 && typeof params.query === "string" && params.query !== "";
1229
1553
  const anyEmbed = cfg.legs.some((l) => l.embed !== void 0);
1230
1554
  const vectorAsked = params.mode !== "text" && (Array.isArray(params.vector) || params.using !== void 0 || params.mode === "vector" || anyEmbed && typeof params.query === "string" && params.query !== "");
@@ -1249,7 +1573,8 @@ function createOps(tx) {
1249
1573
  bind.push(v);
1250
1574
  return `$${bind.length}`;
1251
1575
  };
1252
- const whereSql = compileWhere(table, cfg.colSet, params.where ?? {}, add);
1576
+ const userWhere = compileWhere(table, cfg.colSet, params.where ?? {}, add);
1577
+ const whereSql = userWhere + excludeSql(cfg.pk, excl, add) + validityFor(add);
1253
1578
  const live = await at();
1254
1579
  const K = 50;
1255
1580
  let semSql = "";
@@ -1258,7 +1583,7 @@ function createOps(tx) {
1258
1583
  const sch = await vectorSchemaWithGuc(live);
1259
1584
  const op = METRIC_OPERATOR[leg.metric] ?? METRIC_OPERATOR.cosine;
1260
1585
  let exactOrder = "";
1261
- if (whereSql !== "") {
1586
+ if (userWhere !== "") {
1262
1587
  const probeRows = await live.unsafe(
1263
1588
  `SELECT count(*)::int AS n FROM (SELECT 1 FROM ${quoteIdent(table)} t WHERE t.${quoteIdent(leg.column)} IS NOT NULL${whereSql} LIMIT ${SELECTIVITY_EXACT_THRESHOLD + 1}) s`,
1264
1589
  bind.slice()
@@ -1271,27 +1596,235 @@ function createOps(tx) {
1271
1596
  const vp = add(toVectorLiteral(qv));
1272
1597
  semSql = `SELECT t.${quoteIdent(cfg.pk)} AS id, ROW_NUMBER() OVER (ORDER BY (t.${quoteIdent(leg.column)} OPERATOR(${quoteIdent(sch)}.${op}) ${vp}::${quoteIdent(sch)}.vector)${exactOrder}) AS r FROM ${quoteIdent(table)} t WHERE t.${quoteIdent(leg.column)} IS NOT NULL${whereSql} ORDER BY r LIMIT ${pool}`;
1273
1598
  }
1599
+ let qpRef = "";
1600
+ let qpIdx = -1;
1274
1601
  if (wantText) {
1275
- const qp = add(params.query);
1602
+ const qText = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
1603
+ const qp = add(qText);
1604
+ qpIdx = bind.length - 1;
1605
+ qpRef = qp;
1276
1606
  kwSql = `SELECT t.${quoteIdent(cfg.pk)} AS id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(t.palbase_fts, websearch_to_tsquery('simple', ${qp})) DESC) AS r FROM ${quoteIdent(table)} t WHERE t.palbase_fts @@ websearch_to_tsquery('simple', ${qp})${whereSql} ORDER BY r LIMIT ${pool}`;
1277
1607
  }
1278
1608
  const colList = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
1279
- let sql;
1280
- if (semSql !== "" && kwSql !== "") {
1281
- sql = `WITH sem AS (${semSql}), kw AS (${kwSql}), fused AS (SELECT COALESCE(sem.id, kw.id) AS id, (COALESCE(1.0/(${K} + sem.r), 0) + COALESCE(1.0/(${K} + kw.r), 0))::float8 AS _score FROM sem FULL OUTER JOIN kw ON sem.id = kw.id) SELECT ${colList}, fused._score AS _score FROM fused JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = fused.id ORDER BY fused._score DESC, t.${quoteIdent(cfg.pk)} LIMIT ${limit}`;
1609
+ const msP = params.minScore === void 0 ? "" : add(params.minScore);
1610
+ const hlCol = params.highlight === true && wantText ? `, ts_headline('simple', ${ftsExprOf(cfg.ftsCols)}, websearch_to_tsquery('simple', ${qpRef})) AS _highlight` : "";
1611
+ const assemble = (kwIn, withKwn) => {
1612
+ const kwnCol = withKwn ? `, (SELECT count(*) FROM kw)::int AS _kwn` : "";
1613
+ let inner;
1614
+ let orderScore;
1615
+ if (semSql !== "" && kwIn !== "") {
1616
+ const score = scoreMul === "" ? "fused._score" : `(fused._score)${scoreMul}`;
1617
+ inner = `WITH sem AS (${semSql}), kw AS (${kwIn}), fused AS (SELECT COALESCE(sem.id, kw.id) AS id, (COALESCE(1.0/(${K} + sem.r), 0) + COALESCE(1.0/(${K} + kw.r), 0))::float8 AS _score FROM sem FULL OUTER JOIN kw ON sem.id = kw.id) SELECT ${colList}, ${score} AS _score${hlCol}${kwnCol} FROM fused JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = fused.id`;
1618
+ orderScore = scoreMul === "" ? "fused._score" : "_score";
1619
+ } else {
1620
+ const single = semSql !== "" ? `sem AS (${semSql})` : `kw AS (${kwIn})`;
1621
+ const alias = semSql !== "" ? "sem" : "kw";
1622
+ const base = `(1.0/(${K} + ${alias}.r))::float8`;
1623
+ const score = scoreMul === "" ? base : `(${base})${scoreMul}`;
1624
+ inner = `WITH ${single} SELECT ${colList}, ${score} AS _score${hlCol}${semSql !== "" ? "" : kwnCol} FROM ${alias} JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = ${alias}.id`;
1625
+ orderScore = "_score";
1626
+ }
1627
+ return msP === "" ? `${inner} ORDER BY ${orderScore} DESC, t.${quoteIdent(cfg.pk)} LIMIT ${limit}` : `SELECT * FROM (${inner}) q WHERE q._score >= ${msP} ORDER BY q._score DESC, q.${quoteIdent(cfg.pk)} LIMIT ${limit}`;
1628
+ };
1629
+ let rows = await live.unsafe(assemble(kwSql, kwSql !== ""), bind);
1630
+ if (wantText && kwSql !== "" && (rows.length === 0 || rows[0]._kwn === 0)) {
1631
+ const tsch = await trgmSchemaOf(live);
1632
+ 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}`;
1634
+ const retryBind = bind.slice();
1635
+ if (qpIdx >= 0) retryBind[qpIdx] = params.query;
1636
+ rows = await live.unsafe(assemble(trgmKw, false), retryBind);
1637
+ }
1638
+ for (const r of rows) delete r._kwn;
1639
+ const out = asTableRows(table, rows).map((r) => ({ ...r, blocks: [] }));
1640
+ if (params.facets !== void 0 && params.facets.length > 0) {
1641
+ Object.assign(out, {
1642
+ _facets: await fetchFacets(live, table, cfg.colSet, params.facets, params.where ?? {}, validityFor)
1643
+ });
1644
+ }
1645
+ logNoResults(table, params.query, params.mode, out.length);
1646
+ return out;
1647
+ },
1648
+ /**
1649
+ * similar (T018, FR-022): "bu satıra benzeyenler". Hedef vektör DB'den
1650
+ * okunur — satır-modu satırın kendi kolonu, chunk-modu parent chunk'larının
1651
+ * şema-nitelikli avg'ı — ve ana arama search'ün vector yoluyla koşar;
1652
+ * kaynak satır sonuçtan düşer. İKİ tur BİLİNÇLİ (plan T018): target-CTE'li
1653
+ * tek SQL'de "id yok" ile "0 komşu" ayrılamazdı; +1 küçük turla id-yokluğu
1654
+ * adlandırılmış hataya çevrilir. Sonuç şekli search ile aynı (FR-015).
1655
+ */
1656
+ async similar(table, id, opts = {}) {
1657
+ const cfg = searchConfigFor(table);
1658
+ if (!cfg) {
1659
+ throw new Error(`similar(${table}): tablo aranabilir de\u011Fil \u2014 ne vector kolonu ne search beyan\u0131 var (FR-013)`);
1660
+ }
1661
+ const live = await at();
1662
+ let v;
1663
+ if (cfg.chunk !== void 0) {
1664
+ const sch = await vectorSchemaWithGuc(live);
1665
+ const rows = await live.unsafe(
1666
+ `SELECT ${quoteIdent(sch)}.avg(c.embedding) AS v FROM ${quoteIdent(cfg.chunk.table)} c WHERE c.${quoteIdent(`parent_${cfg.pk}`)} = $1 AND c.embedding IS NOT NULL`,
1667
+ [id]
1668
+ );
1669
+ v = rows?.[0]?.v;
1282
1670
  } else {
1283
- const single = semSql !== "" ? `sem AS (${semSql})` : `kw AS (${kwSql})`;
1284
- const alias = semSql !== "" ? "sem" : "kw";
1285
- sql = `WITH ${single} SELECT ${colList}, (1.0/(${K} + ${alias}.r))::float8 AS _score FROM ${alias} JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = ${alias}.id ORDER BY _score DESC, t.${quoteIdent(cfg.pk)} LIMIT ${limit}`;
1671
+ const leg = pickLeg(table, cfg.legs, opts.using);
1672
+ if (leg === null) {
1673
+ throw new Error(`similar(${table}): tabloda vekt\xF6r kolonu yok \u2014 benzerlik semantik vekt\xF6r ister (FR-022)`);
1674
+ }
1675
+ const rows = await live.unsafe(
1676
+ `SELECT t.${quoteIdent(leg.column)} AS v FROM ${quoteIdent(table)} t WHERE t.${quoteIdent(cfg.pk)} = $1`,
1677
+ [id]
1678
+ );
1679
+ v = rows?.[0]?.v;
1680
+ }
1681
+ if (typeof v !== "string") {
1682
+ throw new Error(`similar(${table}): id "${String(id)}" bulunamad\u0131 ya da embedding'i yok (FR-022)`);
1286
1683
  }
1287
- const rows = await live.unsafe(sql, bind);
1288
- return asTableRows(table, rows);
1684
+ const p = {
1685
+ vector: JSON.parse(v),
1686
+ mode: "vector",
1687
+ where: opts.where,
1688
+ limit: opts.limit,
1689
+ using: opts.using,
1690
+ minScore: opts.minScore,
1691
+ recency: opts.recency,
1692
+ blocksPerRow: opts.blocksPerRow,
1693
+ validity: opts.validity,
1694
+ boost: opts.boost,
1695
+ __excludeIds: [id]
1696
+ };
1697
+ return ops.search(table, p);
1698
+ },
1699
+ /**
1700
+ * recommend (T018, FR-023): positive/negative id kümelerinden öneri.
1701
+ * Hedef vektör DB-İÇİ CTE'lerle türer: pos = avg(embedding of positives),
1702
+ * negative varsa hedef = pos.v + (pos.v - neg.v) — pgvector avg agregası
1703
+ * ve +/- operatörleri ŞEMA-NİTELİKLİ (M-1: search_path'e güvenilmez).
1704
+ * Vektör matematiği istemciye inmez; bulunamayan positive/negative
1705
+ * adlandırılmış hatadır ve kaynak id'ler sonuçtan düşer.
1706
+ */
1707
+ async recommend(table, opts) {
1708
+ const positive = opts?.positive;
1709
+ if (!Array.isArray(positive) || positive.length === 0) {
1710
+ throw new Error(`recommend(${table}): positive bo\u015F olamaz \u2014 en az bir kaynak id gerekir (FR-023)`);
1711
+ }
1712
+ const negative = Array.isArray(opts.negative) ? opts.negative : [];
1713
+ const cfg = searchConfigFor(table);
1714
+ if (!cfg) {
1715
+ throw new Error(`recommend(${table}): tablo aranabilir de\u011Fil \u2014 ne vector kolonu ne search beyan\u0131 var (FR-013)`);
1716
+ }
1717
+ let leg = null;
1718
+ if (cfg.chunk === void 0) {
1719
+ leg = pickLeg(table, cfg.legs, opts.using);
1720
+ if (leg === null) {
1721
+ throw new Error(`recommend(${table}): tabloda vekt\xF6r kolonu yok \u2014 \xF6neri semantik vekt\xF6r ister (FR-023)`);
1722
+ }
1723
+ }
1724
+ const live = await at();
1725
+ const sch = await vectorSchemaWithGuc(live);
1726
+ const bind = [];
1727
+ const add = (x) => {
1728
+ bind.push(x);
1729
+ return `$${bind.length}`;
1730
+ };
1731
+ const avgSel = (ids) => {
1732
+ const inList = ids.map((x) => add(x)).join(", ");
1733
+ return cfg.chunk !== void 0 ? `SELECT ${quoteIdent(sch)}.avg(c.embedding) AS v FROM ${quoteIdent(cfg.chunk.table)} c WHERE c.${quoteIdent(`parent_${cfg.pk}`)} IN (${inList}) AND c.embedding IS NOT NULL` : `SELECT ${quoteIdent(sch)}.avg(t.${quoteIdent(leg.column)}) AS v FROM ${quoteIdent(table)} t WHERE t.${quoteIdent(cfg.pk)} IN (${inList}) AND t.${quoteIdent(leg.column)} IS NOT NULL`;
1734
+ };
1735
+ const targetSql = negative.length === 0 ? `WITH pos AS (${avgSel(positive)}) SELECT pos.v AS v, (pos.v IS NULL) AS pos_missing FROM pos` : `WITH pos AS (${avgSel(positive)}), neg AS (${avgSel(negative)}) SELECT (pos.v OPERATOR(${quoteIdent(sch)}.+) (pos.v OPERATOR(${quoteIdent(sch)}.-) neg.v)) AS v, (pos.v IS NULL) AS pos_missing, (neg.v IS NULL) AS neg_missing FROM pos, neg`;
1736
+ const rows = await live.unsafe(targetSql, bind);
1737
+ const r0 = rows?.[0];
1738
+ if (r0 === void 0 || r0.pos_missing === true) {
1739
+ throw new Error(`recommend(${table}): positive id'lerin hi\xE7biri bulunamad\u0131 ya da embedding'i yok (FR-023)`);
1740
+ }
1741
+ if (negative.length > 0 && r0.neg_missing === true) {
1742
+ throw new Error(`recommend(${table}): negative id'leri bulunamad\u0131 ya da embedding'i yok (FR-023)`);
1743
+ }
1744
+ if (typeof r0.v !== "string") {
1745
+ throw new Error(`recommend(${table}): hedef vekt\xF6r hesaplanamad\u0131 (FR-023)`);
1746
+ }
1747
+ const p = {
1748
+ vector: JSON.parse(r0.v),
1749
+ mode: "vector",
1750
+ where: opts.where,
1751
+ limit: opts.limit,
1752
+ using: opts.using,
1753
+ minScore: opts.minScore,
1754
+ recency: opts.recency,
1755
+ blocksPerRow: opts.blocksPerRow,
1756
+ validity: opts.validity,
1757
+ boost: opts.boost,
1758
+ __excludeIds: [...positive, ...negative]
1759
+ };
1760
+ return ops.search(table, p);
1761
+ },
1762
+ /**
1763
+ * supersede (T021, FR-029, C-9): validity'li tabloda satırın YENİ
1764
+ * versiyonunu TEK savepoint'te yazar — eski satır kapatılır
1765
+ * (valid_to = now(), superseded_by = yeni pk), yeni satır eklenir, dönüş
1766
+ * yeni satırdır. Yeni pk CLIENT'ta üretilir (row'da verilmemişse
1767
+ * randomUUID — declared şemaların uuid().defaultRandom() standardı) ki
1768
+ * kapatma UPDATE'i INSERT'ten ÖNCE koşabilsin: 0 satır = zaten superseded
1769
+ * ya da yok → INSERT hiç denenmez; INSERT hatası ise savepoint'le
1770
+ * kapatmayı da geri sarar — yarım supersede diye bir durum yoktur.
1771
+ */
1772
+ async supersede(table, id, row) {
1773
+ const cfg = searchConfigFor(table);
1774
+ if (cfg === null || cfg.validity !== true) {
1775
+ throw new Error(`supersede(${table}): tablo validity beyan\u0131 ta\u015F\u0131m\u0131yor (FR-029)`);
1776
+ }
1777
+ const live = await at();
1778
+ return live.savepoint(async (sp) => {
1779
+ const newId = row[cfg.pk] ?? crypto.randomUUID();
1780
+ const closed = await sp.unsafe(
1781
+ `UPDATE ${quoteIdent(table)} SET "valid_to" = now(), "superseded_by" = $1 WHERE ${quoteIdent(cfg.pk)} = $2 AND "valid_to" IS NULL RETURNING ${quoteIdent(cfg.pk)}`,
1782
+ [newId, id]
1783
+ );
1784
+ if (closed.length === 0) {
1785
+ throw new Error(`supersede(${table}): id "${String(id)}" zaten superseded ya da yok (FR-029)`);
1786
+ }
1787
+ const data = { ...row, [cfg.pk]: newId };
1788
+ const cols = Object.keys(data);
1789
+ const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
1790
+ const rows = await sp.unsafe(
1791
+ `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) RETURNING *`,
1792
+ asBindParams(table, cols, data)
1793
+ );
1794
+ if (!rows[0]) {
1795
+ throw new Error(
1796
+ `supersede(${table}): INSERT sat\u0131r d\xF6nd\xFCrmedi \u2014 yazma reddedildi (b\xFCy\xFCk olas\u0131l\u0131kla bir RLS policy'si)`
1797
+ );
1798
+ }
1799
+ return asTableRow(table, rows[0]);
1800
+ });
1289
1801
  },
1290
1802
  /** A real SAVEPOINT inside the request's transaction. */
1291
1803
  async transaction(cb) {
1292
1804
  const live = await at();
1293
1805
  return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
1294
1806
  },
1807
+ /**
1808
+ * Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
1809
+ * rolls back only what that handle wrote and the request can keep writing.
1810
+ *
1811
+ * WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
1812
+ * ... Database.insert(...) ... })`, with no parameter — would have to point
1813
+ * the ambient `Database` at the savepoint for the duration, and a request is
1814
+ * concurrent with itself: `Promise.all([Database.insert(a),
1815
+ * Database.attempt(...)])` would put `a` inside the savepoint and roll it
1816
+ * back with it. Silent data loss, and the same interleaving this file already
1817
+ * refuses for `asService()`. Passing the handle makes the boundary something
1818
+ * you can see in the code that crosses it.
1819
+ *
1820
+ * Postgres, not us: the savepoint is released on success and rolled back on
1821
+ * failure by the driver, so an aborted statement inside `fn` does not poison
1822
+ * the surrounding transaction.
1823
+ */
1824
+ async attempt(fn) {
1825
+ const live = await at();
1826
+ return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
1827
+ },
1295
1828
  /**
1296
1829
  * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
1297
1830
  *
@@ -1329,6 +1862,7 @@ function createOps(tx) {
1329
1862
  var currentSchema = {};
1330
1863
  function setSchema(schema) {
1331
1864
  cachedVectorSchema = null;
1865
+ cachedTrgmSchema = null;
1332
1866
  const s = schema;
1333
1867
  currentSchema = (s && "default" in s ? s.default : s) ?? {};
1334
1868
  }
@@ -1341,22 +1875,47 @@ function withTables(ops, schema = currentSchema) {
1341
1875
  update: (id, data) => ops.update(name, id, data),
1342
1876
  delete: (id) => ops.delete(name, id),
1343
1877
  findById: (id) => ops.findById(name, id),
1344
- findMany: (query) => ops.findMany(name, query ?? {})
1878
+ findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
1879
+ upsert: (data, opts) => ops.upsert(name, data, opts)
1345
1880
  };
1346
1881
  }
1347
1882
  const base = /* @__PURE__ */ Object.create(null);
1348
1883
  return Object.assign(base, ops, { tables });
1349
1884
  }
1350
1885
  var SERVICE_LOCK_TIMEOUT = "5s";
1886
+ function isAbortedTransaction(e) {
1887
+ const code = e?.code;
1888
+ const message = String(e?.message ?? "");
1889
+ return code === "25P02" || /current transaction is aborted/i.test(message);
1890
+ }
1891
+ function isForeignKeyViolation(e) {
1892
+ return e?.code === "23503";
1893
+ }
1351
1894
  function isLockTimeout(e) {
1352
1895
  const code = e?.code;
1353
1896
  const message = String(e?.message ?? "");
1354
1897
  return code === "55P03" || /lock timeout/i.test(message);
1355
1898
  }
1356
- function diagnosingDriver(sql) {
1357
- const explain = (e) => isLockTimeout(e) ? new Error(
1358
- `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)})`
1359
- ) : e;
1899
+ function diagnosingDriver(sql, surface = "service") {
1900
+ const original = (e) => String(e?.message ?? e);
1901
+ const explain = (e) => {
1902
+ if (isAbortedTransaction(e)) {
1903
+ return new Error(
1904
+ `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
+ );
1906
+ }
1907
+ if (surface === "service" && isForeignKeyViolation(e)) {
1908
+ return new Error(
1909
+ `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)})`
1910
+ );
1911
+ }
1912
+ if (isLockTimeout(e)) {
1913
+ return new Error(
1914
+ `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)})`
1915
+ );
1916
+ }
1917
+ return e;
1918
+ };
1360
1919
  const wrapTx = (tx) => ({
1361
1920
  async unsafe(text, params) {
1362
1921
  try {
@@ -1375,13 +1934,13 @@ function diagnosingDriver(sql) {
1375
1934
  };
1376
1935
  }
1377
1936
  function createRequestDatabase(sql, identity) {
1378
- const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
1937
+ const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
1379
1938
  let serviceTx = null;
1380
1939
  let serviceClient = null;
1381
1940
  const asService = () => {
1382
1941
  if (serviceClient === null) {
1383
1942
  serviceTx = createLazyTransaction(
1384
- diagnosingDriver(sql),
1943
+ diagnosingDriver(sql, "service"),
1385
1944
  identity.serviceRole,
1386
1945
  identity.claimsJson,
1387
1946
  { lockTimeout: SERVICE_LOCK_TIMEOUT }
@@ -1462,6 +2021,23 @@ async function runPlanOp(sp, op, results) {
1462
2021
  sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
1463
2022
  break;
1464
2023
  }
2024
+ case "upsert": {
2025
+ const cols = Object.keys(op.values ?? {});
2026
+ const conflict = op.onConflict ?? [];
2027
+ if (cols.length === 0 || conflict.length === 0) {
2028
+ throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
2029
+ error_code: "tx_bad_upsert"
2030
+ });
2031
+ }
2032
+ const rendered = cols.map(
2033
+ (c) => renderValue(op.values[c], c, args, results, opVectorCols)
2034
+ );
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 *`;
2039
+ break;
2040
+ }
1465
2041
  case "insertMany": {
1466
2042
  const rows = op.rows ?? [];
1467
2043
  if (rows.length === 0 || !rows[0]) return [];
@@ -1658,6 +2234,41 @@ function verifySignature(presented, expected) {
1658
2234
  return diff === 0;
1659
2235
  }
1660
2236
 
2237
+ // src/engine/sse.ts
2238
+ var SSE_CONTENT_TYPE = "text/event-stream";
2239
+ function encodeFrame(value) {
2240
+ return `data: ${JSON.stringify(value)}
2241
+
2242
+ `;
2243
+ }
2244
+ function encodeErrorFrame(requestId) {
2245
+ return `event: error
2246
+ data: ${JSON.stringify({ requestId })}
2247
+
2248
+ `;
2249
+ }
2250
+ function makeSseWriter(opts) {
2251
+ let begun = false;
2252
+ let chain = Promise.resolve();
2253
+ return {
2254
+ write(value) {
2255
+ const frame = encodeFrame(value);
2256
+ if (!begun) {
2257
+ begun = true;
2258
+ chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
2259
+ return;
2260
+ }
2261
+ chain = chain.then(() => opts.enqueue(frame));
2262
+ },
2263
+ started() {
2264
+ return begun;
2265
+ },
2266
+ drained() {
2267
+ return chain;
2268
+ }
2269
+ };
2270
+ }
2271
+
1661
2272
  // src/engine/index.ts
1662
2273
  var JSON_HEADERS = { "content-type": "application/json" };
1663
2274
  function fieldErrors(err) {
@@ -1736,7 +2347,6 @@ async function createApp(opts) {
1736
2347
  Notifications: modules.Notifications ?? stubModule("Notifications"),
1737
2348
  Flags: modules.Flags ?? stubModule("Flags"),
1738
2349
  Realtime: modules.Realtime ?? stubModule("Realtime"),
1739
- Purchases: modules.Purchases ?? stubModule("Purchases"),
1740
2350
  // Named, never undefined. A backend started without a secrets client
1741
2351
  // that returned `undefined` here would fail inside the handler as
1742
2352
  // "Cannot read properties of undefined", which says nothing about what
@@ -1833,6 +2443,13 @@ async function createApp(opts) {
1833
2443
  const args = [];
1834
2444
  let parsedBody;
1835
2445
  let bodyRead = false;
2446
+ let sseEnqueue = null;
2447
+ let sseSettle = async () => {
2448
+ };
2449
+ const sseWriter = makeSseWriter({
2450
+ enqueue: (chunk) => sseEnqueue?.(chunk),
2451
+ onFirstWrite: () => sseSettle()
2452
+ });
1836
2453
  for (const p of meta.params ?? []) {
1837
2454
  switch (p.kind) {
1838
2455
  case "body": {
@@ -1936,6 +2553,21 @@ async function createApp(opts) {
1936
2553
  case "req":
1937
2554
  args[p.index] = req;
1938
2555
  break;
2556
+ // `@Signal()` — the raw request's AbortSignal, which enters the aborted
2557
+ // state when the client disconnects. Measured on Bun: an infinite
2558
+ // producer guarded by it stops within a few frames of the client dying.
2559
+ // It is deliberately NOT reachable through `@Req()`: PBRequest carries
2560
+ // request-scoped data and no signal.
2561
+ case "signal":
2562
+ args[p.index] = req.signal;
2563
+ break;
2564
+ // `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
2565
+ // on any other route it is an inert writer whose frames go nowhere,
2566
+ // which is the same shape `@UploadedObject()` has off an `@Upload`
2567
+ // route.
2568
+ case "sseOut":
2569
+ args[p.index] = sseWriter;
2570
+ break;
1939
2571
  default:
1940
2572
  args[p.index] = void 0;
1941
2573
  }
@@ -1947,6 +2579,84 @@ async function createApp(opts) {
1947
2579
  });
1948
2580
  try {
1949
2581
  const services = buildServices(db);
2582
+ if (meta.options?.sseConfig !== void 0) {
2583
+ let sseController;
2584
+ const sseEncoder = new TextEncoder();
2585
+ const stream = new ReadableStream({
2586
+ start(c) {
2587
+ sseController = c;
2588
+ }
2589
+ });
2590
+ sseEnqueue = (chunk) => {
2591
+ try {
2592
+ sseController.enqueue(sseEncoder.encode(chunk));
2593
+ } catch {
2594
+ }
2595
+ };
2596
+ let sawFirstFrame;
2597
+ const firstFrame = new Promise((r) => sawFirstFrame = r);
2598
+ sseSettle = async () => {
2599
+ await db.commit();
2600
+ sawFirstFrame();
2601
+ };
2602
+ const guarded = {
2603
+ ...services,
2604
+ Database: new Proxy(services.Database, {
2605
+ get(target, prop, recv) {
2606
+ if (sseWriter.started()) {
2607
+ throw new Error(
2608
+ `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.`
2609
+ );
2610
+ }
2611
+ return Reflect.get(target, prop, recv);
2612
+ }
2613
+ })
2614
+ };
2615
+ let thrown = null;
2616
+ const ran = Promise.resolve(runWithRuntime(guarded, () => {
2617
+ const box = requestALS.getStore();
2618
+ if (box) {
2619
+ box.userId = userId ?? null;
2620
+ box.requestId = requestId;
2621
+ box.idempotencyKey = req.headers.get("idempotency-key");
2622
+ }
2623
+ const method = hit.entry.instance[meta.fnName];
2624
+ if (typeof method !== "function") {
2625
+ throw new Error(
2626
+ `route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
2627
+ );
2628
+ }
2629
+ return method.apply(hit.entry.instance, args);
2630
+ })).then(
2631
+ () => void 0,
2632
+ (err) => {
2633
+ thrown = err;
2634
+ }
2635
+ );
2636
+ await Promise.race([firstFrame, ran]);
2637
+ if (!sseWriter.started()) {
2638
+ await ran;
2639
+ await db.commit();
2640
+ if (thrown !== null) throw thrown;
2641
+ return new Response(null, { status: 204 });
2642
+ }
2643
+ void (async () => {
2644
+ await ran;
2645
+ await sseWriter.drained();
2646
+ try {
2647
+ if (thrown !== null) {
2648
+ log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
2649
+ sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
2650
+ }
2651
+ sseController.close();
2652
+ } catch {
2653
+ }
2654
+ })();
2655
+ return new Response(stream, {
2656
+ status: 200,
2657
+ headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
2658
+ });
2659
+ }
1950
2660
  const result = await runWithRuntime(services, () => {
1951
2661
  const box = requestALS.getStore();
1952
2662
  if (box) {