@edge-base/server 0.1.4 → 0.2.0

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 (77) hide show
  1. package/admin-build/_app/immutable/chunks/{Bed8WcZp.js → 2nyN5wuZ.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{Bwq290TU.js → B-WlnirM.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DYRfe1lC.js → B14gOIqE.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{CfT4rpr6.js → BKLsgaNT.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{FA-xxanK.js → BSfSfeDG.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{BebaNaL1.js → CN6aakgF.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/CfPHB4r5.js +1 -0
  8. package/admin-build/_app/immutable/chunks/{ChX-qyfY.js → CkdaVlhQ.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{DMIs26Al.js → D43CH5ty.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{mVKEd0n6.js → D8Nrx_IG.js} +3 -3
  11. package/admin-build/_app/immutable/chunks/{mmI0365x.js → DP9kmlCd.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{Fw9oK_yh.js → DgxOZ3uv.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{DuldBlfT.js → DpuSetmN.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{B-bKFoyc.js → cqSkc6KP.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{6a5nHrK1.js → mD4EETH_.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/uboHVq-x.js +1 -0
  17. package/admin-build/_app/immutable/entry/{app.B0Wfop7v.js → app.Dc071f6C.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.Bhlxoqtt.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.DXd3Dmjw.js → 0.CCfcYVV2.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.DYs0DOEf.js → 1.rMaczUKT.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.C5zdvzz2.js → 10.DIOlO4hv.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.CZn94rTD.js → 11.WxD9E0Eq.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.zeHwsLsn.js → 12.CNcefK3l.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.nkWGiViq.js → 13.aAWsqDdR.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.C0cOviHd.js → 14.C9hdr3EN.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.q5eTZ0ns.js → 15.43r5uVx5.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.BrpfTEYF.js → 16.D519948J.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.3p6MQums.js → 17.ks4I4yoH.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.aj9cF39Y.js → 18.ZuNm22dY.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.BrpfTEYF.js → 19.D519948J.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.BUEsrK6V.js → 20.C9ASlwCn.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.BhSD2EfX.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.V2clGNAS.js → 22.6k8cg0Pr.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.3XSLKKOr.js → 23.B9hcFTU-.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.F-qrYmNi.js → 24.OsQM9QtS.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/25.ClwkdaPp.js +2 -0
  37. package/admin-build/_app/immutable/nodes/{26.D0WXHf08.js → 26._-65WG0q.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.DvWhuH9-.js → 27.J1QASB3b.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.5Oz_jlro.js → 28.BKP1tVcZ.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.dDyAUaup.js → 29.mqIe62On.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.BcXkDIYV.js → 3.WkDZWDQC.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.2JCWdjTn.js → 30.BRk-4B3j.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.qG9kkJ9Q.js → 31.BBqGNVXN.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.Cl5grO75.js → 4.Bi91lv2V.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.Bel35v4W.js → 5.BumjsbNK.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.CjpfkMIg.js → 6.CMTP_7xN.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CA1OBWip.js → 7.4T4wo7Kg.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.DS5xal_X.js → 8.MUZQPNsN.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.OYw1MaR9.js → 9.3SV00WXe.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +6710 -5866
  53. package/package.json +2 -2
  54. package/src/__tests__/functions-route.test.ts +153 -0
  55. package/src/__tests__/internal-request.test.ts +5 -5
  56. package/src/__tests__/meta-export-coverage.test.ts +3 -2
  57. package/src/__tests__/meta-route-registration.test.ts +3 -2
  58. package/src/__tests__/openapi-coverage.test.ts +5 -1
  59. package/src/__tests__/rate-limit.test.ts +0 -1
  60. package/src/__tests__/room-handler-context.test.ts +31 -0
  61. package/src/__tests__/room-runtime-routing.test.ts +48 -0
  62. package/src/__tests__/runtime-surface-accounting.test.ts +5 -4
  63. package/src/__tests__/smoke-skip-report.test.ts +3 -2
  64. package/src/durable-objects/database-do.ts +6 -1
  65. package/src/durable-objects/database-live-do.ts +22 -10
  66. package/src/durable-objects/rooms-do.ts +202 -0
  67. package/src/lib/internal-request.ts +5 -8
  68. package/src/lib/openapi.ts +1 -0
  69. package/src/routes/admin.ts +28 -1
  70. package/src/routes/auth.ts +67 -33
  71. package/src/routes/room.ts +42 -0
  72. package/src/types.ts +6 -0
  73. package/admin-build/_app/immutable/chunks/DSsNi9zA.js +0 -1
  74. package/admin-build/_app/immutable/chunks/eLBKp9m8.js +0 -1
  75. package/admin-build/_app/immutable/entry/start.C1a0bzUm.js +0 -1
  76. package/admin-build/_app/immutable/nodes/21.Bch9bUk6.js +0 -1
  77. package/admin-build/_app/immutable/nodes/25.BdrY4DyK.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edge-base/server",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "EdgeBase runtime assets consumed by the EdgeBase CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "jose": "^6.0.0",
35
35
  "pg": "^8.16.3",
36
36
  "zod": "^4.3.6",
37
- "@edge-base/shared": "0.1.4"
37
+ "@edge-base/shared": "0.2.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@cloudflare/vitest-pool-workers": "^0.8.71",
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
+ import type { FunctionDefinition } from '@edge-base/shared';
2
3
  import { OpenAPIHono } from '../lib/hono.js';
3
4
  import { setConfig } from '../lib/do-router.js';
4
5
  import {
@@ -73,6 +74,29 @@ function createExecutionContext(): ExecutionContext {
73
74
  } as unknown as ExecutionContext;
74
75
  }
75
76
 
77
+ function httpFunction(
78
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
79
+ handler: (ctx: Record<string, any>) => Promise<unknown> | unknown,
80
+ path?: string,
81
+ ): FunctionDefinition {
82
+ return {
83
+ trigger: {
84
+ type: 'http' as const,
85
+ method,
86
+ ...(path ? { path } : {}),
87
+ },
88
+ handler: async (ctx: unknown) => handler(ctx as Record<string, any>),
89
+ };
90
+ }
91
+
92
+ async function invokeFunction(path: string, method = 'GET') {
93
+ return createApp().fetch(
94
+ new Request(`http://localhost/api/functions/${path}`, { method }),
95
+ createEnv(),
96
+ createExecutionContext(),
97
+ );
98
+ }
99
+
76
100
  afterEach(() => {
77
101
  clearFunctionRegistry();
78
102
  clearMiddlewareRegistry();
@@ -137,3 +161,132 @@ describe('functionsRoute FunctionError compatibility', () => {
137
161
  });
138
162
  });
139
163
  });
164
+
165
+ describe('functionsRoute HTTP contracts', () => {
166
+ it('serializes plain objects as JSON responses', async () => {
167
+ registerFunction('reports/summary', httpFunction('GET', async () => ({
168
+ ok: true,
169
+ total: 3,
170
+ })));
171
+ rebuildCompiledRoutes();
172
+
173
+ const response = await invokeFunction('reports/summary');
174
+
175
+ expect(response.status).toBe(200);
176
+ expect(response.headers.get('content-type')).toContain('application/json');
177
+ await expect(response.json()).resolves.toEqual({ ok: true, total: 3 });
178
+ });
179
+
180
+ it('returns text/plain when handler returns a string', async () => {
181
+ registerFunction('health', httpFunction('GET', async () => 'healthy'));
182
+ rebuildCompiledRoutes();
183
+
184
+ const response = await invokeFunction('health');
185
+
186
+ expect(response.status).toBe(200);
187
+ expect(response.headers.get('content-type')).toContain('text/plain');
188
+ await expect(response.text()).resolves.toBe('healthy');
189
+ });
190
+
191
+ it('returns 204 when handler returns null', async () => {
192
+ registerFunction('empty', httpFunction('POST', async () => null));
193
+ rebuildCompiledRoutes();
194
+
195
+ const response = await invokeFunction('empty', 'POST');
196
+
197
+ expect(response.status).toBe(204);
198
+ await expect(response.text()).resolves.toBe('');
199
+ });
200
+
201
+ it('passes through native Response objects untouched', async () => {
202
+ registerFunction('created', httpFunction('POST', async () => (
203
+ new Response('created-body', {
204
+ status: 201,
205
+ headers: { 'content-type': 'text/plain', 'x-fn': 'direct-response' },
206
+ })
207
+ )));
208
+ rebuildCompiledRoutes();
209
+
210
+ const response = await invokeFunction('created', 'POST');
211
+
212
+ expect(response.status).toBe(201);
213
+ expect(response.headers.get('x-fn')).toBe('direct-response');
214
+ await expect(response.text()).resolves.toBe('created-body');
215
+ });
216
+
217
+ it('executes directory middleware before the handler', async () => {
218
+ const executionOrder: string[] = [];
219
+ registerMiddleware('secure', async () => {
220
+ executionOrder.push('middleware');
221
+ });
222
+ registerFunction('secure/audit', httpFunction('GET', async () => {
223
+ executionOrder.push('handler');
224
+ return { executionOrder };
225
+ }));
226
+ rebuildCompiledRoutes();
227
+
228
+ const response = await invokeFunction('secure/audit');
229
+
230
+ expect(response.status).toBe(200);
231
+ await expect(response.json()).resolves.toEqual({
232
+ executionOrder: ['middleware', 'handler'],
233
+ });
234
+ });
235
+
236
+ it('supports custom trigger.path params and preserves query strings', async () => {
237
+ registerFunction(
238
+ 'shortlink/resolve',
239
+ httpFunction(
240
+ 'GET',
241
+ async (ctx) => {
242
+ const requestUrl = new URL(ctx.request.url);
243
+ return {
244
+ code: ctx.params.code,
245
+ target: requestUrl.searchParams.get('target'),
246
+ };
247
+ },
248
+ '/s/:code',
249
+ ),
250
+ );
251
+ rebuildCompiledRoutes();
252
+
253
+ const response = await createApp().fetch(
254
+ new Request('http://localhost/api/functions/s/abc123?target=docs', { method: 'GET' }),
255
+ createEnv(),
256
+ createExecutionContext(),
257
+ );
258
+
259
+ expect(response.status).toBe(200);
260
+ await expect(response.json()).resolves.toEqual({
261
+ code: 'abc123',
262
+ target: 'docs',
263
+ });
264
+ });
265
+
266
+ it('supports catch-all params at execution time', async () => {
267
+ registerFunction('docs/[...slug]', httpFunction('GET', async (ctx) => ({
268
+ slug: ctx.params.slug,
269
+ })));
270
+ rebuildCompiledRoutes();
271
+
272
+ const response = await invokeFunction('docs/guides/getting-started/install');
273
+
274
+ expect(response.status).toBe(200);
275
+ await expect(response.json()).resolves.toEqual({
276
+ slug: 'guides/getting-started/install',
277
+ });
278
+ });
279
+
280
+ it('returns 405 with structured JSON when method does not match', async () => {
281
+ registerFunction('users', httpFunction('GET', async () => ({ ok: true })));
282
+ rebuildCompiledRoutes();
283
+
284
+ const response = await invokeFunction('users', 'POST');
285
+
286
+ expect(response.status).toBe(405);
287
+ await expect(response.json()).resolves.toEqual({
288
+ code: 405,
289
+ message: "Method POST not allowed for 'users'.",
290
+ });
291
+ });
292
+ });
@@ -21,16 +21,16 @@ function makeContext(
21
21
  }
22
22
 
23
23
  describe('internal request helpers', () => {
24
- it('isTrustedInternalRequestUrl only trusts worker-internal hosts', () => {
25
- expect(isTrustedInternalRequestUrl('http://internal/api/db/shared')).toBe(true);
26
- expect(isTrustedInternalRequestUrl('http://do/internal/functions/reindex')).toBe(true);
24
+ it('isTrustedInternalRequestUrl never trusts hostnames', () => {
25
+ expect(isTrustedInternalRequestUrl('http://internal/api/db/shared')).toBe(false);
26
+ expect(isTrustedInternalRequestUrl('http://do/internal/functions/reindex')).toBe(false);
27
27
  expect(isTrustedInternalRequestUrl('http://localhost:8787/api/db/shared')).toBe(false);
28
28
  expect(isTrustedInternalRequestUrl('not-a-url')).toBe(false);
29
29
  });
30
30
 
31
- it('isTrustedInternalContext accepts explicit internal flags or trusted internal hosts', () => {
31
+ it('isTrustedInternalContext only accepts explicit internal flags', () => {
32
32
  expect(isTrustedInternalContext(makeContext('http://localhost/api/db/shared', true))).toBe(true);
33
- expect(isTrustedInternalContext(makeContext('http://do/api/db/shared'))).toBe(true);
33
+ expect(isTrustedInternalContext(makeContext('http://do/api/db/shared'))).toBe(false);
34
34
  expect(isTrustedInternalContext(makeContext('http://example.com/api/db/shared'))).toBe(false);
35
35
  });
36
36
 
@@ -9,10 +9,11 @@
9
9
  */
10
10
  import { readFileSync, readdirSync } from 'fs';
11
11
  import { resolve } from 'path';
12
+ import { fileURLToPath } from 'url';
12
13
  import { describe, it, expect } from 'vitest';
13
14
 
14
- const LIB_DIR = resolve(new URL('../lib', import.meta.url).pathname);
15
- const TEST_DIR = resolve(new URL('.', import.meta.url).pathname);
15
+ const LIB_DIR = resolve(fileURLToPath(new URL('../lib', import.meta.url)));
16
+ const TEST_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)));
16
17
 
17
18
  // ─── Lib files that do NOT yet have dedicated tests ─────────────────────────
18
19
  // Remove entries as tests are added — each removal is a net win.
@@ -6,14 +6,15 @@
6
6
  */
7
7
  import { readFileSync, readdirSync } from 'fs';
8
8
  import { resolve } from 'path';
9
+ import { fileURLToPath } from 'url';
9
10
  import { describe, it, expect } from 'vitest';
10
11
 
11
12
  describe('index.ts route registration completeness', () => {
12
13
  const source = readFileSync(
13
- new URL('../index.ts', import.meta.url),
14
+ fileURLToPath(new URL('../index.ts', import.meta.url)),
14
15
  'utf-8',
15
16
  );
16
- const routesDir = resolve(new URL('../routes', import.meta.url).pathname);
17
+ const routesDir = resolve(fileURLToPath(new URL('../routes', import.meta.url)));
17
18
  const EXPECTED_ROUTES = readdirSync(routesDir)
18
19
  .filter((fileName) => fileName.endsWith('.ts'))
19
20
  .sort()
@@ -12,10 +12,11 @@
12
12
  */
13
13
  import { readFileSync, readdirSync } from 'fs';
14
14
  import { resolve } from 'path';
15
+ import { fileURLToPath } from 'url';
15
16
  import { describe, it, expect } from 'vitest';
16
17
  import { normalizeOpenApiDocument, type OpenApiSpec } from '../lib/openapi.js';
17
18
 
18
- const ROUTES_DIR = resolve(new URL('../routes', import.meta.url).pathname);
19
+ const ROUTES_DIR = resolve(fileURLToPath(new URL('../routes', import.meta.url)));
19
20
 
20
21
  // ─── Intentionally non-OpenAPI route registrations ───────────────────────────
21
22
  // Format: "filename.ts:<line-content-substring>"
@@ -118,6 +119,7 @@ describe('OpenAPI route coverage', () => {
118
119
  '/admin/api/setup': { get: {} },
119
120
  '/admin/api/data/users': { get: {} },
120
121
  '/api/room/media/realtime/session': { post: {} },
122
+ '/api/room/media/cloudflare_realtimekit/session': { post: {} },
121
123
  },
122
124
  };
123
125
 
@@ -134,6 +136,8 @@ describe('OpenAPI route coverage', () => {
134
136
  .toEqual([{ userBearerAuth: [] }]);
135
137
  expect((normalized.paths?.['/api/room/media/realtime/session'] as Record<string, { security?: unknown }>).post.security)
136
138
  .toEqual([{ userBearerAuth: [] }]);
139
+ expect((normalized.paths?.['/api/room/media/cloudflare_realtimekit/session'] as Record<string, { security?: unknown }>).post.security)
140
+ .toEqual([{ userBearerAuth: [] }]);
137
141
  expect((normalized.paths?.['/api/sql'] as Record<string, { security?: unknown }>).post.security)
138
142
  .toEqual([{ serviceKeyAuth: [] }]);
139
143
  expect((normalized.paths?.['/admin/api/setup'] as Record<string, { security?: unknown }>).get.security)
@@ -11,7 +11,6 @@ import {
11
11
  getLimit,
12
12
  parseWindow,
13
13
  FixedWindowCounter,
14
- RATE_LIMIT_DEFAULTS,
15
14
  RATE_LIMIT_DEV_DEFAULTS,
16
15
  rateLimitMiddleware,
17
16
  } from '../middleware/rate-limit.js';
@@ -127,4 +127,35 @@ describe('RoomsDO handler context', () => {
127
127
  }),
128
128
  );
129
129
  });
130
+
131
+ it('returns 409 when creating a Cloudflare RealtimeKit session while media is already published', async () => {
132
+ const { RoomsDO } = await import('../durable-objects/rooms-do.js');
133
+
134
+ const room: any = Object.create(RoomsDO.prototype);
135
+ room.readJsonBody = vi.fn().mockResolvedValue({ connectionId: 'conn-1' });
136
+ room.authenticateRealtimeRequest = vi.fn().mockResolvedValue({
137
+ memberId: 'member-1',
138
+ connectionId: 'conn-1',
139
+ meta: {
140
+ authenticated: true,
141
+ connectionId: 'conn-1',
142
+ },
143
+ });
144
+ room.hasPublishedTracks = vi.fn().mockReturnValue(true);
145
+
146
+ const response = await room.handleCloudflareRealtimeKitSessionCreate(
147
+ new Request('http://do/media/cloudflare_realtimekit/session?room=game::room-1', {
148
+ method: 'POST',
149
+ body: JSON.stringify({ connectionId: 'conn-1' }),
150
+ headers: { 'Content-Type': 'application/json' },
151
+ }),
152
+ new URL('http://do/media/cloudflare_realtimekit/session?room=game::room-1'),
153
+ );
154
+
155
+ expect(response.status).toBe(409);
156
+ await expect(response.json()).resolves.toEqual({
157
+ code: 409,
158
+ message: 'Unpublish existing room media before creating a new Cloudflare RealtimeKit session',
159
+ });
160
+ });
130
161
  });
@@ -219,4 +219,52 @@ describe('room route runtime routing', () => {
219
219
  expect(doFetch).toHaveBeenCalledTimes(1);
220
220
  expect(new URL((doFetch.mock.calls[0][0] as Request).url).searchParams.get('room')).toBe('game::room-1');
221
221
  });
222
+
223
+ it('routes room cloudflare realtimekit session requests to the rooms runtime', async () => {
224
+ setConfig(defineConfig({
225
+ rooms: {
226
+ game: {
227
+ runtime: {
228
+ target: 'rooms',
229
+ },
230
+ },
231
+ },
232
+ }));
233
+
234
+ const doFetch = vi.fn(async (request: Request) => new Response(JSON.stringify({
235
+ runtime: 'rooms',
236
+ path: new URL(request.url).pathname,
237
+ auth: request.headers.get('Authorization'),
238
+ body: await request.clone().json(),
239
+ }), {
240
+ headers: { 'Content-Type': 'application/json' },
241
+ status: 201,
242
+ }));
243
+
244
+ const env = createRoomRuntimeEnv();
245
+ env.ROOMS = {
246
+ idFromName: (name: string) => name as unknown as DurableObjectId,
247
+ get: () => ({ fetch: doFetch }),
248
+ } as unknown as DurableObjectNamespace;
249
+
250
+ const app = createAuthedRoomApp();
251
+ const response = await app.request('/api/room/media/cloudflare_realtimekit/session?namespace=game&id=room-1', {
252
+ method: 'POST',
253
+ headers: {
254
+ Authorization: 'Bearer room-token',
255
+ 'Content-Type': 'application/json',
256
+ },
257
+ body: JSON.stringify({ connectionId: 'conn-1' }),
258
+ }, env);
259
+
260
+ expect(response.status).toBe(201);
261
+ await expect(response.json()).resolves.toMatchObject({
262
+ runtime: 'rooms',
263
+ path: '/media/cloudflare_realtimekit/session',
264
+ auth: 'Bearer room-token',
265
+ body: { connectionId: 'conn-1' },
266
+ });
267
+ expect(doFetch).toHaveBeenCalledTimes(1);
268
+ expect(new URL((doFetch.mock.calls[0][0] as Request).url).searchParams.get('room')).toBe('game::room-1');
269
+ });
222
270
  });
@@ -7,10 +7,11 @@
7
7
  * indirectly covered.
8
8
  */
9
9
  import { readFileSync, readdirSync } from 'fs';
10
- import { resolve } from 'path';
10
+ import { basename, relative, resolve } from 'path';
11
+ import { fileURLToPath } from 'url';
11
12
  import { describe, expect, it } from 'vitest';
12
13
 
13
- const SRC_ROOT = resolve(new URL('..', import.meta.url).pathname);
14
+ const SRC_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
14
15
  const PACKAGE_ROOT = resolve(SRC_ROOT, '..');
15
16
  const RUNTIME_DIRS = [
16
17
  resolve(SRC_ROOT, 'routes'),
@@ -21,7 +22,7 @@ const TEST_DIRS = [
21
22
  resolve(SRC_ROOT, '__tests__'),
22
23
  resolve(PACKAGE_ROOT, 'test/integration'),
23
24
  ];
24
- const THIS_TEST_PATH = resolve(new URL(import.meta.url).pathname);
25
+ const THIS_TEST_PATH = resolve(fileURLToPath(import.meta.url));
25
26
 
26
27
  // Files below are exercised indirectly, but the test sources do not mention the
27
28
  // exact filename/stem yet. Tracking them here keeps the gap reviewable.
@@ -60,7 +61,7 @@ function collectFiles(dir: string, predicate: (path: string) => boolean): string
60
61
  function toRuntimeKey(absPath: string): string {
61
62
  for (const dir of RUNTIME_DIRS) {
62
63
  if (absPath.startsWith(dir)) {
63
- return absPath.slice(dir.length + 1).replace(/^/, `${dir.split('/').at(-1)}/`);
64
+ return `${basename(dir)}/${relative(dir, absPath).replace(/\\/g, '/')}`;
64
65
  }
65
66
  }
66
67
  throw new Error(`Unexpected runtime path: ${absPath}`);
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
3
4
  import { describe, expect, it } from 'vitest';
4
5
 
5
6
  interface SmokeSkipEntry {
@@ -18,7 +19,7 @@ interface SmokeSkipReport {
18
19
  }
19
20
 
20
21
  const REPORT_PATH = resolve(
21
- new URL('../../test/integration/generated/smoke-skip-report.json', import.meta.url).pathname,
22
+ fileURLToPath(new URL('../../test/integration/generated/smoke-skip-report.json', import.meta.url)),
22
23
  );
23
24
 
24
25
  function readReport(): SmokeSkipReport {
@@ -37,7 +38,7 @@ describe('smoke skip report', () => {
37
38
  {
38
39
  "skippedRouteCount": 0,
39
40
  "summaryByReason": {},
40
- "totalRoutes": 192,
41
+ "totalRoutes": 197,
41
42
  }
42
43
  `);
43
44
  });
@@ -48,7 +48,7 @@ import {
48
48
  type FilterTuple,
49
49
  } from '../lib/query-engine.js';
50
50
  import { summarizeValidationErrors, validateInsert, validateUpdate } from '../lib/validation.js';
51
- import { hookRejectedError, validationError, notFoundError } from '../lib/errors.js';
51
+ import { hookRejectedError, validationError, notFoundError, normalizeDatabaseError } from '../lib/errors.js';
52
52
  import {
53
53
  executeDbTriggers,
54
54
  getRegisteredFunctions,
@@ -1936,6 +1936,11 @@ export class DatabaseDO extends DurableObject<DOEnv> {
1936
1936
  const e = err as { code: number; message: string; data?: unknown };
1937
1937
  return c.json({ code: e.code, message: e.message, data: e.data }, e.code as 200);
1938
1938
  }
1939
+ // Normalize well-known database errors (e.g. UNIQUE constraint violations → 409)
1940
+ const normalizedDbError = normalizeDatabaseError(err);
1941
+ if (normalizedDbError) {
1942
+ return c.json(normalizedDbError.toJSON(), normalizedDbError.code as 400);
1943
+ }
1939
1944
  console.error('DatabaseDO Error:', err);
1940
1945
  return c.json({ code: 500, message: 'Internal server error.' }, 500);
1941
1946
  });
@@ -573,11 +573,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
573
573
  if (!canRead) continue;
574
574
 
575
575
  let shouldSend = true;
576
- if (change.data && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0)) {
576
+ if (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) {
577
577
  const filters = meta.channelFilters.get(batch.channel) || [];
578
578
  const orFilters = meta.channelOrFilters.get(batch.channel) || [];
579
579
  if (filters.length > 0 || orFilters.length > 0) {
580
- shouldSend = evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters);
580
+ shouldSend = change.data
581
+ ? evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters)
582
+ : true; // DELETE events (null data) pass filters
581
583
  }
582
584
  }
583
585
 
@@ -608,11 +610,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
608
610
  if (!canRead) continue;
609
611
 
610
612
  let shouldSend = true;
611
- if (change.data && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0)) {
613
+ if (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) {
612
614
  const filters = meta.channelFilters.get(batch.channel) || [];
613
615
  const orFilters = meta.channelOrFilters.get(batch.channel) || [];
614
616
  if (filters.length > 0 || orFilters.length > 0) {
615
- shouldSend = evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters);
617
+ shouldSend = change.data
618
+ ? evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters)
619
+ : true; // DELETE events (null data) pass filters
616
620
  }
617
621
  }
618
622
 
@@ -727,11 +731,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
727
731
  if (!canRead) continue;
728
732
 
729
733
  let shouldSend = true;
730
- if (eventData && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) && msgChannel) {
734
+ if ((meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) && msgChannel) {
731
735
  const filters = meta.channelFilters.get(msgChannel) || [];
732
736
  const orFilters = meta.channelOrFilters.get(msgChannel) || [];
733
737
  if (filters.length > 0 || orFilters.length > 0) {
734
- shouldSend = evaluateDatabaseLiveFilters(eventData, filters, orFilters);
738
+ shouldSend = eventData
739
+ ? evaluateDatabaseLiveFilters(eventData, filters, orFilters)
740
+ : true; // DELETE events (null data) pass filters
735
741
  }
736
742
  }
737
743
 
@@ -820,11 +826,14 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
820
826
  const result = await Promise.race([
821
827
  Promise.resolve(rule(authCtx as Record<string, unknown> | null, row)),
822
828
  new Promise<never>((_, reject) =>
823
- setTimeout(() => reject(new Error('Database live row access timeout')), 50),
829
+ setTimeout(() => reject(new Error('Database live row access timeout')), 500),
824
830
  ),
825
831
  ]);
826
832
  return Boolean(result);
827
- } catch {
833
+ } catch (e) {
834
+ if (e instanceof Error && e.message.includes('timeout')) {
835
+ console.warn('DatabaseLive: row access rule timed out after 500ms');
836
+ }
828
837
  return false;
829
838
  }
830
839
  }
@@ -848,11 +857,14 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
848
857
  const result = await Promise.race([
849
858
  Promise.resolve(tableRules(authCtx as Record<string, unknown> | null, {})),
850
859
  new Promise<never>((_, reject) =>
851
- setTimeout(() => reject(new Error('Database live channel access timeout')), 50),
860
+ setTimeout(() => reject(new Error('Database live channel access timeout')), 500),
852
861
  ),
853
862
  ]);
854
863
  return Boolean(result);
855
- } catch {
864
+ } catch (e) {
865
+ if (e instanceof Error && e.message.includes('timeout')) {
866
+ console.warn('DatabaseLive: channel access rule timed out after 500ms');
867
+ }
856
868
  return false;
857
869
  }
858
870
  }