@edge-base/server 0.2.3 → 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.
- package/admin-build/_app/immutable/chunks/{CzSAxmuj.js → 5RQRbp5q.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B5Nwfelm.js → BME_U9TJ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dlty5069.js → BYI6CUvd.js} +1 -1
- package/admin-build/_app/immutable/chunks/{A_3UuvCe.js → BgDzp0i0.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DRqPU3wD.js → BjWZuf8W.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dc1-6Po6.js → C6lpZLE2.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BxoNtYHK.js → D5GswVnI.js} +3 -3
- package/admin-build/_app/immutable/chunks/DBsVqhuh.js +1 -0
- package/admin-build/_app/immutable/chunks/{nZvorU8i.js → DYaCRWMA.js} +1 -1
- package/admin-build/_app/immutable/chunks/D__dwMuW.js +1 -0
- package/admin-build/_app/immutable/chunks/{DpVAayDG.js → Dj-E9-FO.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DCvwWZrm.js → Dj0QUuOf.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B-_-hJ9o.js → XQM1k9PM.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Du5vWVa2.js → fYEKMQ-Z.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CZ0TVkCa.js → g_-Kpxu3.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DCKcAiQH.js → wCNueVYy.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.CfrmEXPD.js → app.C8ylfBe6.js} +2 -2
- package/admin-build/_app/immutable/entry/start.CtsqDyfj.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.Cn2BZ4da.js → 0.CJJ6HZbp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.Dv4LX_Co.js → 1.B4sI5cB4.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.DPVv3kat.js → 10.D6hvCer6.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.CiCb6Ayu.js → 11.Dx7b8aQ5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CIPyeekF.js → 12.Bqmy5KIF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.Z15Lt36e.js → 13.CC6KpXgS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.s0l5bAq3.js → 14.yCo1Ix8E.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.UwSSNO76.js → 15.co0UfPlh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.qiD8i883.js → 16.D0xkPUBW.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.Dy3dcSvu.js → 17.CebNqPeh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DeXyPYsO.js → 18.JUoLOZxh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CAbuyS6w.js → 19.ND8kmQJe.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.Bec0T7un.js → 20.DYb-q3W8.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.cz3IN9Cc.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.CdVprrv2.js → 22.UOzm8WYV.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.Y8RzVLoF.js → 23.BLgq21om.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.CWhHYFBx.js → 24.DN9usmUs.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.wCBplOVt.js → 25.BddRfAyE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.Cod_JRFK.js → 26.Dl6XHIeT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BO2HVMu9.js → 27.D0iNwALG.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DxG-FBVQ.js → 28.9dKQmdGi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CjGqWGvE.js → 29.wXzfJUXp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.By3_OmdZ.js → 3.z8ut3jS-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.M_H7Htpq.js → 30.BtZETNsL.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.DEU18izM.js → 31.CYonj2Jh.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.DeYhKtzJ.js → 4.COtDPQ9b.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.9WLgxhrD.js → 5.CTRCeIhp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.BdT2i_dd.js → 6.ChHi3QkR.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.CHq0s4K6.js → 7.CCMtr6Ac.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.DuvRw-XZ.js → 8.DpWJ-X_-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.C2Ub82wn.js → 9.DOkvfmir.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/package.json +3 -3
- package/src/__tests__/admin-data-routes.test.ts +29 -0
- package/src/__tests__/database-do-route-validation.test.ts +105 -0
- package/src/__tests__/database-live-route.test.ts +82 -0
- package/src/__tests__/do-router.test.ts +116 -0
- package/src/__tests__/functions-context.test.ts +84 -0
- package/src/__tests__/functions-d1-proxy.test.ts +54 -0
- package/src/__tests__/plugin-migration-routing.test.ts +32 -0
- package/src/__tests__/provider-aware-sql.test.ts +9 -3
- package/src/__tests__/scheduled.test.ts +55 -0
- package/src/__tests__/service-key-db-proxy.test.ts +122 -1
- package/src/__tests__/sql-route.test.ts +66 -0
- package/src/__tests__/table-hook-runtime.test.ts +137 -0
- package/src/durable-objects/database-do.ts +36 -45
- package/src/index.ts +12 -6
- package/src/lib/d1-handler.ts +10 -21
- package/src/lib/do-router.ts +135 -3
- package/src/lib/functions.ts +4 -3
- package/src/lib/internal-transport.ts +28 -12
- package/src/lib/plugin-migration-routing.ts +28 -0
- package/src/lib/postgres-handler.ts +12 -20
- package/src/lib/provider-aware-sql.ts +19 -15
- package/src/lib/table-hook-runtime.ts +62 -0
- package/src/routes/admin.ts +41 -41
- package/src/routes/database-live.ts +110 -12
- package/src/routes/sql.ts +22 -17
- package/src/routes/tables.ts +42 -29
- package/admin-build/_app/immutable/chunks/DiyBpamp.js +0 -1
- package/admin-build/_app/immutable/chunks/byv2rTy8.js +0 -1
- package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +0 -1
- package/admin-build/_app/immutable/nodes/21.DuDYelMY.js +0 -1
|
@@ -122,11 +122,17 @@ describe('provider-aware raw SQL helpers', () => {
|
|
|
122
122
|
|
|
123
123
|
const result = await executeProviderAwareSql(
|
|
124
124
|
{
|
|
125
|
-
config: {
|
|
125
|
+
config: {
|
|
126
|
+
databases: {
|
|
127
|
+
shared: {
|
|
128
|
+
tables: { posts: {} },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
126
132
|
workerUrl: 'http://localhost:8787',
|
|
127
133
|
serviceKey: 'service-key',
|
|
128
134
|
},
|
|
129
|
-
'
|
|
135
|
+
'shared',
|
|
130
136
|
undefined,
|
|
131
137
|
'SELECT ? AS id',
|
|
132
138
|
['p1'],
|
|
@@ -146,7 +152,7 @@ describe('provider-aware raw SQL helpers', () => {
|
|
|
146
152
|
'X-EdgeBase-Service-Key': 'service-key',
|
|
147
153
|
}),
|
|
148
154
|
body: JSON.stringify({
|
|
149
|
-
namespace: '
|
|
155
|
+
namespace: 'shared',
|
|
150
156
|
id: undefined,
|
|
151
157
|
sql: 'SELECT ? AS id',
|
|
152
158
|
params: ['p1'],
|
|
@@ -6,12 +6,14 @@ const {
|
|
|
6
6
|
ensureAuthSchemaMock,
|
|
7
7
|
deleteAnonMock,
|
|
8
8
|
resolveAuthDbMock,
|
|
9
|
+
executePluginMigrationsMock,
|
|
9
10
|
} = vi.hoisted(() => ({
|
|
10
11
|
cleanExpiredSessionsMock: vi.fn(),
|
|
11
12
|
cleanStaleAnonymousAccountsMock: vi.fn(),
|
|
12
13
|
ensureAuthSchemaMock: vi.fn(),
|
|
13
14
|
deleteAnonMock: vi.fn(),
|
|
14
15
|
resolveAuthDbMock: vi.fn(),
|
|
16
|
+
executePluginMigrationsMock: vi.fn(),
|
|
15
17
|
}));
|
|
16
18
|
|
|
17
19
|
vi.mock('cloudflare:workers', () => ({
|
|
@@ -44,6 +46,10 @@ vi.mock('../lib/auth-db-adapter.js', async () => {
|
|
|
44
46
|
};
|
|
45
47
|
});
|
|
46
48
|
|
|
49
|
+
vi.mock('../lib/plugin-migrations.js', () => ({
|
|
50
|
+
executePluginMigrations: executePluginMigrationsMock,
|
|
51
|
+
}));
|
|
52
|
+
|
|
47
53
|
describe('scheduled handler', () => {
|
|
48
54
|
beforeEach(() => {
|
|
49
55
|
vi.resetModules();
|
|
@@ -52,6 +58,7 @@ describe('scheduled handler', () => {
|
|
|
52
58
|
ensureAuthSchemaMock.mockReset().mockResolvedValue(undefined);
|
|
53
59
|
deleteAnonMock.mockReset().mockResolvedValue(undefined);
|
|
54
60
|
resolveAuthDbMock.mockReset().mockReturnValue({ kind: 'auth-db' });
|
|
61
|
+
executePluginMigrationsMock.mockReset().mockResolvedValue(undefined);
|
|
55
62
|
});
|
|
56
63
|
|
|
57
64
|
it('runs system cleanup even when no user schedule functions are registered', async () => {
|
|
@@ -77,4 +84,52 @@ describe('scheduled handler', () => {
|
|
|
77
84
|
expect(cleanStaleAnonymousAccountsMock.mock.calls[0]?.[0]).toEqual({ kind: 'auth-db' });
|
|
78
85
|
expect(deleteAnonMock).not.toHaveBeenCalled();
|
|
79
86
|
}, 15_000);
|
|
87
|
+
|
|
88
|
+
it('runs plugin migration reconciliation before scheduled work when plugins are configured', async () => {
|
|
89
|
+
const worker = (await import('../index.js')).default;
|
|
90
|
+
const pending: Promise<unknown>[] = [];
|
|
91
|
+
const ctx = {
|
|
92
|
+
waitUntil: vi.fn((promise: Promise<unknown>) => {
|
|
93
|
+
pending.push(Promise.resolve(promise));
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
const env = {
|
|
97
|
+
EDGEBASE_CONFIG: {
|
|
98
|
+
release: true,
|
|
99
|
+
plugins: [
|
|
100
|
+
{
|
|
101
|
+
name: 'cert-plugin',
|
|
102
|
+
version: '0.1.0',
|
|
103
|
+
config: {},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
await worker.scheduled(
|
|
110
|
+
{ scheduledTime: Date.parse('2026-03-07T03:00:00Z') } as never,
|
|
111
|
+
env as never,
|
|
112
|
+
ctx as never,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(executePluginMigrationsMock).toHaveBeenCalledWith(
|
|
116
|
+
[
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
name: 'cert-plugin',
|
|
119
|
+
version: '0.1.0',
|
|
120
|
+
}),
|
|
121
|
+
],
|
|
122
|
+
env,
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
plugins: [
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
name: 'cert-plugin',
|
|
127
|
+
version: '0.1.0',
|
|
128
|
+
}),
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
'http://internal',
|
|
132
|
+
);
|
|
133
|
+
await Promise.all(pending);
|
|
134
|
+
}, 15_000);
|
|
80
135
|
});
|
|
@@ -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,
|
|
@@ -48,6 +48,72 @@ describe('sql route', () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
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
|
+
},
|
|
59
|
+
},
|
|
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(),
|
|
79
|
+
},
|
|
80
|
+
DB_D1_SHARED: {
|
|
81
|
+
prepare: vi.fn(),
|
|
82
|
+
},
|
|
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
|
+
|
|
51
117
|
it('retries dynamic DO SQL after create handshake and forwards the DO name', async () => {
|
|
52
118
|
setConfig(
|
|
53
119
|
defineConfig({
|
|
@@ -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
|
+
});
|
|
@@ -54,15 +54,12 @@ import {
|
|
|
54
54
|
getRegisteredFunctions,
|
|
55
55
|
buildFunctionContext,
|
|
56
56
|
} from '../lib/functions.js';
|
|
57
|
-
import { parseDbDoName, parseConfig as getGlobalConfig } from '../lib/do-router.js';
|
|
57
|
+
import { parseDbDoName, parseConfig as getGlobalConfig, isDynamicDbBlock } from '../lib/do-router.js';
|
|
58
58
|
import { parseDuration } from '../lib/jwt.js';
|
|
59
|
-
import { createPushProvider } from '../lib/push-provider.js';
|
|
60
|
-
import { getDevicesForUser } from '../lib/push-token.js';
|
|
61
|
-
import { ensureAuthSchema } from '../lib/auth-d1.js';
|
|
62
|
-
import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
|
|
63
59
|
import { buildDbLiveChannel, DATABASE_LIVE_HUB_DO_NAME } from '../lib/database-live-emitter.js';
|
|
64
60
|
import { resolveRootServiceKey } from '../lib/service-key.js';
|
|
65
61
|
import { resolveDbLiveBatchThreshold } from '../lib/database-live-config.js';
|
|
62
|
+
import { buildTableHookRuntimeServices } from '../lib/table-hook-runtime.js';
|
|
66
63
|
import type { Env } from '../types.js';
|
|
67
64
|
|
|
68
65
|
// ─── Types ───
|
|
@@ -104,8 +101,36 @@ export class DatabaseDO extends DurableObject<DOEnv> {
|
|
|
104
101
|
if (!this.initialized) {
|
|
105
102
|
// §36: Newly created DO must be authorized before initialization.
|
|
106
103
|
// If X-DO-Create-Authorized header is absent, signal Worker to evaluate canCreate.
|
|
107
|
-
//
|
|
108
|
-
const
|
|
104
|
+
// Single-instance DB blocks and internal/system DOs skip this gate.
|
|
105
|
+
const parsedDoName = this.doName ? parseDbDoName(this.doName) : null;
|
|
106
|
+
const dbBlock = parsedDoName ? this.config.databases?.[parsedDoName.namespace] : undefined;
|
|
107
|
+
const dynamicDbBlock = isDynamicDbBlock(dbBlock);
|
|
108
|
+
if (parsedDoName && dbBlock) {
|
|
109
|
+
if (dynamicDbBlock && !parsedDoName.id) {
|
|
110
|
+
return Response.json(
|
|
111
|
+
{
|
|
112
|
+
code: 400,
|
|
113
|
+
message: `instanceId is required for dynamic namespace '${parsedDoName.namespace}'`,
|
|
114
|
+
error: 'INVALID_DB_INSTANCE_ID',
|
|
115
|
+
},
|
|
116
|
+
{ status: 400 },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (!dynamicDbBlock && parsedDoName.id) {
|
|
120
|
+
return Response.json(
|
|
121
|
+
{
|
|
122
|
+
code: 400,
|
|
123
|
+
message: `instanceId is not allowed for single-instance namespace '${parsedDoName.namespace}'`,
|
|
124
|
+
error: 'INVALID_DB_INSTANCE_ID',
|
|
125
|
+
},
|
|
126
|
+
{ status: 400 },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const isStaticDO =
|
|
131
|
+
!this.doName
|
|
132
|
+
|| this.doName.startsWith('_')
|
|
133
|
+
|| (!!dbBlock && !dynamicDbBlock && !parsedDoName?.id);
|
|
109
134
|
if (!isStaticDO && !request.headers.get('X-DO-Create-Authorized')) {
|
|
110
135
|
// Check if _meta table already exists (i.e., DO was previously initialized)
|
|
111
136
|
let alreadyExists = false;
|
|
@@ -118,9 +143,8 @@ export class DatabaseDO extends DurableObject<DOEnv> {
|
|
|
118
143
|
|
|
119
144
|
if (!alreadyExists) {
|
|
120
145
|
// Signal Worker: this DO needs canCreate evaluation before init
|
|
121
|
-
const parsed = this.doName ? parseDbDoName(this.doName) : null;
|
|
122
146
|
return Response.json(
|
|
123
|
-
{ needsCreate: true, namespace:
|
|
147
|
+
{ needsCreate: true, namespace: parsedDoName?.namespace ?? 'shared', id: parsedDoName?.id },
|
|
124
148
|
{ status: 201 },
|
|
125
149
|
);
|
|
126
150
|
}
|
|
@@ -173,6 +197,8 @@ export class DatabaseDO extends DurableObject<DOEnv> {
|
|
|
173
197
|
* db.get/list/exists use local SQL; databaseLive.broadcast uses emitDbLiveEvent.
|
|
174
198
|
*/
|
|
175
199
|
private buildHookCtx(_table: string): HookCtx {
|
|
200
|
+
const runtimeServices = buildTableHookRuntimeServices(this.config, this.env as unknown as Env);
|
|
201
|
+
|
|
176
202
|
return {
|
|
177
203
|
db: {
|
|
178
204
|
get: (tbl: string, id: string) => {
|
|
@@ -201,42 +227,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
|
|
|
201
227
|
return Promise.resolve(rows.length > 0);
|
|
202
228
|
},
|
|
203
229
|
},
|
|
204
|
-
|
|
205
|
-
broadcast: (channel: string, event: string, data: unknown) => {
|
|
206
|
-
return this.sendBroadcastToDatabaseLiveDO(
|
|
207
|
-
channel,
|
|
208
|
-
{ channel, event, payload: data ?? {} },
|
|
209
|
-
);
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
push: {
|
|
213
|
-
// Push from hooks — direct FCM via push-provider + KV device tokens
|
|
214
|
-
send: async (userId: string, payload: { title?: string; body: string }) => {
|
|
215
|
-
// Fire-and-forget — hooks are non-critical side effects
|
|
216
|
-
try {
|
|
217
|
-
if (!this.env.KV) return;
|
|
218
|
-
const provider = createPushProvider(this.config.push, this.env as unknown as Env);
|
|
219
|
-
if (!provider) return;
|
|
220
|
-
let tokenStore: KVNamespace | { kv: KVNamespace; authDb?: AuthDb | null } = this.env.KV;
|
|
221
|
-
try {
|
|
222
|
-
const authDb = resolveAuthDb(this.env as unknown as Record<string, unknown>);
|
|
223
|
-
await ensureAuthSchema(authDb);
|
|
224
|
-
tokenStore = { kv: this.env.KV, authDb };
|
|
225
|
-
} catch {
|
|
226
|
-
tokenStore = this.env.KV;
|
|
227
|
-
}
|
|
228
|
-
const devices = await getDevicesForUser(tokenStore, userId);
|
|
229
|
-
if (devices.length === 0) return;
|
|
230
|
-
await Promise.allSettled(
|
|
231
|
-
devices.map((device) =>
|
|
232
|
-
provider.send({ token: device.token, platform: device.platform, payload }),
|
|
233
|
-
),
|
|
234
|
-
);
|
|
235
|
-
} catch {
|
|
236
|
-
/* best-effort */
|
|
237
|
-
}
|
|
238
|
-
},
|
|
239
|
-
},
|
|
230
|
+
...runtimeServices,
|
|
240
231
|
waitUntil: (p: Promise<unknown>) => this.ctx.waitUntil(p),
|
|
241
232
|
};
|
|
242
233
|
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { createAdminAssetRequest } from './lib/admin-assets.js';
|
|
|
35
35
|
import { resolveAdminFaviconTarget, resolveAdminRedirectTarget } from './lib/admin-routing.js';
|
|
36
36
|
import { zodDefaultHook } from './lib/schemas.js';
|
|
37
37
|
import { executePluginMigrations } from './lib/plugin-migrations.js';
|
|
38
|
+
import { shouldRunPluginMigrationsForRequestPath } from './lib/plugin-migration-routing.js';
|
|
38
39
|
import { getFunctionsByTrigger, buildFunctionContext, getWorkerUrl } from './lib/functions.js';
|
|
39
40
|
import { parseCron, matchesCron } from './lib/cron.js';
|
|
40
41
|
import { parseDuration } from './lib/jwt.js';
|
|
@@ -104,15 +105,12 @@ app.use('*', corsMiddleware);
|
|
|
104
105
|
// middleware below rejects any request with this header unless it comes via internal
|
|
105
106
|
// stub.fetch (which is allowed by the x-internal whitelist). No stripping needed.
|
|
106
107
|
|
|
107
|
-
// Plugin migration middleware —
|
|
108
|
+
// Plugin migration middleware — lazily reconciles plugin control state before
|
|
109
|
+
// routes that can execute plugin code or touch plugin-managed tables.
|
|
108
110
|
app.use('*', async (c, next) => {
|
|
109
111
|
const config = parseConfig(c.env);
|
|
110
112
|
const requestPath = new URL(c.req.url).pathname;
|
|
111
|
-
|
|
112
|
-
requestPath.startsWith('/admin/api/backup/') ||
|
|
113
|
-
requestPath.startsWith('/admin/api/data/backup/') ||
|
|
114
|
-
requestPath.startsWith('/internal/backup/');
|
|
115
|
-
if (!shouldSkipPluginMigrations && config?.plugins?.length) {
|
|
113
|
+
if (config?.plugins?.length && shouldRunPluginMigrationsForRequestPath(requestPath)) {
|
|
116
114
|
await executePluginMigrations(config.plugins, c.env, config, getWorkerUrl(c.req.url, c.env));
|
|
117
115
|
}
|
|
118
116
|
return next();
|
|
@@ -306,6 +304,14 @@ export default {
|
|
|
306
304
|
*/
|
|
307
305
|
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
308
306
|
const config = parseConfig(env);
|
|
307
|
+
if (config.plugins?.length) {
|
|
308
|
+
await executePluginMigrations(
|
|
309
|
+
config.plugins,
|
|
310
|
+
env,
|
|
311
|
+
config,
|
|
312
|
+
getWorkerUrl('http://internal/scheduled', env),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
309
315
|
const scheduleFns = getFunctionsByTrigger('schedule');
|
|
310
316
|
|
|
311
317
|
const now = new Date(event.scheduledTime);
|