@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.
Files changed (82) hide show
  1. package/admin-build/_app/immutable/chunks/{CzSAxmuj.js → 5RQRbp5q.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{B5Nwfelm.js → BME_U9TJ.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{Dlty5069.js → BYI6CUvd.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{A_3UuvCe.js → BgDzp0i0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{DRqPU3wD.js → BjWZuf8W.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{Dc1-6Po6.js → C6lpZLE2.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{BxoNtYHK.js → D5GswVnI.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/DBsVqhuh.js +1 -0
  9. package/admin-build/_app/immutable/chunks/{nZvorU8i.js → DYaCRWMA.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/D__dwMuW.js +1 -0
  11. package/admin-build/_app/immutable/chunks/{DpVAayDG.js → Dj-E9-FO.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{DCvwWZrm.js → Dj0QUuOf.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{B-_-hJ9o.js → XQM1k9PM.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{Du5vWVa2.js → fYEKMQ-Z.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CZ0TVkCa.js → g_-Kpxu3.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DCKcAiQH.js → wCNueVYy.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.CfrmEXPD.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.Cn2BZ4da.js → 0.CJJ6HZbp.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.Dv4LX_Co.js → 1.B4sI5cB4.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.DPVv3kat.js → 10.D6hvCer6.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.CiCb6Ayu.js → 11.Dx7b8aQ5.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CIPyeekF.js → 12.Bqmy5KIF.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.Z15Lt36e.js → 13.CC6KpXgS.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.s0l5bAq3.js → 14.yCo1Ix8E.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.UwSSNO76.js → 15.co0UfPlh.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.qiD8i883.js → 16.D0xkPUBW.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.Dy3dcSvu.js → 17.CebNqPeh.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DeXyPYsO.js → 18.JUoLOZxh.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CAbuyS6w.js → 19.ND8kmQJe.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.Bec0T7un.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.CdVprrv2.js → 22.UOzm8WYV.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.Y8RzVLoF.js → 23.BLgq21om.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CWhHYFBx.js → 24.DN9usmUs.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.wCBplOVt.js → 25.BddRfAyE.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.Cod_JRFK.js → 26.Dl6XHIeT.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BO2HVMu9.js → 27.D0iNwALG.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DxG-FBVQ.js → 28.9dKQmdGi.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CjGqWGvE.js → 29.wXzfJUXp.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.By3_OmdZ.js → 3.z8ut3jS-.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.M_H7Htpq.js → 30.BtZETNsL.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.DEU18izM.js → 31.CYonj2Jh.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.DeYhKtzJ.js → 4.COtDPQ9b.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.9WLgxhrD.js → 5.CTRCeIhp.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BdT2i_dd.js → 6.ChHi3QkR.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CHq0s4K6.js → 7.CCMtr6Ac.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.DuvRw-XZ.js → 8.DpWJ-X_-.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.C2Ub82wn.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__/database-do-route-validation.test.ts +105 -0
  55. package/src/__tests__/database-live-route.test.ts +82 -0
  56. package/src/__tests__/do-router.test.ts +116 -0
  57. package/src/__tests__/functions-context.test.ts +84 -0
  58. package/src/__tests__/functions-d1-proxy.test.ts +54 -0
  59. package/src/__tests__/plugin-migration-routing.test.ts +32 -0
  60. package/src/__tests__/provider-aware-sql.test.ts +9 -3
  61. package/src/__tests__/scheduled.test.ts +55 -0
  62. package/src/__tests__/service-key-db-proxy.test.ts +122 -1
  63. package/src/__tests__/sql-route.test.ts +66 -0
  64. package/src/__tests__/table-hook-runtime.test.ts +137 -0
  65. package/src/durable-objects/database-do.ts +36 -45
  66. package/src/index.ts +12 -6
  67. package/src/lib/d1-handler.ts +10 -21
  68. package/src/lib/do-router.ts +135 -3
  69. package/src/lib/functions.ts +4 -3
  70. package/src/lib/internal-transport.ts +28 -12
  71. package/src/lib/plugin-migration-routing.ts +28 -0
  72. package/src/lib/postgres-handler.ts +12 -20
  73. package/src/lib/provider-aware-sql.ts +19 -15
  74. package/src/lib/table-hook-runtime.ts +62 -0
  75. package/src/routes/admin.ts +41 -41
  76. package/src/routes/database-live.ts +110 -12
  77. package/src/routes/sql.ts +22 -17
  78. package/src/routes/tables.ts +42 -29
  79. package/admin-build/_app/immutable/chunks/DiyBpamp.js +0 -1
  80. package/admin-build/_app/immutable/chunks/byv2rTy8.js +0 -1
  81. package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +0 -1
  82. 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
- 'workspace',
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: 'workspace',
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
- // Shared/static DOs (doName === 'shared' or system) skip this gate.
108
- const isStaticDO = !this.doName || this.doName === 'shared' || this.doName.startsWith('_');
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: parsed?.namespace ?? 'shared', id: parsed?.id },
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
- databaseLive: {
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 — lazy, runs once per cold-start
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
- const shouldSkipPluginMigrations =
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);