@palbase/backend 23.0.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 +313 -24
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-Y5HXVUMP.js → chunk-HQRJDARQ.js} +2 -2
- package/dist/{chunk-REZU6UKT.js → chunk-M5MCBWJI.js} +296 -25
- 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-FSGSB42K.js → chunk-OHALWEOG.js} +9 -4
- package/dist/chunk-OHALWEOG.js.map +1 -0
- package/dist/{chunk-HAF67F2H.js → chunk-PY7YJDCT.js} +57 -3
- package/dist/chunk-PY7YJDCT.js.map +1 -0
- package/dist/{chunk-ZC6Q2BRD.js → chunk-R3KN6RHD.js} +2 -2
- package/dist/{chunk-OMRTHM4X.js → chunk-RCLNBJCM.js} +1 -1
- package/dist/chunk-RCLNBJCM.js.map +1 -0
- package/dist/db/index.cjs +68 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +2 -2
- package/dist/{endpoint-BavvbW4P.d.ts → endpoint-CVWXh6oG.d.ts} +70 -8
- package/dist/{endpoint-i8TTCohk.d.cts → endpoint-c9h5jriX.d.cts} +70 -8
- package/dist/engine/index.cjs +313 -24
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +4 -4
- package/dist/engine/index.d.ts +4 -4
- package/dist/engine/index.js +4 -4
- package/dist/{index-B7YBEG5w.d.ts → index-BZrJXnVh.d.ts} +49 -4
- package/dist/{index-Bmvx1EvJ.d.cts → index-By8Dle5U.d.cts} +86 -17
- package/dist/{index-B3jmmItD.d.ts → index-CwAJ7HEe.d.ts} +86 -17
- package/dist/{index-E7OscPJT.d.cts → index-CxeQSfJP.d.cts} +49 -4
- package/dist/index.cjs +104 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +32 -6
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +29 -4
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +5 -2
- package/dist/openapi/index.d.ts +5 -2
- package/dist/openapi/index.js +32 -7
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-DY3d9l1k.d.ts → registry-B3niOVYp.d.ts} +101 -7
- package/dist/{registry-C3H2uPeZ.d.cts → registry-CqPK2Qby.d.cts} +101 -7
- 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/package.json +4 -3
- package/stager/return_types.js +23 -0
- package/dist/chunk-FSGSB42K.js.map +0 -1
- package/dist/chunk-HAF67F2H.js.map +0 -1
- package/dist/chunk-OMRTHM4X.js.map +0 -1
- package/dist/chunk-REZU6UKT.js.map +0 -1
- package/dist/chunk-W5ODXPY3.js.map +0 -1
- /package/dist/{chunk-Y5HXVUMP.js.map → chunk-HQRJDARQ.js.map} +0 -0
- /package/dist/{chunk-ZC6Q2BRD.js.map → chunk-R3KN6RHD.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,7 +438,8 @@ 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),
|
|
441
|
+
findMany: (query, opts) => ops().findMany(name, query, opts),
|
|
442
|
+
upsert: (data, opts) => ops().upsert(name, data, opts),
|
|
429
443
|
search: (params) => ops().search(name, params),
|
|
430
444
|
similar: (id, params) => ops().similar(name, id, params),
|
|
431
445
|
recommend: (params) => ops().recommend(name, params),
|
|
@@ -445,13 +459,17 @@ function makeTypedSurface(raw) {
|
|
|
445
459
|
update: (table, id, data) => raw.update(table, id, data),
|
|
446
460
|
delete: (table, id) => raw.delete(table, id),
|
|
447
461
|
findById: (table, id) => raw.findById(table, id),
|
|
448
|
-
findMany: (table, query) => raw.findMany(table, query),
|
|
462
|
+
findMany: (table, query, opts) => raw.findMany(table, query, opts),
|
|
463
|
+
upsert: (table, data, opts) => raw.upsert(table, data, opts),
|
|
449
464
|
search: (table, params) => raw.search(table, params),
|
|
450
465
|
similar: (table, id, params) => reco.similar(table, id, params),
|
|
451
466
|
recommend: (table, params) => reco.recommend(table, params),
|
|
452
467
|
supersede: (table, id, row) => raw.supersede(table, id, row)
|
|
453
468
|
};
|
|
454
469
|
return Object.assign(ops, {
|
|
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),
|
|
455
473
|
tables: makeTablesAccessor(() => reco),
|
|
456
474
|
transaction(fn) {
|
|
457
475
|
const builder = new TxPlanBuilder();
|
|
@@ -920,6 +938,14 @@ function makeMemoryCache(opts = {}) {
|
|
|
920
938
|
function quoteIdent(name) {
|
|
921
939
|
return `"${name.replace(/"/g, '""')}"`;
|
|
922
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
|
+
}
|
|
923
949
|
var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
|
|
924
950
|
function createLazyTransaction(sql, role, claimsJson, options = {}) {
|
|
925
951
|
const { lockTimeout } = options;
|
|
@@ -1140,7 +1166,9 @@ function expandSynonyms(query, map) {
|
|
|
1140
1166
|
}).join("");
|
|
1141
1167
|
}).join("");
|
|
1142
1168
|
}
|
|
1143
|
-
var searchLogger =
|
|
1169
|
+
var searchLogger = (evt, fields) => {
|
|
1170
|
+
console.log(evt, JSON.stringify(fields));
|
|
1171
|
+
};
|
|
1144
1172
|
function logNoResults(table, query, mode, n) {
|
|
1145
1173
|
if (n === 0) {
|
|
1146
1174
|
searchLogger?.("palbase.search.no_results", {
|
|
@@ -1181,19 +1209,41 @@ function validitySql(v, add) {
|
|
|
1181
1209
|
function excludeSql(pk, ids, add) {
|
|
1182
1210
|
if (ids.length === 0) return "";
|
|
1183
1211
|
if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
|
|
1184
|
-
return ` AND t.${quoteIdent(pk)} <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
|
|
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}`;
|
|
1185
1235
|
}
|
|
1186
|
-
function compileWhere(table, colSet, where, add) {
|
|
1236
|
+
function compileWhere(table, colSet, where, add, caller = "search") {
|
|
1187
1237
|
const parts = [];
|
|
1188
1238
|
for (const [col, cond] of Object.entries(where)) {
|
|
1189
|
-
if (!colSet.has(col)) {
|
|
1190
|
-
throw new Error(
|
|
1239
|
+
if (colSet !== null && !colSet.has(col)) {
|
|
1240
|
+
throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
|
|
1191
1241
|
}
|
|
1192
1242
|
const q = `t.${quoteIdent(col)}`;
|
|
1193
1243
|
if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
|
|
1194
1244
|
for (const [op, v] of Object.entries(cond)) {
|
|
1195
1245
|
if (op === "in") {
|
|
1196
|
-
if (!Array.isArray(v)) throw new Error(
|
|
1246
|
+
if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
|
|
1197
1247
|
if (v.length === 0) {
|
|
1198
1248
|
parts.push("false");
|
|
1199
1249
|
continue;
|
|
@@ -1202,7 +1252,7 @@ function compileWhere(table, colSet, where, add) {
|
|
|
1202
1252
|
} else if (op in WHERE_OPS) {
|
|
1203
1253
|
parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
|
|
1204
1254
|
} else {
|
|
1205
|
-
throw new Error(
|
|
1255
|
+
throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
|
|
1206
1256
|
}
|
|
1207
1257
|
}
|
|
1208
1258
|
} else {
|
|
@@ -1282,7 +1332,8 @@ function createOps(tx) {
|
|
|
1282
1332
|
const at = () => resolveTx(tx);
|
|
1283
1333
|
const ops = {
|
|
1284
1334
|
async query(sql, params = []) {
|
|
1285
|
-
|
|
1335
|
+
const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
|
|
1336
|
+
return asWireRows(await (await at()).unsafe(sql, bound));
|
|
1286
1337
|
},
|
|
1287
1338
|
async insert(table, data) {
|
|
1288
1339
|
const cols = Object.keys(data);
|
|
@@ -1298,6 +1349,41 @@ function createOps(tx) {
|
|
|
1298
1349
|
}
|
|
1299
1350
|
return asTableRow(table, inserted);
|
|
1300
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
|
+
},
|
|
1301
1387
|
async update(table, id, data) {
|
|
1302
1388
|
const cols = Object.keys(data);
|
|
1303
1389
|
if (cols.length === 0) return ops.findById(table, id);
|
|
@@ -1316,13 +1402,18 @@ function createOps(tx) {
|
|
|
1316
1402
|
);
|
|
1317
1403
|
return rows[0] ? asTableRow(table, rows[0]) : null;
|
|
1318
1404
|
},
|
|
1319
|
-
async findMany(table, query = {}) {
|
|
1320
|
-
const
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
)
|
|
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));
|
|
1326
1417
|
},
|
|
1327
1418
|
/**
|
|
1328
1419
|
* Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
|
|
@@ -1713,6 +1804,27 @@ function createOps(tx) {
|
|
|
1713
1804
|
const live = await at();
|
|
1714
1805
|
return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
|
|
1715
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
|
+
},
|
|
1716
1828
|
/**
|
|
1717
1829
|
* Execute a whole transaction plan — what `Database.transaction(fn)` builds.
|
|
1718
1830
|
*
|
|
@@ -1763,22 +1875,47 @@ function withTables(ops, schema = currentSchema) {
|
|
|
1763
1875
|
update: (id, data) => ops.update(name, id, data),
|
|
1764
1876
|
delete: (id) => ops.delete(name, id),
|
|
1765
1877
|
findById: (id) => ops.findById(name, id),
|
|
1766
|
-
findMany: (query) => ops.findMany(name, query ?? {})
|
|
1878
|
+
findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
|
|
1879
|
+
upsert: (data, opts) => ops.upsert(name, data, opts)
|
|
1767
1880
|
};
|
|
1768
1881
|
}
|
|
1769
1882
|
const base = /* @__PURE__ */ Object.create(null);
|
|
1770
1883
|
return Object.assign(base, ops, { tables });
|
|
1771
1884
|
}
|
|
1772
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
|
+
}
|
|
1773
1894
|
function isLockTimeout(e) {
|
|
1774
1895
|
const code = e?.code;
|
|
1775
1896
|
const message = String(e?.message ?? "");
|
|
1776
1897
|
return code === "55P03" || /lock timeout/i.test(message);
|
|
1777
1898
|
}
|
|
1778
|
-
function diagnosingDriver(sql) {
|
|
1779
|
-
const
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
+
};
|
|
1782
1919
|
const wrapTx = (tx) => ({
|
|
1783
1920
|
async unsafe(text, params) {
|
|
1784
1921
|
try {
|
|
@@ -1797,13 +1934,13 @@ function diagnosingDriver(sql) {
|
|
|
1797
1934
|
};
|
|
1798
1935
|
}
|
|
1799
1936
|
function createRequestDatabase(sql, identity) {
|
|
1800
|
-
const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
|
|
1937
|
+
const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
|
|
1801
1938
|
let serviceTx = null;
|
|
1802
1939
|
let serviceClient = null;
|
|
1803
1940
|
const asService = () => {
|
|
1804
1941
|
if (serviceClient === null) {
|
|
1805
1942
|
serviceTx = createLazyTransaction(
|
|
1806
|
-
diagnosingDriver(sql),
|
|
1943
|
+
diagnosingDriver(sql, "service"),
|
|
1807
1944
|
identity.serviceRole,
|
|
1808
1945
|
identity.claimsJson,
|
|
1809
1946
|
{ lockTimeout: SERVICE_LOCK_TIMEOUT }
|
|
@@ -1884,6 +2021,23 @@ async function runPlanOp(sp, op, results) {
|
|
|
1884
2021
|
sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
|
|
1885
2022
|
break;
|
|
1886
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
|
+
}
|
|
1887
2041
|
case "insertMany": {
|
|
1888
2042
|
const rows = op.rows ?? [];
|
|
1889
2043
|
if (rows.length === 0 || !rows[0]) return [];
|
|
@@ -2080,6 +2234,41 @@ function verifySignature(presented, expected) {
|
|
|
2080
2234
|
return diff === 0;
|
|
2081
2235
|
}
|
|
2082
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
|
+
|
|
2083
2272
|
// src/engine/index.ts
|
|
2084
2273
|
var JSON_HEADERS = { "content-type": "application/json" };
|
|
2085
2274
|
function fieldErrors(err) {
|
|
@@ -2254,6 +2443,13 @@ async function createApp(opts) {
|
|
|
2254
2443
|
const args = [];
|
|
2255
2444
|
let parsedBody;
|
|
2256
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
|
+
});
|
|
2257
2453
|
for (const p of meta.params ?? []) {
|
|
2258
2454
|
switch (p.kind) {
|
|
2259
2455
|
case "body": {
|
|
@@ -2357,6 +2553,21 @@ async function createApp(opts) {
|
|
|
2357
2553
|
case "req":
|
|
2358
2554
|
args[p.index] = req;
|
|
2359
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;
|
|
2360
2571
|
default:
|
|
2361
2572
|
args[p.index] = void 0;
|
|
2362
2573
|
}
|
|
@@ -2368,6 +2579,84 @@ async function createApp(opts) {
|
|
|
2368
2579
|
});
|
|
2369
2580
|
try {
|
|
2370
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
|
+
}
|
|
2371
2660
|
const result = await runWithRuntime(services, () => {
|
|
2372
2661
|
const box = requestALS.getStore();
|
|
2373
2662
|
if (box) {
|