@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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL batch semantics (D1/DO parity alignment):
|
|
3
|
+
* - all-or-nothing: BEGIN/COMMIT around every batch, ROLLBACK on failure
|
|
4
|
+
* - tri-key { inserts, updates, deletes } support with per-row rules
|
|
5
|
+
* - zero-op {} → {} with 200 (no transaction opened)
|
|
6
|
+
* - batch-by-filter per-row function-rule filtering
|
|
7
|
+
*
|
|
8
|
+
* Uses a mocked executor (same pattern as postgres-transact.test.ts).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
11
|
+
import { defineConfig } from '@edge-base/shared';
|
|
12
|
+
import { buildInternalHandlerContext } from '../lib/internal-request.js';
|
|
13
|
+
import type { Env } from '../types.js';
|
|
14
|
+
|
|
15
|
+
const CONFIG = defineConfig({
|
|
16
|
+
release: true,
|
|
17
|
+
databases: {
|
|
18
|
+
shared: {
|
|
19
|
+
provider: 'postgres',
|
|
20
|
+
connectionString: 'DB_POSTGRES_SHARED_URL',
|
|
21
|
+
tables: {
|
|
22
|
+
batch_docs: {
|
|
23
|
+
schema: {
|
|
24
|
+
title: { type: 'string', required: true },
|
|
25
|
+
status: { type: 'string' },
|
|
26
|
+
},
|
|
27
|
+
access: {
|
|
28
|
+
read: () => true,
|
|
29
|
+
insert: () => true,
|
|
30
|
+
// Row-dependent rules: pass on an empty row, deny 'locked' rows.
|
|
31
|
+
update: (_auth, row) => row?.status !== 'locked',
|
|
32
|
+
delete: (_auth, row) => row?.status !== 'locked',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function makeEnv(): Env {
|
|
41
|
+
return {
|
|
42
|
+
EDGEBASE_CONFIG: CONFIG,
|
|
43
|
+
DB_POSTGRES_SHARED_URL: 'postgres://edgebase:test@localhost/shared',
|
|
44
|
+
} as unknown as Env;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mockExecutorModule(
|
|
48
|
+
handler: (sql: string, params: unknown[]) => { rows: Record<string, unknown>[]; rowCount: number },
|
|
49
|
+
{ sidecar = false } = {},
|
|
50
|
+
) {
|
|
51
|
+
const calls: Array<{ sql: string; params: unknown[] }> = [];
|
|
52
|
+
vi.doMock('../lib/postgres-executor.js', () => ({
|
|
53
|
+
executePostgresQuery: vi.fn(),
|
|
54
|
+
ensureLocalDevPostgresSchema: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
withPostgresConnection: vi.fn(async (_cs: string, fn: (q: unknown) => Promise<unknown>) =>
|
|
56
|
+
fn(async (sql: string, params: unknown[] = []) => {
|
|
57
|
+
calls.push({ sql, params });
|
|
58
|
+
return handler(sql, params);
|
|
59
|
+
})),
|
|
60
|
+
getLocalDevPostgresExecOptions: vi.fn(() =>
|
|
61
|
+
sidecar ? { namespace: 'shared', sidecarPort: '5999', sidecarSecret: 's' } : undefined),
|
|
62
|
+
getProviderBindingName: () => 'DB_POSTGRES_SHARED',
|
|
63
|
+
}));
|
|
64
|
+
vi.doMock('../lib/postgres-schema-init.js', () => ({
|
|
65
|
+
ensurePgSchema: vi.fn().mockResolvedValue(undefined),
|
|
66
|
+
}));
|
|
67
|
+
return calls;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Non-service-key handler context (buildInternalHandlerContext always marks
|
|
72
|
+
* requests as trusted internal, which bypasses rules).
|
|
73
|
+
*/
|
|
74
|
+
function makeNonServiceKeyCtx(options: { env: Env; url: string; body?: unknown }) {
|
|
75
|
+
const url = new URL(options.url);
|
|
76
|
+
const request = new Request(options.url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
env: options.env,
|
|
82
|
+
executionCtx: { waitUntil() {} },
|
|
83
|
+
req: {
|
|
84
|
+
raw: request,
|
|
85
|
+
url: request.url,
|
|
86
|
+
header: (name: string) => request.headers.get(name) ?? undefined,
|
|
87
|
+
json: async () => options.body ?? {},
|
|
88
|
+
query: (name?: string) =>
|
|
89
|
+
name
|
|
90
|
+
? url.searchParams.get(name) ?? undefined
|
|
91
|
+
: Object.fromEntries(url.searchParams.entries()),
|
|
92
|
+
},
|
|
93
|
+
get(key: string) {
|
|
94
|
+
if (key === 'auth') return null;
|
|
95
|
+
if (key === 'isServiceKey') return false;
|
|
96
|
+
if (key === 'isInternalRequest') return false;
|
|
97
|
+
return undefined;
|
|
98
|
+
},
|
|
99
|
+
json(payload: unknown, status = 200) {
|
|
100
|
+
return new Response(JSON.stringify(payload), {
|
|
101
|
+
status,
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function callBatch(
|
|
109
|
+
body: unknown,
|
|
110
|
+
{ serviceKey = true, env = makeEnv(), path = '/tables/batch_docs/batch' } = {},
|
|
111
|
+
) {
|
|
112
|
+
const { handlePgRequest } = await import('../lib/postgres-handler.js');
|
|
113
|
+
const url = `http://internal/api/db/shared${path}`;
|
|
114
|
+
const ctx = serviceKey
|
|
115
|
+
? buildInternalHandlerContext({
|
|
116
|
+
env,
|
|
117
|
+
request: new Request(url, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json', 'X-Is-Service-Key': 'true' },
|
|
120
|
+
}),
|
|
121
|
+
body: body as Record<string, unknown>,
|
|
122
|
+
})
|
|
123
|
+
: makeNonServiceKeyCtx({ env, url, body });
|
|
124
|
+
return handlePgRequest(ctx as never, 'shared', 'batch_docs', path);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('postgres batch', () => {
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
vi.resetModules();
|
|
130
|
+
vi.clearAllMocks();
|
|
131
|
+
vi.unstubAllGlobals();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('wraps batch inserts in BEGIN/COMMIT on one session', async () => {
|
|
135
|
+
let n = 0;
|
|
136
|
+
const calls = mockExecutorModule((sql) => {
|
|
137
|
+
if (sql.startsWith('INSERT INTO "batch_docs"')) {
|
|
138
|
+
n += 1;
|
|
139
|
+
return { rows: [{ id: `doc-${n}`, title: `Doc ${n}`, status: 'open' }], rowCount: 1 };
|
|
140
|
+
}
|
|
141
|
+
return { rows: [], rowCount: 0 };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const response = await callBatch({
|
|
145
|
+
inserts: [{ title: 'Doc 1' }, { title: 'Doc 2' }],
|
|
146
|
+
});
|
|
147
|
+
expect(response.status).toBe(200);
|
|
148
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
149
|
+
expect(json.inserted).toHaveLength(2);
|
|
150
|
+
expect(json.items).toHaveLength(2); // deprecated echo kept for legacy clients
|
|
151
|
+
expect(json.updated).toBeUndefined();
|
|
152
|
+
expect(json.deleted).toBeUndefined();
|
|
153
|
+
|
|
154
|
+
const sqls = calls.map((call) => call.sql);
|
|
155
|
+
expect(sqls[0]).toBe('BEGIN');
|
|
156
|
+
expect(sqls[sqls.length - 1]).toBe('COMMIT');
|
|
157
|
+
expect(sqls.filter((sql) => sql.startsWith('INSERT INTO'))).toHaveLength(2);
|
|
158
|
+
expect(sqls).not.toContain('ROLLBACK');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rolls back the whole batch when a statement fails mid-batch', async () => {
|
|
162
|
+
let n = 0;
|
|
163
|
+
const calls = mockExecutorModule((sql) => {
|
|
164
|
+
if (sql.startsWith('INSERT INTO "batch_docs"')) {
|
|
165
|
+
n += 1;
|
|
166
|
+
if (n === 2) throw new Error('duplicate key value violates unique constraint');
|
|
167
|
+
return { rows: [{ id: `doc-${n}`, title: `Doc ${n}` }], rowCount: 1 };
|
|
168
|
+
}
|
|
169
|
+
return { rows: [], rowCount: 0 };
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(
|
|
173
|
+
callBatch({ inserts: [{ title: 'Doc 1' }, { title: 'Doc 2' }] }),
|
|
174
|
+
).rejects.toThrow('duplicate key');
|
|
175
|
+
|
|
176
|
+
const sqls = calls.map((call) => call.sql);
|
|
177
|
+
expect(sqls[0]).toBe('BEGIN');
|
|
178
|
+
expect(sqls).toContain('ROLLBACK');
|
|
179
|
+
expect(sqls).not.toContain('COMMIT');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('supports tri-key { inserts, updates, deletes } inside one transaction with rules', async () => {
|
|
183
|
+
const rowsById: Record<string, Record<string, unknown>> = {
|
|
184
|
+
'u1': { id: 'u1', title: 'Old', status: 'open' },
|
|
185
|
+
'd1': { id: 'd1', title: 'Bye', status: 'open' },
|
|
186
|
+
};
|
|
187
|
+
const calls = mockExecutorModule((sql, params) => {
|
|
188
|
+
if (sql.startsWith('INSERT INTO "batch_docs"')) {
|
|
189
|
+
return { rows: [{ id: 'n1', title: 'New', status: 'open' }], rowCount: 1 };
|
|
190
|
+
}
|
|
191
|
+
if (sql.startsWith('SELECT * FROM "batch_docs"')) {
|
|
192
|
+
const row = rowsById[String(params[0])];
|
|
193
|
+
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
|
|
194
|
+
}
|
|
195
|
+
if (sql.startsWith('UPDATE "batch_docs"')) {
|
|
196
|
+
return { rows: [{ id: 'u1', title: 'Updated', status: 'open' }], rowCount: 1 };
|
|
197
|
+
}
|
|
198
|
+
if (sql.startsWith('DELETE FROM "batch_docs"')) {
|
|
199
|
+
return { rows: [{ id: 'd1' }], rowCount: 1 };
|
|
200
|
+
}
|
|
201
|
+
return { rows: [], rowCount: 0 };
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const response = await callBatch(
|
|
205
|
+
{
|
|
206
|
+
inserts: [{ title: 'New' }],
|
|
207
|
+
updates: [{ id: 'u1', data: { title: 'Updated' } }],
|
|
208
|
+
deletes: ['d1'],
|
|
209
|
+
},
|
|
210
|
+
{ serviceKey: false }, // rules active: rows are 'open', so all allowed
|
|
211
|
+
);
|
|
212
|
+
expect(response.status).toBe(200);
|
|
213
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
214
|
+
expect((json.inserted as unknown[])).toHaveLength(1);
|
|
215
|
+
expect((json.updated as Array<Record<string, unknown>>)[0].title).toBe('Updated');
|
|
216
|
+
expect(json.deleted).toBe(1);
|
|
217
|
+
|
|
218
|
+
const sqls = calls.map((call) => call.sql);
|
|
219
|
+
expect(sqls[0]).toBe('BEGIN');
|
|
220
|
+
expect(sqls[sqls.length - 1]).toBe('COMMIT');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('denies per-row via the delete rule and rolls back everything (403)', async () => {
|
|
224
|
+
const calls = mockExecutorModule((sql, params) => {
|
|
225
|
+
if (sql.startsWith('INSERT INTO "batch_docs"')) {
|
|
226
|
+
return { rows: [{ id: 'n1', title: 'New' }], rowCount: 1 };
|
|
227
|
+
}
|
|
228
|
+
if (sql.startsWith('SELECT * FROM "batch_docs"')) {
|
|
229
|
+
return { rows: [{ id: String(params[0]), title: 'Locked', status: 'locked' }], rowCount: 1 };
|
|
230
|
+
}
|
|
231
|
+
return { rows: [], rowCount: 0 };
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const response = await callBatch(
|
|
235
|
+
{ inserts: [{ title: 'New' }], deletes: ['locked-1'] },
|
|
236
|
+
{ serviceKey: false },
|
|
237
|
+
);
|
|
238
|
+
expect(response.status).toBe(403);
|
|
239
|
+
|
|
240
|
+
const sqls = calls.map((call) => call.sql);
|
|
241
|
+
expect(sqls[0]).toBe('BEGIN');
|
|
242
|
+
expect(sqls).toContain('ROLLBACK');
|
|
243
|
+
expect(sqls).not.toContain('COMMIT');
|
|
244
|
+
expect(sqls.some((sql) => sql.startsWith('DELETE FROM'))).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('denies per-row via the update rule (403) but service key bypasses', async () => {
|
|
248
|
+
const handler = (sql: string, params: unknown[]) => {
|
|
249
|
+
if (sql.startsWith('SELECT * FROM "batch_docs"')) {
|
|
250
|
+
return { rows: [{ id: String(params[0]), title: 'Locked', status: 'locked' }], rowCount: 1 };
|
|
251
|
+
}
|
|
252
|
+
if (sql.startsWith('UPDATE "batch_docs"')) {
|
|
253
|
+
return { rows: [{ id: 'locked-1', title: 'Changed', status: 'locked' }], rowCount: 1 };
|
|
254
|
+
}
|
|
255
|
+
return { rows: [], rowCount: 0 };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
mockExecutorModule(handler);
|
|
259
|
+
const denied = await callBatch(
|
|
260
|
+
{ updates: [{ id: 'locked-1', data: { title: 'Changed' } }] },
|
|
261
|
+
{ serviceKey: false },
|
|
262
|
+
);
|
|
263
|
+
expect(denied.status).toBe(403);
|
|
264
|
+
|
|
265
|
+
vi.resetModules();
|
|
266
|
+
mockExecutorModule(handler);
|
|
267
|
+
const bypassed = await callBatch(
|
|
268
|
+
{ updates: [{ id: 'locked-1', data: { title: 'Changed' } }] },
|
|
269
|
+
{ serviceKey: true },
|
|
270
|
+
);
|
|
271
|
+
expect(bypassed.status).toBe(200);
|
|
272
|
+
const json = (await bypassed.json()) as { updated: Array<Record<string, unknown>> };
|
|
273
|
+
expect(json.updated[0].title).toBe('Changed');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('returns {} with 200 for a zero-op batch without opening a transaction', async () => {
|
|
277
|
+
const calls = mockExecutorModule(() => ({ rows: [], rowCount: 0 }));
|
|
278
|
+
const response = await callBatch({});
|
|
279
|
+
expect(response.status).toBe(200);
|
|
280
|
+
expect(await response.json()).toEqual({});
|
|
281
|
+
expect(calls.map((call) => call.sql)).not.toContain('BEGIN');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('echoes empty keyed results for empty arrays (200)', async () => {
|
|
285
|
+
mockExecutorModule(() => ({ rows: [], rowCount: 0 }));
|
|
286
|
+
const response = await callBatch({ inserts: [], updates: [], deletes: [] });
|
|
287
|
+
expect(response.status).toBe(200);
|
|
288
|
+
expect(await response.json()).toEqual({ inserted: [], items: [], updated: [], deleted: 0 });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('rejects the local dev sidecar with 501 (all-or-nothing cannot be honored)', async () => {
|
|
292
|
+
mockExecutorModule(() => ({ rows: [], rowCount: 0 }), { sidecar: true });
|
|
293
|
+
const response = await callBatch({ inserts: [{ title: 'Doc' }] });
|
|
294
|
+
expect(response.status).toBe(501);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('batch-by-filter filters matched rows per-row through function rules', async () => {
|
|
298
|
+
const calls = mockExecutorModule((sql) => {
|
|
299
|
+
if (sql.startsWith('SELECT "batch_docs".*')) {
|
|
300
|
+
return {
|
|
301
|
+
rows: [
|
|
302
|
+
{ id: 'r1', title: 'A', status: 'open' },
|
|
303
|
+
{ id: 'r2', title: 'B', status: 'locked' },
|
|
304
|
+
],
|
|
305
|
+
rowCount: 2,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (sql.startsWith('UPDATE "batch_docs"')) {
|
|
309
|
+
return { rows: [{ id: 'r1', title: 'A', status: 'done' }], rowCount: 1 };
|
|
310
|
+
}
|
|
311
|
+
return { rows: [], rowCount: 0 };
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const response = await callBatch(
|
|
315
|
+
{
|
|
316
|
+
action: 'update',
|
|
317
|
+
filter: [['title', '!=', '']],
|
|
318
|
+
update: { status: 'done' },
|
|
319
|
+
},
|
|
320
|
+
{ serviceKey: false, path: '/tables/batch_docs/batch-by-filter' },
|
|
321
|
+
);
|
|
322
|
+
expect(response.status).toBe(200);
|
|
323
|
+
const json = (await response.json()) as { processed: number; succeeded: number };
|
|
324
|
+
expect(json.processed).toBe(2); // both matched the filter
|
|
325
|
+
expect(json.succeeded).toBe(1); // only the unlocked row was updated
|
|
326
|
+
|
|
327
|
+
const updateCall = calls.find((call) => call.sql.startsWith('UPDATE "batch_docs"'))!;
|
|
328
|
+
expect(updateCall.params).toContain('r1');
|
|
329
|
+
expect(updateCall.params).not.toContain('r2');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('batch-by-filter returns 403 when the rule blocks every matched row', async () => {
|
|
333
|
+
const calls = mockExecutorModule((sql) => {
|
|
334
|
+
if (sql.startsWith('SELECT "batch_docs".*')) {
|
|
335
|
+
return { rows: [{ id: 'r2', title: 'B', status: 'locked' }], rowCount: 1 };
|
|
336
|
+
}
|
|
337
|
+
return { rows: [], rowCount: 0 };
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const response = await callBatch(
|
|
341
|
+
{ action: 'delete', filter: [['status', '==', 'locked']] },
|
|
342
|
+
{ serviceKey: false, path: '/tables/batch_docs/batch-by-filter' },
|
|
343
|
+
);
|
|
344
|
+
expect(response.status).toBe(403);
|
|
345
|
+
expect(calls.some((call) => call.sql.startsWith('DELETE FROM'))).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('batch-by-filter delete removes matched rows (service key bypasses rules)', async () => {
|
|
349
|
+
const calls = mockExecutorModule((sql) => {
|
|
350
|
+
if (sql.startsWith('SELECT "batch_docs".*')) {
|
|
351
|
+
return {
|
|
352
|
+
rows: [
|
|
353
|
+
{ id: 'r1', title: 'A', status: 'open' },
|
|
354
|
+
{ id: 'r2', title: 'B', status: 'locked' },
|
|
355
|
+
],
|
|
356
|
+
rowCount: 2,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (sql.startsWith('DELETE FROM "batch_docs"')) {
|
|
360
|
+
return { rows: [{ id: 'r1' }, { id: 'r2' }], rowCount: 2 };
|
|
361
|
+
}
|
|
362
|
+
return { rows: [], rowCount: 0 };
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const response = await callBatch(
|
|
366
|
+
{ action: 'delete', filter: [['title', '!=', '']] },
|
|
367
|
+
{ serviceKey: true, path: '/tables/batch_docs/batch-by-filter' },
|
|
368
|
+
);
|
|
369
|
+
expect(response.status).toBe(200);
|
|
370
|
+
const json = (await response.json()) as {
|
|
371
|
+
processed: number; succeeded: number; deleted: number; items: unknown[];
|
|
372
|
+
};
|
|
373
|
+
expect(json.processed).toBe(2);
|
|
374
|
+
expect(json.succeeded).toBe(2);
|
|
375
|
+
expect(json.deleted).toBe(2);
|
|
376
|
+
expect(json.items).toHaveLength(2);
|
|
377
|
+
expect(calls.some((call) => call.sql.startsWith('DELETE FROM "batch_docs"'))).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('batch-by-filter update with an empty update object → 400', async () => {
|
|
381
|
+
mockExecutorModule((sql) => {
|
|
382
|
+
if (sql.startsWith('SELECT "batch_docs".*')) {
|
|
383
|
+
return { rows: [{ id: 'r1', title: 'A', status: 'open' }], rowCount: 1 };
|
|
384
|
+
}
|
|
385
|
+
return { rows: [], rowCount: 0 };
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const response = await callBatch(
|
|
389
|
+
{ action: 'update', filter: [['title', '!=', '']], update: {} },
|
|
390
|
+
{ serviceKey: true, path: '/tables/batch_docs/batch-by-filter' },
|
|
391
|
+
);
|
|
392
|
+
expect(response.status).toBe(400);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -203,7 +203,8 @@ describe('postgres field operator compatibility', () => {
|
|
|
203
203
|
|
|
204
204
|
const selectSql = executePostgresQuery.mock.calls[0][1] as string;
|
|
205
205
|
const selectParams = executePostgresQuery.mock.calls[0][2] as unknown[];
|
|
206
|
-
|
|
206
|
+
// Full rows are selected so per-row access rules can be evaluated.
|
|
207
|
+
expect(selectSql).toContain('SELECT "posts".* FROM "posts"');
|
|
207
208
|
expect(selectSql).toContain('WHERE "id" = $1');
|
|
208
209
|
expect(selectSql).toContain('LIMIT $2');
|
|
209
210
|
expect(selectSql).toContain('OFFSET $3');
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import { defineConfig } from '@edge-base/shared';
|
|
3
|
+
import { buildInternalHandlerContext } from '../lib/internal-request.js';
|
|
4
|
+
import type { Env } from '../types.js';
|
|
5
|
+
|
|
6
|
+
const CONFIG = defineConfig({
|
|
7
|
+
release: true,
|
|
8
|
+
databases: {
|
|
9
|
+
shared: {
|
|
10
|
+
provider: 'postgres',
|
|
11
|
+
connectionString: 'DB_POSTGRES_SHARED_URL',
|
|
12
|
+
tables: {
|
|
13
|
+
tx_docs: {
|
|
14
|
+
schema: {
|
|
15
|
+
title: { type: 'string', required: true },
|
|
16
|
+
status: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
access: { read: () => true, insert: () => true, update: () => true, delete: () => true },
|
|
19
|
+
},
|
|
20
|
+
tx_audit: {
|
|
21
|
+
schema: {
|
|
22
|
+
action: { type: 'string', required: true },
|
|
23
|
+
},
|
|
24
|
+
access: { read: () => true, insert: () => true },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeEnv(): Env {
|
|
32
|
+
return {
|
|
33
|
+
EDGEBASE_CONFIG: CONFIG,
|
|
34
|
+
DB_POSTGRES_SHARED_URL: 'postgres://edgebase:test@localhost/shared',
|
|
35
|
+
} as unknown as Env;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mockExecutorModule(
|
|
39
|
+
handler: (sql: string, params: unknown[]) => { rows: Record<string, unknown>[]; rowCount: number },
|
|
40
|
+
{ sidecar = false } = {},
|
|
41
|
+
) {
|
|
42
|
+
const calls: Array<{ sql: string; params: unknown[] }> = [];
|
|
43
|
+
vi.doMock('../lib/postgres-executor.js', () => ({
|
|
44
|
+
executePostgresQuery: vi.fn(),
|
|
45
|
+
ensureLocalDevPostgresSchema: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
withPostgresConnection: vi.fn(async (_cs: string, fn: (q: unknown) => Promise<unknown>) =>
|
|
47
|
+
fn(async (sql: string, params: unknown[] = []) => {
|
|
48
|
+
calls.push({ sql, params });
|
|
49
|
+
return handler(sql, params);
|
|
50
|
+
})),
|
|
51
|
+
getLocalDevPostgresExecOptions: vi.fn(() =>
|
|
52
|
+
sidecar ? { namespace: 'shared', sidecarPort: 5999, sidecarSecret: 's' } : undefined),
|
|
53
|
+
getProviderBindingName: () => 'DB_POSTGRES_SHARED',
|
|
54
|
+
}));
|
|
55
|
+
vi.doMock('../lib/postgres-schema-init.js', () => ({
|
|
56
|
+
ensurePgSchema: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
}));
|
|
58
|
+
return calls;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function callTransact(operations: unknown[], env = makeEnv()) {
|
|
62
|
+
const { handlePgRequest } = await import('../lib/postgres-handler.js');
|
|
63
|
+
const request = new Request('http://internal/api/db/shared/transact', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json', 'X-Is-Service-Key': 'true' },
|
|
66
|
+
});
|
|
67
|
+
const ctx = buildInternalHandlerContext({ env, request, body: { operations } });
|
|
68
|
+
return handlePgRequest(ctx, 'shared', '', '/transact');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('postgres transact', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.resetModules();
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
vi.unstubAllGlobals();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('wraps cross-table ops in BEGIN/COMMIT and returns ordered results', async () => {
|
|
79
|
+
const calls = mockExecutorModule((sql) => {
|
|
80
|
+
if (sql.startsWith('INSERT INTO "tx_docs"')) {
|
|
81
|
+
return { rows: [{ id: 'd1', title: 'Doc', status: 'draft' }], rowCount: 1 };
|
|
82
|
+
}
|
|
83
|
+
if (sql.startsWith('INSERT INTO "tx_audit"')) {
|
|
84
|
+
return { rows: [{ id: 'a1', action: 'created' }], rowCount: 1 };
|
|
85
|
+
}
|
|
86
|
+
if (sql.startsWith('UPDATE "tx_docs"')) {
|
|
87
|
+
return { rows: [{ id: 'd1', title: 'Doc', status: 'done' }], rowCount: 1 };
|
|
88
|
+
}
|
|
89
|
+
return { rows: [], rowCount: 0 };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const response = await callTransact([
|
|
93
|
+
{ table: 'tx_docs', op: 'insert', data: { title: 'Doc', status: 'draft' } },
|
|
94
|
+
{ table: 'tx_audit', op: 'insert', data: { action: 'created' } },
|
|
95
|
+
{ table: 'tx_docs', op: 'update', id: 'd1', data: { status: 'done' } },
|
|
96
|
+
]);
|
|
97
|
+
expect(response.status).toBe(200);
|
|
98
|
+
const body = (await response.json()) as { results: Array<Record<string, unknown>> };
|
|
99
|
+
expect(body.results).toHaveLength(3);
|
|
100
|
+
expect((body.results[0].inserted as Record<string, unknown>).id).toBe('d1');
|
|
101
|
+
expect((body.results[2].updated as Record<string, unknown>).status).toBe('done');
|
|
102
|
+
|
|
103
|
+
const sqls = calls.map((call) => call.sql);
|
|
104
|
+
expect(sqls[0]).toBe('BEGIN');
|
|
105
|
+
expect(sqls[sqls.length - 1]).toBe('COMMIT');
|
|
106
|
+
expect(sqls).not.toContain('ROLLBACK');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rolls back with 409 when an expect assertion is unmet', async () => {
|
|
110
|
+
const calls = mockExecutorModule((sql) => {
|
|
111
|
+
if (sql.startsWith('SELECT * FROM "tx_docs"')) {
|
|
112
|
+
return { rows: [], rowCount: 0 }; // expectation probe finds nothing
|
|
113
|
+
}
|
|
114
|
+
if (sql.startsWith('INSERT INTO "tx_audit"')) {
|
|
115
|
+
return { rows: [{ id: 'a1', action: 'created' }], rowCount: 1 };
|
|
116
|
+
}
|
|
117
|
+
return { rows: [], rowCount: 0 };
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const response = await callTransact([
|
|
121
|
+
{ table: 'tx_audit', op: 'insert', data: { action: 'created' } },
|
|
122
|
+
{ table: 'tx_docs', op: 'expect', id: 'missing', where: [['status', '==', 'active']], exists: true },
|
|
123
|
+
]);
|
|
124
|
+
expect(response.status).toBe(409);
|
|
125
|
+
|
|
126
|
+
const sqls = calls.map((call) => call.sql);
|
|
127
|
+
expect(sqls[0]).toBe('BEGIN');
|
|
128
|
+
expect(sqls).toContain('ROLLBACK');
|
|
129
|
+
expect(sqls).not.toContain('COMMIT');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('rejects the local dev sidecar with 501', async () => {
|
|
133
|
+
mockExecutorModule(() => ({ rows: [], rowCount: 0 }), { sidecar: true });
|
|
134
|
+
const response = await callTransact([
|
|
135
|
+
{ table: 'tx_docs', op: 'insert', data: { title: 'Doc' } },
|
|
136
|
+
]);
|
|
137
|
+
expect(response.status).toBe(501);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('validates operations before opening a transaction', async () => {
|
|
141
|
+
const calls = mockExecutorModule(() => ({ rows: [], rowCount: 0 }));
|
|
142
|
+
const response = await callTransact([
|
|
143
|
+
{ table: 'no_such_table', op: 'insert', data: { title: 'x' } },
|
|
144
|
+
]);
|
|
145
|
+
expect(response.status).toBe(400);
|
|
146
|
+
expect(calls.map((call) => call.sql)).not.toContain('BEGIN');
|
|
147
|
+
});
|
|
148
|
+
});
|