@edge-base/server 0.2.3 → 0.2.5

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 (89) hide show
  1. package/admin-build/_app/immutable/chunks/{DpVAayDG.js → 6oMK_164.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{B5Nwfelm.js → B2TnDKF7.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DCvwWZrm.js → B6MschND.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{Du5vWVa2.js → B94PilAN.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{Dc1-6Po6.js → BEW7Ez_g.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{Dlty5069.js → BoOooyH6.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{CzSAxmuj.js → BqTb6Mxk.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{DCKcAiQH.js → BvHnF5tV.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{B-_-hJ9o.js → CaVKAiCe.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{DRqPU3wD.js → Cdm5zBRA.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{byv2rTy8.js → CrOZMmdF.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{DiyBpamp.js → Cw6OYcq-.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{A_3UuvCe.js → D2j3I1VQ.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BxoNtYHK.js → DPdQ7z0T.js} +3 -3
  15. package/admin-build/_app/immutable/chunks/{nZvorU8i.js → J2Gw0SMu.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{CZ0TVkCa.js → pUxw8jfq.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.CfrmEXPD.js → app.D3flihMw.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.Cn2BZ4da.js → 0.CdczqZLK.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.Dv4LX_Co.js → 1.DxcSsEqS.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.DPVv3kat.js → 10.DuAd4aIm.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.CiCb6Ayu.js → 11.0jgHQL92.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CIPyeekF.js → 12.CKNPqmyy.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.Z15Lt36e.js → 13.B1p2POXS.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.s0l5bAq3.js → 14.Bb-REBND.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.UwSSNO76.js → 15.1uBFCX0X.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.qiD8i883.js → 16.BR7WwQrS.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.Dy3dcSvu.js → 17.Cm57KKXV.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.DeXyPYsO.js → 18.CoiwfAuQ.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CAbuyS6w.js → 19.B8ZdLlXj.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.Bec0T7un.js → 20.DnHeFlTv.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.CdVprrv2.js → 22.CItETFzy.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.Y8RzVLoF.js → 23.CWSGMcKJ.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CWhHYFBx.js → 24.CWbEqNMB.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.wCBplOVt.js → 25.DRkLEhKi.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.Cod_JRFK.js → 26.BRxO8AYH.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.BO2HVMu9.js → 27.BLs-nVHz.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.DxG-FBVQ.js → 28.G79qkdBK.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.CjGqWGvE.js → 29.BOcI6g0N.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.By3_OmdZ.js → 3.B6q-7qr8.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.M_H7Htpq.js → 30.DAIC7dKd.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.DEU18izM.js → 31.pl0XXjXF.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.DeYhKtzJ.js → 4.DOdvVlZj.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.9WLgxhrD.js → 5.BW_zlgye.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BdT2i_dd.js → 6.Dxy1CAI2.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CHq0s4K6.js → 7.BG98w_o7.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.DuvRw-XZ.js → 8.DoG5R2rG.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.C2Ub82wn.js → 9.Dmxf6zAC.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 +108 -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__/meta-route-registration.test.ts +20 -15
  60. package/src/__tests__/plugin-migration-routing.test.ts +32 -0
  61. package/src/__tests__/provider-aware-sql.test.ts +9 -3
  62. package/src/__tests__/room-auth-state-loss.test.ts +122 -0
  63. package/src/__tests__/room-handler-context.test.ts +4 -4
  64. package/src/__tests__/room-rate-limit-scopes.test.ts +38 -0
  65. package/src/__tests__/runtime-startup.test.ts +49 -0
  66. package/src/__tests__/scheduled.test.ts +55 -0
  67. package/src/__tests__/service-key-db-proxy.test.ts +122 -1
  68. package/src/__tests__/sql-route.test.ts +66 -0
  69. package/src/__tests__/table-hook-runtime.test.ts +137 -0
  70. package/src/durable-objects/database-do.ts +50 -45
  71. package/src/durable-objects/database-live-do.ts +15 -0
  72. package/src/durable-objects/room-runtime-base.ts +387 -129
  73. package/src/durable-objects/rooms-do.ts +31 -24
  74. package/src/index.ts +334 -282
  75. package/src/lib/d1-handler.ts +10 -21
  76. package/src/lib/do-router.ts +135 -3
  77. package/src/lib/functions.ts +4 -3
  78. package/src/lib/internal-transport.ts +28 -12
  79. package/src/lib/plugin-migration-routing.ts +28 -0
  80. package/src/lib/postgres-handler.ts +12 -20
  81. package/src/lib/provider-aware-sql.ts +19 -15
  82. package/src/lib/runtime-startup.ts +53 -0
  83. package/src/lib/table-hook-runtime.ts +62 -0
  84. package/src/routes/admin.ts +41 -41
  85. package/src/routes/database-live.ts +110 -12
  86. package/src/routes/sql.ts +22 -17
  87. package/src/routes/tables.ts +42 -29
  88. package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +0 -1
  89. package/admin-build/_app/immutable/nodes/21.DuDYelMY.js +0 -1
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Meta-test: route registration completeness.
3
3
  *
4
- * Ensures every route file imported in index.ts is also registered via app.route().
5
- * The expected route exports are derived from the current route files.
4
+ * Ensures every route module imported in index.ts is also registered via app.route().
5
+ * The expected route exports are derived from the route modules the entrypoint actually loads.
6
6
  */
7
- import { readFileSync, readdirSync } from 'fs';
7
+ import { readFileSync } from 'fs';
8
8
  import { resolve } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { describe, it, expect } from 'vitest';
@@ -15,9 +15,11 @@ describe('index.ts route registration completeness', () => {
15
15
  'utf-8',
16
16
  );
17
17
  const routesDir = resolve(fileURLToPath(new URL('../routes', import.meta.url)));
18
- const EXPECTED_ROUTES = readdirSync(routesDir)
19
- .filter((fileName) => fileName.endsWith('.ts'))
20
- .sort()
18
+ const ROUTE_IMPORTS = [...source.matchAll(/import\('\.\/routes\/([^']+\.js)'\)/g)]
19
+ .map((match) => match[1])
20
+ .sort();
21
+ const ROUTE_FILES = ROUTE_IMPORTS.map((fileName) => fileName.replace(/\.js$/, '.ts'));
22
+ const EXPECTED_ROUTES = ROUTE_FILES
21
23
  .flatMap((fileName) => {
22
24
  const routeSource = readFileSync(resolve(routesDir, fileName), 'utf-8');
23
25
  const directExports = [...routeSource.matchAll(/export const (\w+)\s*=\s*new OpenAPIHono/g)].map(
@@ -29,20 +31,23 @@ describe('index.ts route registration completeness', () => {
29
31
  return [...directExports, ...aliasExports];
30
32
  });
31
33
 
32
- const EXPECTED_COUNT = EXPECTED_ROUTES.length;
33
-
34
- it(`total route imports = ${EXPECTED_COUNT}`, () => {
35
- const importMatches = source.match(/import \{ \w+ \} from '\.\/routes\//g) || [];
36
- expect(importMatches.length).toBe(EXPECTED_COUNT);
34
+ it(`total route module imports = ${ROUTE_FILES.length}`, () => {
35
+ expect(ROUTE_IMPORTS.length).toBe(ROUTE_FILES.length);
37
36
  });
38
37
 
39
- for (const routeVar of EXPECTED_ROUTES) {
40
- it(`${routeVar} is imported`, () => {
41
- expect(source).toMatch(new RegExp(`import\\s*\\{[^}]*\\b${routeVar}\\b[^}]*\\}\\s*from '\\./routes/`));
38
+ for (const routeFile of ROUTE_FILES) {
39
+ const routePath = routeFile.replace(/\.ts$/, '.js');
40
+
41
+ it(`${routeFile} is dynamically imported`, () => {
42
+ expect(source).toContain(`import('./routes/${routePath}')`);
42
43
  });
44
+ }
43
45
 
46
+ for (const routeVar of EXPECTED_ROUTES) {
44
47
  it(`${routeVar} is registered via app.route()`, () => {
45
- expect(source).toMatch(new RegExp(`app\\.route\\([^)]+,\\s*${routeVar}\\)`));
48
+ const directRegistration = new RegExp(`app\\.route\\([^)]+,\\s*${routeVar}\\)`);
49
+ const moduleRegistration = new RegExp(`app\\.route\\([^)]+,\\s*\\w+\\.${routeVar}\\)`);
50
+ expect(directRegistration.test(source) || moduleRegistration.test(source)).toBe(true);
46
51
  });
47
52
  }
48
53
  });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shouldRunPluginMigrationsForRequestPath } from '../lib/plugin-migration-routing.js';
3
+
4
+ describe('shouldRunPluginMigrationsForRequestPath', () => {
5
+ it.each([
6
+ '/api/auth/sign-in',
7
+ '/api/db/shared/tables/posts',
8
+ '/api/functions/send-welcome-email',
9
+ '/api/sql/shared',
10
+ '/api/storage/assets/upload',
11
+ '/admin/api/data/tables/posts',
12
+ ])('runs plugin migrations before %s', (path) => {
13
+ expect(shouldRunPluginMigrationsForRequestPath(path)).toBe(true);
14
+ });
15
+
16
+ it.each([
17
+ '/',
18
+ '/api/health',
19
+ '/openapi.json',
20
+ '/admin',
21
+ '/admin/login',
22
+ '/_app/version.json',
23
+ '/harness/scenarios/demo',
24
+ '/admin/api/backup',
25
+ '/admin/api/backup/shared',
26
+ '/admin/api/data/backup/shared',
27
+ '/internal/backup',
28
+ '/internal/backup/shared',
29
+ ])('skips plugin migrations for %s', (path) => {
30
+ expect(shouldRunPluginMigrationsForRequestPath(path)).toBe(false);
31
+ });
32
+ });
@@ -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'],
@@ -5,6 +5,128 @@ vi.mock('cloudflare:workers', () => ({
5
5
  }));
6
6
 
7
7
  describe('room auth-state loss recovery', () => {
8
+ it('treats ephemeral timer persistence failures as non-fatal', async () => {
9
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
10
+
11
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
12
+ const pending: Promise<unknown>[] = [];
13
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
14
+
15
+ room.pendingAuth = new Map([['conn-1', Date.now() + 5_000]]);
16
+ room.disconnectTimers = new Map();
17
+ room.namespace = 'game';
18
+ room.roomId = 'room-1';
19
+ room.ctx = {
20
+ storage: {
21
+ put: vi.fn().mockRejectedValue(new Error('Exceeded allowed rows written in Durable Objects free tier.')),
22
+ delete: vi.fn(),
23
+ },
24
+ waitUntil: vi.fn((promise: Promise<unknown>) => {
25
+ pending.push(promise);
26
+ }),
27
+ };
28
+
29
+ expect(() => room.syncEphemeralTimersToStorage()).not.toThrow();
30
+ await Promise.allSettled(pending);
31
+
32
+ expect(warnSpy).toHaveBeenCalledWith(
33
+ '[Room] Ephemeral timer persistence skipped',
34
+ expect.objectContaining({
35
+ room: 'game::room-1',
36
+ pendingAuthCount: 1,
37
+ disconnectCount: 0,
38
+ message: 'Exceeded allowed rows written in Durable Objects free tier.',
39
+ }),
40
+ );
41
+
42
+ warnSpy.mockRestore();
43
+ });
44
+
45
+ it('persists alarm-backed room deadlines alongside auth and disconnect timers', async () => {
46
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
47
+
48
+ const pending: Promise<unknown>[] = [];
49
+ const putSpy = vi.fn().mockResolvedValue(undefined);
50
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
51
+ room.pendingAuth = new Map([['conn-1', 11_111]]);
52
+ room.disconnectTimers = new Map([['user-1', { fireAt: 22_222, connectionId: 'conn-1' }]]);
53
+ room._stateSaveAt = 33_333;
54
+ room._emptyRoomCleanupAt = 44_444;
55
+ room._stateTTLAlarmAt = 55_555;
56
+ room.ctx = {
57
+ storage: {
58
+ put: putSpy,
59
+ delete: vi.fn(),
60
+ },
61
+ waitUntil: vi.fn((promise: Promise<unknown>) => {
62
+ pending.push(promise);
63
+ }),
64
+ };
65
+
66
+ room.syncEphemeralTimersToStorage();
67
+ await Promise.allSettled(pending);
68
+
69
+ expect(putSpy).toHaveBeenCalledWith('roomEphemeralTimers', {
70
+ pendingAuth: { 'conn-1': 11_111 },
71
+ disconnects: { 'user-1': { fireAt: 22_222, connectionId: 'conn-1' } },
72
+ stateSaveAt: 33_333,
73
+ emptyRoomCleanupAt: 44_444,
74
+ stateTTLAlarmAt: 55_555,
75
+ });
76
+ });
77
+
78
+ it('does not rewrite ephemeral timer storage when state is already dirty', async () => {
79
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
80
+
81
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
82
+ room.dirty = false;
83
+ room._stateSaveAt = 33_333;
84
+ room.namespaceConfig = {};
85
+ room.syncEphemeralTimersToStorage = vi.fn();
86
+ room._scheduleNextAlarm = vi.fn();
87
+
88
+ room.markDirty();
89
+
90
+ expect(room.dirty).toBe(true);
91
+ expect(room._stateSaveAt).toBe(33_333);
92
+ expect(room.syncEphemeralTimersToStorage).not.toHaveBeenCalled();
93
+ expect(room._scheduleNextAlarm).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ it('recovers persisted timers before alarm processing after a cold wake without sockets', async () => {
97
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
98
+
99
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
100
+ room.stateRecoveryNeeded = false;
101
+ room.roomCreated = false;
102
+ room.sharedState = {};
103
+ room.playerStates = new Map();
104
+ room.serverState = {};
105
+ room.players = new Map();
106
+ room.userToConnections = new Map();
107
+ room.pendingAuth = new Map();
108
+ room.disconnectTimers = new Map();
109
+ room._timers = new Map();
110
+ room._stateSaveAt = null;
111
+ room._emptyRoomCleanupAt = null;
112
+ room._stateTTLAlarmAt = null;
113
+ room._metadata = {};
114
+ room.config = {};
115
+ room.ctx = {
116
+ getWebSockets: vi.fn(() => []),
117
+ };
118
+ room.ensureRuntimeReady = vi.fn(async () => {});
119
+ room.recoverFromStorage = vi.fn(async () => {});
120
+ room.findWebSocketByConnectionId = vi.fn(() => null);
121
+ room.finalizePlayerLeave = vi.fn(async () => {});
122
+ room.syncEphemeralTimersToStorage = vi.fn();
123
+ room._scheduleNextAlarm = vi.fn();
124
+
125
+ await room.alarm();
126
+
127
+ expect(room.recoverFromStorage).toHaveBeenCalledTimes(1);
128
+ });
129
+
8
130
  it('marks websocket metadata rebuilt from hibernation tags as auth-state-lost', async () => {
9
131
  const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
10
132
 
@@ -48,7 +48,7 @@ describe('RoomsDO handler context', () => {
48
48
  },
49
49
  };
50
50
 
51
- const ctx = room.buildHandlerContext();
51
+ const ctx = await room.buildHandlerContext();
52
52
  const inserted = await ctx.admin.db('shared').table('signals').insert({ title: 'Room inserted' });
53
53
 
54
54
  expect(inserted).toEqual({ id: 'sig-1', title: 'Room inserted' });
@@ -63,7 +63,7 @@ describe('RoomsDO handler context', () => {
63
63
  }),
64
64
  }),
65
65
  );
66
- });
66
+ }, 15_000);
67
67
 
68
68
  it('routes admin.db().upsert() through the database durable object', async () => {
69
69
  const { RoomsDO } = await import('../durable-objects/rooms-do.js');
@@ -104,7 +104,7 @@ describe('RoomsDO handler context', () => {
104
104
  },
105
105
  };
106
106
 
107
- const ctx = room.buildHandlerContext();
107
+ const ctx = await room.buildHandlerContext();
108
108
  const upserted = await ctx.admin.db('shared').table('signals').upsert({
109
109
  id: 'sig-1',
110
110
  title: 'Room upserted',
@@ -126,7 +126,7 @@ describe('RoomsDO handler context', () => {
126
126
  }),
127
127
  }),
128
128
  );
129
- });
129
+ }, 15_000);
130
130
 
131
131
  it('returns 409 when creating a Cloudflare RealtimeKit session while media is already published', async () => {
132
132
  const { RoomsDO } = await import('../durable-objects/rooms-do.js');
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('cloudflare:workers', () => ({
4
+ DurableObject: class DurableObject {},
5
+ }));
6
+
7
+ describe('room rate-limit scopes', () => {
8
+ it('keeps signal/media/admin buckets independent per connection', async () => {
9
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
10
+
11
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
12
+ room.namespaceConfig = {
13
+ rateLimit: {
14
+ actions: 2,
15
+ signals: 4,
16
+ media: 1,
17
+ admin: 1,
18
+ },
19
+ };
20
+ room.rateBuckets = new Map();
21
+
22
+ expect(room.checkRateLimit('conn-1', 'signals')).toBe(true);
23
+ expect(room.checkRateLimit('conn-1', 'signals')).toBe(true);
24
+ expect(room.checkRateLimit('conn-1', 'signals')).toBe(true);
25
+ expect(room.checkRateLimit('conn-1', 'signals')).toBe(true);
26
+ expect(room.checkRateLimit('conn-1', 'signals')).toBe(false);
27
+
28
+ expect(room.checkRateLimit('conn-1', 'media')).toBe(true);
29
+ expect(room.checkRateLimit('conn-1', 'media')).toBe(false);
30
+
31
+ expect(room.checkRateLimit('conn-1', 'admin')).toBe(true);
32
+ expect(room.checkRateLimit('conn-1', 'admin')).toBe(false);
33
+
34
+ expect(room.checkRateLimit('conn-1', 'actions')).toBe(true);
35
+ expect(room.checkRateLimit('conn-1', 'actions')).toBe(true);
36
+ expect(room.checkRateLimit('conn-1', 'actions')).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('cloudflare:workers', () => ({
4
+ DurableObject: class DurableObject {},
5
+ }));
6
+
7
+ afterEach(() => {
8
+ vi.resetModules();
9
+ if (typeof globalThis === 'object' && globalThis !== null) {
10
+ delete (globalThis as Record<string, unknown>).__EDGEBASE_RUNTIME_CONFIG__;
11
+ }
12
+ });
13
+
14
+ describe('runtime startup bootstrap', () => {
15
+ it('initializes runtime config idempotently for lazy server and DO entrypoints', async () => {
16
+ const { ensureServerStartup } = await import('../lib/runtime-startup.js');
17
+ const { parseConfig } = await import('../lib/do-router.js');
18
+
19
+ await expect(ensureServerStartup()).resolves.toBeUndefined();
20
+ const firstConfig = parseConfig();
21
+
22
+ await expect(ensureServerStartup()).resolves.toBeUndefined();
23
+ const secondConfig = parseConfig();
24
+
25
+ expect(firstConfig).toEqual(secondConfig);
26
+ expect(secondConfig).toBeTypeOf('object');
27
+ });
28
+
29
+ it('does not clobber an explicitly injected runtime config', async () => {
30
+ const { ensureServerStartup } = await import('../lib/runtime-startup.js');
31
+ const { parseConfig, setConfig } = await import('../lib/do-router.js');
32
+
33
+ setConfig({
34
+ release: false,
35
+ auth: {
36
+ allowedRedirectUrls: ['http://localhost:4173'],
37
+ },
38
+ });
39
+
40
+ await expect(ensureServerStartup()).resolves.toBeUndefined();
41
+
42
+ expect(parseConfig()).toMatchObject({
43
+ release: false,
44
+ auth: {
45
+ allowedRedirectUrls: ['http://localhost:4173'],
46
+ },
47
+ });
48
+ });
49
+ });
@@ -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({