@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.
Files changed (72) hide show
  1. package/admin-build/_app/immutable/chunks/{C-zXt-cq.js → ArkGiTMv.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{DF40xjpj.js → B6_FvkIG.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{Cek51lkE.js → BM46GgYk.js} +3 -3
  4. package/admin-build/_app/immutable/chunks/{BSIzoJ5I.js → Bxlz62x0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/ByFwWOVj.js +1 -0
  6. package/admin-build/_app/immutable/chunks/{aPulNooa.js → CKJGIsEj.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{D0mNYcxN.js → CUEBmgYR.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{C1Mycuvd.js → Cav53qqs.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{DOOhHO-v.js → CtOkHpP1.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{BkrC_6Ss.js → D3arvney.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{Dvlxm2Iq.js → DC_HyNNV.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{dZsKGnWZ.js → Da6ZRIF1.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BeYswbvA.js → DfwnKUzy.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/DuAuob_N.js +1 -0
  15. package/admin-build/_app/immutable/chunks/{BunyH6GE.js → ZQw8NgPJ.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{Xm1dHMTE.js → fgIr2CuT.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.j1WHBlZX.js → app.BtTg2P3N.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.D21f0BIj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.Oo7ZpbR-.js → 0.CqnCTNhX.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.zgMnKHns.js → 1.BLn3o9jT.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.u5mkRiUe.js → 10.Bp6HrvpD.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.9ynAgaxV.js → 11.79DhgZw-.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.M_EqzJ6s.js → 12.A7_yb57H.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.D_NQ9q60.js → 13.DgMDZXO8.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.-vENIJ2w.js → 14.DycfMAIE.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.DoMCZhGv.js → 15.Bk_-RJ3j.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.6BmnzScq.js → 16.B7Vjh-Xp.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.q6QQ9N_J.js → 17.DKfB3-O7.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DUhJ6dKj.js → 18.Bwq1YHfM.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CvkvPlGP.js → 19.3S-h9EgK.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.cACSbRIl.js → 20.Cm2djLN8.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.C3hqim4E.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.BsQ6xeKr.js → 22.DltIIio7.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.CNW3mLz5.js → 23.qVqH8BFx.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CReqtEOy.js → 24.BBgqZFZ0.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BuxVhqlb.js → 25.2xJDygjz.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-LzPf62.js → 26.B7pxYnqL.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.Dl5RL_tB.js → 27.P3eodDFO.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CWfSQgjH.js → 28.CPEn1uNw.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.wUSfh2eQ.js → 29.D7ZGYHTI.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.D_laBXeK.js → 3.UaLSRTpv.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.DcMhZyQ0.js → 30.D-iftjK2.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.CSwhNsNi.js → 31.CHSNieUr.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Cq28vPI2.js → 4.BgPFR0hR.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BTT9B3oI.js → 5.BRn6WaF_.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.iCmmzXZp.js → 6.Cdem1OHu.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.qY5v7qqJ.js → 7.FpFS7ceb.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.C6p4RvgK.js → 8.aCQtyAed.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.f548N7zh.js → 9.9BZCZcm_.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +193 -0
  53. package/package.json +3 -3
  54. package/src/__tests__/error-format.test.ts +21 -0
  55. package/src/__tests__/postgres-api-consistency.test.ts +240 -0
  56. package/src/__tests__/postgres-batch-compat.test.ts +33 -27
  57. package/src/__tests__/postgres-batch.test.ts +394 -0
  58. package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
  59. package/src/__tests__/postgres-transact.test.ts +148 -0
  60. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  61. package/src/durable-objects/database-do.ts +455 -15
  62. package/src/durable-objects/room-runtime-base.ts +8 -1
  63. package/src/lib/d1-handler.ts +416 -50
  64. package/src/lib/errors.ts +8 -4
  65. package/src/lib/internal-transport.ts +10 -0
  66. package/src/lib/postgres-handler.ts +562 -84
  67. package/src/routes/auth.ts +58 -46
  68. package/src/routes/tables.ts +140 -0
  69. package/admin-build/_app/immutable/chunks/D3hdqsfp.js +0 -1
  70. package/admin-build/_app/immutable/chunks/DdjFeYwG.js +0 -1
  71. package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +0 -1
  72. package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +0 -1
@@ -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
- if (countSql && countParams) {
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: `Access denied: 'update' rule blocked record "${id}" in table "${tableName}".` }, 403);
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
- // Check delete rules (table-level)
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
- if (!(await evalRowRule(tableAccess.delete, auth, {}))) {
1105
- return c.json({ code: 403, message: d1RuleRejectedMessage(tableName, 'delete') }, 403);
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
- _auth: AuthContext | null,
1255
- _isServiceKey: boolean,
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
- const ids = allRows.map(r => r.id as string);
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 = applySchemaFieldAliases(records[i], tableConfig.schema);
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
- if (/foreign key constraint failed/i.test(haystack)) {
123
- // Try to extract column name from D1/SQLite error: "FOREIGN KEY constraint failed: tableName.columnName"
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
- if (/not null constraint failed|check constraint failed/i.test(haystack)) {
141
- const colMatch = haystack.match(/(?:not null|check) constraint failed[:\s]*(?:\w+\.)?(\w+)/i);
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}`);