@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
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,7 +472,8 @@ 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),
|
|
475
|
+
findMany: (query, opts) => ops().findMany(name, query, opts),
|
|
476
|
+
upsert: (data, opts) => ops().upsert(name, data, opts),
|
|
463
477
|
search: (params) => ops().search(name, params),
|
|
464
478
|
similar: (id, params) => ops().similar(name, id, params),
|
|
465
479
|
recommend: (params) => ops().recommend(name, params),
|
|
@@ -479,13 +493,17 @@ function makeTypedSurface(raw) {
|
|
|
479
493
|
update: (table, id, data) => raw.update(table, id, data),
|
|
480
494
|
delete: (table, id) => raw.delete(table, id),
|
|
481
495
|
findById: (table, id) => raw.findById(table, id),
|
|
482
|
-
findMany: (table, query) => raw.findMany(table, query),
|
|
496
|
+
findMany: (table, query, opts) => raw.findMany(table, query, opts),
|
|
497
|
+
upsert: (table, data, opts) => raw.upsert(table, data, opts),
|
|
483
498
|
search: (table, params) => raw.search(table, params),
|
|
484
499
|
similar: (table, id, params) => reco.similar(table, id, params),
|
|
485
500
|
recommend: (table, params) => reco.recommend(table, params),
|
|
486
501
|
supersede: (table, id, row) => raw.supersede(table, id, row)
|
|
487
502
|
};
|
|
488
503
|
return Object.assign(ops, {
|
|
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),
|
|
489
507
|
tables: makeTablesAccessor(() => reco),
|
|
490
508
|
transaction(fn) {
|
|
491
509
|
const builder = new TxPlanBuilder();
|
|
@@ -954,6 +972,14 @@ function makeMemoryCache(opts = {}) {
|
|
|
954
972
|
function quoteIdent(name) {
|
|
955
973
|
return `"${name.replace(/"/g, '""')}"`;
|
|
956
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
|
+
}
|
|
957
983
|
var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
|
|
958
984
|
function createLazyTransaction(sql, role, claimsJson, options = {}) {
|
|
959
985
|
const { lockTimeout } = options;
|
|
@@ -1174,7 +1200,9 @@ function expandSynonyms(query, map) {
|
|
|
1174
1200
|
}).join("");
|
|
1175
1201
|
}).join("");
|
|
1176
1202
|
}
|
|
1177
|
-
var searchLogger =
|
|
1203
|
+
var searchLogger = (evt, fields) => {
|
|
1204
|
+
console.log(evt, JSON.stringify(fields));
|
|
1205
|
+
};
|
|
1178
1206
|
function logNoResults(table, query, mode, n) {
|
|
1179
1207
|
if (n === 0) {
|
|
1180
1208
|
searchLogger?.("palbase.search.no_results", {
|
|
@@ -1215,19 +1243,41 @@ function validitySql(v, add) {
|
|
|
1215
1243
|
function excludeSql(pk, ids, add) {
|
|
1216
1244
|
if (ids.length === 0) return "";
|
|
1217
1245
|
if (ids.length === 1) return ` AND t.${quoteIdent(pk)} <> ${add(ids[0])}`;
|
|
1218
|
-
return ` AND t.${quoteIdent(pk)} <> ALL(ARRAY[${ids.map((x) => add(x)).join(", ")}])`;
|
|
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;
|
|
1219
1252
|
}
|
|
1220
|
-
function
|
|
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") {
|
|
1221
1271
|
const parts = [];
|
|
1222
1272
|
for (const [col, cond] of Object.entries(where)) {
|
|
1223
|
-
if (!colSet.has(col)) {
|
|
1224
|
-
throw new Error(
|
|
1273
|
+
if (colSet !== null && !colSet.has(col)) {
|
|
1274
|
+
throw new Error(`${caller}(${table}): where kolonu "${col}" tabloda yok (FR-016)`);
|
|
1225
1275
|
}
|
|
1226
1276
|
const q = `t.${quoteIdent(col)}`;
|
|
1227
1277
|
if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
|
|
1228
1278
|
for (const [op, v] of Object.entries(cond)) {
|
|
1229
1279
|
if (op === "in") {
|
|
1230
|
-
if (!Array.isArray(v)) throw new Error(
|
|
1280
|
+
if (!Array.isArray(v)) throw new Error(`${caller}(${table}): where.${col}.in bir dizi olmal\u0131`);
|
|
1231
1281
|
if (v.length === 0) {
|
|
1232
1282
|
parts.push("false");
|
|
1233
1283
|
continue;
|
|
@@ -1236,7 +1286,7 @@ function compileWhere(table, colSet, where, add) {
|
|
|
1236
1286
|
} else if (op in WHERE_OPS) {
|
|
1237
1287
|
parts.push(`${q} ${WHERE_OPS[op]} ${add(v)}`);
|
|
1238
1288
|
} else {
|
|
1239
|
-
throw new Error(
|
|
1289
|
+
throw new Error(`${caller}(${table}): where.${col} bilinmeyen operat\xF6r "${op}" (gt/gte/lt/lte/neq/in)`);
|
|
1240
1290
|
}
|
|
1241
1291
|
}
|
|
1242
1292
|
} else {
|
|
@@ -1316,7 +1366,8 @@ function createOps(tx) {
|
|
|
1316
1366
|
const at = () => resolveTx(tx);
|
|
1317
1367
|
const ops = {
|
|
1318
1368
|
async query(sql, params = []) {
|
|
1319
|
-
|
|
1369
|
+
const bound = params.map((p) => Array.isArray(p) ? encodePgArray(p) : p);
|
|
1370
|
+
return asWireRows(await (await at()).unsafe(sql, bound));
|
|
1320
1371
|
},
|
|
1321
1372
|
async insert(table, data) {
|
|
1322
1373
|
const cols = Object.keys(data);
|
|
@@ -1332,6 +1383,41 @@ function createOps(tx) {
|
|
|
1332
1383
|
}
|
|
1333
1384
|
return asTableRow(table, inserted);
|
|
1334
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
|
+
},
|
|
1335
1421
|
async update(table, id, data) {
|
|
1336
1422
|
const cols = Object.keys(data);
|
|
1337
1423
|
if (cols.length === 0) return ops.findById(table, id);
|
|
@@ -1350,13 +1436,18 @@ function createOps(tx) {
|
|
|
1350
1436
|
);
|
|
1351
1437
|
return rows[0] ? asTableRow(table, rows[0]) : null;
|
|
1352
1438
|
},
|
|
1353
|
-
async findMany(table, query = {}) {
|
|
1354
|
-
const
|
|
1355
|
-
const
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
)
|
|
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));
|
|
1360
1451
|
},
|
|
1361
1452
|
/**
|
|
1362
1453
|
* Tek-SQL hibrit arama (FR-014): iki kol CTE + FULL OUTER JOIN + RRF
|
|
@@ -1747,6 +1838,27 @@ function createOps(tx) {
|
|
|
1747
1838
|
const live = await at();
|
|
1748
1839
|
return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
|
|
1749
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
|
+
},
|
|
1750
1862
|
/**
|
|
1751
1863
|
* Execute a whole transaction plan — what `Database.transaction(fn)` builds.
|
|
1752
1864
|
*
|
|
@@ -1797,22 +1909,47 @@ function withTables(ops, schema = currentSchema) {
|
|
|
1797
1909
|
update: (id, data) => ops.update(name, id, data),
|
|
1798
1910
|
delete: (id) => ops.delete(name, id),
|
|
1799
1911
|
findById: (id) => ops.findById(name, id),
|
|
1800
|
-
findMany: (query) => ops.findMany(name, query ?? {})
|
|
1912
|
+
findMany: (query, opts) => ops.findMany(name, query ?? {}, opts),
|
|
1913
|
+
upsert: (data, opts) => ops.upsert(name, data, opts)
|
|
1801
1914
|
};
|
|
1802
1915
|
}
|
|
1803
1916
|
const base = /* @__PURE__ */ Object.create(null);
|
|
1804
1917
|
return Object.assign(base, ops, { tables });
|
|
1805
1918
|
}
|
|
1806
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
|
+
}
|
|
1807
1928
|
function isLockTimeout(e) {
|
|
1808
1929
|
const code = e?.code;
|
|
1809
1930
|
const message = String(e?.message ?? "");
|
|
1810
1931
|
return code === "55P03" || /lock timeout/i.test(message);
|
|
1811
1932
|
}
|
|
1812
|
-
function diagnosingDriver(sql) {
|
|
1813
|
-
const
|
|
1814
|
-
|
|
1815
|
-
|
|
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
|
+
};
|
|
1816
1953
|
const wrapTx = (tx) => ({
|
|
1817
1954
|
async unsafe(text, params) {
|
|
1818
1955
|
try {
|
|
@@ -1831,13 +1968,13 @@ function diagnosingDriver(sql) {
|
|
|
1831
1968
|
};
|
|
1832
1969
|
}
|
|
1833
1970
|
function createRequestDatabase(sql, identity) {
|
|
1834
|
-
const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
|
|
1971
|
+
const tx = createLazyTransaction(diagnosingDriver(sql, "user"), identity.role, identity.claimsJson);
|
|
1835
1972
|
let serviceTx = null;
|
|
1836
1973
|
let serviceClient = null;
|
|
1837
1974
|
const asService = () => {
|
|
1838
1975
|
if (serviceClient === null) {
|
|
1839
1976
|
serviceTx = createLazyTransaction(
|
|
1840
|
-
diagnosingDriver(sql),
|
|
1977
|
+
diagnosingDriver(sql, "service"),
|
|
1841
1978
|
identity.serviceRole,
|
|
1842
1979
|
identity.claimsJson,
|
|
1843
1980
|
{ lockTimeout: SERVICE_LOCK_TIMEOUT }
|
|
@@ -1918,6 +2055,23 @@ async function runPlanOp(sp, op, results) {
|
|
|
1918
2055
|
sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
|
|
1919
2056
|
break;
|
|
1920
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
|
+
}
|
|
1921
2075
|
case "insertMany": {
|
|
1922
2076
|
const rows = op.rows ?? [];
|
|
1923
2077
|
if (rows.length === 0 || !rows[0]) return [];
|
|
@@ -2114,6 +2268,41 @@ function verifySignature(presented, expected) {
|
|
|
2114
2268
|
return diff === 0;
|
|
2115
2269
|
}
|
|
2116
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
|
+
|
|
2117
2306
|
// src/engine/fence.ts
|
|
2118
2307
|
var SECRET_ENV = [
|
|
2119
2308
|
"DATABASE_URL",
|
|
@@ -2349,6 +2538,13 @@ async function createApp(opts) {
|
|
|
2349
2538
|
const args = [];
|
|
2350
2539
|
let parsedBody;
|
|
2351
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
|
+
});
|
|
2352
2548
|
for (const p of meta.params ?? []) {
|
|
2353
2549
|
switch (p.kind) {
|
|
2354
2550
|
case "body": {
|
|
@@ -2452,6 +2648,21 @@ async function createApp(opts) {
|
|
|
2452
2648
|
case "req":
|
|
2453
2649
|
args[p.index] = req;
|
|
2454
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;
|
|
2455
2666
|
default:
|
|
2456
2667
|
args[p.index] = void 0;
|
|
2457
2668
|
}
|
|
@@ -2463,6 +2674,84 @@ async function createApp(opts) {
|
|
|
2463
2674
|
});
|
|
2464
2675
|
try {
|
|
2465
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
|
+
}
|
|
2466
2755
|
const result = await runWithRuntime(services, () => {
|
|
2467
2756
|
const box = requestALS.getStore();
|
|
2468
2757
|
if (box) {
|