@edge-base/server 0.3.1 → 0.3.3
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/admin-build/_app/immutable/chunks/{C-zXt-cq.js → ArkGiTMv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DF40xjpj.js → B6_FvkIG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Cek51lkE.js → BM46GgYk.js} +3 -3
- package/admin-build/_app/immutable/chunks/{BSIzoJ5I.js → Bxlz62x0.js} +1 -1
- package/admin-build/_app/immutable/chunks/ByFwWOVj.js +1 -0
- package/admin-build/_app/immutable/chunks/{aPulNooa.js → CKJGIsEj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D0mNYcxN.js → CUEBmgYR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{C1Mycuvd.js → Cav53qqs.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DOOhHO-v.js → CtOkHpP1.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BkrC_6Ss.js → D3arvney.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dvlxm2Iq.js → DC_HyNNV.js} +1 -1
- package/admin-build/_app/immutable/chunks/{dZsKGnWZ.js → Da6ZRIF1.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BeYswbvA.js → DfwnKUzy.js} +1 -1
- package/admin-build/_app/immutable/chunks/DuAuob_N.js +1 -0
- package/admin-build/_app/immutable/chunks/{BunyH6GE.js → ZQw8NgPJ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Xm1dHMTE.js → fgIr2CuT.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.j1WHBlZX.js → app.BtTg2P3N.js} +2 -2
- package/admin-build/_app/immutable/entry/start.D21f0BIj.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.Oo7ZpbR-.js → 0.CqnCTNhX.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.zgMnKHns.js → 1.BLn3o9jT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.u5mkRiUe.js → 10.Bp6HrvpD.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.9ynAgaxV.js → 11.79DhgZw-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.M_EqzJ6s.js → 12.A7_yb57H.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.D_NQ9q60.js → 13.DgMDZXO8.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.-vENIJ2w.js → 14.DycfMAIE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.DoMCZhGv.js → 15.Bk_-RJ3j.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.6BmnzScq.js → 16.B7Vjh-Xp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.q6QQ9N_J.js → 17.DKfB3-O7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DUhJ6dKj.js → 18.Bwq1YHfM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CvkvPlGP.js → 19.3S-h9EgK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.cACSbRIl.js → 20.Cm2djLN8.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.C3hqim4E.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.BsQ6xeKr.js → 22.DltIIio7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.CNW3mLz5.js → 23.qVqH8BFx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.CReqtEOy.js → 24.BBgqZFZ0.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.BuxVhqlb.js → 25.2xJDygjz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.C-LzPf62.js → 26.B7pxYnqL.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.Dl5RL_tB.js → 27.P3eodDFO.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.CWfSQgjH.js → 28.CPEn1uNw.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.wUSfh2eQ.js → 29.D7ZGYHTI.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.D_laBXeK.js → 3.UaLSRTpv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.DcMhZyQ0.js → 30.D-iftjK2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.CSwhNsNi.js → 31.CHSNieUr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.Cq28vPI2.js → 4.BgPFR0hR.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BTT9B3oI.js → 5.BRn6WaF_.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.iCmmzXZp.js → 6.Cdem1OHu.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.qY5v7qqJ.js → 7.FpFS7ceb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.C6p4RvgK.js → 8.aCQtyAed.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.f548N7zh.js → 9.9BZCZcm_.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +193 -0
- package/package.json +3 -3
- package/src/__tests__/error-format.test.ts +21 -0
- package/src/__tests__/postgres-api-consistency.test.ts +240 -0
- package/src/__tests__/postgres-batch-compat.test.ts +33 -27
- package/src/__tests__/postgres-batch.test.ts +394 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
- package/src/__tests__/postgres-transact.test.ts +148 -0
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/database-do.ts +455 -15
- package/src/durable-objects/room-runtime-base.ts +8 -1
- package/src/lib/d1-handler.ts +416 -50
- package/src/lib/errors.ts +8 -4
- package/src/lib/internal-transport.ts +10 -0
- package/src/lib/postgres-handler.ts +562 -84
- package/src/routes/auth.ts +58 -46
- package/src/routes/tables.ts +140 -0
- package/admin-build/_app/immutable/chunks/D3hdqsfp.js +0 -1
- package/admin-build/_app/immutable/chunks/DdjFeYwG.js +0 -1
- package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +0 -1
- package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +0 -1
package/src/lib/d1-handler.ts
CHANGED
|
@@ -65,6 +65,17 @@ export async function handleD1Request(
|
|
|
65
65
|
// 1. Resolve D1 binding
|
|
66
66
|
const resolved = resolveD1Binding(c.env, namespace);
|
|
67
67
|
|
|
68
|
+
// Multi-table transact has no table path segment — handle before table validation.
|
|
69
|
+
if (doPath === '/transact' && c.req.raw.method === 'POST') {
|
|
70
|
+
await ensureD1Schema(resolved.db, namespace, resolved.dbBlock.tables ?? {});
|
|
71
|
+
return handleTransact(
|
|
72
|
+
c,
|
|
73
|
+
resolved,
|
|
74
|
+
(c.get('auth') as AuthContext | null | undefined) ?? null,
|
|
75
|
+
checkServiceKey(c),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
// 2. Validate table exists in config
|
|
69
80
|
const tableConfig = resolved.dbBlock.tables?.[tableName];
|
|
70
81
|
if (!tableConfig) {
|
|
@@ -132,7 +143,7 @@ function invalidD1BodyMessage(context: string): string {
|
|
|
132
143
|
|
|
133
144
|
function d1RuleRejectedMessage(
|
|
134
145
|
tableName: string,
|
|
135
|
-
action: 'read' | 'insert' | 'delete' | 'list' | 'count' | 'search',
|
|
146
|
+
action: 'read' | 'insert' | 'update' | 'delete' | 'list' | 'count' | 'search',
|
|
136
147
|
id?: string,
|
|
137
148
|
): string {
|
|
138
149
|
if (id) {
|
|
@@ -463,9 +474,10 @@ async function handleList(
|
|
|
463
474
|
}
|
|
464
475
|
}
|
|
465
476
|
|
|
466
|
-
// Get total count
|
|
477
|
+
// Get total count — skip the COUNT query when ?includeTotal=0/false (total stays null)
|
|
467
478
|
let total: number | null = null;
|
|
468
|
-
|
|
479
|
+
const includeTotal = !['0', 'false'].includes((c.req.query('includeTotal') ?? '').toLowerCase());
|
|
480
|
+
if (includeTotal && countSql && countParams) {
|
|
469
481
|
const countResult = await executeD1Query(resolved.db, countSql, countParams);
|
|
470
482
|
total = Number(countResult.rows[0]?.total ?? 0);
|
|
471
483
|
}
|
|
@@ -644,7 +656,6 @@ async function handleInsert(
|
|
|
644
656
|
} catch {
|
|
645
657
|
return c.json({ code: 400, message: invalidD1BodyMessage(`inserting into table '${tableName}'`) }, 400);
|
|
646
658
|
}
|
|
647
|
-
body = applySchemaFieldAliases(body, tableConfig.schema);
|
|
648
659
|
|
|
649
660
|
// Check insert rule
|
|
650
661
|
const tableAccess = getTableAccess(tableConfig);
|
|
@@ -815,7 +826,6 @@ async function handleUpdate(
|
|
|
815
826
|
} catch {
|
|
816
827
|
return c.json({ code: 400, message: invalidD1BodyMessage(`updating table '${tableName}'`) }, 400);
|
|
817
828
|
}
|
|
818
|
-
body = applySchemaFieldAliases(body, tableConfig.schema);
|
|
819
829
|
|
|
820
830
|
// Validate against schema
|
|
821
831
|
const validation = validateUpdate(body, tableConfig.schema);
|
|
@@ -836,7 +846,7 @@ async function handleUpdate(
|
|
|
836
846
|
const tableHooks = getTableHooks(tableConfig);
|
|
837
847
|
if (!isServiceKey && tableAccess?.update !== undefined) {
|
|
838
848
|
if (!(await evalRowRule(tableAccess.update, auth, existingRow))) {
|
|
839
|
-
return c.json({ code: 403, message:
|
|
849
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(tableName, 'update', id) }, 403);
|
|
840
850
|
}
|
|
841
851
|
}
|
|
842
852
|
|
|
@@ -1070,7 +1080,6 @@ async function handleBatch(
|
|
|
1070
1080
|
|
|
1071
1081
|
// Validate all inserts
|
|
1072
1082
|
if (body.inserts?.length) {
|
|
1073
|
-
body.inserts = body.inserts.map((item) => applySchemaFieldAliases(item, tableConfig.schema));
|
|
1074
1083
|
for (const item of body.inserts) {
|
|
1075
1084
|
const validation = validateInsert(item, tableConfig.schema);
|
|
1076
1085
|
if (!validation.valid) {
|
|
@@ -1081,10 +1090,6 @@ async function handleBatch(
|
|
|
1081
1090
|
|
|
1082
1091
|
// Validate all updates
|
|
1083
1092
|
if (body.updates?.length) {
|
|
1084
|
-
body.updates = body.updates.map((entry) => ({
|
|
1085
|
-
...entry,
|
|
1086
|
-
data: applySchemaFieldAliases(entry.data, tableConfig.schema),
|
|
1087
|
-
}));
|
|
1088
1093
|
for (const entry of body.updates) {
|
|
1089
1094
|
if (!entry.id) {
|
|
1090
1095
|
return c.json({ code: 400, message: `Each batch update entry for table '${tableName}' must include an id.` }, 400);
|
|
@@ -1099,10 +1104,31 @@ async function handleBatch(
|
|
|
1099
1104
|
}
|
|
1100
1105
|
}
|
|
1101
1106
|
|
|
1102
|
-
//
|
|
1107
|
+
// Per-row update rule evaluation on pre-read rows (mirrors this file's
|
|
1108
|
+
// transact path and DO's canonical batch behavior, database-do.ts).
|
|
1109
|
+
// D1 cannot evaluate JS rules inside the SQL batch, so rules run on the
|
|
1110
|
+
// pre-read row state before any operation executes — all-or-nothing.
|
|
1111
|
+
if (!isServiceKey && body.updates?.length && tableAccess?.update !== undefined) {
|
|
1112
|
+
for (const entry of body.updates) {
|
|
1113
|
+
const current = await executeD1Query(resolved.db, `SELECT * FROM ${esc(tableName)} WHERE "id" = ?`, [entry.id]);
|
|
1114
|
+
if (current.rows.length === 0) continue; // missing rows are no-ops (DO parity)
|
|
1115
|
+
const row = normalizeRow(stripInternalFields(current.rows[0]), tableConfig);
|
|
1116
|
+
if (!(await evalRowRule(tableAccess.update, auth, row))) {
|
|
1117
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(tableName, 'update', entry.id) }, 403);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Per-row delete rule evaluation on pre-read rows (DO parity; previously
|
|
1123
|
+
// evaluated once against an empty row, which broke row-dependent rules).
|
|
1103
1124
|
if (!isServiceKey && body.deletes?.length && tableAccess?.delete !== undefined) {
|
|
1104
|
-
|
|
1105
|
-
|
|
1125
|
+
for (const id of body.deletes) {
|
|
1126
|
+
const current = await executeD1Query(resolved.db, `SELECT * FROM ${esc(tableName)} WHERE "id" = ?`, [id]);
|
|
1127
|
+
if (current.rows.length === 0) continue; // missing rows are no-ops (DO parity)
|
|
1128
|
+
const row = normalizeRow(stripInternalFields(current.rows[0]), tableConfig);
|
|
1129
|
+
if (!(await evalRowRule(tableAccess.delete, auth, row))) {
|
|
1130
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(tableName, 'delete', id) }, 403);
|
|
1131
|
+
}
|
|
1106
1132
|
}
|
|
1107
1133
|
}
|
|
1108
1134
|
|
|
@@ -1244,6 +1270,351 @@ async function handleBatch(
|
|
|
1244
1270
|
return c.json(results);
|
|
1245
1271
|
}
|
|
1246
1272
|
|
|
1273
|
+
// ─── TRANSACT (multi-table atomic writes) ───
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Guard table backing `expect` assertions. Each expect op becomes an
|
|
1277
|
+
* `INSERT INTO _edgebase_tx_guard(id) SELECT NULL WHERE <unmet>` statement:
|
|
1278
|
+
* when the expectation is unmet the SELECT yields a row, the NOT NULL primary
|
|
1279
|
+
* key rejects it, and the whole db.batch() transaction rolls back — enforcing
|
|
1280
|
+
* the assertion at commit time, not merely at pre-read time.
|
|
1281
|
+
*/
|
|
1282
|
+
const TX_GUARD_TABLE = '_edgebase_tx_guard';
|
|
1283
|
+
|
|
1284
|
+
interface D1TransactOp {
|
|
1285
|
+
table?: unknown;
|
|
1286
|
+
op?: unknown;
|
|
1287
|
+
id?: unknown;
|
|
1288
|
+
data?: unknown;
|
|
1289
|
+
where?: unknown;
|
|
1290
|
+
exists?: unknown;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function handleTransact(
|
|
1294
|
+
c: Context<HonoEnv>,
|
|
1295
|
+
resolved: D1ResolvedDb,
|
|
1296
|
+
auth: AuthContext | null,
|
|
1297
|
+
isServiceKey: boolean,
|
|
1298
|
+
): Promise<Response> {
|
|
1299
|
+
let body: { operations?: D1TransactOp[] };
|
|
1300
|
+
try {
|
|
1301
|
+
body = await c.req.json();
|
|
1302
|
+
} catch {
|
|
1303
|
+
return c.json({ code: 400, message: invalidD1BodyMessage('transact operations') }, 400);
|
|
1304
|
+
}
|
|
1305
|
+
const operations = body.operations;
|
|
1306
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
1307
|
+
return c.json({ code: 400, message: 'transact requires a non-empty operations array.' }, 400);
|
|
1308
|
+
}
|
|
1309
|
+
const MAX_TRANSACT_OPS = 500;
|
|
1310
|
+
if (operations.length > MAX_TRANSACT_OPS) {
|
|
1311
|
+
return c.json({ code: 400, message: `Transact limit exceeded: ${operations.length} operations (max ${MAX_TRANSACT_OPS}).` }, 400);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const tables = resolved.dbBlock.tables ?? {};
|
|
1315
|
+
interface TxTable {
|
|
1316
|
+
tableConfig: TableConfig;
|
|
1317
|
+
effective: ReturnType<typeof buildEffectiveSchema>;
|
|
1318
|
+
}
|
|
1319
|
+
const tableCtx = new Map<string, TxTable>();
|
|
1320
|
+
for (const op of operations) {
|
|
1321
|
+
if (typeof op?.table !== 'string' || !op.table) {
|
|
1322
|
+
return c.json({ code: 400, message: 'Each transact operation must name a table.' }, 400);
|
|
1323
|
+
}
|
|
1324
|
+
if (op.op !== 'insert' && op.op !== 'update' && op.op !== 'delete' && op.op !== 'expect') {
|
|
1325
|
+
return c.json({ code: 400, message: `Unknown transact op '${String(op.op)}'. Expected insert | update | delete | expect.` }, 400);
|
|
1326
|
+
}
|
|
1327
|
+
if (op.op === 'insert' && (!op.data || typeof op.data !== 'object')) {
|
|
1328
|
+
return c.json({ code: 400, message: 'transact insert requires a data object.' }, 400);
|
|
1329
|
+
}
|
|
1330
|
+
if (op.op === 'update' && (typeof op.id !== 'string' || !op.id || !op.data || typeof op.data !== 'object')) {
|
|
1331
|
+
return c.json({ code: 400, message: 'transact update requires an id and a data object.' }, 400);
|
|
1332
|
+
}
|
|
1333
|
+
if (op.op === 'delete' && (typeof op.id !== 'string' || !op.id)) {
|
|
1334
|
+
return c.json({ code: 400, message: 'transact delete requires an id.' }, 400);
|
|
1335
|
+
}
|
|
1336
|
+
if (op.op === 'expect') {
|
|
1337
|
+
const hasId = typeof op.id === 'string' && op.id.length > 0;
|
|
1338
|
+
const hasWhere = Array.isArray(op.where) && op.where.length > 0;
|
|
1339
|
+
if (!hasId && !hasWhere) {
|
|
1340
|
+
return c.json({ code: 400, message: 'transact expect requires an id and/or where conditions.' }, 400);
|
|
1341
|
+
}
|
|
1342
|
+
if (typeof op.exists !== 'boolean') {
|
|
1343
|
+
return c.json({ code: 400, message: 'transact expect requires a boolean exists.' }, 400);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
if (!tableCtx.has(op.table)) {
|
|
1347
|
+
const tableConfig = tables[op.table];
|
|
1348
|
+
if (!tableConfig) {
|
|
1349
|
+
return c.json({ code: 400, message: `Table '${op.table}' not found in database '${resolved.namespace}'.` }, 400);
|
|
1350
|
+
}
|
|
1351
|
+
tableCtx.set(op.table, {
|
|
1352
|
+
tableConfig,
|
|
1353
|
+
effective: buildEffectiveSchema(tableConfig.schema),
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Table-level insert rules (matches handleBatch semantics).
|
|
1359
|
+
if (!isServiceKey) {
|
|
1360
|
+
const checkedInsert = new Set<string>();
|
|
1361
|
+
for (const op of operations) {
|
|
1362
|
+
if (op.op !== 'insert' || checkedInsert.has(op.table as string)) continue;
|
|
1363
|
+
checkedInsert.add(op.table as string);
|
|
1364
|
+
const access = getTableAccess(tableCtx.get(op.table as string)!.tableConfig);
|
|
1365
|
+
if (access?.insert !== undefined && !(await evalInsertRule(access.insert, auth))) {
|
|
1366
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(op.table as string, 'insert') }, 403);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const now = new Date().toISOString();
|
|
1372
|
+
const stmts: D1PreparedStatement[] = [];
|
|
1373
|
+
// Post-commit result builders aligned with op order.
|
|
1374
|
+
const resultBuilders: Array<() => Promise<Record<string, unknown>>> = [];
|
|
1375
|
+
const allChanges: Array<{
|
|
1376
|
+
table: string;
|
|
1377
|
+
type: 'added' | 'modified' | 'removed';
|
|
1378
|
+
docId: string;
|
|
1379
|
+
data: Record<string, unknown> | null;
|
|
1380
|
+
}> = [];
|
|
1381
|
+
const postFetchChanges: Array<{ table: string; id: string; type: 'added' | 'modified'; resultIndex: number }> = [];
|
|
1382
|
+
|
|
1383
|
+
const escapeCondField = (field: string) => esc(field);
|
|
1384
|
+
|
|
1385
|
+
for (const op of operations) {
|
|
1386
|
+
const name = op.table as string;
|
|
1387
|
+
const { tableConfig, effective } = tableCtx.get(name)!;
|
|
1388
|
+
|
|
1389
|
+
if (op.op === 'expect') {
|
|
1390
|
+
const clauses: string[] = [];
|
|
1391
|
+
const params: unknown[] = [];
|
|
1392
|
+
if (typeof op.id === 'string' && op.id) {
|
|
1393
|
+
clauses.push('"id" = ?');
|
|
1394
|
+
params.push(op.id);
|
|
1395
|
+
}
|
|
1396
|
+
for (const cond of (op.where as unknown[]) ?? []) {
|
|
1397
|
+
if (!Array.isArray(cond) || cond.length !== 3) {
|
|
1398
|
+
return c.json({ code: 400, message: 'transact expect where entries must be [field, "==", value].' }, 400);
|
|
1399
|
+
}
|
|
1400
|
+
const [field, cmp, value] = cond as [unknown, unknown, unknown];
|
|
1401
|
+
if (typeof field !== 'string' || !field) {
|
|
1402
|
+
return c.json({ code: 400, message: 'transact expect where field must be a string.' }, 400);
|
|
1403
|
+
}
|
|
1404
|
+
if (cmp !== '==') {
|
|
1405
|
+
return c.json({ code: 400, message: `transact expect only supports '==' conditions (got '${String(cmp)}').` }, 400);
|
|
1406
|
+
}
|
|
1407
|
+
if (value === null) {
|
|
1408
|
+
clauses.push(`${escapeCondField(field)} IS NULL`);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (typeof value === 'object') {
|
|
1412
|
+
return c.json({ code: 400, message: 'transact expect cannot compare object values.' }, 400);
|
|
1413
|
+
}
|
|
1414
|
+
clauses.push(`${escapeCondField(field)} = ?`);
|
|
1415
|
+
params.push(
|
|
1416
|
+
effective[field]?.type === 'boolean'
|
|
1417
|
+
? (value === true || value === 'true' || value === 1 || value === '1' ? 1 : 0)
|
|
1418
|
+
: value,
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
const condition = `SELECT 1 FROM ${esc(name)} WHERE ${clauses.join(' AND ')}`;
|
|
1422
|
+
|
|
1423
|
+
// Read rule on the probed row (worker-side, like other D1 read paths).
|
|
1424
|
+
if (!isServiceKey) {
|
|
1425
|
+
const access = getTableAccess(tableConfig);
|
|
1426
|
+
if (access?.read !== undefined) {
|
|
1427
|
+
const probe = await executeD1Query(resolved.db, `SELECT * FROM ${esc(name)} WHERE ${clauses.join(' AND ')} LIMIT 1`, params);
|
|
1428
|
+
if (probe.rows.length > 0) {
|
|
1429
|
+
const row = normalizeRow(stripInternalFields(probe.rows[0]), tableConfig);
|
|
1430
|
+
if (!(await evalRowRule(access.read, auth, row))) {
|
|
1431
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(name, 'read') }, 403);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const guardCondition = op.exists === true
|
|
1438
|
+
? `NOT EXISTS (${condition})`
|
|
1439
|
+
: `EXISTS (${condition})`;
|
|
1440
|
+
const guard = resolved.db.prepare(
|
|
1441
|
+
`INSERT INTO ${esc(TX_GUARD_TABLE)} ("id") SELECT NULL WHERE ${guardCondition}`,
|
|
1442
|
+
);
|
|
1443
|
+
stmts.push(params.length > 0 ? guard.bind(...params) : guard);
|
|
1444
|
+
resultBuilders.push(async () => ({ expected: true }));
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (op.op === 'insert') {
|
|
1449
|
+
const item = op.data as Record<string, unknown>;
|
|
1450
|
+
const validation = validateInsert(item, tableConfig.schema);
|
|
1451
|
+
if (!validation.valid) {
|
|
1452
|
+
return c.json({
|
|
1453
|
+
code: 400,
|
|
1454
|
+
message: `Transact insert on '${name}' failed validation. See data for field-level errors.`,
|
|
1455
|
+
data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])),
|
|
1456
|
+
}, 400);
|
|
1457
|
+
}
|
|
1458
|
+
const id = (item.id as string) || generateId();
|
|
1459
|
+
const record: Record<string, unknown> = { ...item, id };
|
|
1460
|
+
if (effective.createdAt) record.createdAt = now;
|
|
1461
|
+
if (effective.updatedAt) record.updatedAt = now;
|
|
1462
|
+
for (const [fname, field] of Object.entries(effective)) {
|
|
1463
|
+
if (record[fname] === undefined && field.default !== undefined) {
|
|
1464
|
+
record[fname] = field.default;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
const data = filterToSchemaColumns(record, effective);
|
|
1468
|
+
serializeJsonFields(data, effective);
|
|
1469
|
+
const columns = Object.keys(data);
|
|
1470
|
+
const values = columns.map((col) => data[col] ?? null);
|
|
1471
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
1472
|
+
const colStr = columns.map(esc).join(', ');
|
|
1473
|
+
const stmt = resolved.db.prepare(`INSERT INTO ${esc(name)} (${colStr}) VALUES (${placeholders})`);
|
|
1474
|
+
stmts.push(values.length > 0 ? stmt.bind(...values) : stmt);
|
|
1475
|
+
postFetchChanges.push({ table: name, id, type: 'added', resultIndex: resultBuilders.length });
|
|
1476
|
+
resultBuilders.push(async () => {
|
|
1477
|
+
const fetched = await executeD1Query(resolved.db, `SELECT * FROM ${esc(name)} WHERE "id" = ?`, [id]);
|
|
1478
|
+
const row = fetched.rows.length > 0
|
|
1479
|
+
? normalizeRow(stripInternalFields(fetched.rows[0]), tableConfig)
|
|
1480
|
+
: { id, ...record };
|
|
1481
|
+
return { inserted: row };
|
|
1482
|
+
});
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (op.op === 'update') {
|
|
1487
|
+
const id = op.id as string;
|
|
1488
|
+
const input = op.data as Record<string, unknown>;
|
|
1489
|
+
const validation = validateUpdate(input, tableConfig.schema);
|
|
1490
|
+
if (!validation.valid) {
|
|
1491
|
+
return c.json({
|
|
1492
|
+
code: 400,
|
|
1493
|
+
message: `Transact update on '${name}' failed validation. See data for field-level errors.`,
|
|
1494
|
+
data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])),
|
|
1495
|
+
}, 400);
|
|
1496
|
+
}
|
|
1497
|
+
// Per-row update rule on the current row (stricter than handleBatch,
|
|
1498
|
+
// which skips update rules entirely on D1).
|
|
1499
|
+
if (!isServiceKey) {
|
|
1500
|
+
const access = getTableAccess(tableConfig);
|
|
1501
|
+
if (access?.update !== undefined) {
|
|
1502
|
+
const current = await executeD1Query(resolved.db, `SELECT * FROM ${esc(name)} WHERE "id" = ?`, [id]);
|
|
1503
|
+
const row = current.rows.length > 0
|
|
1504
|
+
? normalizeRow(stripInternalFields(current.rows[0]), tableConfig)
|
|
1505
|
+
: {};
|
|
1506
|
+
if (!(await evalRowRule(access.update, auth, row))) {
|
|
1507
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(name, 'update') }, 403);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const updateData = { ...input };
|
|
1512
|
+
delete updateData.id;
|
|
1513
|
+
delete updateData.createdAt;
|
|
1514
|
+
if (effective.updatedAt?.onUpdate === 'now') {
|
|
1515
|
+
updateData.updatedAt = now;
|
|
1516
|
+
}
|
|
1517
|
+
for (const [key, value] of Object.entries(updateData)) {
|
|
1518
|
+
if (effective[key]?.type === 'json' && value !== null && value !== undefined && typeof value === 'object' && !('$op' in (value as Record<string, unknown>))) {
|
|
1519
|
+
updateData[key] = JSON.stringify(value);
|
|
1520
|
+
} else if (effective[key]?.type === 'boolean' && value !== null && value !== undefined && (typeof value !== 'object' || !('$op' in value))) {
|
|
1521
|
+
updateData[key] = value === true || value === 'true' || value === 1 || value === '1' ? 1 : 0;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
const { setClauses, params } = parseUpdateBody(updateData);
|
|
1525
|
+
if (setClauses.length > 0) {
|
|
1526
|
+
const stmt = resolved.db.prepare(`UPDATE ${esc(name)} SET ${setClauses.join(', ')} WHERE "id" = ?`);
|
|
1527
|
+
stmts.push(stmt.bind(...params, id));
|
|
1528
|
+
}
|
|
1529
|
+
postFetchChanges.push({ table: name, id, type: 'modified', resultIndex: resultBuilders.length });
|
|
1530
|
+
resultBuilders.push(async () => {
|
|
1531
|
+
const fetched = await executeD1Query(resolved.db, `SELECT * FROM ${esc(name)} WHERE "id" = ?`, [id]);
|
|
1532
|
+
const row = fetched.rows.length > 0
|
|
1533
|
+
? normalizeRow(stripInternalFields(fetched.rows[0]), tableConfig)
|
|
1534
|
+
: { id, ...(op.data as Record<string, unknown>) };
|
|
1535
|
+
return { updated: row };
|
|
1536
|
+
});
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// delete
|
|
1541
|
+
const id = op.id as string;
|
|
1542
|
+
if (!isServiceKey) {
|
|
1543
|
+
const access = getTableAccess(tableConfig);
|
|
1544
|
+
if (access?.delete !== undefined) {
|
|
1545
|
+
const current = await executeD1Query(resolved.db, `SELECT * FROM ${esc(name)} WHERE "id" = ?`, [id]);
|
|
1546
|
+
const row = current.rows.length > 0
|
|
1547
|
+
? normalizeRow(stripInternalFields(current.rows[0]), tableConfig)
|
|
1548
|
+
: {};
|
|
1549
|
+
if (!(await evalRowRule(access.delete, auth, row))) {
|
|
1550
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(name, 'delete') }, 403);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const stmt = resolved.db.prepare(`DELETE FROM ${esc(name)} WHERE "id" = ?`);
|
|
1555
|
+
stmts.push(stmt.bind(id));
|
|
1556
|
+
allChanges.push({ table: name, type: 'removed', docId: id, data: null });
|
|
1557
|
+
resultBuilders.push(async () => ({ deleted: true, id }));
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Lazy guard table, then apply the whole transaction in ONE db.batch().
|
|
1561
|
+
// NOT NULL is explicit: SQLite TEXT PRIMARY KEY alone still allows NULLs.
|
|
1562
|
+
await executeD1Query(resolved.db, `CREATE TABLE IF NOT EXISTS ${esc(TX_GUARD_TABLE)} ("id" TEXT NOT NULL PRIMARY KEY)`, []);
|
|
1563
|
+
try {
|
|
1564
|
+
if (stmts.length > 0) await resolved.db.batch(stmts);
|
|
1565
|
+
} catch (err) {
|
|
1566
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1567
|
+
if (message.includes(TX_GUARD_TABLE)) {
|
|
1568
|
+
return c.json({ code: 409, message: 'Transaction expectation failed; no operations were applied.' }, 409);
|
|
1569
|
+
}
|
|
1570
|
+
throw err;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
const results: Record<string, unknown>[] = [];
|
|
1574
|
+
for (const build of resultBuilders) {
|
|
1575
|
+
results.push(await build());
|
|
1576
|
+
}
|
|
1577
|
+
for (const change of postFetchChanges) {
|
|
1578
|
+
const built = results[change.resultIndex];
|
|
1579
|
+
const row = built ? (((built.inserted ?? built.updated) as Record<string, unknown>) ?? null) : null;
|
|
1580
|
+
allChanges.push({ table: change.table, type: change.type, docId: change.id, data: row });
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// Emit database-live events grouped per table (same thresholds as handleBatch).
|
|
1584
|
+
const changesByTable = new Map<string, typeof allChanges>();
|
|
1585
|
+
for (const change of allChanges) {
|
|
1586
|
+
const list = changesByTable.get(change.table) ?? [];
|
|
1587
|
+
list.push(change);
|
|
1588
|
+
changesByTable.set(change.table, list);
|
|
1589
|
+
}
|
|
1590
|
+
for (const [tableName, changes] of changesByTable) {
|
|
1591
|
+
if (changes.length >= 10) {
|
|
1592
|
+
scheduleDbLive(
|
|
1593
|
+
c.executionCtx,
|
|
1594
|
+
emitDbLiveBatchEvent(
|
|
1595
|
+
c.env,
|
|
1596
|
+
resolved.namespace,
|
|
1597
|
+
tableName,
|
|
1598
|
+
changes.map(({ type, docId, data }) => ({ type, docId, data })),
|
|
1599
|
+
),
|
|
1600
|
+
`emit transact batch ${resolved.namespace}.${tableName} (${changes.length} changes)`,
|
|
1601
|
+
);
|
|
1602
|
+
} else {
|
|
1603
|
+
scheduleDbLive(
|
|
1604
|
+
c.executionCtx,
|
|
1605
|
+
Promise.all(
|
|
1606
|
+
changes.map((ch) =>
|
|
1607
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
|
|
1608
|
+
),
|
|
1609
|
+
).then(() => undefined),
|
|
1610
|
+
`emit transact fan-out ${resolved.namespace}.${tableName} (${changes.length} changes)`,
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
return c.json({ results });
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1247
1618
|
// ─── BATCH BY FILTER ───
|
|
1248
1619
|
|
|
1249
1620
|
async function handleBatchByFilter(
|
|
@@ -1251,8 +1622,8 @@ async function handleBatchByFilter(
|
|
|
1251
1622
|
resolved: D1ResolvedDb,
|
|
1252
1623
|
tableName: string,
|
|
1253
1624
|
tableConfig: TableConfig,
|
|
1254
|
-
|
|
1255
|
-
|
|
1625
|
+
auth: AuthContext | null,
|
|
1626
|
+
isServiceKey: boolean,
|
|
1256
1627
|
): Promise<Response> {
|
|
1257
1628
|
let body: {
|
|
1258
1629
|
action?: string;
|
|
@@ -1277,6 +1648,18 @@ async function handleBatchByFilter(
|
|
|
1277
1648
|
return c.json({ code: 400, message: "batch-by-filter with action 'update' requires 'update' data." }, 400);
|
|
1278
1649
|
}
|
|
1279
1650
|
|
|
1651
|
+
// Row-level access rules (DO parity, database-do.ts batch-by-filter):
|
|
1652
|
+
// table-level pre-check with an empty row (sufficient for boolean/auth-only
|
|
1653
|
+
// rules), then per-row function-rule filtering of matched rows below.
|
|
1654
|
+
// Service keys bypass rules, matching the single-record paths.
|
|
1655
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
1656
|
+
const bfRule = body.action === 'delete' ? tableAccess?.delete : tableAccess?.update;
|
|
1657
|
+
if (!isServiceKey && bfRule !== undefined) {
|
|
1658
|
+
if (!(await evalRowRule(bfRule, auth, {}))) {
|
|
1659
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(tableName, body.action as 'update' | 'delete') }, 403);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1280
1663
|
const limit = Math.min(body.limit ?? 500, 500);
|
|
1281
1664
|
|
|
1282
1665
|
// Find matching records using buildListQuery
|
|
@@ -1293,7 +1676,22 @@ async function handleBatchByFilter(
|
|
|
1293
1676
|
return c.json({ processed: 0, succeeded: 0 });
|
|
1294
1677
|
}
|
|
1295
1678
|
|
|
1296
|
-
|
|
1679
|
+
// Per-row rule evaluation: keep only rows the rule allows (DO parity).
|
|
1680
|
+
let rows = allRows;
|
|
1681
|
+
if (!isServiceKey && typeof bfRule === 'function') {
|
|
1682
|
+
const allowed: Record<string, unknown>[] = [];
|
|
1683
|
+
for (const row of rows) {
|
|
1684
|
+
if (await evalRowRule(bfRule, auth, normalizeRow(stripInternalFields(row), tableConfig))) {
|
|
1685
|
+
allowed.push(row);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if (allowed.length === 0) {
|
|
1689
|
+
return c.json({ code: 403, message: d1RuleRejectedMessage(tableName, body.action as 'update' | 'delete') }, 403);
|
|
1690
|
+
}
|
|
1691
|
+
rows = allowed;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const ids = rows.map(r => r.id as string);
|
|
1297
1695
|
const placeholders = ids.map(() => '?').join(', ');
|
|
1298
1696
|
let succeeded = 0;
|
|
1299
1697
|
|
|
@@ -1363,38 +1761,6 @@ function getTextFields(config: TableConfig): string[] {
|
|
|
1363
1761
|
return fields.length > 0 ? fields : ['id'];
|
|
1364
1762
|
}
|
|
1365
1763
|
|
|
1366
|
-
function toSnakeCase(value: string): string {
|
|
1367
|
-
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
function toCamelCase(value: string): string {
|
|
1371
|
-
return value.replace(/_([a-z0-9])/g, (_match, char: string) => char.toUpperCase());
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
function applySchemaFieldAliases<T extends Record<string, unknown> | null | undefined>(
|
|
1375
|
-
record: T,
|
|
1376
|
-
schema?: Record<string, SchemaField | false>,
|
|
1377
|
-
): T {
|
|
1378
|
-
if (!schema || !record || typeof record !== 'object' || Array.isArray(record)) return record;
|
|
1379
|
-
|
|
1380
|
-
const effectiveSchema = buildEffectiveSchema(schema);
|
|
1381
|
-
const normalized: Record<string, unknown> = { ...record };
|
|
1382
|
-
|
|
1383
|
-
for (const key of Object.keys(effectiveSchema)) {
|
|
1384
|
-
const snake = toSnakeCase(key);
|
|
1385
|
-
const camel = toCamelCase(key);
|
|
1386
|
-
|
|
1387
|
-
if (effectiveSchema[snake] && normalized[snake] === undefined && normalized[key] !== undefined) {
|
|
1388
|
-
normalized[snake] = normalized[key];
|
|
1389
|
-
}
|
|
1390
|
-
if (effectiveSchema[camel] && normalized[camel] === undefined && normalized[key] !== undefined) {
|
|
1391
|
-
normalized[camel] = normalized[key];
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
return normalized as T;
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
1764
|
// ─── Exported batch import for admin routes ───
|
|
1399
1765
|
|
|
1400
1766
|
/**
|
|
@@ -1425,7 +1791,7 @@ export async function d1BatchImport(
|
|
|
1425
1791
|
const errors: Array<{ row: number; message: string }> = [];
|
|
1426
1792
|
|
|
1427
1793
|
for (let i = 0; i < records.length; i++) {
|
|
1428
|
-
const item =
|
|
1794
|
+
const item = records[i];
|
|
1429
1795
|
const validation = validateInsert(item, tableConfig.schema);
|
|
1430
1796
|
if (!validation.valid) {
|
|
1431
1797
|
errors.push({ row: i, message: Object.values(validation.errors).join('; ') });
|
package/src/lib/errors.ts
CHANGED
|
@@ -119,8 +119,9 @@ export function normalizeDatabaseError(error: unknown): EdgeBaseError | null {
|
|
|
119
119
|
|
|
120
120
|
const haystack = `${message}\n${causeMessage}`.trim();
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// Matches both SQLite/D1 ("FOREIGN KEY constraint failed: table.column") and
|
|
123
|
+
// PostgreSQL ('... violates foreign key constraint "table_col_fkey"') phrasings.
|
|
124
|
+
if (/foreign key constraint failed|violates foreign key constraint/i.test(haystack)) {
|
|
124
125
|
const colMatch = haystack.match(/foreign key constraint failed[:\s]*(?:\w+\.)?(\w+)/i);
|
|
125
126
|
const detail = colMatch?.[1]
|
|
126
127
|
? `Referenced record does not exist (column: '${colMatch[1]}').`
|
|
@@ -137,8 +138,11 @@ export function normalizeDatabaseError(error: unknown): EdgeBaseError | null {
|
|
|
137
138
|
return new EdgeBaseError(409, detail, undefined, 'record-already-exists');
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
// Matches SQLite/D1 ("NOT NULL constraint failed: table.column") and
|
|
142
|
+
// PostgreSQL ('null value in column "col" ... violates not-null constraint').
|
|
143
|
+
if (/not null constraint failed|check constraint failed|null value in column .* violates not-null constraint/i.test(haystack)) {
|
|
144
|
+
const colMatch = haystack.match(/(?:not null|check) constraint failed[:\s]*(?:\w+\.)?(\w+)/i)
|
|
145
|
+
?? haystack.match(/null value in column "?(\w+)"?/i);
|
|
142
146
|
const detail = colMatch?.[1]
|
|
143
147
|
? `Database constraint violated (column: '${colMatch[1]}'). Ensure all required fields are provided.`
|
|
144
148
|
: 'Request violates a database constraint. Ensure all required fields are provided.';
|
|
@@ -77,6 +77,16 @@ function parsePath(
|
|
|
77
77
|
} else {
|
|
78
78
|
// Heuristic fallback: find first 'tables' keyword
|
|
79
79
|
tablesIdx = segments.indexOf('tables', 1);
|
|
80
|
+
if (tablesIdx < 0) tablesIdx = segments.indexOf('transact', 1);
|
|
81
|
+
}
|
|
82
|
+
// DB-level transact path has no table segment: /{ns}/transact or /{ns}/{id}/transact
|
|
83
|
+
if (segments[tablesIdx] === 'transact' && tablesIdx === segments.length - 1) {
|
|
84
|
+
return {
|
|
85
|
+
namespace: segments[0],
|
|
86
|
+
instanceId: tablesIdx === 2 ? segments[1] : undefined,
|
|
87
|
+
tableName: '',
|
|
88
|
+
directPath: '/transact',
|
|
89
|
+
};
|
|
80
90
|
}
|
|
81
91
|
if (tablesIdx < 0 || segments[tablesIdx] !== 'tables') {
|
|
82
92
|
throw new Error(`Invalid DB path: missing 'tables' segment in ${path}`);
|