@edge-base/server 0.2.2 → 0.2.4

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 (97) hide show
  1. package/admin-build/_app/immutable/chunks/{C85dMlzL.js → 5RQRbp5q.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{B8DT4fss.js → BME_U9TJ.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{BaCHY17I.js → BYI6CUvd.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{BWyDPAjM.js → BgDzp0i0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{c5iKSdWY.js → BjWZuf8W.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{g3ZZdY-r.js → C6lpZLE2.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{C-DsDCNG.js → D5GswVnI.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/DBsVqhuh.js +1 -0
  9. package/admin-build/_app/immutable/chunks/{BEYYl662.js → DYaCRWMA.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/D__dwMuW.js +1 -0
  11. package/admin-build/_app/immutable/chunks/{4vlsb8ej.js → Dj-E9-FO.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{kiJ6KthZ.js → Dj0QUuOf.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BKXmgPq4.js → XQM1k9PM.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{CTngeX8H.js → fYEKMQ-Z.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CPdXvRUb.js → g_-Kpxu3.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DzXaj-Ja.js → wCNueVYy.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BZxfavhF.js → app.C8ylfBe6.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.CtsqDyfj.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.DlsaydXO.js → 0.CJJ6HZbp.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.D2NWN5eG.js → 1.B4sI5cB4.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.EMDaN3nw.js → 10.D6hvCer6.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.BasqQ_o9.js → 11.Dx7b8aQ5.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.DO31Ljs7.js → 12.Bqmy5KIF.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DhyAy-GZ.js → 13.CC6KpXgS.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.CLecGWc4.js → 14.yCo1Ix8E.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.B9kp3W4e.js → 15.co0UfPlh.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.Pu_8T3RI.js → 16.D0xkPUBW.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.DX4z43t6.js → 17.CebNqPeh.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.BKsSaxrr.js → 18.JUoLOZxh.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.DXNF1htN.js → 19.ND8kmQJe.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.VRVb0wee.js → 20.DYb-q3W8.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.cz3IN9Cc.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.DqZf4CtH.js → 22.UOzm8WYV.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DtyxMiQG.js → 23.BLgq21om.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CloWNmTd.js → 24.DN9usmUs.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.CnZWMq7_.js → 25.BddRfAyE.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.DrV7XOmf.js → 26.Dl6XHIeT.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.DV8L32OF.js → 27.D0iNwALG.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.Stil2D4u.js → 28.9dKQmdGi.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.Zsm1e5Dc.js → 29.wXzfJUXp.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.CKoj2vNz.js → 3.z8ut3jS-.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.Ni0k5bER.js → 30.BtZETNsL.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.mnqj9EbV.js → 31.CYonj2Jh.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.B_-z9AzT.js → 4.COtDPQ9b.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.yiZ72j4k.js → 5.CTRCeIhp.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BqykybBG.js → 6.ChHi3QkR.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.BDAHlhsF.js → 7.CCMtr6Ac.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.D8Xvy0lH.js → 8.DpWJ-X_-.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.Dddmd7_F.js → 9.DOkvfmir.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/package.json +3 -3
  53. package/src/__tests__/admin-data-routes.test.ts +29 -0
  54. package/src/__tests__/d1-live-broadcast-verification.test.ts +271 -0
  55. package/src/__tests__/database-do-route-validation.test.ts +105 -0
  56. package/src/__tests__/database-live-do.test.ts +50 -0
  57. package/src/__tests__/database-live-emitter.test.ts +116 -1
  58. package/src/__tests__/database-live-route.test.ts +82 -0
  59. package/src/__tests__/do-router.test.ts +116 -0
  60. package/src/__tests__/error-format.test.ts +63 -0
  61. package/src/__tests__/functions-context.test.ts +674 -33
  62. package/src/__tests__/functions-d1-proxy.test.ts +54 -0
  63. package/src/__tests__/plugin-migration-routing.test.ts +32 -0
  64. package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
  65. package/src/__tests__/provider-aware-sql.test.ts +163 -0
  66. package/src/__tests__/room-auth-state-loss.test.ts +124 -0
  67. package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
  68. package/src/__tests__/scheduled.test.ts +55 -0
  69. package/src/__tests__/service-key-db-proxy.test.ts +122 -1
  70. package/src/__tests__/sql-route.test.ts +252 -75
  71. package/src/__tests__/table-hook-runtime.test.ts +137 -0
  72. package/src/durable-objects/database-do.ts +36 -45
  73. package/src/durable-objects/database-live-do.ts +46 -1
  74. package/src/durable-objects/room-runtime-base.ts +26 -2
  75. package/src/durable-objects/rooms-do.ts +1 -1
  76. package/src/index.ts +12 -6
  77. package/src/lib/admin-db-target.ts +30 -74
  78. package/src/lib/d1-handler.ts +55 -35
  79. package/src/lib/database-live-emitter.ts +57 -16
  80. package/src/lib/do-router.ts +135 -3
  81. package/src/lib/functions.ts +215 -143
  82. package/src/lib/internal-transport.ts +28 -12
  83. package/src/lib/plugin-migration-routing.ts +28 -0
  84. package/src/lib/plugin-migrations.ts +38 -38
  85. package/src/lib/postgres-handler.ts +51 -31
  86. package/src/lib/provider-aware-sql.ts +831 -0
  87. package/src/lib/table-hook-runtime.ts +62 -0
  88. package/src/routes/admin.ts +41 -41
  89. package/src/routes/auth.ts +7 -2
  90. package/src/routes/database-live.ts +110 -12
  91. package/src/routes/sql.ts +64 -84
  92. package/src/routes/storage.ts +7 -2
  93. package/src/routes/tables.ts +42 -29
  94. package/admin-build/_app/immutable/chunks/5PDcRlfX.js +0 -1
  95. package/admin-build/_app/immutable/chunks/qiZXAKh-.js +0 -1
  96. package/admin-build/_app/immutable/entry/start.Mr9mmopc.js +0 -1
  97. package/admin-build/_app/immutable/nodes/21.Ck3_0D2f.js +0 -1
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { defineConfig } from '@edge-base/shared';
3
3
  import { EdgeBaseError } from '@edge-base/shared';
4
4
  import { setConfig } from '../lib/do-router.js';
@@ -117,6 +117,7 @@ describe('DB proxy service key forwarding', () => {
117
117
  databases: {
118
118
  workspace: {
119
119
  provider: 'do',
120
+ instance: true,
120
121
  tables: {
121
122
  users: {},
122
123
  },
@@ -203,6 +204,7 @@ describe('DB proxy service key forwarding', () => {
203
204
  databases: {
204
205
  workspace: {
205
206
  provider: 'do',
207
+ instance: true,
206
208
  tables: {
207
209
  users: {},
208
210
  },
@@ -375,6 +377,125 @@ describe('DB proxy service key forwarding', () => {
375
377
  await expect(response.json()).resolves.toEqual({ id: 'u1', name: 'June' });
376
378
  });
377
379
 
380
+ it('auto-retries single-instance provider=do namespaces when the DO asks for bootstrap authorization', async () => {
381
+ setConfig(defineConfig({
382
+ release: true,
383
+ databases: {
384
+ app: {
385
+ provider: 'do',
386
+ tables: {
387
+ posts: {
388
+ access: {
389
+ read: () => true,
390
+ },
391
+ },
392
+ },
393
+ },
394
+ },
395
+ }));
396
+
397
+ const forwardedHeaders: Headers[] = [];
398
+ let callCount = 0;
399
+ const app = createApp();
400
+ const response = await app.request('/api/db/app/tables/posts', {
401
+ method: 'GET',
402
+ }, createEnv((_input, init) => {
403
+ forwardedHeaders.push(new Headers(init?.headers));
404
+ callCount += 1;
405
+ if (callCount === 1) {
406
+ return new Response(JSON.stringify({ needsCreate: true, namespace: 'app' }), {
407
+ status: 201,
408
+ headers: { 'Content-Type': 'application/json' },
409
+ });
410
+ }
411
+ return new Response(JSON.stringify({ items: [] }), {
412
+ status: 200,
413
+ headers: { 'Content-Type': 'application/json' },
414
+ });
415
+ }));
416
+
417
+ expect(response.status).toBe(200);
418
+ await expect(response.json()).resolves.toEqual({ items: [] });
419
+ expect(forwardedHeaders).toHaveLength(2);
420
+ expect(forwardedHeaders[0].get('X-DO-Create-Authorized')).toBeNull();
421
+ expect(forwardedHeaders[1].get('X-DO-Create-Authorized')).toBe('1');
422
+ });
423
+
424
+ it('rejects instanceId route segments for single-instance namespaces before touching the DO', async () => {
425
+ setConfig(defineConfig({
426
+ release: true,
427
+ databases: {
428
+ app: {
429
+ provider: 'do',
430
+ tables: {
431
+ posts: {
432
+ access: {
433
+ read: () => true,
434
+ },
435
+ },
436
+ },
437
+ },
438
+ },
439
+ }));
440
+
441
+ const onFetch = vi.fn();
442
+ const app = createApp();
443
+ const response = await app.request('/api/db/app/attacker/tables/posts', {
444
+ method: 'GET',
445
+ }, createEnv(onFetch));
446
+
447
+ expect(response.status).toBe(400);
448
+ await expect(response.json()).resolves.toMatchObject({
449
+ code: 400,
450
+ message: "instanceId is not allowed for single-instance namespace 'app'",
451
+ });
452
+ expect(onFetch).not.toHaveBeenCalled();
453
+ });
454
+
455
+ it('rejects missing instanceId for dynamic namespaces even for service key requests', async () => {
456
+ setConfig(defineConfig({
457
+ release: true,
458
+ databases: {
459
+ workspace: {
460
+ provider: 'do',
461
+ instance: true,
462
+ tables: {
463
+ users: {},
464
+ },
465
+ },
466
+ },
467
+ serviceKeys: {
468
+ keys: [
469
+ {
470
+ kid: 'root',
471
+ tier: 'root',
472
+ scopes: ['*'],
473
+ secretSource: 'inline',
474
+ inlineSecret: 'sk-root',
475
+ },
476
+ ],
477
+ },
478
+ }));
479
+
480
+ const onFetch = vi.fn();
481
+ const app = createApp();
482
+ const response = await app.request('/api/db/workspace/tables/users', {
483
+ method: 'POST',
484
+ headers: {
485
+ 'X-EdgeBase-Service-Key': 'sk-root',
486
+ 'Content-Type': 'application/json',
487
+ },
488
+ body: JSON.stringify({ id: 'user-1' }),
489
+ }, createEnv(onFetch));
490
+
491
+ expect(response.status).toBe(400);
492
+ await expect(response.json()).resolves.toMatchObject({
493
+ code: 400,
494
+ message: "instanceId is required for dynamic namespace 'workspace'",
495
+ });
496
+ expect(onFetch).not.toHaveBeenCalled();
497
+ });
498
+
378
499
  it('does not trust raw X-EdgeBase-Internal on public DB requests', async () => {
379
500
  setConfig(defineConfig({
380
501
  release: true,
@@ -18,22 +18,28 @@ describe('sql route', () => {
18
18
  });
19
19
 
20
20
  it('rejects unconfigured shared namespace instead of treating it as implicit', async () => {
21
- setConfig(defineConfig({
22
- databases: {
23
- app: {
24
- tables: {
25
- posts: { schema: { title: { type: 'string' } } },
21
+ setConfig(
22
+ defineConfig({
23
+ databases: {
24
+ app: {
25
+ tables: {
26
+ posts: { schema: { title: { type: 'string' } } },
27
+ },
26
28
  },
27
29
  },
28
- },
29
- }));
30
+ }),
31
+ );
30
32
 
31
33
  const app = createApp();
32
- const response = await app.request('/api/sql', {
33
- method: 'POST',
34
- headers: { 'Content-Type': 'application/json' },
35
- body: JSON.stringify({ namespace: 'shared', sql: 'SELECT 1' }),
36
- }, {} as Env);
34
+ const response = await app.request(
35
+ '/api/sql',
36
+ {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ namespace: 'shared', sql: 'SELECT 1' }),
40
+ },
41
+ {} as Env,
42
+ );
37
43
 
38
44
  expect(response.status).toBe(404);
39
45
  await expect(response.json()).resolves.toMatchObject({
@@ -42,31 +48,100 @@ describe('sql route', () => {
42
48
  });
43
49
  });
44
50
 
45
- it('retries dynamic DO SQL after create handshake and forwards the DO name', async () => {
46
- setConfig(defineConfig({
47
- databases: {
48
- workspace: {
49
- instance: true,
50
- tables: {
51
- members: { schema: { userId: { type: 'string' } } },
51
+ it('rejects ids for single-instance namespaces before touching any backend', async () => {
52
+ setConfig(
53
+ defineConfig({
54
+ databases: {
55
+ shared: {
56
+ tables: {
57
+ posts: { schema: { title: { type: 'string' } } },
58
+ },
52
59
  },
53
60
  },
61
+ serviceKeys: {
62
+ keys: [
63
+ {
64
+ kid: 'root',
65
+ tier: 'root',
66
+ scopes: ['*'],
67
+ secretSource: 'inline',
68
+ inlineSecret: 'sk-root',
69
+ },
70
+ ],
71
+ },
72
+ }),
73
+ );
74
+
75
+ const env = {
76
+ DATABASE: {
77
+ idFromName: vi.fn(),
78
+ get: vi.fn(),
54
79
  },
55
- serviceKeys: {
56
- keys: [
57
- {
58
- kid: 'root',
59
- tier: 'root',
60
- scopes: ['*'],
61
- secretSource: 'inline',
62
- inlineSecret: 'sk-root',
63
- },
64
- ],
80
+ DB_D1_SHARED: {
81
+ prepare: vi.fn(),
65
82
  },
66
- }));
83
+ } as unknown as Env;
84
+
85
+ const app = createApp();
86
+ const response = await app.request(
87
+ '/api/sql',
88
+ {
89
+ method: 'POST',
90
+ headers: {
91
+ 'Content-Type': 'application/json',
92
+ 'X-EdgeBase-Service-Key': 'sk-root',
93
+ },
94
+ body: JSON.stringify({
95
+ namespace: 'shared',
96
+ id: 'shadow',
97
+ sql: 'SELECT 1',
98
+ }),
99
+ },
100
+ env,
101
+ );
102
+
103
+ expect(response.status).toBe(400);
104
+ await expect(response.json()).resolves.toMatchObject({
105
+ code: 400,
106
+ message: "id is not allowed for single-instance namespace 'shared'",
107
+ });
108
+ expect(
109
+ (env as unknown as { DATABASE: { get: ReturnType<typeof vi.fn> } }).DATABASE.get,
110
+ ).not.toHaveBeenCalled();
111
+ expect(
112
+ (env as unknown as { DB_D1_SHARED: { prepare: ReturnType<typeof vi.fn> } }).DB_D1_SHARED
113
+ .prepare,
114
+ ).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('retries dynamic DO SQL after create handshake and forwards the DO name', async () => {
118
+ setConfig(
119
+ defineConfig({
120
+ databases: {
121
+ workspace: {
122
+ instance: true,
123
+ tables: {
124
+ members: { schema: { userId: { type: 'string' } } },
125
+ },
126
+ },
127
+ },
128
+ serviceKeys: {
129
+ keys: [
130
+ {
131
+ kid: 'root',
132
+ tier: 'root',
133
+ scopes: ['*'],
134
+ secretSource: 'inline',
135
+ inlineSecret: 'sk-root',
136
+ },
137
+ ],
138
+ },
139
+ }),
140
+ );
67
141
 
68
142
  const stub = {
69
- fetch: vi.fn()
143
+ fetch: vi
144
+ .fn()
70
145
  .mockResolvedValueOnce(
71
146
  new Response(JSON.stringify({ needsCreate: true, namespace: 'workspace', id: 'ws-1' }), {
72
147
  status: 201,
@@ -88,19 +163,23 @@ describe('sql route', () => {
88
163
  } as unknown as Env;
89
164
 
90
165
  const app = createApp();
91
- const response = await app.request('/api/sql', {
92
- method: 'POST',
93
- headers: {
94
- 'Content-Type': 'application/json',
95
- 'X-EdgeBase-Service-Key': 'sk-root',
166
+ const response = await app.request(
167
+ '/api/sql',
168
+ {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ 'X-EdgeBase-Service-Key': 'sk-root',
173
+ },
174
+ body: JSON.stringify({
175
+ namespace: 'workspace',
176
+ id: 'ws-1',
177
+ sql: 'SELECT COUNT(*) AS total FROM members',
178
+ params: [],
179
+ }),
96
180
  },
97
- body: JSON.stringify({
98
- namespace: 'workspace',
99
- id: 'ws-1',
100
- sql: 'SELECT COUNT(*) AS total FROM members',
101
- params: [],
102
- }),
103
- }, env);
181
+ env,
182
+ );
104
183
 
105
184
  expect(response.status).toBe(200);
106
185
  await expect(response.json()).resolves.toMatchObject({
@@ -122,26 +201,28 @@ describe('sql route', () => {
122
201
  });
123
202
 
124
203
  it('uses D1 run() for non-SELECT SQL so schema mutations actually execute', async () => {
125
- setConfig(defineConfig({
126
- databases: {
127
- shared: {
128
- tables: {
129
- posts: { schema: { title: { type: 'string' } } },
204
+ setConfig(
205
+ defineConfig({
206
+ databases: {
207
+ shared: {
208
+ tables: {
209
+ posts: { schema: { title: { type: 'string' } } },
210
+ },
130
211
  },
131
212
  },
132
- },
133
- serviceKeys: {
134
- keys: [
135
- {
136
- kid: 'root',
137
- tier: 'root',
138
- scopes: ['*'],
139
- secretSource: 'inline',
140
- inlineSecret: 'sk-root',
141
- },
142
- ],
143
- },
144
- }));
213
+ serviceKeys: {
214
+ keys: [
215
+ {
216
+ kid: 'root',
217
+ tier: 'root',
218
+ scopes: ['*'],
219
+ secretSource: 'inline',
220
+ inlineSecret: 'sk-root',
221
+ },
222
+ ],
223
+ },
224
+ }),
225
+ );
145
226
 
146
227
  const stmt: {
147
228
  bind: ReturnType<typeof vi.fn>;
@@ -159,33 +240,129 @@ describe('sql route', () => {
159
240
  } as unknown as Env;
160
241
 
161
242
  const app = createApp();
162
- const response = await app.request('/api/sql', {
163
- method: 'POST',
164
- headers: {
165
- 'Content-Type': 'application/json',
166
- 'X-EdgeBase-Service-Key': 'sk-root',
243
+ const response = await app.request(
244
+ '/api/sql',
245
+ {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ 'X-EdgeBase-Service-Key': 'sk-root',
250
+ },
251
+ body: JSON.stringify({
252
+ namespace: 'shared',
253
+ sql: 'ALTER TABLE "posts" RENAME TO "articles"',
254
+ }),
167
255
  },
168
- body: JSON.stringify({
169
- namespace: 'shared',
170
- sql: 'ALTER TABLE "posts" RENAME TO "articles"',
171
- }),
172
- }, env);
256
+ env,
257
+ );
173
258
 
174
259
  expect(response.status).toBe(200);
175
260
  await expect(response.json()).resolves.toMatchObject({
176
261
  rows: [],
177
262
  rowCount: 0,
178
263
  });
179
- expect((env as unknown as { DB_D1_SHARED: { prepare: ReturnType<typeof vi.fn> } }).DB_D1_SHARED.prepare).toHaveBeenCalledWith(
180
- 'ALTER TABLE "posts" RENAME TO "articles"',
181
- );
264
+ expect(
265
+ (env as unknown as { DB_D1_SHARED: { prepare: ReturnType<typeof vi.fn> } }).DB_D1_SHARED
266
+ .prepare,
267
+ ).toHaveBeenCalledWith('ALTER TABLE "posts" RENAME TO "articles"');
182
268
  expect(stmt.run).toHaveBeenCalledTimes(1);
183
269
  expect(stmt.all).not.toHaveBeenCalled();
184
270
  });
185
271
 
272
+ it.each(['postgres', 'neon'] as const)(
273
+ 'routes %s raw SQL through the provider-aware executor and normalizes ? placeholders',
274
+ async (provider) => {
275
+ const fetchMock = vi
276
+ .fn()
277
+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
278
+ .mockResolvedValueOnce(
279
+ new Response(
280
+ JSON.stringify({
281
+ columns: ['literal', 'total'],
282
+ rows: [{ literal: '?', total: 3 }],
283
+ rowCount: 1,
284
+ }),
285
+ {
286
+ status: 200,
287
+ headers: { 'Content-Type': 'application/json' },
288
+ },
289
+ ),
290
+ );
291
+ vi.stubGlobal('fetch', fetchMock);
292
+
293
+ setConfig(
294
+ defineConfig({
295
+ databases: {
296
+ shared: {
297
+ provider,
298
+ tables: {
299
+ posts: { schema: { title: { type: 'string' } } },
300
+ },
301
+ },
302
+ },
303
+ serviceKeys: {
304
+ keys: [
305
+ {
306
+ kid: 'root',
307
+ tier: 'root',
308
+ scopes: ['*'],
309
+ secretSource: 'inline',
310
+ inlineSecret: 'sk-root',
311
+ },
312
+ ],
313
+ },
314
+ }),
315
+ );
316
+
317
+ const env = {
318
+ DATABASE: {
319
+ idFromName: vi.fn().mockReturnValue('do-id'),
320
+ get: vi.fn(),
321
+ },
322
+ EDGEBASE_DEV_SIDECAR_PORT: '8788',
323
+ JWT_ADMIN_SECRET: 'jwt-secret',
324
+ DB_POSTGRES_SHARED_URL: 'postgres://edgebase:test@localhost/shared',
325
+ } as unknown as Env;
326
+
327
+ const app = createApp();
328
+ const response = await app.request(
329
+ '/api/sql',
330
+ {
331
+ method: 'POST',
332
+ headers: {
333
+ 'Content-Type': 'application/json',
334
+ 'X-EdgeBase-Service-Key': 'sk-root',
335
+ },
336
+ body: JSON.stringify({
337
+ namespace: 'shared',
338
+ sql: "SELECT '?' AS literal, COUNT(*) AS total FROM posts WHERE title = ?",
339
+ params: ['owner'],
340
+ }),
341
+ },
342
+ env,
343
+ );
344
+
345
+ expect(response.status).toBe(200);
346
+ await expect(response.json()).resolves.toMatchObject({
347
+ rows: [{ literal: '?', total: 3 }],
348
+ rowCount: 1,
349
+ });
350
+ expect(fetchMock).toHaveBeenCalledTimes(2);
351
+ expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? '{}'))).toEqual({
352
+ namespace: 'shared',
353
+ sql: "SELECT '?' AS literal, COUNT(*) AS total FROM posts WHERE title = $1",
354
+ params: ['owner'],
355
+ });
356
+ expect(
357
+ (env as unknown as { DATABASE: { get: ReturnType<typeof vi.fn> } }).DATABASE.get,
358
+ ).not.toHaveBeenCalled();
359
+ },
360
+ );
361
+
186
362
  it('executeDoSql retries the create handshake before returning rows', async () => {
187
363
  const stub = {
188
- fetch: vi.fn()
364
+ fetch: vi
365
+ .fn()
189
366
  .mockResolvedValueOnce(
190
367
  new Response(JSON.stringify({ needsCreate: true, namespace: 'workspace', id: 'ws-2' }), {
191
368
  status: 201,
@@ -0,0 +1,137 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Env } from '../types.js';
3
+
4
+ const {
5
+ ensureAuthSchemaMock,
6
+ resolveAuthDbMock,
7
+ sendToDatabaseLiveDOMock,
8
+ createPushProviderMock,
9
+ getDevicesForUserMock,
10
+ providerSendMock,
11
+ } = vi.hoisted(() => ({
12
+ ensureAuthSchemaMock: vi.fn(),
13
+ resolveAuthDbMock: vi.fn(),
14
+ sendToDatabaseLiveDOMock: vi.fn(),
15
+ createPushProviderMock: vi.fn(),
16
+ getDevicesForUserMock: vi.fn(),
17
+ providerSendMock: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('../lib/auth-d1.js', () => ({
21
+ ensureAuthSchema: ensureAuthSchemaMock,
22
+ }));
23
+
24
+ vi.mock('../lib/auth-db-adapter.js', () => ({
25
+ resolveAuthDb: resolveAuthDbMock,
26
+ }));
27
+
28
+ vi.mock('../lib/database-live-emitter.js', () => ({
29
+ sendToDatabaseLiveDO: sendToDatabaseLiveDOMock,
30
+ }));
31
+
32
+ vi.mock('../lib/push-provider.js', () => ({
33
+ createPushProvider: createPushProviderMock,
34
+ }));
35
+
36
+ vi.mock('../lib/push-token.js', () => ({
37
+ getDevicesForUser: getDevicesForUserMock,
38
+ }));
39
+
40
+ describe('buildTableHookRuntimeServices', () => {
41
+ beforeEach(() => {
42
+ vi.resetModules();
43
+ ensureAuthSchemaMock.mockReset().mockResolvedValue(undefined);
44
+ resolveAuthDbMock.mockReset().mockReturnValue({ kind: 'auth-db' });
45
+ sendToDatabaseLiveDOMock.mockReset().mockResolvedValue(undefined);
46
+ createPushProviderMock.mockReset().mockReturnValue({ send: providerSendMock });
47
+ getDevicesForUserMock.mockReset().mockResolvedValue([]);
48
+ providerSendMock.mockReset().mockResolvedValue({ success: true });
49
+ });
50
+
51
+ it('broadcasts hook events through DatabaseLiveDO', async () => {
52
+ const { buildTableHookRuntimeServices } = await import('../lib/table-hook-runtime.js');
53
+ const env = {
54
+ DATABASE_LIVE: {} as DurableObjectNamespace,
55
+ } as Env;
56
+
57
+ const services = buildTableHookRuntimeServices({} as never, env);
58
+ await services.databaseLive.broadcast('posts', 'created', { id: 'post-1' });
59
+
60
+ expect(sendToDatabaseLiveDOMock).toHaveBeenCalledWith(
61
+ env,
62
+ {
63
+ channel: 'posts',
64
+ event: 'created',
65
+ payload: { id: 'post-1' },
66
+ },
67
+ '/internal/broadcast',
68
+ );
69
+ });
70
+
71
+ it('sends push notifications using auth-backed device lookups when available', async () => {
72
+ const { buildTableHookRuntimeServices } = await import('../lib/table-hook-runtime.js');
73
+ const authDb = { kind: 'auth-db' };
74
+ resolveAuthDbMock.mockReturnValue(authDb);
75
+ getDevicesForUserMock.mockResolvedValue([
76
+ { token: 'token-1', platform: 'ios' },
77
+ { token: 'token-2', platform: 'android' },
78
+ ]);
79
+ const env = {
80
+ KV: {} as KVNamespace,
81
+ AUTH_DB: {} as D1Database,
82
+ } as Env;
83
+
84
+ const pushConfig = {
85
+ fcm: {
86
+ projectId: 'demo-project',
87
+ serviceAccount: '{"client_email":"demo@example.com"}',
88
+ },
89
+ };
90
+ const services = buildTableHookRuntimeServices({ push: pushConfig } as never, env);
91
+ await services.push.send('user-123', { title: 'Hello', body: 'World' });
92
+
93
+ expect(resolveAuthDbMock).toHaveBeenCalledWith(env);
94
+ expect(ensureAuthSchemaMock).toHaveBeenCalledWith(authDb);
95
+ expect(createPushProviderMock).toHaveBeenCalledWith(pushConfig, env);
96
+ expect(getDevicesForUserMock).toHaveBeenCalledWith({ kv: env.KV, authDb }, 'user-123');
97
+ expect(providerSendMock).toHaveBeenCalledTimes(2);
98
+ expect(providerSendMock).toHaveBeenNthCalledWith(1, {
99
+ token: 'token-1',
100
+ platform: 'ios',
101
+ payload: { title: 'Hello', body: 'World' },
102
+ });
103
+ expect(providerSendMock).toHaveBeenNthCalledWith(2, {
104
+ token: 'token-2',
105
+ platform: 'android',
106
+ payload: { title: 'Hello', body: 'World' },
107
+ });
108
+ });
109
+
110
+ it('falls back to KV-only token lookups when auth db resolution fails', async () => {
111
+ const { buildTableHookRuntimeServices } = await import('../lib/table-hook-runtime.js');
112
+ resolveAuthDbMock.mockImplementation(() => {
113
+ throw new Error('missing auth db');
114
+ });
115
+ getDevicesForUserMock.mockResolvedValue([{ token: 'token-1', platform: 'web' }]);
116
+ const env = {
117
+ KV: {} as KVNamespace,
118
+ } as Env;
119
+
120
+ const pushConfig = {
121
+ fcm: {
122
+ projectId: 'demo-project',
123
+ serviceAccount: '{"client_email":"demo@example.com"}',
124
+ },
125
+ };
126
+ const services = buildTableHookRuntimeServices({ push: pushConfig } as never, env);
127
+ await services.push.send('user-123', { body: 'Fallback works' });
128
+
129
+ expect(ensureAuthSchemaMock).not.toHaveBeenCalled();
130
+ expect(getDevicesForUserMock).toHaveBeenCalledWith(env.KV, 'user-123');
131
+ expect(providerSendMock).toHaveBeenCalledWith({
132
+ token: 'token-1',
133
+ platform: 'web',
134
+ payload: { body: 'Fallback works' },
135
+ });
136
+ });
137
+ });