@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.
- package/dist/bin/palbase-backend.cjs +750 -40
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-YL4C5NRY.js → chunk-HQRJDARQ.js} +2 -2
- package/dist/{chunk-74XDEF5J.js → chunk-M5MCBWJI.js} +723 -37
- package/dist/chunk-M5MCBWJI.js.map +1 -0
- package/dist/{chunk-W5ODXPY3.js → chunk-NS5V43YQ.js} +14 -1
- package/dist/chunk-NS5V43YQ.js.map +1 -0
- package/dist/{chunk-SQC5EIWY.js → chunk-OHALWEOG.js} +19 -9
- package/dist/chunk-OHALWEOG.js.map +1 -0
- package/dist/{chunk-I3ON7MYF.js → chunk-PY7YJDCT.js} +129 -18
- package/dist/chunk-PY7YJDCT.js.map +1 -0
- package/dist/{chunk-N32VDWKH.js → chunk-R3KN6RHD.js} +4 -59
- package/dist/chunk-R3KN6RHD.js.map +1 -0
- package/dist/{chunk-QMVK4X3V.js → chunk-RCLNBJCM.js} +98 -98
- package/dist/chunk-RCLNBJCM.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +3 -21
- package/dist/db/env.d.ts +3 -21
- package/dist/db/index.cjs +140 -16
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +3 -2
- package/dist/db/index.d.ts +3 -2
- package/dist/db/index.js +2 -2
- package/dist/{endpoint-BVT6jcVW.d.cts → endpoint-CVWXh6oG.d.ts} +147 -15
- package/dist/{endpoint-BVT6jcVW.d.ts → endpoint-c9h5jriX.d.cts} +147 -15
- package/dist/engine/index.cjs +750 -40
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +6 -5
- package/dist/engine/index.d.ts +6 -5
- package/dist/engine/index.js +4 -4
- package/dist/{index-BS1gW4nV.d.cts → index-BZrJXnVh.d.ts} +142 -72
- package/dist/{index-BqCiHao8.d.cts → index-By8Dle5U.d.cts} +196 -21
- package/dist/{index-vwHoS0l2.d.ts → index-CwAJ7HEe.d.ts} +196 -21
- package/dist/{index-CCZqzych.d.ts → index-CxeQSfJP.d.cts} +142 -72
- package/dist/index.cjs +388 -1101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -1134
- package/dist/index.d.ts +89 -1134
- package/dist/index.js +133 -902
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +32 -61
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +6 -2
- package/dist/openapi/index.d.ts +6 -2
- package/dist/openapi/index.js +34 -26
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-Bsuf-orT.d.ts → registry-B3niOVYp.d.ts} +108 -170
- package/dist/{registry-BWttGlaT.d.cts → registry-CqPK2Qby.d.cts} +108 -170
- package/dist/{purchases/keys.cjs → stack.cjs} +4 -4
- package/dist/stack.cjs.map +1 -0
- package/dist/stack.d.cts +76 -0
- package/dist/stack.d.ts +76 -0
- package/dist/stack.js +1 -0
- package/dist/test/index.cjs +482 -9
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +35 -3
- package/dist/test/index.d.ts +35 -3
- package/dist/test/index.js +480 -8
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +7 -6
- package/docs/llms-full.txt +7 -260
- package/docs/llms.txt +0 -2
- package/package.json +9 -8
- package/stager/return_types.js +23 -0
- package/template/package.json +1 -1
- package/dist/chunk-74XDEF5J.js.map +0 -1
- package/dist/chunk-I3ON7MYF.js.map +0 -1
- package/dist/chunk-N32VDWKH.js.map +0 -1
- package/dist/chunk-QMVK4X3V.js.map +0 -1
- package/dist/chunk-SQC5EIWY.js.map +0 -1
- package/dist/chunk-W5ODXPY3.js.map +0 -1
- package/dist/purchases/keys.cjs.map +0 -1
- package/dist/purchases/keys.d.cts +0 -42
- package/dist/purchases/keys.d.ts +0 -42
- package/dist/purchases/keys.js +0 -1
- package/docs/config.md +0 -147
- package/docs/resources.md +0 -97
- package/template/config/secrets.ts +0 -24
- /package/dist/{chunk-YL4C5NRY.js.map → chunk-HQRJDARQ.js.map} +0 -0
- /package/dist/{purchases/keys.js.map → stack.js.map} +0 -0
package/dist/engine/index.cjs
CHANGED
|
@@ -252,6 +252,19 @@ var TxPlanBuilder = class {
|
|
|
252
252
|
}
|
|
253
253
|
return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
|
|
254
254
|
},
|
|
255
|
+
upsert: (values, options) => {
|
|
256
|
+
const encoded = encodeMap(values, false);
|
|
257
|
+
if (Object.keys(encoded).length === 0) {
|
|
258
|
+
throw new TxPlanError(`${name}.upsert() needs at least one column`);
|
|
259
|
+
}
|
|
260
|
+
if (options.onConflict.length === 0) {
|
|
261
|
+
throw new TxPlanError(`${name}.upsert() needs at least one onConflict column`);
|
|
262
|
+
}
|
|
263
|
+
return this.push(
|
|
264
|
+
{ op: "upsert", table: name, values: encoded, onConflict: options.onConflict },
|
|
265
|
+
`${name}.upsert()`
|
|
266
|
+
);
|
|
267
|
+
},
|
|
255
268
|
insertMany: (rows) => {
|
|
256
269
|
if (rows.length === 0) {
|
|
257
270
|
return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
|
|
@@ -459,8 +472,12 @@ function makeTablesAccessor(ops) {
|
|
|
459
472
|
update: (id, data) => ops().update(name, id, data),
|
|
460
473
|
delete: (id) => ops().delete(name, id),
|
|
461
474
|
findById: (id) => ops().findById(name, id),
|
|
462
|
-
findMany: (query) => ops().findMany(name, query),
|
|
463
|
-
|
|
475
|
+
findMany: (query, opts) => ops().findMany(name, query, opts),
|
|
476
|
+
upsert: (data, opts) => ops().upsert(name, data, opts),
|
|
477
|
+
search: (params) => ops().search(name, params),
|
|
478
|
+
similar: (id, params) => ops().similar(name, id, params),
|
|
479
|
+
recommend: (params) => ops().recommend(name, params),
|
|
480
|
+
supersede: (id, row) => ops().supersede(name, id, row)
|
|
464
481
|
};
|
|
465
482
|
}
|
|
466
483
|
}
|
|
@@ -469,17 +486,25 @@ function makeTablesAccessor(ops) {
|
|
|
469
486
|
}
|
|
470
487
|
var rawDatabase = makeServiceProxy("Database");
|
|
471
488
|
function makeTypedSurface(raw) {
|
|
489
|
+
const reco = raw;
|
|
472
490
|
const ops = {
|
|
473
491
|
query: (sql, params) => raw.query(sql, params),
|
|
474
492
|
insert: (table, data) => raw.insert(table, data),
|
|
475
493
|
update: (table, id, data) => raw.update(table, id, data),
|
|
476
494
|
delete: (table, id) => raw.delete(table, id),
|
|
477
495
|
findById: (table, id) => raw.findById(table, id),
|
|
478
|
-
findMany: (table, query) => raw.findMany(table, query),
|
|
479
|
-
|
|
496
|
+
findMany: (table, query, opts) => raw.findMany(table, query, opts),
|
|
497
|
+
upsert: (table, data, opts) => raw.upsert(table, data, opts),
|
|
498
|
+
search: (table, params) => raw.search(table, params),
|
|
499
|
+
similar: (table, id, params) => reco.similar(table, id, params),
|
|
500
|
+
recommend: (table, params) => reco.recommend(table, params),
|
|
501
|
+
supersede: (table, id, row) => raw.supersede(table, id, row)
|
|
480
502
|
};
|
|
481
503
|
return Object.assign(ops, {
|
|
482
|
-
|
|
504
|
+
// Both surfaces get it: a savepoint on the service transaction is as useful
|
|
505
|
+
// as one on the request's, and each is bound to its own connection.
|
|
506
|
+
attempt: (fn) => raw.attempt(fn),
|
|
507
|
+
tables: makeTablesAccessor(() => reco),
|
|
483
508
|
transaction(fn) {
|
|
484
509
|
const builder = new TxPlanBuilder();
|
|
485
510
|
return runTxPlan(raw, makeTxTablesAccessor(builder), builder, fn);
|
|
@@ -542,7 +567,6 @@ var Cache = makeServiceProxy("Cache");
|
|
|
542
567
|
var Secrets = makeServiceProxy("Secrets");
|
|
543
568
|
var Log = makeServiceProxy("Log");
|
|
544
569
|
var Notifications = makeServiceProxy("Notifications");
|
|
545
|
-
var Purchases = makeServiceProxy("Purchases");
|
|
546
570
|
var rawFlags = makeServiceProxy("Flags");
|
|
547
571
|
var Flags = Object.assign(
|
|
548
572
|
{
|
|
@@ -948,6 +972,14 @@ function makeMemoryCache(opts = {}) {
|
|
|
948
972
|
function quoteIdent(name) {
|
|
949
973
|
return `"${name.replace(/"/g, '""')}"`;
|
|
950
974
|
}
|
|
975
|
+
function encodePgArray(value) {
|
|
976
|
+
const element = (x) => {
|
|
977
|
+
if (x === null || x === void 0) return "NULL";
|
|
978
|
+
if (Array.isArray(x)) return encodePgArray(x);
|
|
979
|
+
return `"${String(x).replace(/(["\\])/g, "\\$1")}"`;
|
|
980
|
+
};
|
|
981
|
+
return `{${value.map(element).join(",")}}`;
|
|
982
|
+
}
|
|
951
983
|
var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
|
|
952
984
|
function createLazyTransaction(sql, role, claimsJson, options = {}) {
|
|
953
985
|
const { lockTimeout } = options;
|
|
@@ -1066,7 +1098,42 @@ function searchConfigFor(table) {
|
|
|
1066
1098
|
);
|
|
1067
1099
|
}
|
|
1068
1100
|
const search = t.search;
|
|
1069
|
-
const
|
|
1101
|
+
const synonyms = search?.synonyms !== void 0 && Object.keys(search.synonyms).length > 0 ? { synonyms: search.synonyms } : {};
|
|
1102
|
+
const validity = search?.validity === true ? { validity: true } : {};
|
|
1103
|
+
if (search?.from !== void 0 && search.model !== void 0) {
|
|
1104
|
+
const m = search.model;
|
|
1105
|
+
const embed = {
|
|
1106
|
+
model: m.model,
|
|
1107
|
+
apiKeyName: m.apiKeyName ?? "OPENAI_API_KEY",
|
|
1108
|
+
...m.baseURL !== void 0 ? { baseURL: m.baseURL } : {},
|
|
1109
|
+
...m.dimensions !== void 0 ? { dimensions: m.dimensions } : {}
|
|
1110
|
+
};
|
|
1111
|
+
const metric = search.metric ?? "cosine";
|
|
1112
|
+
const ftsOn = search.text !== false;
|
|
1113
|
+
const ftsColsNew = ftsOn ? Array.isArray(search.text) && search.text.length > 0 ? search.text : search.from : [];
|
|
1114
|
+
if (vectorCols.length > 0) {
|
|
1115
|
+
return {
|
|
1116
|
+
pk,
|
|
1117
|
+
cols,
|
|
1118
|
+
colSet: new Set(cols),
|
|
1119
|
+
ftsCols: ftsColsNew,
|
|
1120
|
+
legs: [{ column: vectorCols[0], metric, embed }],
|
|
1121
|
+
...synonyms,
|
|
1122
|
+
...validity
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
pk,
|
|
1127
|
+
cols,
|
|
1128
|
+
colSet: new Set(cols),
|
|
1129
|
+
ftsCols: ftsColsNew,
|
|
1130
|
+
legs: [],
|
|
1131
|
+
chunk: { table: `${table}__palbase_chunks`, metric, embed, fts: ftsOn },
|
|
1132
|
+
...synonyms,
|
|
1133
|
+
...validity
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
const ftsCols = (Array.isArray(search?.text) ? search.text : void 0) ?? [];
|
|
1070
1137
|
const rawLegs = search?.vector === void 0 ? [] : Array.isArray(search.vector) ? search.vector : [search.vector];
|
|
1071
1138
|
let legs;
|
|
1072
1139
|
if (rawLegs.length > 0) {
|
|
@@ -1092,7 +1159,7 @@ function searchConfigFor(table) {
|
|
|
1092
1159
|
legs = vectorCols.map((column) => ({ column, metric: "cosine" }));
|
|
1093
1160
|
}
|
|
1094
1161
|
if (ftsCols.length === 0 && legs.length === 0) return null;
|
|
1095
|
-
return { pk, cols, colSet: new Set(cols), ftsCols, legs };
|
|
1162
|
+
return { pk, cols, colSet: new Set(cols), ftsCols, legs, ...synonyms, ...validity };
|
|
1096
1163
|
}
|
|
1097
1164
|
function pickLeg(table, legs, using) {
|
|
1098
1165
|
if (legs.length === 0) return null;
|
|
@@ -1108,18 +1175,109 @@ function pickLeg(table, legs, using) {
|
|
|
1108
1175
|
if (legs.length === 1) return legs[0];
|
|
1109
1176
|
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`);
|
|
1110
1177
|
}
|
|
1178
|
+
var HALF_LIFE_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
1179
|
+
function parseHalfLife(s) {
|
|
1180
|
+
const m = /^(\d+)(s|m|h|d)$/.exec(s);
|
|
1181
|
+
const sec = m === null ? 0 : Number(m[1]) * HALF_LIFE_UNIT_SECONDS[m[2]];
|
|
1182
|
+
if (!Number.isFinite(sec) || sec <= 0) {
|
|
1183
|
+
throw new Error(`recency.halfLife "${s}" \xE7\xF6z\xFCmlenemedi \u2014 beklenen: pozitif <say\u0131>+(s|m|h|d), \xF6rn. "30d" (FR-004)`);
|
|
1184
|
+
}
|
|
1185
|
+
return sec;
|
|
1186
|
+
}
|
|
1111
1187
|
var WHERE_OPS = { gt: ">", gte: ">=", lt: "<", lte: "<=", neq: "<>" };
|
|
1112
|
-
function
|
|
1188
|
+
function ftsExprOf(ftsCols) {
|
|
1189
|
+
return [...ftsCols].sort().map((c) => `coalesce(t.${quoteIdent(c)},'')`).join(" || ' ' || ");
|
|
1190
|
+
}
|
|
1191
|
+
function expandSynonyms(query, map) {
|
|
1192
|
+
const lower = {};
|
|
1193
|
+
for (const [word, alts] of Object.entries(map)) lower[word.toLowerCase()] = alts;
|
|
1194
|
+
return query.split(/("[^"]*")/).map((seg) => {
|
|
1195
|
+
if (seg.startsWith('"')) return seg;
|
|
1196
|
+
return seg.split(/(\s+)/).map((tok) => {
|
|
1197
|
+
if (tok === "" || /^\s+$/.test(tok)) return tok;
|
|
1198
|
+
const alts = lower[tok.toLowerCase()];
|
|
1199
|
+
return alts !== void 0 && alts.length > 0 ? `(${tok} OR ${alts.join(" OR ")})` : tok;
|
|
1200
|
+
}).join("");
|
|
1201
|
+
}).join("");
|
|
1202
|
+
}
|
|
1203
|
+
var searchLogger = (evt, fields) => {
|
|
1204
|
+
console.log(evt, JSON.stringify(fields));
|
|
1205
|
+
};
|
|
1206
|
+
function logNoResults(table, query, mode, n) {
|
|
1207
|
+
if (n === 0) {
|
|
1208
|
+
searchLogger?.("palbase.search.no_results", {
|
|
1209
|
+
table,
|
|
1210
|
+
query: (query ?? "").slice(0, 200),
|
|
1211
|
+
mode: mode ?? "hybrid"
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async function fetchFacets(live, table, colSet, facets, where, extraWhere = () => "") {
|
|
1216
|
+
const bind = [];
|
|
1217
|
+
const add = (v) => {
|
|
1218
|
+
bind.push(v);
|
|
1219
|
+
return `$${bind.length}`;
|
|
1220
|
+
};
|
|
1221
|
+
const whereSql = compileWhere(table, colSet, where, add) + extraWhere(add);
|
|
1222
|
+
const parts = facets.map(
|
|
1223
|
+
(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)`
|
|
1224
|
+
);
|
|
1225
|
+
const rows = await live.unsafe(parts.join(" UNION ALL "), bind);
|
|
1226
|
+
const out = {};
|
|
1227
|
+
for (const col of facets) out[col] = [];
|
|
1228
|
+
for (const r of rows) {
|
|
1229
|
+
if (typeof r.f === "string" && out[r.f] !== void 0) {
|
|
1230
|
+
out[r.f].push({ value: r.v ?? null, count: Number(r.n) });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return out;
|
|
1234
|
+
}
|
|
1235
|
+
function validitySql(v, add) {
|
|
1236
|
+
if (v === "all") return "";
|
|
1237
|
+
if (v !== void 0) {
|
|
1238
|
+
const p = add(v.asOf);
|
|
1239
|
+
return ` AND t."valid_from" <= ${p} AND (t."valid_to" IS NULL OR t."valid_to" > ${p})`;
|
|
1240
|
+
}
|
|
1241
|
+
return ` AND t."valid_to" IS NULL`;
|
|
1242
|
+
}
|
|
1243
|
+
function excludeSql(pk, ids, add) {
|
|
1244
|
+
if (ids.length === 0) return "";
|
|
1245
|
+
if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
|
|
1246
|
+
return ` AND t.${quoteIdent(pk)}::text <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
|
|
1247
|
+
}
|
|
1248
|
+
function schemaColumns(table) {
|
|
1249
|
+
const t = Object.values(currentSchema.tables ?? {}).find((x) => x?.name === table);
|
|
1250
|
+
const cols = t?.columns ? Object.keys(t.columns) : [];
|
|
1251
|
+
return cols.length > 0 ? new Set(cols) : null;
|
|
1252
|
+
}
|
|
1253
|
+
function orderClause(table, known, orderBy) {
|
|
1254
|
+
if (!orderBy) return "";
|
|
1255
|
+
const col = orderBy.column;
|
|
1256
|
+
const shapeOK = /^[A-Za-z_][A-Za-z0-9_]*$/.test(col);
|
|
1257
|
+
if (known !== null ? !known.has(col) : !shapeOK) {
|
|
1258
|
+
throw new Error(`findMany(${table}): orderBy kolonu "${col}" tabloda yok`);
|
|
1259
|
+
}
|
|
1260
|
+
const dir = orderBy.direction === "desc" ? "DESC" : "ASC";
|
|
1261
|
+
return ` ORDER BY ${quoteIdent(col)} ${dir}`;
|
|
1262
|
+
}
|
|
1263
|
+
function limitClause(limit) {
|
|
1264
|
+
if (limit === void 0) return "";
|
|
1265
|
+
if (!Number.isInteger(limit) || limit < 0) {
|
|
1266
|
+
throw new Error(`findMany: limit bir negatif olmayan tam say\u0131 olmal\u0131 (geldi: ${String(limit)})`);
|
|
1267
|
+
}
|
|
1268
|
+
return ` LIMIT ${limit}`;
|
|
1269
|
+
}
|
|
1270
|
+
function compileWhere(table, colSet, where, add, caller = "search") {
|
|
1113
1271
|
const parts = [];
|
|
1114
1272
|
for (const [col, cond] of Object.entries(where)) {
|
|
1115
|
-
if (!colSet.has(col)) {
|
|
1116
|
-
throw new Error(
|
|
1273
|
+
if (colSet !== null && !colSet.has(col)) {
|
|
1274
|
+
throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
|
|
1117
1275
|
}
|
|
1118
1276
|
const q = `t.${quoteIdent(col)}`;
|
|
1119
1277
|
if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
|
|
1120
1278
|
for (const [op, v] of Object.entries(cond)) {
|
|
1121
1279
|
if (op === "in") {
|
|
1122
|
-
if (!Array.isArray(v)) throw new Error(
|
|
1280
|
+
if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
|
|
1123
1281
|
if (v.length === 0) {
|
|
1124
1282
|
parts.push("false");
|
|
1125
1283
|
continue;
|
|
@@ -1128,7 +1286,7 @@ function compileWhere(table, colSet, where, add) {
|
|
|
1128
1286
|
} else if (op in WHERE_OPS) {
|
|
1129
1287
|
parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
|
|
1130
1288
|
} else {
|
|
1131
|
-
throw new Error(
|
|
1289
|
+
throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
|
|
1132
1290
|
}
|
|
1133
1291
|
}
|
|
1134
1292
|
} else {
|
|
@@ -1138,6 +1296,15 @@ function compileWhere(table, colSet, where, add) {
|
|
|
1138
1296
|
return parts.length === 0 ? "" : ` AND ${parts.join(" AND ")}`;
|
|
1139
1297
|
}
|
|
1140
1298
|
var cachedVectorSchema = null;
|
|
1299
|
+
var cachedTrgmSchema = null;
|
|
1300
|
+
async function trgmSchemaOf(live) {
|
|
1301
|
+
if (cachedTrgmSchema !== null) return cachedTrgmSchema;
|
|
1302
|
+
const rows = await live.unsafe(
|
|
1303
|
+
"select n.nspname from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'"
|
|
1304
|
+
);
|
|
1305
|
+
cachedTrgmSchema = rows?.[0]?.nspname ?? "public";
|
|
1306
|
+
return cachedTrgmSchema;
|
|
1307
|
+
}
|
|
1141
1308
|
async function vectorSchemaWithGuc(runner) {
|
|
1142
1309
|
if (cachedVectorSchema !== null) {
|
|
1143
1310
|
await runner.unsafe(
|
|
@@ -1199,7 +1366,8 @@ function createOps(tx) {
|
|
|
1199
1366
|
const at = () => resolveTx(tx);
|
|
1200
1367
|
const ops = {
|
|
1201
1368
|
async query(sql, params = []) {
|
|
1202
|
-
|
|
1369
|
+
const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
|
|
1370
|
+
return asWireRows(await (await at()).unsafe(sql, bound));
|
|
1203
1371
|
},
|
|
1204
1372
|
async insert(table, data) {
|
|
1205
1373
|
const cols = Object.keys(data);
|
|
@@ -1215,6 +1383,41 @@ function createOps(tx) {
|
|
|
1215
1383
|
}
|
|
1216
1384
|
return asTableRow(table, inserted);
|
|
1217
1385
|
},
|
|
1386
|
+
/**
|
|
1387
|
+
* INSERT the row, or UPDATE it when it collides on `onConflict`.
|
|
1388
|
+
*
|
|
1389
|
+
* WHY IT IS AN OPERATION rather than a recipe. "Try the insert, catch the
|
|
1390
|
+
* unique violation, update instead" does not work here: a request runs in ONE
|
|
1391
|
+
* Postgres transaction, so the failed insert aborts it and every later
|
|
1392
|
+
* statement answers `current transaction is aborted`. A tenant measured that
|
|
1393
|
+
* as 7 of 8 concurrent requests returning 500, gave up on upsert, and had a
|
|
1394
|
+
* trigger create the row instead — a workaround that needs a new trigger for
|
|
1395
|
+
* every table with a unique row.
|
|
1396
|
+
*
|
|
1397
|
+
* The conflict columns are excluded from the SET list: they are what MATCHED,
|
|
1398
|
+
* so writing them back is at best a no-op and at worst a surprise.
|
|
1399
|
+
*/
|
|
1400
|
+
async upsert(table, data, opts) {
|
|
1401
|
+
const conflict = opts.onConflict;
|
|
1402
|
+
if (conflict.length === 0) {
|
|
1403
|
+
throw new Error(`upsert into ${table}: onConflict en az bir kolon ad\u0131 ister`);
|
|
1404
|
+
}
|
|
1405
|
+
const cols = Object.keys(data);
|
|
1406
|
+
if (cols.length === 0) throw new Error(`upsert into ${table}: no columns given`);
|
|
1407
|
+
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
|
|
1408
|
+
const conflictSet = new Set(conflict);
|
|
1409
|
+
const assignments = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
|
|
1410
|
+
const action = assignments.length ? `DO UPDATE SET ${assignments.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
|
|
1411
|
+
const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
|
|
1412
|
+
const rows = await (await at()).unsafe(sql, asBindParams(table, cols, data));
|
|
1413
|
+
const row = rows[0];
|
|
1414
|
+
if (!row) {
|
|
1415
|
+
throw new Error(
|
|
1416
|
+
`upsert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
return asTableRow(table, row);
|
|
1420
|
+
},
|
|
1218
1421
|
async update(table, id, data) {
|
|
1219
1422
|
const cols = Object.keys(data);
|
|
1220
1423
|
if (cols.length === 0) return ops.findById(table, id);
|
|
@@ -1233,13 +1436,18 @@ function createOps(tx) {
|
|
|
1233
1436
|
);
|
|
1234
1437
|
return rows[0] ? asTableRow(table, rows[0]) : null;
|
|
1235
1438
|
},
|
|
1236
|
-
async findMany(table, query = {}) {
|
|
1237
|
-
const
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
)
|
|
1439
|
+
async findMany(table, query = {}, opts = {}) {
|
|
1440
|
+
const params = [];
|
|
1441
|
+
const add = (v) => {
|
|
1442
|
+
params.push(v);
|
|
1443
|
+
return `$${params.length}`;
|
|
1444
|
+
};
|
|
1445
|
+
const known = schemaColumns(table);
|
|
1446
|
+
const whereSql = compileWhere(table, known, query, add, "findMany");
|
|
1447
|
+
const order = orderClause(table, known, opts.orderBy);
|
|
1448
|
+
const limit = limitClause(opts.limit);
|
|
1449
|
+
const sql = `SELECT * FROM ${quoteIdent(table)} t WHERE true${whereSql}${order}${limit}`;
|
|
1450
|
+
return asTableRows(table, await (await at()).unsafe(sql, params));
|
|
1243
1451
|
},
|
|
1244
1452
|
/**
|
|
1245
1453
|
* Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
|
|
@@ -1253,12 +1461,128 @@ function createOps(tx) {
|
|
|
1253
1461
|
if (!cfg) {
|
|
1254
1462
|
throw new Error(`search(${table}): tablo aranabilir de\u011Fil \u2014 ne vector kolonu ne search beyan\u0131 var (FR-013)`);
|
|
1255
1463
|
}
|
|
1464
|
+
const excl = params.__excludeIds ?? [];
|
|
1465
|
+
if (params.facets !== void 0) {
|
|
1466
|
+
for (const col of params.facets) {
|
|
1467
|
+
if (!cfg.colSet.has(col)) {
|
|
1468
|
+
throw new Error(`search(${table}): facets kolonu "${col}" tabloda yok (FR-027)`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (params.validity !== void 0 && cfg.validity !== true) {
|
|
1473
|
+
throw new Error(
|
|
1474
|
+
`search(${table}): validity parametresi verildi ama tablo validity beyan\u0131 ta\u015F\u0131m\u0131yor (FR-029)`
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
const validityFor = (adder) => cfg.validity === true ? validitySql(params.validity, adder) : "";
|
|
1256
1478
|
const rawLimit = params.limit ?? 20;
|
|
1257
1479
|
if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)) {
|
|
1258
1480
|
throw new Error(`search(${table}): limit sonlu bir say\u0131 olmal\u0131, ${String(rawLimit)} verildi (FR-013)`);
|
|
1259
1481
|
}
|
|
1260
1482
|
const limit = Math.min(Math.max(1, Math.trunc(rawLimit)), 100);
|
|
1261
1483
|
const pool = Math.max(limit * 3, 30);
|
|
1484
|
+
if (params.minScore !== void 0 && (typeof params.minScore !== "number" || !Number.isFinite(params.minScore))) {
|
|
1485
|
+
throw new Error(`search(${table}): minScore sonlu bir say\u0131 olmal\u0131, ${String(params.minScore)} verildi (FR-001)`);
|
|
1486
|
+
}
|
|
1487
|
+
let recencyMul = "";
|
|
1488
|
+
if (params.recency !== void 0) {
|
|
1489
|
+
const { field, halfLife } = params.recency;
|
|
1490
|
+
if (!cfg.colSet.has(field)) {
|
|
1491
|
+
throw new Error(`search(${table}): recency.field "${field}" tabloda yok (FR-004)`);
|
|
1492
|
+
}
|
|
1493
|
+
recencyMul = ` * exp(-ln(2) * extract(epoch from (now() - t.${quoteIdent(field)})) / ${parseHalfLife(halfLife)})`;
|
|
1494
|
+
}
|
|
1495
|
+
let boostMul = "";
|
|
1496
|
+
if (params.boost !== void 0) {
|
|
1497
|
+
const { field, weight } = params.boost;
|
|
1498
|
+
if (!cfg.colSet.has(field)) {
|
|
1499
|
+
throw new Error(`search(${table}): boost.field "${field}" tabloda yok (FR-030)`);
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof weight !== "number" || !Number.isFinite(weight)) {
|
|
1502
|
+
throw new Error(`search(${table}): boost.weight sonlu bir say\u0131 olmal\u0131, ${String(weight)} verildi (FR-030)`);
|
|
1503
|
+
}
|
|
1504
|
+
const g = `greatest(t.${quoteIdent(field)},0)`;
|
|
1505
|
+
boostMul = ` * (1 + ${weight} * ${g}/(1+${g}))`;
|
|
1506
|
+
}
|
|
1507
|
+
const scoreMul = boostMul + recencyMul;
|
|
1508
|
+
if (cfg.chunk !== void 0) {
|
|
1509
|
+
const ck = cfg.chunk;
|
|
1510
|
+
const pidQ = quoteIdent(`parent_${cfg.pk}`);
|
|
1511
|
+
const bpr = Math.min(Math.max(1, Math.trunc(params.blocksPerRow ?? 3)), 10);
|
|
1512
|
+
const wantTextC = params.mode !== "vector" && ck.fts && typeof params.query === "string" && params.query !== "";
|
|
1513
|
+
let qvC = Array.isArray(params.vector) ? params.vector : null;
|
|
1514
|
+
if (qvC === null && ck.embed !== void 0 && params.mode !== "text" && typeof params.query === "string" && params.query !== "") {
|
|
1515
|
+
try {
|
|
1516
|
+
qvC = await embedQuery(ck.embed, params.query);
|
|
1517
|
+
} catch (e) {
|
|
1518
|
+
if (!wantTextC) throw e;
|
|
1519
|
+
qvC = null;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (!wantTextC && qvC === null) {
|
|
1523
|
+
throw new Error(
|
|
1524
|
+
`search(${table}): ko\u015Fulabilir kol yok \u2014 metin i\xE7in 'query' (FTS beyan\u0131 gerekir), semantik i\xE7in 'vector' verin (FR-015)`
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
const bindC = [];
|
|
1528
|
+
const addC = (v) => {
|
|
1529
|
+
bindC.push(v);
|
|
1530
|
+
return `$${bindC.length}`;
|
|
1531
|
+
};
|
|
1532
|
+
const whereC = compileWhere(table, cfg.colSet, params.where ?? {}, addC) + excludeSql(cfg.pk, excl, addC) + validityFor(addC);
|
|
1533
|
+
const liveC = await at();
|
|
1534
|
+
const K2 = 50;
|
|
1535
|
+
const cpool = Math.max(limit * 9, 90);
|
|
1536
|
+
let semC = "";
|
|
1537
|
+
let kwC = "";
|
|
1538
|
+
if (qvC !== null) {
|
|
1539
|
+
const sch = await vectorSchemaWithGuc(liveC);
|
|
1540
|
+
const op = METRIC_OPERATOR[ck.metric] ?? METRIC_OPERATOR.cosine;
|
|
1541
|
+
const vp = addC(toVectorLiteral(qvC));
|
|
1542
|
+
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}`;
|
|
1543
|
+
}
|
|
1544
|
+
let qpC = "";
|
|
1545
|
+
let qpCIdx = -1;
|
|
1546
|
+
if (wantTextC) {
|
|
1547
|
+
const qTextC = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
|
|
1548
|
+
qpC = addC(qTextC);
|
|
1549
|
+
qpCIdx = bindC.length - 1;
|
|
1550
|
+
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}`;
|
|
1551
|
+
}
|
|
1552
|
+
const msC = params.minScore === void 0 ? "" : addC(params.minScore);
|
|
1553
|
+
const colListC = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
|
|
1554
|
+
const scoreC = scoreMul === "" ? "g._score" : `(g._score)${scoreMul}`;
|
|
1555
|
+
const assembleC = (kwIn, withKwn) => {
|
|
1556
|
+
const kwnCol = withKwn ? `, (SELECT count(*) FROM kw)::int AS _kwn` : "";
|
|
1557
|
+
let fusedSrc;
|
|
1558
|
+
if (semC !== "" && kwIn !== "") {
|
|
1559
|
+
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)`;
|
|
1560
|
+
} else if (semC !== "") {
|
|
1561
|
+
fusedSrc = `WITH sem AS (${semC}), fused AS (SELECT sem.pid, sem.chunk_seq, (1.0/(${K2} + sem.r))::float8 AS cscore FROM sem)`;
|
|
1562
|
+
} else {
|
|
1563
|
+
fusedSrc = `WITH kw AS (${kwIn}), fused AS (SELECT kw.pid, kw.chunk_seq, (1.0/(${K2} + kw.r))::float8 AS cscore FROM kw)`;
|
|
1564
|
+
}
|
|
1565
|
+
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}`;
|
|
1566
|
+
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}`;
|
|
1567
|
+
};
|
|
1568
|
+
let rowsC = await liveC.unsafe(assembleC(kwC, kwC !== ""), bindC);
|
|
1569
|
+
if (wantTextC && kwC !== "" && (rowsC.length === 0 || rowsC[0]._kwn === 0)) {
|
|
1570
|
+
const tsch = await trgmSchemaOf(liveC);
|
|
1571
|
+
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}`;
|
|
1572
|
+
const retryBindC = bindC.slice();
|
|
1573
|
+
if (qpCIdx >= 0) retryBindC[qpCIdx] = params.query;
|
|
1574
|
+
rowsC = await liveC.unsafe(assembleC(trgmKwC, false), retryBindC);
|
|
1575
|
+
}
|
|
1576
|
+
for (const r of rowsC) delete r._kwn;
|
|
1577
|
+
const outC = asTableRows(table, rowsC).map((r) => ({ ...r, blocks: r.blocks ?? [] }));
|
|
1578
|
+
if (params.facets !== void 0 && params.facets.length > 0) {
|
|
1579
|
+
Object.assign(outC, {
|
|
1580
|
+
_facets: await fetchFacets(liveC, table, cfg.colSet, params.facets, params.where ?? {}, validityFor)
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
logNoResults(table, params.query, params.mode, outC.length);
|
|
1584
|
+
return outC;
|
|
1585
|
+
}
|
|
1262
1586
|
const wantText = params.mode !== "vector" && cfg.ftsCols.length > 0 && typeof params.query === "string" && params.query !== "";
|
|
1263
1587
|
const anyEmbed = cfg.legs.some((l) => l.embed !== void 0);
|
|
1264
1588
|
const vectorAsked = params.mode !== "text" && (Array.isArray(params.vector) || params.using !== void 0 || params.mode === "vector" || anyEmbed && typeof params.query === "string" && params.query !== "");
|
|
@@ -1283,7 +1607,8 @@ function createOps(tx) {
|
|
|
1283
1607
|
bind.push(v);
|
|
1284
1608
|
return `$${bind.length}`;
|
|
1285
1609
|
};
|
|
1286
|
-
const
|
|
1610
|
+
const userWhere = compileWhere(table, cfg.colSet, params.where ?? {}, add);
|
|
1611
|
+
const whereSql = userWhere + excludeSql(cfg.pk, excl, add) + validityFor(add);
|
|
1287
1612
|
const live = await at();
|
|
1288
1613
|
const K = 50;
|
|
1289
1614
|
let semSql = "";
|
|
@@ -1292,7 +1617,7 @@ function createOps(tx) {
|
|
|
1292
1617
|
const sch = await vectorSchemaWithGuc(live);
|
|
1293
1618
|
const op = METRIC_OPERATOR[leg.metric] ?? METRIC_OPERATOR.cosine;
|
|
1294
1619
|
let exactOrder = "";
|
|
1295
|
-
if (
|
|
1620
|
+
if (userWhere !== "") {
|
|
1296
1621
|
const probeRows = await live.unsafe(
|
|
1297
1622
|
`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`,
|
|
1298
1623
|
bind.slice()
|
|
@@ -1305,27 +1630,235 @@ function createOps(tx) {
|
|
|
1305
1630
|
const vp = add(toVectorLiteral(qv));
|
|
1306
1631
|
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}`;
|
|
1307
1632
|
}
|
|
1633
|
+
let qpRef = "";
|
|
1634
|
+
let qpIdx = -1;
|
|
1308
1635
|
if (wantText) {
|
|
1309
|
-
const
|
|
1636
|
+
const qText = cfg.synonyms !== void 0 ? expandSynonyms(params.query, cfg.synonyms) : params.query;
|
|
1637
|
+
const qp = add(qText);
|
|
1638
|
+
qpIdx = bind.length - 1;
|
|
1639
|
+
qpRef = qp;
|
|
1310
1640
|
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}`;
|
|
1311
1641
|
}
|
|
1312
1642
|
const colList = cfg.cols.map((c) => `t.${quoteIdent(c)}`).join(", ");
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1643
|
+
const msP = params.minScore === void 0 ? "" : add(params.minScore);
|
|
1644
|
+
const hlCol = params.highlight === true && wantText ? `, ts_headline('simple', ${ftsExprOf(cfg.ftsCols)}, websearch_to_tsquery('simple', ${qpRef})) AS _highlight` : "";
|
|
1645
|
+
const assemble = (kwIn, withKwn) => {
|
|
1646
|
+
const kwnCol = withKwn ? `, (SELECT count(*) FROM kw)::int AS _kwn` : "";
|
|
1647
|
+
let inner;
|
|
1648
|
+
let orderScore;
|
|
1649
|
+
if (semSql !== "" && kwIn !== "") {
|
|
1650
|
+
const score = scoreMul === "" ? "fused._score" : `(fused._score)${scoreMul}`;
|
|
1651
|
+
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`;
|
|
1652
|
+
orderScore = scoreMul === "" ? "fused._score" : "_score";
|
|
1653
|
+
} else {
|
|
1654
|
+
const single = semSql !== "" ? `sem AS (${semSql})` : `kw AS (${kwIn})`;
|
|
1655
|
+
const alias = semSql !== "" ? "sem" : "kw";
|
|
1656
|
+
const base = `(1.0/(${K} + ${alias}.r))::float8`;
|
|
1657
|
+
const score = scoreMul === "" ? base : `(${base})${scoreMul}`;
|
|
1658
|
+
inner = `WITH ${single} SELECT ${colList}, ${score} AS _score${hlCol}${semSql !== "" ? "" : kwnCol} FROM ${alias} JOIN ${quoteIdent(table)} t ON t.${quoteIdent(cfg.pk)} = ${alias}.id`;
|
|
1659
|
+
orderScore = "_score";
|
|
1660
|
+
}
|
|
1661
|
+
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}`;
|
|
1662
|
+
};
|
|
1663
|
+
let rows = await live.unsafe(assemble(kwSql, kwSql !== ""), bind);
|
|
1664
|
+
if (wantText && kwSql !== "" && (rows.length === 0 || rows[0]._kwn === 0)) {
|
|
1665
|
+
const tsch = await trgmSchemaOf(live);
|
|
1666
|
+
const expr = ftsExprOf(cfg.ftsCols);
|
|
1667
|
+
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}`;
|
|
1668
|
+
const retryBind = bind.slice();
|
|
1669
|
+
if (qpIdx >= 0) retryBind[qpIdx] = params.query;
|
|
1670
|
+
rows = await live.unsafe(assemble(trgmKw, false), retryBind);
|
|
1671
|
+
}
|
|
1672
|
+
for (const r of rows) delete r._kwn;
|
|
1673
|
+
const out = asTableRows(table, rows).map((r) => ({ ...r, blocks: [] }));
|
|
1674
|
+
if (params.facets !== void 0 && params.facets.length > 0) {
|
|
1675
|
+
Object.assign(out, {
|
|
1676
|
+
_facets: await fetchFacets(live, table, cfg.colSet, params.facets, params.where ?? {}, validityFor)
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
logNoResults(table, params.query, params.mode, out.length);
|
|
1680
|
+
return out;
|
|
1681
|
+
},
|
|
1682
|
+
/**
|
|
1683
|
+
* similar (T018, FR-022): "bu satıra benzeyenler". Hedef vektör DB'den
|
|
1684
|
+
* okunur — satır-modu satırın kendi kolonu, chunk-modu parent chunk'larının
|
|
1685
|
+
* şema-nitelikli avg'ı — ve ana arama search'ün vector yoluyla koşar;
|
|
1686
|
+
* kaynak satır sonuçtan düşer. İKİ tur BİLİNÇLİ (plan T018): target-CTE'li
|
|
1687
|
+
* tek SQL'de "id yok" ile "0 komşu" ayrılamazdı; +1 küçük turla id-yokluğu
|
|
1688
|
+
* adlandırılmış hataya çevrilir. Sonuç şekli search ile aynı (FR-015).
|
|
1689
|
+
*/
|
|
1690
|
+
async similar(table, id, opts = {}) {
|
|
1691
|
+
const cfg = searchConfigFor(table);
|
|
1692
|
+
if (!cfg) {
|
|
1693
|
+
throw new Error(`similar(${table}): tablo aranabilir de\u011Fil \u2014 ne vector kolonu ne search beyan\u0131 var (FR-013)`);
|
|
1694
|
+
}
|
|
1695
|
+
const live = await at();
|
|
1696
|
+
let v;
|
|
1697
|
+
if (cfg.chunk !== void 0) {
|
|
1698
|
+
const sch = await vectorSchemaWithGuc(live);
|
|
1699
|
+
const rows = await live.unsafe(
|
|
1700
|
+
`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`,
|
|
1701
|
+
[id]
|
|
1702
|
+
);
|
|
1703
|
+
v = rows?.[0]?.v;
|
|
1316
1704
|
} else {
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1705
|
+
const leg = pickLeg(table, cfg.legs, opts.using);
|
|
1706
|
+
if (leg === null) {
|
|
1707
|
+
throw new Error(`similar(${table}): tabloda vekt\xF6r kolonu yok \u2014 benzerlik semantik vekt\xF6r ister (FR-022)`);
|
|
1708
|
+
}
|
|
1709
|
+
const rows = await live.unsafe(
|
|
1710
|
+
`SELECT t.${quoteIdent(leg.column)} AS v FROM ${quoteIdent(table)} t WHERE t.${quoteIdent(cfg.pk)} = $1`,
|
|
1711
|
+
[id]
|
|
1712
|
+
);
|
|
1713
|
+
v = rows?.[0]?.v;
|
|
1320
1714
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1715
|
+
if (typeof v !== "string") {
|
|
1716
|
+
throw new Error(`similar(${table}): id "${String(id)}" bulunamad\u0131 ya da embedding'i yok (FR-022)`);
|
|
1717
|
+
}
|
|
1718
|
+
const p = {
|
|
1719
|
+
vector: JSON.parse(v),
|
|
1720
|
+
mode: "vector",
|
|
1721
|
+
where: opts.where,
|
|
1722
|
+
limit: opts.limit,
|
|
1723
|
+
using: opts.using,
|
|
1724
|
+
minScore: opts.minScore,
|
|
1725
|
+
recency: opts.recency,
|
|
1726
|
+
blocksPerRow: opts.blocksPerRow,
|
|
1727
|
+
validity: opts.validity,
|
|
1728
|
+
boost: opts.boost,
|
|
1729
|
+
__excludeIds: [id]
|
|
1730
|
+
};
|
|
1731
|
+
return ops.search(table, p);
|
|
1732
|
+
},
|
|
1733
|
+
/**
|
|
1734
|
+
* recommend (T018, FR-023): positive/negative id kümelerinden öneri.
|
|
1735
|
+
* Hedef vektör DB-İÇİ CTE'lerle türer: pos = avg(embedding of positives),
|
|
1736
|
+
* negative varsa hedef = pos.v + (pos.v - neg.v) — pgvector avg agregası
|
|
1737
|
+
* ve +/- operatörleri ŞEMA-NİTELİKLİ (M-1: search_path'e güvenilmez).
|
|
1738
|
+
* Vektör matematiği istemciye inmez; bulunamayan positive/negative
|
|
1739
|
+
* adlandırılmış hatadır ve kaynak id'ler sonuçtan düşer.
|
|
1740
|
+
*/
|
|
1741
|
+
async recommend(table, opts) {
|
|
1742
|
+
const positive = opts?.positive;
|
|
1743
|
+
if (!Array.isArray(positive) || positive.length === 0) {
|
|
1744
|
+
throw new Error(`recommend(${table}): positive bo\u015F olamaz \u2014 en az bir kaynak id gerekir (FR-023)`);
|
|
1745
|
+
}
|
|
1746
|
+
const negative = Array.isArray(opts.negative) ? opts.negative : [];
|
|
1747
|
+
const cfg = searchConfigFor(table);
|
|
1748
|
+
if (!cfg) {
|
|
1749
|
+
throw new Error(`recommend(${table}): tablo aranabilir de\u011Fil \u2014 ne vector kolonu ne search beyan\u0131 var (FR-013)`);
|
|
1750
|
+
}
|
|
1751
|
+
let leg = null;
|
|
1752
|
+
if (cfg.chunk === void 0) {
|
|
1753
|
+
leg = pickLeg(table, cfg.legs, opts.using);
|
|
1754
|
+
if (leg === null) {
|
|
1755
|
+
throw new Error(`recommend(${table}): tabloda vekt\xF6r kolonu yok \u2014 \xF6neri semantik vekt\xF6r ister (FR-023)`);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
const live = await at();
|
|
1759
|
+
const sch = await vectorSchemaWithGuc(live);
|
|
1760
|
+
const bind = [];
|
|
1761
|
+
const add = (x) => {
|
|
1762
|
+
bind.push(x);
|
|
1763
|
+
return `$${bind.length}`;
|
|
1764
|
+
};
|
|
1765
|
+
const avgSel = (ids) => {
|
|
1766
|
+
const inList = ids.map((x) => add(x)).join(", ");
|
|
1767
|
+
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`;
|
|
1768
|
+
};
|
|
1769
|
+
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`;
|
|
1770
|
+
const rows = await live.unsafe(targetSql, bind);
|
|
1771
|
+
const r0 = rows?.[0];
|
|
1772
|
+
if (r0 === void 0 || r0.pos_missing === true) {
|
|
1773
|
+
throw new Error(`recommend(${table}): positive id'lerin hi\xE7biri bulunamad\u0131 ya da embedding'i yok (FR-023)`);
|
|
1774
|
+
}
|
|
1775
|
+
if (negative.length > 0 && r0.neg_missing === true) {
|
|
1776
|
+
throw new Error(`recommend(${table}): negative id'leri bulunamad\u0131 ya da embedding'i yok (FR-023)`);
|
|
1777
|
+
}
|
|
1778
|
+
if (typeof r0.v !== "string") {
|
|
1779
|
+
throw new Error(`recommend(${table}): hedef vekt\xF6r hesaplanamad\u0131 (FR-023)`);
|
|
1780
|
+
}
|
|
1781
|
+
const p = {
|
|
1782
|
+
vector: JSON.parse(r0.v),
|
|
1783
|
+
mode: "vector",
|
|
1784
|
+
where: opts.where,
|
|
1785
|
+
limit: opts.limit,
|
|
1786
|
+
using: opts.using,
|
|
1787
|
+
minScore: opts.minScore,
|
|
1788
|
+
recency: opts.recency,
|
|
1789
|
+
blocksPerRow: opts.blocksPerRow,
|
|
1790
|
+
validity: opts.validity,
|
|
1791
|
+
boost: opts.boost,
|
|
1792
|
+
__excludeIds: [...positive, ...negative]
|
|
1793
|
+
};
|
|
1794
|
+
return ops.search(table, p);
|
|
1795
|
+
},
|
|
1796
|
+
/**
|
|
1797
|
+
* supersede (T021, FR-029, C-9): validity'li tabloda satırın YENİ
|
|
1798
|
+
* versiyonunu TEK savepoint'te yazar — eski satır kapatılır
|
|
1799
|
+
* (valid_to = now(), superseded_by = yeni pk), yeni satır eklenir, dönüş
|
|
1800
|
+
* yeni satırdır. Yeni pk CLIENT'ta üretilir (row'da verilmemişse
|
|
1801
|
+
* randomUUID — declared şemaların uuid().defaultRandom() standardı) ki
|
|
1802
|
+
* kapatma UPDATE'i INSERT'ten ÖNCE koşabilsin: 0 satır = zaten superseded
|
|
1803
|
+
* ya da yok → INSERT hiç denenmez; INSERT hatası ise savepoint'le
|
|
1804
|
+
* kapatmayı da geri sarar — yarım supersede diye bir durum yoktur.
|
|
1805
|
+
*/
|
|
1806
|
+
async supersede(table, id, row) {
|
|
1807
|
+
const cfg = searchConfigFor(table);
|
|
1808
|
+
if (cfg === null || cfg.validity !== true) {
|
|
1809
|
+
throw new Error(`supersede(${table}): tablo validity beyan\u0131 ta\u015F\u0131m\u0131yor (FR-029)`);
|
|
1810
|
+
}
|
|
1811
|
+
const live = await at();
|
|
1812
|
+
return live.savepoint(async (sp) => {
|
|
1813
|
+
const newId = row[cfg.pk] ?? crypto.randomUUID();
|
|
1814
|
+
const closed = await sp.unsafe(
|
|
1815
|
+
`UPDATE ${quoteIdent(table)} SET "valid_to" = now(), "superseded_by" = $1 WHERE ${quoteIdent(cfg.pk)} = $2 AND "valid_to" IS NULL RETURNING ${quoteIdent(cfg.pk)}`,
|
|
1816
|
+
[newId, id]
|
|
1817
|
+
);
|
|
1818
|
+
if (closed.length === 0) {
|
|
1819
|
+
throw new Error(`supersede(${table}): id "${String(id)}" zaten superseded ya da yok (FR-029)`);
|
|
1820
|
+
}
|
|
1821
|
+
const data = { ...row, [cfg.pk]: newId };
|
|
1822
|
+
const cols = Object.keys(data);
|
|
1823
|
+
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
|
|
1824
|
+
const rows = await sp.unsafe(
|
|
1825
|
+
`INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) RETURNING *`,
|
|
1826
|
+
asBindParams(table, cols, data)
|
|
1827
|
+
);
|
|
1828
|
+
if (!rows[0]) {
|
|
1829
|
+
throw new Error(
|
|
1830
|
+
`supersede(${table}): INSERT sat\u0131r d\xF6nd\xFCrmedi \u2014 yazma reddedildi (b\xFCy\xFCk olas\u0131l\u0131kla bir RLS policy'si)`
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
return asTableRow(table, rows[0]);
|
|
1834
|
+
});
|
|
1323
1835
|
},
|
|
1324
1836
|
/** A real SAVEPOINT inside the request's transaction. */
|
|
1325
1837
|
async transaction(cb) {
|
|
1326
1838
|
const live = await at();
|
|
1327
1839
|
return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
|
|
1328
1840
|
},
|
|
1841
|
+
/**
|
|
1842
|
+
* Run `fn` against a handle bound to a SAVEPOINT, so a failure inside it
|
|
1843
|
+
* rolls back only what that handle wrote and the request can keep writing.
|
|
1844
|
+
*
|
|
1845
|
+
* WHY THE HANDLE IS AN ARGUMENT. The obvious shape — `attempt(async () => {
|
|
1846
|
+
* ... Database.insert(...) ... })`, with no parameter — would have to point
|
|
1847
|
+
* the ambient `Database` at the savepoint for the duration, and a request is
|
|
1848
|
+
* concurrent with itself: `Promise.all([Database.insert(a),
|
|
1849
|
+
* Database.attempt(...)])` would put `a` inside the savepoint and roll it
|
|
1850
|
+
* back with it. Silent data loss, and the same interleaving this file already
|
|
1851
|
+
* refuses for `asService()`. Passing the handle makes the boundary something
|
|
1852
|
+
* you can see in the code that crosses it.
|
|
1853
|
+
*
|
|
1854
|
+
* Postgres, not us: the savepoint is released on success and rolled back on
|
|
1855
|
+
* failure by the driver, so an aborted statement inside `fn` does not poison
|
|
1856
|
+
* the surrounding transaction.
|
|
1857
|
+
*/
|
|
1858
|
+
async attempt(fn) {
|
|
1859
|
+
const live = await at();
|
|
1860
|
+
return live.savepoint(async (sp) => fn(withTables(createOps(sp), currentSchema)));
|
|
1861
|
+
},
|
|
1329
1862
|
/**
|
|
1330
1863
|
* Execute a whole transaction plan — what `Database.transaction(fn)` builds.
|
|
1331
1864
|
*
|
|
@@ -1363,6 +1896,7 @@ function createOps(tx) {
|
|
|
1363
1896
|
var currentSchema = {};
|
|
1364
1897
|
function setSchema(schema) {
|
|
1365
1898
|
cachedVectorSchema = null;
|
|
1899
|
+
cachedTrgmSchema = null;
|
|
1366
1900
|
const s = schema;
|
|
1367
1901
|
currentSchema = (s && "default" in s ? s.default : s) ?? {};
|
|
1368
1902
|
}
|
|
@@ -1375,22 +1909,47 @@ function withTables(ops, schema = currentSchema) {
|
|
|
1375
1909
|
update: (id, data) => ops.update(name, id, data),
|
|
1376
1910
|
delete: (id) => ops.delete(name, id),
|
|
1377
1911
|
findById: (id) => ops.findById(name, id),
|
|
1378
|
-
findMany: (query) => ops.findMany(name, query ?? {})
|
|
1912
|
+
findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
|
|
1913
|
+
upsert: (data, opts) => ops.upsert(name, data, opts)
|
|
1379
1914
|
};
|
|
1380
1915
|
}
|
|
1381
1916
|
const base = /* @__PURE__ */ Object.create(null);
|
|
1382
1917
|
return Object.assign(base, ops, { tables });
|
|
1383
1918
|
}
|
|
1384
1919
|
var SERVICE_LOCK_TIMEOUT = "5s";
|
|
1920
|
+
function isAbortedTransaction(e) {
|
|
1921
|
+
const code = e?.code;
|
|
1922
|
+
const message = String(e?.message ?? "");
|
|
1923
|
+
return code === "25P02" || /current transaction is aborted/i.test(message);
|
|
1924
|
+
}
|
|
1925
|
+
function isForeignKeyViolation(e) {
|
|
1926
|
+
return e?.code === "23503";
|
|
1927
|
+
}
|
|
1385
1928
|
function isLockTimeout(e) {
|
|
1386
1929
|
const code = e?.code;
|
|
1387
1930
|
const message = String(e?.message ?? "");
|
|
1388
1931
|
return code === "55P03" || /lock timeout/i.test(message);
|
|
1389
1932
|
}
|
|
1390
|
-
function diagnosingDriver(sql) {
|
|
1391
|
-
const
|
|
1392
|
-
|
|
1393
|
-
|
|
1933
|
+
function diagnosingDriver(sql, surface = "service") {
|
|
1934
|
+
const original = (e) => String(e?.message ?? e);
|
|
1935
|
+
const explain = (e) => {
|
|
1936
|
+
if (isAbortedTransaction(e)) {
|
|
1937
|
+
return new Error(
|
|
1938
|
+
`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)})`
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
if (surface === "service" && isForeignKeyViolation(e)) {
|
|
1942
|
+
return new Error(
|
|
1943
|
+
`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)})`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
if (isLockTimeout(e)) {
|
|
1947
|
+
return new Error(
|
|
1948
|
+
`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)})`
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
return e;
|
|
1952
|
+
};
|
|
1394
1953
|
const wrapTx = (tx) => ({
|
|
1395
1954
|
async unsafe(text, params) {
|
|
1396
1955
|
try {
|
|
@@ -1409,13 +1968,13 @@ function diagnosingDriver(sql) {
|
|
|
1409
1968
|
};
|
|
1410
1969
|
}
|
|
1411
1970
|
function createRequestDatabase(sql, identity) {
|
|
1412
|
-
const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
|
|
1971
|
+
const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
|
|
1413
1972
|
let serviceTx = null;
|
|
1414
1973
|
let serviceClient = null;
|
|
1415
1974
|
const asService = () => {
|
|
1416
1975
|
if (serviceClient === null) {
|
|
1417
1976
|
serviceTx = createLazyTransaction(
|
|
1418
|
-
diagnosingDriver(sql),
|
|
1977
|
+
diagnosingDriver(sql, "service"),
|
|
1419
1978
|
identity.serviceRole,
|
|
1420
1979
|
identity.claimsJson,
|
|
1421
1980
|
{ lockTimeout: SERVICE_LOCK_TIMEOUT }
|
|
@@ -1496,6 +2055,23 @@ async function runPlanOp(sp, op, results) {
|
|
|
1496
2055
|
sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
|
|
1497
2056
|
break;
|
|
1498
2057
|
}
|
|
2058
|
+
case "upsert": {
|
|
2059
|
+
const cols = Object.keys(op.values ?? {});
|
|
2060
|
+
const conflict = op.onConflict ?? [];
|
|
2061
|
+
if (cols.length === 0 || conflict.length === 0) {
|
|
2062
|
+
throw Object.assign(new Error(`upsert on ${op.table} needs columns and onConflict`), {
|
|
2063
|
+
error_code: "tx_bad_upsert"
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
const rendered = cols.map(
|
|
2067
|
+
(c) => renderValue(op.values[c], c, args, results, opVectorCols)
|
|
2068
|
+
);
|
|
2069
|
+
const conflictSet = new Set(conflict);
|
|
2070
|
+
const sets = cols.filter((c) => !conflictSet.has(c)).map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`);
|
|
2071
|
+
const action = sets.length ? `DO UPDATE SET ${sets.join(", ")}` : `DO UPDATE SET ${quoteIdent(conflict[0])} = EXCLUDED.${quoteIdent(conflict[0])}`;
|
|
2072
|
+
sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) ON CONFLICT (${conflict.map(quoteIdent).join(", ")}) ${action} RETURNING *`;
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
1499
2075
|
case "insertMany": {
|
|
1500
2076
|
const rows = op.rows ?? [];
|
|
1501
2077
|
if (rows.length === 0 || !rows[0]) return [];
|
|
@@ -1692,6 +2268,41 @@ function verifySignature(presented, expected) {
|
|
|
1692
2268
|
return diff === 0;
|
|
1693
2269
|
}
|
|
1694
2270
|
|
|
2271
|
+
// src/engine/sse.ts
|
|
2272
|
+
var SSE_CONTENT_TYPE = "text/event-stream";
|
|
2273
|
+
function encodeFrame(value) {
|
|
2274
|
+
return `data: ${JSON.stringify(value)}
|
|
2275
|
+
|
|
2276
|
+
`;
|
|
2277
|
+
}
|
|
2278
|
+
function encodeErrorFrame(requestId) {
|
|
2279
|
+
return `event: error
|
|
2280
|
+
data: ${JSON.stringify({ requestId })}
|
|
2281
|
+
|
|
2282
|
+
`;
|
|
2283
|
+
}
|
|
2284
|
+
function makeSseWriter(opts) {
|
|
2285
|
+
let begun = false;
|
|
2286
|
+
let chain = Promise.resolve();
|
|
2287
|
+
return {
|
|
2288
|
+
write(value) {
|
|
2289
|
+
const frame = encodeFrame(value);
|
|
2290
|
+
if (!begun) {
|
|
2291
|
+
begun = true;
|
|
2292
|
+
chain = chain.then(() => opts.onFirstWrite()).then(() => opts.enqueue(frame));
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
chain = chain.then(() => opts.enqueue(frame));
|
|
2296
|
+
},
|
|
2297
|
+
started() {
|
|
2298
|
+
return begun;
|
|
2299
|
+
},
|
|
2300
|
+
drained() {
|
|
2301
|
+
return chain;
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
|
|
1695
2306
|
// src/engine/fence.ts
|
|
1696
2307
|
var SECRET_ENV = [
|
|
1697
2308
|
"DATABASE_URL",
|
|
@@ -1831,7 +2442,6 @@ async function createApp(opts) {
|
|
|
1831
2442
|
Notifications: modules.Notifications ?? stubModule("Notifications"),
|
|
1832
2443
|
Flags: modules.Flags ?? stubModule("Flags"),
|
|
1833
2444
|
Realtime: modules.Realtime ?? stubModule("Realtime"),
|
|
1834
|
-
Purchases: modules.Purchases ?? stubModule("Purchases"),
|
|
1835
2445
|
// Named, never undefined. A backend started without a secrets client
|
|
1836
2446
|
// that returned `undefined` here would fail inside the handler as
|
|
1837
2447
|
// "Cannot read properties of undefined", which says nothing about what
|
|
@@ -1928,6 +2538,13 @@ async function createApp(opts) {
|
|
|
1928
2538
|
const args = [];
|
|
1929
2539
|
let parsedBody;
|
|
1930
2540
|
let bodyRead = false;
|
|
2541
|
+
let sseEnqueue = null;
|
|
2542
|
+
let sseSettle = async () => {
|
|
2543
|
+
};
|
|
2544
|
+
const sseWriter = makeSseWriter({
|
|
2545
|
+
enqueue: (chunk) => sseEnqueue?.(chunk),
|
|
2546
|
+
onFirstWrite: () => sseSettle()
|
|
2547
|
+
});
|
|
1931
2548
|
for (const p of meta.params ?? []) {
|
|
1932
2549
|
switch (p.kind) {
|
|
1933
2550
|
case "body": {
|
|
@@ -2031,6 +2648,21 @@ async function createApp(opts) {
|
|
|
2031
2648
|
case "req":
|
|
2032
2649
|
args[p.index] = req;
|
|
2033
2650
|
break;
|
|
2651
|
+
// `@Signal()` — the raw request's AbortSignal, which enters the aborted
|
|
2652
|
+
// state when the client disconnects. Measured on Bun: an infinite
|
|
2653
|
+
// producer guarded by it stops within a few frames of the client dying.
|
|
2654
|
+
// It is deliberately NOT reachable through `@Req()`: PBRequest carries
|
|
2655
|
+
// request-scoped data and no signal.
|
|
2656
|
+
case "signal":
|
|
2657
|
+
args[p.index] = req.signal;
|
|
2658
|
+
break;
|
|
2659
|
+
// `@SseOut()` — the frame writer. Meaningful only on an `@Sse` route;
|
|
2660
|
+
// on any other route it is an inert writer whose frames go nowhere,
|
|
2661
|
+
// which is the same shape `@UploadedObject()` has off an `@Upload`
|
|
2662
|
+
// route.
|
|
2663
|
+
case "sseOut":
|
|
2664
|
+
args[p.index] = sseWriter;
|
|
2665
|
+
break;
|
|
2034
2666
|
default:
|
|
2035
2667
|
args[p.index] = void 0;
|
|
2036
2668
|
}
|
|
@@ -2042,6 +2674,84 @@ async function createApp(opts) {
|
|
|
2042
2674
|
});
|
|
2043
2675
|
try {
|
|
2044
2676
|
const services = buildServices(db);
|
|
2677
|
+
if (meta.options?.sseConfig !== void 0) {
|
|
2678
|
+
let sseController;
|
|
2679
|
+
const sseEncoder = new TextEncoder();
|
|
2680
|
+
const stream = new ReadableStream({
|
|
2681
|
+
start(c) {
|
|
2682
|
+
sseController = c;
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
sseEnqueue = (chunk) => {
|
|
2686
|
+
try {
|
|
2687
|
+
sseController.enqueue(sseEncoder.encode(chunk));
|
|
2688
|
+
} catch {
|
|
2689
|
+
}
|
|
2690
|
+
};
|
|
2691
|
+
let sawFirstFrame;
|
|
2692
|
+
const firstFrame = new Promise((r) => sawFirstFrame = r);
|
|
2693
|
+
sseSettle = async () => {
|
|
2694
|
+
await db.commit();
|
|
2695
|
+
sawFirstFrame();
|
|
2696
|
+
};
|
|
2697
|
+
const guarded = {
|
|
2698
|
+
...services,
|
|
2699
|
+
Database: new Proxy(services.Database, {
|
|
2700
|
+
get(target, prop, recv) {
|
|
2701
|
+
if (sseWriter.started()) {
|
|
2702
|
+
throw new Error(
|
|
2703
|
+
`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.`
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
return Reflect.get(target, prop, recv);
|
|
2707
|
+
}
|
|
2708
|
+
})
|
|
2709
|
+
};
|
|
2710
|
+
let thrown = null;
|
|
2711
|
+
const ran = Promise.resolve(runWithRuntime(guarded, () => {
|
|
2712
|
+
const box = requestALS.getStore();
|
|
2713
|
+
if (box) {
|
|
2714
|
+
box.userId = userId ?? null;
|
|
2715
|
+
box.requestId = requestId;
|
|
2716
|
+
box.idempotencyKey = req.headers.get("idempotency-key");
|
|
2717
|
+
}
|
|
2718
|
+
const method = hit.entry.instance[meta.fnName];
|
|
2719
|
+
if (typeof method !== "function") {
|
|
2720
|
+
throw new Error(
|
|
2721
|
+
`route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
return method.apply(hit.entry.instance, args);
|
|
2725
|
+
})).then(
|
|
2726
|
+
() => void 0,
|
|
2727
|
+
(err) => {
|
|
2728
|
+
thrown = err;
|
|
2729
|
+
}
|
|
2730
|
+
);
|
|
2731
|
+
await Promise.race([firstFrame, ran]);
|
|
2732
|
+
if (!sseWriter.started()) {
|
|
2733
|
+
await ran;
|
|
2734
|
+
await db.commit();
|
|
2735
|
+
if (thrown !== null) throw thrown;
|
|
2736
|
+
return new Response(null, { status: 204 });
|
|
2737
|
+
}
|
|
2738
|
+
void (async () => {
|
|
2739
|
+
await ran;
|
|
2740
|
+
await sseWriter.drained();
|
|
2741
|
+
try {
|
|
2742
|
+
if (thrown !== null) {
|
|
2743
|
+
log.error(`[engine] ${hit.entry.id} threw mid-stream`, thrown);
|
|
2744
|
+
sseController.enqueue(sseEncoder.encode(encodeErrorFrame(requestId)));
|
|
2745
|
+
}
|
|
2746
|
+
sseController.close();
|
|
2747
|
+
} catch {
|
|
2748
|
+
}
|
|
2749
|
+
})();
|
|
2750
|
+
return new Response(stream, {
|
|
2751
|
+
status: 200,
|
|
2752
|
+
headers: { "content-type": SSE_CONTENT_TYPE, "cache-control": "no-cache" }
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2045
2755
|
const result = await runWithRuntime(services, () => {
|
|
2046
2756
|
const box = requestALS.getStore();
|
|
2047
2757
|
if (box) {
|