@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
894
|
-
const
|
|
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
|
|
898
|
-
return c.json({ code: 400, message:
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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 (
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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(
|
|
972
|
-
|
|
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,
|
|
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}.${
|
|
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
|
-
|
|
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
|
-
|
|
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);
|