@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.
- package/admin-build/_app/immutable/chunks/{DpVAayDG.js → 6oMK_164.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B5Nwfelm.js → B2TnDKF7.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DCvwWZrm.js → B6MschND.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Du5vWVa2.js → B94PilAN.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dc1-6Po6.js → BEW7Ez_g.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dlty5069.js → BoOooyH6.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CzSAxmuj.js → BqTb6Mxk.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DCKcAiQH.js → BvHnF5tV.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B-_-hJ9o.js → CaVKAiCe.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DRqPU3wD.js → Cdm5zBRA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{byv2rTy8.js → CrOZMmdF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DiyBpamp.js → Cw6OYcq-.js} +1 -1
- package/admin-build/_app/immutable/chunks/{A_3UuvCe.js → D2j3I1VQ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BxoNtYHK.js → DPdQ7z0T.js} +3 -3
- package/admin-build/_app/immutable/chunks/{nZvorU8i.js → J2Gw0SMu.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CZ0TVkCa.js → pUxw8jfq.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.CfrmEXPD.js → app.D3flihMw.js} +2 -2
- package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.Cn2BZ4da.js → 0.CdczqZLK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.Dv4LX_Co.js → 1.DxcSsEqS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.DPVv3kat.js → 10.DuAd4aIm.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.CiCb6Ayu.js → 11.0jgHQL92.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CIPyeekF.js → 12.CKNPqmyy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.Z15Lt36e.js → 13.B1p2POXS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.s0l5bAq3.js → 14.Bb-REBND.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.UwSSNO76.js → 15.1uBFCX0X.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.qiD8i883.js → 16.BR7WwQrS.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.Dy3dcSvu.js → 17.Cm57KKXV.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DeXyPYsO.js → 18.CoiwfAuQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CAbuyS6w.js → 19.B8ZdLlXj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.Bec0T7un.js → 20.DnHeFlTv.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.CdVprrv2.js → 22.CItETFzy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.Y8RzVLoF.js → 23.CWSGMcKJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.CWhHYFBx.js → 24.CWbEqNMB.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.wCBplOVt.js → 25.DRkLEhKi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.Cod_JRFK.js → 26.BRxO8AYH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BO2HVMu9.js → 27.BLs-nVHz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DxG-FBVQ.js → 28.G79qkdBK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CjGqWGvE.js → 29.BOcI6g0N.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.By3_OmdZ.js → 3.B6q-7qr8.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.M_H7Htpq.js → 30.DAIC7dKd.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.DEU18izM.js → 31.pl0XXjXF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.DeYhKtzJ.js → 4.DOdvVlZj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.9WLgxhrD.js → 5.BW_zlgye.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.BdT2i_dd.js → 6.Dxy1CAI2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.CHq0s4K6.js → 7.BG98w_o7.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.DuvRw-XZ.js → 8.DoG5R2rG.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.C2Ub82wn.js → 9.Dmxf6zAC.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 +108 -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__/meta-route-registration.test.ts +20 -15
- package/src/__tests__/plugin-migration-routing.test.ts +32 -0
- package/src/__tests__/provider-aware-sql.test.ts +9 -3
- package/src/__tests__/room-auth-state-loss.test.ts +122 -0
- package/src/__tests__/room-handler-context.test.ts +4 -4
- package/src/__tests__/room-rate-limit-scopes.test.ts +38 -0
- package/src/__tests__/runtime-startup.test.ts +49 -0
- 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 +50 -45
- package/src/durable-objects/database-live-do.ts +15 -0
- package/src/durable-objects/room-runtime-base.ts +387 -129
- package/src/durable-objects/rooms-do.ts +31 -24
- package/src/index.ts +334 -282
- 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/runtime-startup.ts +53 -0
- 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/entry/start.l1WvHznQ.js +0 -1
- 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
|
|
5
|
-
* The expected route exports are derived from the
|
|
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
|
|
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
|
|
19
|
-
.
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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'],
|
|
@@ -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({
|