@edge-base/server 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/admin-build/_app/immutable/chunks/{C-zXt-cq.js → ArkGiTMv.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{DF40xjpj.js → B6_FvkIG.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{Cek51lkE.js → BM46GgYk.js} +3 -3
  4. package/admin-build/_app/immutable/chunks/{BSIzoJ5I.js → Bxlz62x0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/ByFwWOVj.js +1 -0
  6. package/admin-build/_app/immutable/chunks/{aPulNooa.js → CKJGIsEj.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{D0mNYcxN.js → CUEBmgYR.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{C1Mycuvd.js → Cav53qqs.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{DOOhHO-v.js → CtOkHpP1.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{BkrC_6Ss.js → D3arvney.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{Dvlxm2Iq.js → DC_HyNNV.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{dZsKGnWZ.js → Da6ZRIF1.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BeYswbvA.js → DfwnKUzy.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/DuAuob_N.js +1 -0
  15. package/admin-build/_app/immutable/chunks/{BunyH6GE.js → ZQw8NgPJ.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{Xm1dHMTE.js → fgIr2CuT.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.j1WHBlZX.js → app.BtTg2P3N.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.D21f0BIj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.Oo7ZpbR-.js → 0.CqnCTNhX.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.zgMnKHns.js → 1.BLn3o9jT.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.u5mkRiUe.js → 10.Bp6HrvpD.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.9ynAgaxV.js → 11.79DhgZw-.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.M_EqzJ6s.js → 12.A7_yb57H.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.D_NQ9q60.js → 13.DgMDZXO8.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.-vENIJ2w.js → 14.DycfMAIE.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.DoMCZhGv.js → 15.Bk_-RJ3j.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.6BmnzScq.js → 16.B7Vjh-Xp.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.q6QQ9N_J.js → 17.DKfB3-O7.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DUhJ6dKj.js → 18.Bwq1YHfM.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CvkvPlGP.js → 19.3S-h9EgK.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.cACSbRIl.js → 20.Cm2djLN8.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.C3hqim4E.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.BsQ6xeKr.js → 22.DltIIio7.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.CNW3mLz5.js → 23.qVqH8BFx.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CReqtEOy.js → 24.BBgqZFZ0.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BuxVhqlb.js → 25.2xJDygjz.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.C-LzPf62.js → 26.B7pxYnqL.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.Dl5RL_tB.js → 27.P3eodDFO.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CWfSQgjH.js → 28.CPEn1uNw.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.wUSfh2eQ.js → 29.D7ZGYHTI.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.D_laBXeK.js → 3.UaLSRTpv.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.DcMhZyQ0.js → 30.D-iftjK2.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.CSwhNsNi.js → 31.CHSNieUr.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Cq28vPI2.js → 4.BgPFR0hR.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BTT9B3oI.js → 5.BRn6WaF_.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.iCmmzXZp.js → 6.Cdem1OHu.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.qY5v7qqJ.js → 7.FpFS7ceb.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.C6p4RvgK.js → 8.aCQtyAed.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.f548N7zh.js → 9.9BZCZcm_.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +193 -0
  53. package/package.json +3 -3
  54. package/src/__tests__/error-format.test.ts +21 -0
  55. package/src/__tests__/postgres-api-consistency.test.ts +240 -0
  56. package/src/__tests__/postgres-batch-compat.test.ts +33 -27
  57. package/src/__tests__/postgres-batch.test.ts +394 -0
  58. package/src/__tests__/postgres-field-ops-compat.test.ts +2 -1
  59. package/src/__tests__/postgres-transact.test.ts +148 -0
  60. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  61. package/src/durable-objects/database-do.ts +455 -15
  62. package/src/durable-objects/room-runtime-base.ts +8 -1
  63. package/src/lib/d1-handler.ts +416 -50
  64. package/src/lib/errors.ts +8 -4
  65. package/src/lib/internal-transport.ts +10 -0
  66. package/src/lib/postgres-handler.ts +562 -84
  67. package/src/routes/auth.ts +58 -46
  68. package/src/routes/tables.ts +140 -0
  69. package/admin-build/_app/immutable/chunks/D3hdqsfp.js +0 -1
  70. package/admin-build/_app/immutable/chunks/DdjFeYwG.js +0 -1
  71. package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +0 -1
  72. package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +0 -1
@@ -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
- expect(selectSql).toContain('SELECT "posts"."id" FROM "posts"');
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
+ });
@@ -38,7 +38,7 @@ describe('smoke skip report', () => {
38
38
  {
39
39
  "skippedRouteCount": 0,
40
40
  "summaryByReason": {},
41
- "totalRoutes": 191,
41
+ "totalRoutes": 193,
42
42
  }
43
43
  `);
44
44
  });