@edge-base/server 0.3.1 → 0.3.2

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 (69) hide show
  1. package/admin-build/_app/immutable/chunks/{D3hdqsfp.js → BENT96gS.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{BSIzoJ5I.js → BR-X1vqg.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{BkrC_6Ss.js → BYTXdmFz.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{DOOhHO-v.js → C-_7bS5w.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{aPulNooa.js → CCB6XzFZ.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{DF40xjpj.js → CWTeyOdB.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{Dvlxm2Iq.js → CX5CmbTG.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{Cek51lkE.js → CdgSuiqt.js} +3 -3
  9. package/admin-build/_app/immutable/chunks/{Xm1dHMTE.js → Cuny2nPU.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{C1Mycuvd.js → D15DP4DQ.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{dZsKGnWZ.js → DAzRCtch.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{DdjFeYwG.js → DYF4byEY.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BunyH6GE.js → Dcey5WNo.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{C-zXt-cq.js → HK54RDdv.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{BeYswbvA.js → gUtOgqgM.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{D0mNYcxN.js → pnqPwnFK.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.j1WHBlZX.js → app.DwslmBQo.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Bi51W3Uj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.Oo7ZpbR-.js → 0.SSaZIC3t.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.zgMnKHns.js → 1.CqSv9lq5.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.u5mkRiUe.js → 10.BoQz_717.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.9ynAgaxV.js → 11.B-yS_gft.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.M_EqzJ6s.js → 12.WsVtLoVx.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.D_NQ9q60.js → 13.TbqRQJKh.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.-vENIJ2w.js → 14.cldePwj3.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.DoMCZhGv.js → 15.Bec037-f.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.6BmnzScq.js → 16.Dd0yTYhN.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.q6QQ9N_J.js → 17.CoiqH0lF.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DUhJ6dKj.js → 18.BDXxM1Qi.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CvkvPlGP.js → 19.lo9mTPiR.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.cACSbRIl.js → 20.B7kbGqh_.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.BGm1TcfO.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.BsQ6xeKr.js → 22.VOtCuTkf.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.CNW3mLz5.js → 23.C2Uxx-kO.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CReqtEOy.js → 24.BxdEwsS3.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BuxVhqlb.js → 25.CVu2qoCv.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-LzPf62.js → 26.DqYlpX7Z.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.Dl5RL_tB.js → 27.emgbpP8y.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CWfSQgjH.js → 28.BVPSPK68.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.wUSfh2eQ.js → 29.BsDQQ16z.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.D_laBXeK.js → 3.uc8joqGb.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.DcMhZyQ0.js → 30.D4CYqQrE.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.CSwhNsNi.js → 31.DvUuXrVU.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Cq28vPI2.js → 4.BqTZ0Uoq.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BTT9B3oI.js → 5.DAO3--6W.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.iCmmzXZp.js → 6.CBbGl_Wo.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.qY5v7qqJ.js → 7.s-I8cKAh.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.C6p4RvgK.js → 8.Ct6B-y3f.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.f548N7zh.js → 9.Dok_ZoeZ.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/lib/d1-handler.ts +416 -50
  63. package/src/lib/errors.ts +8 -4
  64. package/src/lib/internal-transport.ts +10 -0
  65. package/src/lib/postgres-handler.ts +562 -84
  66. package/src/routes/auth.ts +58 -46
  67. package/src/routes/tables.ts +140 -0
  68. package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +0 -1
  69. package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +0 -1
@@ -65,8 +65,10 @@ function postgresInvalidJsonMessage(context: string, tableName: string): string
65
65
  return `Invalid JSON body for ${context} on table '${tableName}'. Send application/json with the expected fields.`;
66
66
  }
67
67
 
68
- function postgresRuleRejectedMessage(tableName: string, action: 'insert' | 'update' | 'delete'): string {
69
- return `Access denied by access rules for ${action} on table '${tableName}'.`;
68
+ function postgresRuleRejectedMessage(tableName: string, action: 'read' | 'insert' | 'update' | 'delete'): string {
69
+ // Wording kept in lockstep with the D1 handler's d1RuleRejectedMessage so the
70
+ // two SQL-dialect providers surface identical 403 messages.
71
+ return `Access denied. The '${action}' access rule for table '${tableName}' rejected this request.`;
70
72
  }
71
73
 
72
74
  function postgresValidationMessage(context: string, tableName: string): string {
@@ -91,6 +93,35 @@ export async function handlePgRequest(
91
93
  doPath: string,
92
94
  ): Promise<Response> {
93
95
  const resolved = resolvePgConnection(c.env, namespace);
96
+
97
+ // Multi-table transact has no table path segment — handle before table
98
+ // validation. Requires a session-scoped connection for BEGIN/COMMIT, which
99
+ // the local dev sidecar (independent statement calls) cannot provide.
100
+ if (doPath === '/transact' && c.req.raw.method === 'POST') {
101
+ const transactLocalDev = getLocalDevPostgresExecOptions(
102
+ c.env as unknown as Record<string, unknown>,
103
+ namespace,
104
+ );
105
+ if (transactLocalDev) {
106
+ return c.json({
107
+ code: 501,
108
+ message:
109
+ 'transact is not supported through the local dev PostgreSQL sidecar: '
110
+ + 'statements execute on independent connections, so BEGIN/COMMIT cannot span them.',
111
+ }, 501);
112
+ }
113
+ return withPostgresConnection(resolved.connectionString, async (query) => {
114
+ await ensurePgSchema(resolved.connectionString, namespace, resolved.dbBlock.tables ?? {}, query);
115
+ return handleTransact(
116
+ c,
117
+ resolved,
118
+ (c.get('auth') as AuthContext | null | undefined) ?? null,
119
+ checkServiceKey(c),
120
+ query,
121
+ );
122
+ });
123
+ }
124
+
94
125
  const tableConfig = resolved.dbBlock.tables?.[tableName];
95
126
  if (!tableConfig) {
96
127
  return c.json({ code: 404, message: `Table '${tableName}' not found in database '${namespace}'.` }, 404);
@@ -437,7 +468,9 @@ async function handleSearch(
437
468
  const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
438
469
  const searchTerm = queryOpts.search || '';
439
470
  if (!searchTerm) {
440
- return c.json({ code: 400, message: 'Search term is required (use ?search=)' }, 400);
471
+ // Missing/empty search term return empty result (same as D1/DO handler;
472
+ // the ?search= query param is optional per openapi.json).
473
+ return c.json({ items: [] });
441
474
  }
442
475
 
443
476
  // Use FTS fields from config, or fallback to text columns from schema
@@ -557,7 +590,6 @@ async function handleInsert(
557
590
  code: 400,
558
591
  message: summarizeValidationErrors(validation.errors),
559
592
  data: toFieldErrorData(validation.errors),
560
- errors: validation.errors,
561
593
  }, 400);
562
594
  }
563
595
 
@@ -685,7 +717,6 @@ async function handleUpdate(
685
717
  code: 400,
686
718
  message: postgresValidationMessage(`update record '${id}'`, tableName),
687
719
  data: toFieldErrorData(validation.errors),
688
- errors: validation.errors,
689
720
  }, 400);
690
721
  }
691
722
 
@@ -723,7 +754,9 @@ async function handleUpdate(
723
754
  const { data } = preparePgUpdateData(body, tableConfig);
724
755
 
725
756
  if (Object.keys(data).length === 0) {
726
- return c.json({ code: 400, message: 'No valid fields to update.' }, 400);
757
+ // Empty update body return the existing record as-is (same as D1/DO
758
+ // handler); a no-op update is not an error per openapi.json.
759
+ return c.json(stripInternalPgFields(existingRow));
727
760
  }
728
761
 
729
762
  const { setClauses, params, nextParamIndex } = parseUpdateBody(
@@ -859,7 +892,7 @@ async function handleDelete(
859
892
  ),
860
893
  );
861
894
 
862
- return c.json({ success: true, deleted: stripInternalPgFields(existingRow) });
895
+ return c.json({ deleted: true });
863
896
  }
864
897
 
865
898
  // ─── BATCH ───
@@ -874,6 +907,7 @@ async function handleBatch(
874
907
  query: PostgresExecutor,
875
908
  ): Promise<Response> {
876
909
  let body: {
910
+ /** DEPRECATED: legacy alias for `inserts` (pinned by src/__tests__/postgres-batch-compat.test.ts). */
877
911
  items?: Record<string, unknown>[];
878
912
  inserts?: Record<string, unknown>[];
879
913
  updates?: { id: string; data: Record<string, unknown> }[];
@@ -882,112 +916,545 @@ async function handleBatch(
882
916
  try {
883
917
  body = await c.req.json();
884
918
  } catch {
885
- return c.json({ code: 400, message: postgresInvalidJsonMessage('batch insert', tableName) }, 400);
919
+ return c.json({ code: 400, message: postgresInvalidJsonMessage('batch operations', tableName) }, 400);
886
920
  }
887
921
 
922
+ // DEPRECATED: the `items` request alias (for `inserts`) and the `items`
923
+ // response echo are kept for backwards compatibility with early PG-only
924
+ // clients. New clients should send the D1/DO-aligned
925
+ // { inserts, updates, deletes } shape.
926
+ const hasInserts = Array.isArray(body.inserts) || Array.isArray(body.items);
888
927
  const inserts = Array.isArray(body.inserts)
889
928
  ? body.inserts
890
929
  : Array.isArray(body.items)
891
930
  ? body.items
892
931
  : [];
893
- const updates = Array.isArray(body.updates) ? body.updates : [];
894
- const deletes = Array.isArray(body.deletes) ? body.deletes : [];
932
+ const hasUpdates = Array.isArray(body.updates);
933
+ const updates = hasUpdates ? body.updates! : [];
934
+ const hasDeletes = Array.isArray(body.deletes);
935
+ const deletes = hasDeletes ? body.deletes! : [];
895
936
 
937
+ // Batch size limit: 500 total ops (D1/DO parity)
938
+ const MAX_BATCH_SIZE = 500;
896
939
  const totalOps = inserts.length + updates.length + deletes.length;
897
- if (totalOps === 0) {
898
- return c.json({ code: 400, message: 'items array is required and must not be empty' }, 400);
899
- }
900
-
901
- if (totalOps > 500) {
902
- return c.json({ code: 400, message: 'Batch size cannot exceed 500 items.' }, 400);
940
+ if (totalOps > MAX_BATCH_SIZE) {
941
+ return c.json({ code: 400, message: `Batch limit exceeded: ${totalOps} operations (max ${MAX_BATCH_SIZE}).` }, 400);
903
942
  }
904
943
 
905
- if (updates.length > 0 || deletes.length > 0) {
906
- return c.json({
907
- code: 400,
908
- message: 'PostgreSQL batch currently supports inserts/upserts only. Use batch-by-filter for updates/deletes.',
909
- }, 400);
910
- }
911
-
912
- // Check insert rule (table-level, once)
913
- const tableAccess = getTableAccess(tableConfig);
914
- if (!isServiceKey && inserts.length > 0 && tableAccess?.insert !== undefined) {
915
- if (!(await evalInsertRule(tableAccess.insert, auth))) {
916
- return c.json({ code: 403, message: postgresRuleRejectedMessage(tableName, 'insert') }, 403);
917
- }
944
+ // Response keys mirror the request keys (D1/DO parity); {} → {} with 200.
945
+ const results: Record<string, unknown> = {};
946
+ if (hasInserts) {
947
+ results.inserted = [];
948
+ // DEPRECATED response echo: `items` mirrors `inserted` for legacy clients.
949
+ results.items = [];
918
950
  }
951
+ if (hasUpdates) results.updated = [];
952
+ if (hasDeletes) results.deleted = 0;
919
953
 
920
954
  const upsertMode = c.req.query('upsert') === 'true';
921
955
  const conflictTarget = c.req.query('conflictTarget') || 'id';
922
- const results: Record<string, unknown>[] = [];
923
956
 
957
+ // ── Pre-validate ALL operations before executing any (D1 parity) ──
924
958
  for (const item of inserts) {
925
- // Validate
926
959
  const validation = validateInsert(item, tableConfig.schema);
927
960
  if (!validation.valid) {
928
961
  return c.json({
929
962
  code: 400,
930
963
  message: postgresValidationMessage('batch insert', tableName),
931
964
  data: toFieldErrorData(validation.errors),
932
- errors: validation.errors,
933
965
  }, 400);
934
966
  }
967
+ }
968
+ for (const entry of updates) {
969
+ if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || !entry.id) {
970
+ return c.json({ code: 400, message: `Each batch update entry for table '${tableName}' must include an id.` }, 400);
971
+ }
972
+ if (!entry.data || typeof entry.data !== 'object') {
973
+ return c.json({ code: 400, message: `Each batch update entry for table '${tableName}' must include a data object.` }, 400);
974
+ }
975
+ const validation = validateUpdate(entry.data, tableConfig.schema);
976
+ if (!validation.valid) {
977
+ return c.json({
978
+ code: 400,
979
+ message: postgresValidationMessage('batch update', tableName),
980
+ data: toFieldErrorData(validation.errors),
981
+ }, 400);
982
+ }
983
+ }
984
+
985
+ // Check insert rule (table-level, once — D1/DO batch parity)
986
+ const tableAccess = getTableAccess(tableConfig);
987
+ if (!isServiceKey && inserts.length > 0 && tableAccess?.insert !== undefined) {
988
+ if (!(await evalInsertRule(tableAccess.insert, auth))) {
989
+ return c.json({ code: 403, message: postgresRuleRejectedMessage(tableName, 'insert') }, 403);
990
+ }
991
+ }
992
+
993
+ if (totalOps === 0) {
994
+ return c.json(results);
995
+ }
996
+
997
+ // Batch is documented as all-or-nothing, which requires BEGIN/COMMIT on a
998
+ // session-scoped connection. The local dev sidecar executes statements on
999
+ // independent connections, so it cannot honor that promise — reject like
1000
+ // the /transact guard does.
1001
+ const localDevBatch = getLocalDevPostgresExecOptions(
1002
+ c.env as unknown as Record<string, unknown>,
1003
+ resolved.namespace,
1004
+ );
1005
+ if (localDevBatch) {
1006
+ return c.json({
1007
+ code: 501,
1008
+ message:
1009
+ 'batch is not supported through the local dev PostgreSQL sidecar: '
1010
+ + 'statements execute on independent connections, so BEGIN/COMMIT cannot span them.',
1011
+ }, 501);
1012
+ }
1013
+
1014
+ const insertedRows: Record<string, unknown>[] = [];
1015
+ const updatedRows: Record<string, unknown>[] = [];
1016
+ const liveChanges: Array<{
1017
+ type: 'added' | 'modified' | 'removed';
1018
+ docId: string;
1019
+ data: Record<string, unknown> | null;
1020
+ }> = [];
1021
+
1022
+ // All-or-nothing: run every operation inside one transaction. Per-row
1023
+ // update/delete rules are evaluated on rows pre-read inside the transaction
1024
+ // (accepted parity with the DO reference — JS rules cannot run inside SQL).
1025
+ await query('BEGIN');
1026
+ try {
1027
+ // ── Inserts (or upserts when ?upsert=true) ──
1028
+ for (const item of inserts) {
1029
+ const { data } = preparePgInsertData(item, tableConfig);
1030
+
1031
+ const columns = Object.keys(data);
1032
+ const values = columns.map(col => data[col] ?? null);
1033
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
1034
+ let sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders})`;
1035
+ if (upsertMode) {
1036
+ const updateCols = columns.filter((col) => col !== 'id' && col !== 'createdAt' && col !== conflictTarget);
1037
+ if (updateCols.length > 0) {
1038
+ const updateSet = updateCols
1039
+ .map((col) => `${escapePgIdentifier(col)} = EXCLUDED.${escapePgIdentifier(col)}`)
1040
+ .join(', ');
1041
+ sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO UPDATE SET ${updateSet}`;
1042
+ } else {
1043
+ sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO NOTHING`;
1044
+ }
1045
+ }
1046
+ sql += ' RETURNING *';
1047
+
1048
+ const result = await query(sql, values);
1049
+ if (result.rows.length > 0) {
1050
+ const row = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
1051
+ insertedRows.push(row);
1052
+ liveChanges.push({ type: 'added', docId: String(row.id ?? ''), data: row });
1053
+ }
1054
+ }
1055
+
1056
+ // ── Updates (per-row rule on the current row, read inside the transaction) ──
1057
+ for (const entry of updates) {
1058
+ if (!isServiceKey && tableAccess?.update !== undefined) {
1059
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, entry.id, undefined, 'postgres');
1060
+ const current = await query(getSql, getParams);
1061
+ // Missing rows are no-ops (DO parity) — only evaluate rules on real rows.
1062
+ if (
1063
+ current.rows.length > 0
1064
+ && !(await evalRowRule(tableAccess.update, auth, current.rows[0] as Record<string, unknown>))
1065
+ ) {
1066
+ throw new EdgeBaseError(403, postgresRuleRejectedMessage(tableName, 'update'));
1067
+ }
1068
+ }
1069
+
1070
+ const { data } = preparePgUpdateData(entry.data, tableConfig);
1071
+ if (Object.keys(data).length === 0) {
1072
+ // Nothing to set — echo the current row (D1 parity on empty set clauses).
1073
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, entry.id, undefined, 'postgres');
1074
+ const current = await query(getSql, getParams);
1075
+ updatedRows.push(current.rows.length > 0
1076
+ ? stripInternalPgFields(current.rows[0] as Record<string, unknown>)
1077
+ : { id: entry.id, ...entry.data });
1078
+ continue;
1079
+ }
1080
+
1081
+ const { setClauses, params, nextParamIndex } = parseUpdateBody(
1082
+ data,
1083
+ ['id'],
1084
+ { dialect: 'postgres', startIndex: 1 },
1085
+ );
1086
+ const sql = `UPDATE ${escapePgIdentifier(tableName)} SET ${setClauses.join(', ')} WHERE "id" = $${nextParamIndex} RETURNING *`;
1087
+ const updated = await query(sql, [...params, entry.id]);
1088
+ const row = updated.rows.length > 0
1089
+ ? stripInternalPgFields(updated.rows[0] as Record<string, unknown>)
1090
+ : { id: entry.id, ...entry.data };
1091
+ updatedRows.push(row);
1092
+ if (updated.rows.length > 0) {
1093
+ liveChanges.push({ type: 'modified', docId: String(row.id ?? entry.id), data: row });
1094
+ }
1095
+ }
935
1096
 
936
- const { data } = preparePgInsertData(item, tableConfig);
937
-
938
- const columns = Object.keys(data);
939
- const values = columns.map(col => data[col] ?? null);
940
- const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
941
- let sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders})`;
942
- if (upsertMode) {
943
- const updateCols = columns.filter((col) => col !== 'id' && col !== 'createdAt' && col !== conflictTarget);
944
- if (updateCols.length > 0) {
945
- const updateSet = updateCols
946
- .map((col) => `${escapePgIdentifier(col)} = EXCLUDED.${escapePgIdentifier(col)}`)
947
- .join(', ');
948
- sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO UPDATE SET ${updateSet}`;
949
- } else {
950
- sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO NOTHING`;
1097
+ // ── Deletes (per-row rule on the current row, read inside the transaction) ──
1098
+ for (const id of deletes) {
1099
+ if (!isServiceKey && tableAccess?.delete !== undefined) {
1100
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'postgres');
1101
+ const current = await query(getSql, getParams);
1102
+ // Missing rows are no-ops (DO parity) only evaluate rules on real rows.
1103
+ if (
1104
+ current.rows.length > 0
1105
+ && !(await evalRowRule(tableAccess.delete, auth, current.rows[0] as Record<string, unknown>))
1106
+ ) {
1107
+ throw new EdgeBaseError(403, postgresRuleRejectedMessage(tableName, 'delete'));
1108
+ }
1109
+ }
1110
+ const deleted = await query(
1111
+ `DELETE FROM ${escapePgIdentifier(tableName)} WHERE "id" = $1 RETURNING "id"`,
1112
+ [id],
1113
+ );
1114
+ if (deleted.rows.length > 0) {
1115
+ liveChanges.push({ type: 'removed', docId: id, data: null });
951
1116
  }
952
1117
  }
953
- sql += ' RETURNING *';
954
1118
 
955
- const result = await query(sql, values);
956
- if (result.rows.length > 0) {
957
- results.push(stripInternalPgFields(result.rows[0] as Record<string, unknown>));
1119
+ await query('COMMIT');
1120
+ } catch (error) {
1121
+ try {
1122
+ await query('ROLLBACK');
1123
+ } catch {
1124
+ // connection-level failure — the transaction dies with the session
958
1125
  }
1126
+ if (error instanceof EdgeBaseError) {
1127
+ return c.json(error.toJSON(), error.status as 400);
1128
+ }
1129
+ throw error;
1130
+ }
1131
+
1132
+ if (hasInserts) {
1133
+ results.inserted = insertedRows;
1134
+ // DEPRECATED response echo: `items` mirrors `inserted` for legacy clients
1135
+ // (pinned by src/__tests__/postgres-batch-compat.test.ts).
1136
+ results.items = insertedRows;
959
1137
  }
1138
+ if (hasUpdates) results.updated = updatedRows;
1139
+ // D1 parity: `deleted` echoes the number of requested ids, not affected rows.
1140
+ if (hasDeletes) results.deleted = deletes.length;
960
1141
 
961
1142
  // Emit batch database-live events
962
- if (results.length > 0) {
963
- const changes = results.map(r => ({
964
- type: 'added' as const,
965
- docId: String((r as Record<string, unknown>).id ?? ''),
966
- data: r as Record<string, unknown>,
967
- }));
1143
+ if (liveChanges.length > 0) {
1144
+ if (liveChanges.length >= 10) {
1145
+ scheduleDbLive(
1146
+ c.executionCtx,
1147
+ emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, liveChanges),
1148
+ `emit batch ${resolved.namespace}.${tableName} (${liveChanges.length} changes)`,
1149
+ );
1150
+ } else {
1151
+ scheduleDbLive(
1152
+ c.executionCtx,
1153
+ Promise.all(
1154
+ liveChanges.map((ch) =>
1155
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
1156
+ ),
1157
+ ).then(() => undefined),
1158
+ `emit fan-out ${resolved.namespace}.${tableName} (${liveChanges.length} changes)`,
1159
+ );
1160
+ }
1161
+ }
1162
+
1163
+ return c.json(results);
1164
+ }
1165
+
1166
+ // ─── TRANSACT (multi-table atomic writes) ───
1167
+
1168
+ interface PgTransactOp {
1169
+ table?: unknown;
1170
+ op?: unknown;
1171
+ id?: unknown;
1172
+ data?: unknown;
1173
+ where?: unknown;
1174
+ exists?: unknown;
1175
+ }
1176
+
1177
+ /**
1178
+ * Multi-table atomic writes on a single session-scoped connection via
1179
+ * BEGIN/COMMIT. `expect` ops run real SELECTs inside the transaction, so
1180
+ * assertions hold at commit time under PostgreSQL's isolation, and any
1181
+ * failure rolls the whole batch back.
1182
+ */
1183
+ async function handleTransact(
1184
+ c: Context<HonoEnv>,
1185
+ resolved: PgResolvedDb,
1186
+ auth: AuthContext | null,
1187
+ isServiceKey: boolean,
1188
+ query: PostgresExecutor,
1189
+ ): Promise<Response> {
1190
+ let body: { operations?: PgTransactOp[] };
1191
+ try {
1192
+ body = await c.req.json();
1193
+ } catch {
1194
+ return c.json({ code: 400, message: postgresInvalidJsonMessage('transact operations', '(multi-table)') }, 400);
1195
+ }
1196
+ const operations = body.operations;
1197
+ if (!Array.isArray(operations) || operations.length === 0) {
1198
+ return c.json({ code: 400, message: 'transact requires a non-empty operations array.' }, 400);
1199
+ }
1200
+ const MAX_TRANSACT_OPS = 500;
1201
+ if (operations.length > MAX_TRANSACT_OPS) {
1202
+ return c.json({ code: 400, message: `Transact limit exceeded: ${operations.length} operations (max ${MAX_TRANSACT_OPS}).` }, 400);
1203
+ }
1204
+
1205
+ const tables = resolved.dbBlock.tables ?? {};
1206
+ const tableCtx = new Map<string, TableConfig>();
1207
+ for (const op of operations) {
1208
+ if (typeof op?.table !== 'string' || !op.table) {
1209
+ return c.json({ code: 400, message: 'Each transact operation must name a table.' }, 400);
1210
+ }
1211
+ if (op.op !== 'insert' && op.op !== 'update' && op.op !== 'delete' && op.op !== 'expect') {
1212
+ return c.json({ code: 400, message: `Unknown transact op '${String(op.op)}'. Expected insert | update | delete | expect.` }, 400);
1213
+ }
1214
+ if (op.op === 'insert' && (!op.data || typeof op.data !== 'object')) {
1215
+ return c.json({ code: 400, message: 'transact insert requires a data object.' }, 400);
1216
+ }
1217
+ if (op.op === 'update' && (typeof op.id !== 'string' || !op.id || !op.data || typeof op.data !== 'object')) {
1218
+ return c.json({ code: 400, message: 'transact update requires an id and a data object.' }, 400);
1219
+ }
1220
+ if (op.op === 'delete' && (typeof op.id !== 'string' || !op.id)) {
1221
+ return c.json({ code: 400, message: 'transact delete requires an id.' }, 400);
1222
+ }
1223
+ if (op.op === 'expect') {
1224
+ const hasId = typeof op.id === 'string' && op.id.length > 0;
1225
+ const hasWhere = Array.isArray(op.where) && op.where.length > 0;
1226
+ if (!hasId && !hasWhere) {
1227
+ return c.json({ code: 400, message: 'transact expect requires an id and/or where conditions.' }, 400);
1228
+ }
1229
+ if (typeof op.exists !== 'boolean') {
1230
+ return c.json({ code: 400, message: 'transact expect requires a boolean exists.' }, 400);
1231
+ }
1232
+ }
1233
+ if (!tableCtx.has(op.table)) {
1234
+ const tableConfig = tables[op.table];
1235
+ if (!tableConfig) {
1236
+ return c.json({ code: 400, message: `Table '${op.table}' not found in database '${resolved.namespace}'.` }, 400);
1237
+ }
1238
+ tableCtx.set(op.table, tableConfig);
1239
+ }
1240
+ }
1241
+
1242
+ // Table-level insert rules (matches batch semantics).
1243
+ if (!isServiceKey) {
1244
+ const checkedInsert = new Set<string>();
1245
+ for (const op of operations) {
1246
+ if (op.op !== 'insert' || checkedInsert.has(op.table as string)) continue;
1247
+ checkedInsert.add(op.table as string);
1248
+ const access = getTableAccess(tableCtx.get(op.table as string)!);
1249
+ if (access?.insert !== undefined && !(await evalInsertRule(access.insert, auth))) {
1250
+ return c.json({ code: 403, message: postgresRuleRejectedMessage(op.table as string, 'insert') }, 403);
1251
+ }
1252
+ }
1253
+ }
1254
+
1255
+ const results: Record<string, unknown>[] = [];
1256
+ const liveChanges: Array<{
1257
+ table: string;
1258
+ type: 'added' | 'modified' | 'removed';
1259
+ docId: string;
1260
+ data: Record<string, unknown> | null;
1261
+ }> = [];
1262
+
1263
+ await query('BEGIN');
1264
+ try {
1265
+ for (const op of operations) {
1266
+ const name = op.table as string;
1267
+ const tableConfig = tableCtx.get(name)!;
1268
+
1269
+ if (op.op === 'expect') {
1270
+ const clauses: string[] = [];
1271
+ const params: unknown[] = [];
1272
+ if (typeof op.id === 'string' && op.id) {
1273
+ params.push(op.id);
1274
+ clauses.push(`"id" = $${params.length}`);
1275
+ }
1276
+ for (const cond of (op.where as unknown[]) ?? []) {
1277
+ if (!Array.isArray(cond) || cond.length !== 3) {
1278
+ throw new EdgeBaseError(400, 'transact expect where entries must be [field, "==", value].');
1279
+ }
1280
+ const [field, cmp, value] = cond as [unknown, unknown, unknown];
1281
+ if (typeof field !== 'string' || !field) {
1282
+ throw new EdgeBaseError(400, 'transact expect where field must be a string.');
1283
+ }
1284
+ if (cmp !== '==') {
1285
+ throw new EdgeBaseError(400, `transact expect only supports '==' conditions (got '${String(cmp)}').`);
1286
+ }
1287
+ if (value === null) {
1288
+ clauses.push(`${escapePgIdentifier(field)} IS NULL`);
1289
+ continue;
1290
+ }
1291
+ if (typeof value === 'object') {
1292
+ throw new EdgeBaseError(400, 'transact expect cannot compare object values.');
1293
+ }
1294
+ params.push(value);
1295
+ clauses.push(`${escapePgIdentifier(field)} = $${params.length}`);
1296
+ }
1297
+ const probe = await query(
1298
+ `SELECT * FROM ${escapePgIdentifier(name)} WHERE ${clauses.join(' AND ')} LIMIT 1`,
1299
+ params,
1300
+ );
1301
+ // Read rule guards expect probes so they cannot leak row existence.
1302
+ if (!isServiceKey && probe.rows.length > 0) {
1303
+ const access = getTableAccess(tableConfig);
1304
+ if (
1305
+ access?.read !== undefined
1306
+ && !(await evalRowRule(access.read, auth, probe.rows[0] as Record<string, unknown>))
1307
+ ) {
1308
+ throw new EdgeBaseError(403, postgresRuleRejectedMessage(name, 'read'));
1309
+ }
1310
+ }
1311
+ if (op.exists === true && probe.rows.length === 0) {
1312
+ throw new EdgeBaseError(409, `Transaction expectation failed: expected a matching row in "${name}".`);
1313
+ }
1314
+ if (op.exists === false && probe.rows.length > 0) {
1315
+ throw new EdgeBaseError(409, `Transaction expectation failed: expected no matching row in "${name}".`);
1316
+ }
1317
+ results.push({ expected: true });
1318
+ continue;
1319
+ }
1320
+
1321
+ if (op.op === 'insert') {
1322
+ const item = op.data as Record<string, unknown>;
1323
+ const validation = validateInsert(item, tableConfig.schema);
1324
+ if (!validation.valid) {
1325
+ throw new EdgeBaseError(
1326
+ 400,
1327
+ postgresValidationMessage('transact insert', name),
1328
+ toFieldErrorData(validation.errors) as never,
1329
+ );
1330
+ }
1331
+ const { data } = preparePgInsertData(item, tableConfig);
1332
+ const columns = Object.keys(data);
1333
+ const values = columns.map((col) => data[col] ?? null);
1334
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
1335
+ const sql = `INSERT INTO ${escapePgIdentifier(name)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`;
1336
+ const inserted = await query(sql, values);
1337
+ const row = inserted.rows.length > 0
1338
+ ? stripInternalPgFields(inserted.rows[0] as Record<string, unknown>)
1339
+ : { ...data };
1340
+ results.push({ inserted: row });
1341
+ liveChanges.push({ table: name, type: 'added', docId: String(row.id ?? ''), data: row });
1342
+ continue;
1343
+ }
1344
+
1345
+ if (op.op === 'update') {
1346
+ const id = op.id as string;
1347
+ const bodyData = op.data as Record<string, unknown>;
1348
+ const validation = validateUpdate(bodyData, tableConfig.schema);
1349
+ if (!validation.valid) {
1350
+ throw new EdgeBaseError(
1351
+ 400,
1352
+ postgresValidationMessage('transact update', name),
1353
+ toFieldErrorData(validation.errors) as never,
1354
+ );
1355
+ }
1356
+ // Per-row update rule on the current row, read inside the transaction.
1357
+ if (!isServiceKey) {
1358
+ const access = getTableAccess(tableConfig);
1359
+ if (access?.update !== undefined) {
1360
+ const { sql: getSql, params: getParams } = buildGetQuery(name, id, undefined, 'postgres');
1361
+ const current = await query(getSql, getParams);
1362
+ const row = current.rows.length > 0 ? (current.rows[0] as Record<string, unknown>) : {};
1363
+ if (!(await evalRowRule(access.update, auth, row))) {
1364
+ throw new EdgeBaseError(403, postgresRuleRejectedMessage(name, 'update'));
1365
+ }
1366
+ }
1367
+ }
1368
+ const { data } = preparePgUpdateData(bodyData, tableConfig);
1369
+ if (Object.keys(data).length === 0) {
1370
+ results.push({ updated: { id } });
1371
+ continue;
1372
+ }
1373
+ const { setClauses, params, nextParamIndex } = parseUpdateBody(
1374
+ data,
1375
+ ['id'],
1376
+ { dialect: 'postgres', startIndex: 1 },
1377
+ );
1378
+ const sql = `UPDATE ${escapePgIdentifier(name)} SET ${setClauses.join(', ')} WHERE "id" = $${nextParamIndex} RETURNING *`;
1379
+ const updated = await query(sql, [...params, id]);
1380
+ const row = updated.rows.length > 0
1381
+ ? stripInternalPgFields(updated.rows[0] as Record<string, unknown>)
1382
+ : { id, ...bodyData };
1383
+ results.push({ updated: row });
1384
+ if (updated.rows.length > 0) {
1385
+ liveChanges.push({ table: name, type: 'modified', docId: id, data: row });
1386
+ }
1387
+ continue;
1388
+ }
1389
+
1390
+ // delete
1391
+ const id = op.id as string;
1392
+ if (!isServiceKey) {
1393
+ const access = getTableAccess(tableConfig);
1394
+ if (access?.delete !== undefined) {
1395
+ const { sql: getSql, params: getParams } = buildGetQuery(name, id, undefined, 'postgres');
1396
+ const current = await query(getSql, getParams);
1397
+ const row = current.rows.length > 0 ? (current.rows[0] as Record<string, unknown>) : {};
1398
+ if (!(await evalRowRule(access.delete, auth, row))) {
1399
+ throw new EdgeBaseError(403, postgresRuleRejectedMessage(name, 'delete'));
1400
+ }
1401
+ }
1402
+ }
1403
+ const deleted = await query(
1404
+ `DELETE FROM ${escapePgIdentifier(name)} WHERE "id" = $1 RETURNING "id"`,
1405
+ [id],
1406
+ );
1407
+ if (deleted.rows.length > 0) {
1408
+ liveChanges.push({ table: name, type: 'removed', docId: id, data: null });
1409
+ }
1410
+ results.push({ deleted: true, id });
1411
+ }
1412
+ await query('COMMIT');
1413
+ } catch (error) {
1414
+ try {
1415
+ await query('ROLLBACK');
1416
+ } catch {
1417
+ // connection-level failure — the transaction dies with the session
1418
+ }
1419
+ if (error instanceof EdgeBaseError) {
1420
+ return c.json(error.toJSON(), error.status as 400);
1421
+ }
1422
+ throw error;
1423
+ }
1424
+
1425
+ // Emit database-live events grouped per table (same thresholds as batch).
1426
+ const changesByTable = new Map<string, typeof liveChanges>();
1427
+ for (const change of liveChanges) {
1428
+ const list = changesByTable.get(change.table) ?? [];
1429
+ list.push(change);
1430
+ changesByTable.set(change.table, list);
1431
+ }
1432
+ for (const [table, changes] of changesByTable) {
968
1433
  if (changes.length >= 10) {
969
1434
  scheduleDbLive(
970
1435
  c.executionCtx,
971
- emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, changes),
972
- `emit batch ${resolved.namespace}.${tableName} (${changes.length} changes)`,
1436
+ emitDbLiveBatchEvent(
1437
+ c.env,
1438
+ resolved.namespace,
1439
+ table,
1440
+ changes.map(({ type, docId, data }) => ({ type, docId, data })),
1441
+ ),
1442
+ `emit transact batch ${resolved.namespace}.${table} (${changes.length} changes)`,
973
1443
  );
974
1444
  } else {
975
1445
  scheduleDbLive(
976
1446
  c.executionCtx,
977
1447
  Promise.all(
978
1448
  changes.map((ch) =>
979
- emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
1449
+ emitDbLiveEvent(c.env, resolved.namespace, table, ch.type, ch.docId, ch.data),
980
1450
  ),
981
1451
  ).then(() => undefined),
982
- `emit fan-out ${resolved.namespace}.${tableName} (${changes.length} changes)`,
1452
+ `emit transact fan-out ${resolved.namespace}.${table} (${changes.length} changes)`,
983
1453
  );
984
1454
  }
985
1455
  }
986
1456
 
987
- return c.json({
988
- inserted: results,
989
- items: results,
990
- });
1457
+ return c.json({ results });
991
1458
  }
992
1459
 
993
1460
  // ─── BATCH BY FILTER ───
@@ -997,7 +1464,7 @@ async function handleBatchByFilter(
997
1464
  resolved: PgResolvedDb,
998
1465
  tableName: string,
999
1466
  tableConfig: TableConfig,
1000
- _auth: AuthContext | null,
1467
+ auth: AuthContext | null,
1001
1468
  isServiceKey: boolean,
1002
1469
  query: PostgresExecutor,
1003
1470
  ): Promise<Response> {
@@ -1026,12 +1493,24 @@ async function handleBatchByFilter(
1026
1493
  return c.json({ code: 400, message: "batch-by-filter with action 'update' requires 'update' data." }, 400);
1027
1494
  }
1028
1495
 
1496
+ // Row-level access rules (DO parity, database-do.ts batch-by-filter):
1497
+ // table-level pre-check with an empty row (sufficient for boolean/auth-only
1498
+ // rules), then per-row function-rule filtering of the matched rows below.
1499
+ // Service keys bypass rules, matching the single-record paths.
1500
+ const tableAccess = getTableAccess(tableConfig);
1501
+ const bfAction = body.action as 'delete' | 'update';
1502
+ const bfRule = bfAction === 'delete' ? tableAccess?.delete : tableAccess?.update;
1503
+ if (!isServiceKey && bfRule !== undefined) {
1504
+ if (!(await evalRowRule(bfRule, auth, {}))) {
1505
+ return c.json({ code: 403, message: postgresRuleRejectedMessage(tableName, bfAction) }, 403);
1506
+ }
1507
+ }
1508
+
1029
1509
  const limit = Math.min(body.limit ?? 500, 500);
1030
1510
  const { sql: selectSql, params: selectParams } = buildListQuery(tableName, {
1031
1511
  filters: body.filter,
1032
1512
  orFilters: body.orFilter,
1033
1513
  pagination: { limit },
1034
- fields: ['id'],
1035
1514
  }, 'postgres');
1036
1515
  const selectResult = await query(selectSql, selectParams);
1037
1516
  const allRows = selectResult.rows;
@@ -1041,19 +1520,26 @@ async function handleBatchByFilter(
1041
1520
  return c.json({ processed: 0, succeeded: 0 });
1042
1521
  }
1043
1522
 
1044
- const ids = allRows.map((row) => String((row as Record<string, unknown>).id));
1523
+ // Per-row rule evaluation: keep only rows the rule allows (DO parity).
1524
+ let rows = allRows as Record<string, unknown>[];
1525
+ if (!isServiceKey && typeof bfRule === 'function') {
1526
+ const allowed: Record<string, unknown>[] = [];
1527
+ for (const row of rows) {
1528
+ if (await evalRowRule(bfRule, auth, row)) {
1529
+ allowed.push(row);
1530
+ }
1531
+ }
1532
+ if (allowed.length === 0) {
1533
+ return c.json({ code: 403, message: postgresRuleRejectedMessage(tableName, bfAction) }, 403);
1534
+ }
1535
+ rows = allowed;
1536
+ }
1537
+
1538
+ const ids = rows.map((row) => String(row.id));
1045
1539
  const idPlaceholders = ids.map((_, index) => `$${index + 1}`).join(', ');
1046
1540
  let succeeded = 0;
1047
1541
 
1048
1542
  if (body.action === 'delete') {
1049
- // Check delete rule at table level
1050
- const tableAccess = getTableAccess(tableConfig);
1051
- if (!isServiceKey && tableAccess?.delete !== undefined) {
1052
- if (typeof tableAccess.delete === 'boolean' && !tableAccess.delete) {
1053
- return c.json({ code: 403, message: postgresRuleRejectedMessage(tableName, 'delete') }, 403);
1054
- }
1055
- }
1056
-
1057
1543
  const sql = `DELETE FROM ${escapePgIdentifier(tableName)} WHERE "id" IN (${idPlaceholders}) RETURNING *`;
1058
1544
  const result = await query(sql, ids);
1059
1545
  succeeded = result.rowCount;
@@ -1079,14 +1565,6 @@ async function handleBatchByFilter(
1079
1565
  return c.json({ code: 400, message: 'data is required for update action.' }, 400);
1080
1566
  }
1081
1567
 
1082
- // Check update rule at table level
1083
- const tableAccess = getTableAccess(tableConfig);
1084
- if (!isServiceKey && tableAccess?.update !== undefined) {
1085
- if (typeof tableAccess.update === 'boolean' && !tableAccess.update) {
1086
- return c.json({ code: 403, message: postgresRuleRejectedMessage(tableName, 'update') }, 403);
1087
- }
1088
- }
1089
-
1090
1568
  const prepared = preparePgUpdateData(updateData, tableConfig).data;
1091
1569
  if (Object.keys(prepared).length === 0) {
1092
1570
  return c.json({ code: 400, message: 'No valid fields to update.' }, 400);