@edge-base/server 0.2.2 → 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 (97) hide show
  1. package/admin-build/_app/immutable/chunks/{C85dMlzL.js → 5RQRbp5q.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{B8DT4fss.js → BME_U9TJ.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{BaCHY17I.js → BYI6CUvd.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{BWyDPAjM.js → BgDzp0i0.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{c5iKSdWY.js → BjWZuf8W.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{g3ZZdY-r.js → C6lpZLE2.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{C-DsDCNG.js → D5GswVnI.js} +3 -3
  8. package/admin-build/_app/immutable/chunks/DBsVqhuh.js +1 -0
  9. package/admin-build/_app/immutable/chunks/{BEYYl662.js → DYaCRWMA.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/D__dwMuW.js +1 -0
  11. package/admin-build/_app/immutable/chunks/{4vlsb8ej.js → Dj-E9-FO.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{kiJ6KthZ.js → Dj0QUuOf.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BKXmgPq4.js → XQM1k9PM.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{CTngeX8H.js → fYEKMQ-Z.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CPdXvRUb.js → g_-Kpxu3.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DzXaj-Ja.js → wCNueVYy.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BZxfavhF.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.DlsaydXO.js → 0.CJJ6HZbp.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.D2NWN5eG.js → 1.B4sI5cB4.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.EMDaN3nw.js → 10.D6hvCer6.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.BasqQ_o9.js → 11.Dx7b8aQ5.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.DO31Ljs7.js → 12.Bqmy5KIF.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DhyAy-GZ.js → 13.CC6KpXgS.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.CLecGWc4.js → 14.yCo1Ix8E.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.B9kp3W4e.js → 15.co0UfPlh.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.Pu_8T3RI.js → 16.D0xkPUBW.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.DX4z43t6.js → 17.CebNqPeh.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.BKsSaxrr.js → 18.JUoLOZxh.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.DXNF1htN.js → 19.ND8kmQJe.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.VRVb0wee.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.DqZf4CtH.js → 22.UOzm8WYV.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.DtyxMiQG.js → 23.BLgq21om.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.CloWNmTd.js → 24.DN9usmUs.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.CnZWMq7_.js → 25.BddRfAyE.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.DrV7XOmf.js → 26.Dl6XHIeT.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.DV8L32OF.js → 27.D0iNwALG.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.Stil2D4u.js → 28.9dKQmdGi.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.Zsm1e5Dc.js → 29.wXzfJUXp.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.CKoj2vNz.js → 3.z8ut3jS-.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.Ni0k5bER.js → 30.BtZETNsL.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.mnqj9EbV.js → 31.CYonj2Jh.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.B_-z9AzT.js → 4.COtDPQ9b.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.yiZ72j4k.js → 5.CTRCeIhp.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.BqykybBG.js → 6.ChHi3QkR.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.BDAHlhsF.js → 7.CCMtr6Ac.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.D8Xvy0lH.js → 8.DpWJ-X_-.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.Dddmd7_F.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__/d1-live-broadcast-verification.test.ts +271 -0
  55. package/src/__tests__/database-do-route-validation.test.ts +105 -0
  56. package/src/__tests__/database-live-do.test.ts +50 -0
  57. package/src/__tests__/database-live-emitter.test.ts +116 -1
  58. package/src/__tests__/database-live-route.test.ts +82 -0
  59. package/src/__tests__/do-router.test.ts +116 -0
  60. package/src/__tests__/error-format.test.ts +63 -0
  61. package/src/__tests__/functions-context.test.ts +674 -33
  62. package/src/__tests__/functions-d1-proxy.test.ts +54 -0
  63. package/src/__tests__/plugin-migration-routing.test.ts +32 -0
  64. package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
  65. package/src/__tests__/provider-aware-sql.test.ts +163 -0
  66. package/src/__tests__/room-auth-state-loss.test.ts +124 -0
  67. package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
  68. package/src/__tests__/scheduled.test.ts +55 -0
  69. package/src/__tests__/service-key-db-proxy.test.ts +122 -1
  70. package/src/__tests__/sql-route.test.ts +252 -75
  71. package/src/__tests__/table-hook-runtime.test.ts +137 -0
  72. package/src/durable-objects/database-do.ts +36 -45
  73. package/src/durable-objects/database-live-do.ts +46 -1
  74. package/src/durable-objects/room-runtime-base.ts +26 -2
  75. package/src/durable-objects/rooms-do.ts +1 -1
  76. package/src/index.ts +12 -6
  77. package/src/lib/admin-db-target.ts +30 -74
  78. package/src/lib/d1-handler.ts +55 -35
  79. package/src/lib/database-live-emitter.ts +57 -16
  80. package/src/lib/do-router.ts +135 -3
  81. package/src/lib/functions.ts +215 -143
  82. package/src/lib/internal-transport.ts +28 -12
  83. package/src/lib/plugin-migration-routing.ts +28 -0
  84. package/src/lib/plugin-migrations.ts +38 -38
  85. package/src/lib/postgres-handler.ts +51 -31
  86. package/src/lib/provider-aware-sql.ts +831 -0
  87. package/src/lib/table-hook-runtime.ts +62 -0
  88. package/src/routes/admin.ts +41 -41
  89. package/src/routes/auth.ts +7 -2
  90. package/src/routes/database-live.ts +110 -12
  91. package/src/routes/sql.ts +64 -84
  92. package/src/routes/storage.ts +7 -2
  93. package/src/routes/tables.ts +42 -29
  94. package/admin-build/_app/immutable/chunks/5PDcRlfX.js +0 -1
  95. package/admin-build/_app/immutable/chunks/qiZXAKh-.js +0 -1
  96. package/admin-build/_app/immutable/entry/start.Mr9mmopc.js +0 -1
  97. package/admin-build/_app/immutable/nodes/21.Ck3_0D2f.js +0 -1
@@ -226,4 +226,58 @@ describe('buildFunctionContext admin.db D1 routing', () => {
226
226
  expect(workerFetch).not.toHaveBeenCalled();
227
227
  expect(databaseFetch).not.toHaveBeenCalled();
228
228
  });
229
+
230
+ it('rejects instance ids for single-instance namespaces before touching D1 handlers', async () => {
231
+ const handleD1Request = vi.fn();
232
+ vi.doMock('../lib/d1-handler.js', () => ({
233
+ handleD1Request,
234
+ }));
235
+
236
+ const workerFetch = vi.fn();
237
+ vi.stubGlobal('fetch', workerFetch);
238
+
239
+ const databaseFetch = vi.fn();
240
+ const { buildFunctionContext } = await import('../lib/functions.js');
241
+
242
+ const ctx = buildFunctionContext({
243
+ request: new Request('http://localhost/api/functions/save-room-signal'),
244
+ auth: null,
245
+ databaseNamespace: {
246
+ idFromName: vi.fn(() => 'shared-id'),
247
+ get: vi.fn(() => ({ fetch: databaseFetch })),
248
+ } as unknown as DurableObjectNamespace,
249
+ authNamespace: {
250
+ idFromName: vi.fn(() => 'auth-id'),
251
+ get: vi.fn(() => ({ fetch: vi.fn() })),
252
+ } as unknown as DurableObjectNamespace,
253
+ d1Database: {} as D1Database,
254
+ env: {
255
+ DATABASE: {} as DurableObjectNamespace,
256
+ AUTH: {} as DurableObjectNamespace,
257
+ AUTH_DB: {} as D1Database,
258
+ DB_D1_SHARED: {} as D1Database,
259
+ } as never,
260
+ executionCtx: { waitUntil: vi.fn() } as unknown as ExecutionContext,
261
+ config: {
262
+ databases: {
263
+ shared: {
264
+ tables: {
265
+ signals: {
266
+ schema: {
267
+ title: { type: 'string', required: true },
268
+ },
269
+ },
270
+ },
271
+ },
272
+ },
273
+ },
274
+ });
275
+
276
+ await expect(
277
+ ctx.admin.db('shared', 'shadow').table('signals').getList(),
278
+ ).rejects.toThrow("instanceId is not allowed for single-instance namespace 'shared'");
279
+ expect(handleD1Request).not.toHaveBeenCalled();
280
+ expect(workerFetch).not.toHaveBeenCalled();
281
+ expect(databaseFetch).not.toHaveBeenCalled();
282
+ });
229
283
  });
@@ -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
+ });
@@ -5,6 +5,7 @@ import type { Env } from '../types.js';
5
5
 
6
6
  describe('postgres field operator compatibility', () => {
7
7
  afterEach(() => {
8
+ vi.restoreAllMocks();
8
9
  vi.resetModules();
9
10
  vi.clearAllMocks();
10
11
  vi.unstubAllGlobals();
@@ -219,4 +220,113 @@ describe('postgres field operator compatibility', () => {
219
220
  expect(typeof updateParams[2]).toBe('string');
220
221
  expect(waitUntil).toHaveBeenCalledTimes(1);
221
222
  });
223
+
224
+ it('catches rejected db-live promises for batch-by-filter updates before scheduling background work', async () => {
225
+ const executePostgresQuery = vi.fn()
226
+ .mockResolvedValueOnce({
227
+ rows: [{ id: 'post-1' }],
228
+ rowCount: 1,
229
+ })
230
+ .mockResolvedValueOnce({
231
+ rows: [{
232
+ id: 'post-1',
233
+ viewCount: 3,
234
+ }],
235
+ rowCount: 1,
236
+ });
237
+ const emitDbLiveEvent = vi.fn().mockRejectedValue(new Error('database-live unavailable'));
238
+
239
+ vi.doMock('../lib/postgres-executor.js', () => ({
240
+ executePostgresQuery,
241
+ ensureLocalDevPostgresSchema: vi.fn().mockResolvedValue(undefined),
242
+ withPostgresConnection: vi.fn(async (_connectionString, fn) =>
243
+ fn((sql: string, params: unknown[] = []) => executePostgresQuery(_connectionString, sql, params))),
244
+ getLocalDevPostgresExecOptions: vi.fn(() => undefined),
245
+ getProviderBindingName: () => 'DB_SHARED',
246
+ }));
247
+ vi.doMock('../lib/postgres-schema-init.js', () => ({
248
+ ensurePgSchema: vi.fn().mockResolvedValue(undefined),
249
+ }));
250
+ vi.doMock('../lib/database-live-emitter.js', () => ({
251
+ emitDbLiveEvent,
252
+ emitDbLiveBatchEvent: vi.fn().mockResolvedValue(undefined),
253
+ }));
254
+
255
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
256
+ const { handlePgRequest } = await import('../lib/postgres-handler.js');
257
+
258
+ const env = {
259
+ EDGEBASE_CONFIG: defineConfig({
260
+ release: true,
261
+ databases: {
262
+ shared: {
263
+ provider: 'postgres',
264
+ connectionString: 'DB_POSTGRES_SHARED_URL',
265
+ tables: {
266
+ posts: {
267
+ schema: {
268
+ viewCount: { type: 'number' },
269
+ },
270
+ access: {
271
+ update: () => true,
272
+ },
273
+ },
274
+ },
275
+ },
276
+ },
277
+ }),
278
+ DB_POSTGRES_SHARED_URL: 'postgres://edgebase:test@localhost/shared',
279
+ } as unknown as Env;
280
+
281
+ const request = new Request(
282
+ 'http://internal/api/db/shared/tables/posts/batch-by-filter',
283
+ {
284
+ method: 'POST',
285
+ headers: {
286
+ 'Content-Type': 'application/json',
287
+ 'X-Is-Service-Key': 'true',
288
+ },
289
+ },
290
+ );
291
+
292
+ const pending: Promise<unknown>[] = [];
293
+ const executionCtx = {
294
+ waitUntil(promise: Promise<unknown>) {
295
+ pending.push(promise);
296
+ },
297
+ } as unknown as ExecutionContext;
298
+ const ctx = buildInternalHandlerContext({
299
+ env,
300
+ request,
301
+ executionCtx,
302
+ body: {
303
+ action: 'update',
304
+ filter: [['id', '==', 'post-1']],
305
+ update: {
306
+ viewCount: { $op: 'increment', value: 2 },
307
+ },
308
+ },
309
+ });
310
+
311
+ const response = await handlePgRequest(ctx, 'shared', 'posts', '/tables/posts/batch-by-filter');
312
+ const json = await response.json() as {
313
+ processed: number;
314
+ succeeded: number;
315
+ updated: number;
316
+ };
317
+
318
+ expect(response.status).toBe(200);
319
+ expect(json).toMatchObject({
320
+ processed: 1,
321
+ succeeded: 1,
322
+ updated: 1,
323
+ });
324
+ expect(emitDbLiveEvent).toHaveBeenCalledTimes(1);
325
+ expect(pending).toHaveLength(1);
326
+ await expect(pending[0]).resolves.toBeUndefined();
327
+ expect(warn).toHaveBeenCalledWith(
328
+ '[db-live] emit bulk shared.posts (update) failed',
329
+ expect.any(Error),
330
+ );
331
+ });
222
332
  });
@@ -0,0 +1,163 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ executeProviderAwareSql,
4
+ normalizePostgresSqlPlaceholders,
5
+ } from '../lib/provider-aware-sql.js';
6
+
7
+ describe('provider-aware raw SQL helpers', () => {
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ });
11
+
12
+ it('normalizes question-mark placeholders while preserving quoted and commented question marks', () => {
13
+ const query = `
14
+ SELECT '?' AS literal, "weird?" AS "column?"
15
+ FROM posts
16
+ WHERE title = ?
17
+ AND body = $$literal ?$$
18
+ /* keep ? */
19
+ -- keep ?
20
+ AND status = ?
21
+ `;
22
+
23
+ const normalized = normalizePostgresSqlPlaceholders(query, 2);
24
+
25
+ expect(normalized).toContain('WHERE title = $1');
26
+ expect(normalized).toContain('AND status = $2');
27
+ expect(normalized).toContain('SELECT \'?\' AS literal, "weird?" AS "column?"');
28
+ expect(normalized).toContain('$$literal ?$$');
29
+ expect(normalized).toContain('/* keep ? */');
30
+ });
31
+
32
+ it('rejects mixed question-mark and PostgreSQL-style positional placeholders', () => {
33
+ expect(() =>
34
+ normalizePostgresSqlPlaceholders('SELECT * FROM posts WHERE id = $1 AND title = ?', 1),
35
+ ).toThrow('Cannot mix ? placeholders with PostgreSQL-style $n placeholders.');
36
+ });
37
+
38
+ it('allows PostgreSQL question-mark operators alongside positional placeholders', () => {
39
+ expect(
40
+ normalizePostgresSqlPlaceholders(
41
+ "SELECT * FROM posts WHERE metadata ? 'featured' AND title = $1",
42
+ 1,
43
+ ),
44
+ ).toBe("SELECT * FROM posts WHERE metadata ? 'featured' AND title = $1");
45
+ });
46
+
47
+ it('leaves PostgreSQL question-mark operators alone when no params are provided', () => {
48
+ expect(
49
+ normalizePostgresSqlPlaceholders("SELECT * FROM posts WHERE metadata ? 'featured'", 0),
50
+ ).toBe("SELECT * FROM posts WHERE metadata ? 'featured'");
51
+ });
52
+
53
+ it('normalizes bind placeholders without touching PostgreSQL question-mark operators', () => {
54
+ expect(
55
+ normalizePostgresSqlPlaceholders(
56
+ "SELECT * FROM posts WHERE metadata ? 'featured' AND id = ?",
57
+ 1,
58
+ ),
59
+ ).toBe("SELECT * FROM posts WHERE metadata ? 'featured' AND id = $1");
60
+ });
61
+
62
+ it('preserves PostgreSQL @? operators while still normalizing bind placeholders', () => {
63
+ expect(
64
+ normalizePostgresSqlPlaceholders(
65
+ "SELECT * FROM posts WHERE metadata @? '$.featured' AND id = ?",
66
+ 1,
67
+ ),
68
+ ).toBe("SELECT * FROM posts WHERE metadata @? '$.featured' AND id = $1");
69
+ });
70
+
71
+ it('preserves PostgreSQL ?| operators while still normalizing bind placeholders', () => {
72
+ expect(
73
+ normalizePostgresSqlPlaceholders(
74
+ "SELECT * FROM posts WHERE tags ?| ARRAY['featured', 'pinned'] AND id = ?",
75
+ 1,
76
+ ),
77
+ ).toBe("SELECT * FROM posts WHERE tags ?| ARRAY['featured', 'pinned'] AND id = $1");
78
+ });
79
+
80
+ it('supports escaped PostgreSQL question-mark operators as a raw SQL escape hatch', () => {
81
+ expect(
82
+ normalizePostgresSqlPlaceholders(
83
+ "SELECT * FROM posts WHERE metadata @\\? '$.featured' AND id = ?",
84
+ 1,
85
+ ),
86
+ ).toBe("SELECT * FROM posts WHERE metadata @? '$.featured' AND id = $1");
87
+ });
88
+
89
+ it('unescapes escaped PostgreSQL question-mark operators when only positional placeholders are present', () => {
90
+ expect(
91
+ normalizePostgresSqlPlaceholders(
92
+ "SELECT * FROM posts WHERE metadata @\\? '$.featured' AND id = $1",
93
+ 1,
94
+ ),
95
+ ).toBe("SELECT * FROM posts WHERE metadata @? '$.featured' AND id = $1");
96
+ });
97
+
98
+ it('does not rewrite escaped question marks inside PostgreSQL E-strings', () => {
99
+ const query = String.raw`SELECT E'it\'s\?' AS literal, id FROM posts WHERE id = $1`;
100
+
101
+ expect(normalizePostgresSqlPlaceholders(query, 1)).toBe(query);
102
+ });
103
+
104
+ it('still treats question marks after prefix operators as bind placeholders when an expression is expected', () => {
105
+ expect(normalizePostgresSqlPlaceholders('SELECT @?::int', 1)).toBe('SELECT @$1::int');
106
+ });
107
+
108
+ it('treats SELECT-list question marks as bind placeholders, not operators', () => {
109
+ expect(normalizePostgresSqlPlaceholders('SELECT ?, ? FROM posts', 2)).toBe(
110
+ 'SELECT $1, $2 FROM posts',
111
+ );
112
+ });
113
+
114
+ it('falls back to the worker /api/sql route when direct execution bindings are unavailable', async () => {
115
+ const fetchMock = vi.fn().mockResolvedValue(
116
+ new Response(JSON.stringify({ items: [{ id: 'p1' }], rowCount: 1 }), {
117
+ status: 200,
118
+ headers: { 'Content-Type': 'application/json' },
119
+ }),
120
+ );
121
+ vi.stubGlobal('fetch', fetchMock);
122
+
123
+ const result = await executeProviderAwareSql(
124
+ {
125
+ config: {
126
+ databases: {
127
+ shared: {
128
+ tables: { posts: {} },
129
+ },
130
+ },
131
+ },
132
+ workerUrl: 'http://localhost:8787',
133
+ serviceKey: 'service-key',
134
+ },
135
+ 'shared',
136
+ undefined,
137
+ 'SELECT ? AS id',
138
+ ['p1'],
139
+ );
140
+
141
+ expect(result).toEqual({
142
+ columns: ['id'],
143
+ rows: [{ id: 'p1' }],
144
+ rowCount: 1,
145
+ });
146
+ expect(fetchMock).toHaveBeenCalledWith(
147
+ 'http://localhost:8787/api/sql',
148
+ expect.objectContaining({
149
+ method: 'POST',
150
+ headers: expect.objectContaining({
151
+ 'Content-Type': 'application/json',
152
+ 'X-EdgeBase-Service-Key': 'service-key',
153
+ }),
154
+ body: JSON.stringify({
155
+ namespace: 'shared',
156
+ id: undefined,
157
+ sql: 'SELECT ? AS id',
158
+ params: ['p1'],
159
+ }),
160
+ }),
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('cloudflare:workers', () => ({
4
+ DurableObject: class DurableObject {},
5
+ }));
6
+
7
+ describe('room auth-state loss recovery', () => {
8
+ it('marks websocket metadata rebuilt from hibernation tags as auth-state-lost', async () => {
9
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
10
+
11
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
12
+ room._metaCache = new Map();
13
+ room.ctx = {
14
+ getTags: vi.fn(() => [
15
+ 'conn:conn-1',
16
+ 'ip:127.0.0.1',
17
+ 'room:test-signals::room-1',
18
+ ]),
19
+ };
20
+ room.config = {
21
+ rooms: {
22
+ 'test-signals': {},
23
+ },
24
+ };
25
+ room.namespace = null;
26
+ room.roomId = null;
27
+ room.namespaceConfig = null;
28
+
29
+ const ws = {} as WebSocket;
30
+ const meta = room.getWSMeta(ws);
31
+
32
+ expect(meta).toMatchObject({
33
+ authenticated: false,
34
+ authStateLost: true,
35
+ connectionId: 'conn-1',
36
+ ip: '127.0.0.1',
37
+ });
38
+ expect(room.namespace).toBe('test-signals');
39
+ expect(room.roomId).toBe('room-1');
40
+ });
41
+
42
+ it('keeps normal pre-auth protocol errors as NOT_AUTHENTICATED without closing the socket', async () => {
43
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
44
+
45
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
46
+ const ws = { close: vi.fn() } as unknown as WebSocket;
47
+ room._metaCache = new Map([[ws, {
48
+ authenticated: false,
49
+ authStateLost: false,
50
+ connectionId: 'conn-1',
51
+ }]]);
52
+ room.safeSend = vi.fn();
53
+
54
+ await room.webSocketMessage(ws, JSON.stringify({ type: 'ping' }));
55
+
56
+ expect(room.safeSend).toHaveBeenCalledWith(ws, {
57
+ type: 'error',
58
+ code: 'NOT_AUTHENTICATED',
59
+ message: 'Authenticate first',
60
+ });
61
+ expect(ws.close).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('closes stale sockets after auth-state loss in the shared room runtime guard', async () => {
65
+ const { RoomRuntimeBaseDO } = await import('../durable-objects/room-runtime-base.js');
66
+
67
+ const room: any = Object.create(RoomRuntimeBaseDO.prototype);
68
+ const ws = { close: vi.fn() } as unknown as WebSocket;
69
+ room._metaCache = new Map([[ws, {
70
+ authenticated: false,
71
+ authStateLost: true,
72
+ connectionId: 'conn-1',
73
+ }]]);
74
+ room.safeSend = vi.fn();
75
+
76
+ await room.webSocketMessage(ws, JSON.stringify({ type: 'ping' }));
77
+
78
+ expect(room.safeSend).toHaveBeenCalledWith(ws, {
79
+ type: 'error',
80
+ code: 'AUTH_STATE_LOST',
81
+ message: 'Room authentication state lost. Reconnect required.',
82
+ });
83
+ expect(ws.close).toHaveBeenCalledWith(4006, 'Room authentication state lost');
84
+ });
85
+
86
+ it('closes stale sockets for room-specific signal and member-state messages too', async () => {
87
+ const { RoomsDO } = await import('../durable-objects/rooms-do.js');
88
+
89
+ const room: any = Object.create(RoomsDO.prototype);
90
+ const ws = { close: vi.fn() } as unknown as WebSocket;
91
+ room._metaCache = new Map([[ws, {
92
+ authenticated: false,
93
+ authStateLost: true,
94
+ connectionId: 'conn-1',
95
+ }]]);
96
+ room.safeSend = vi.fn();
97
+
98
+ await room.webSocketMessage(ws, JSON.stringify({
99
+ type: 'signal',
100
+ event: 'chat.message',
101
+ payload: { text: 'hello' },
102
+ requestId: 'signal-1',
103
+ }));
104
+
105
+ await room.webSocketMessage(ws, JSON.stringify({
106
+ type: 'member_state',
107
+ state: { mood: 'awake' },
108
+ requestId: 'member-1',
109
+ }));
110
+
111
+ expect(room.safeSend).toHaveBeenNthCalledWith(1, ws, {
112
+ type: 'error',
113
+ code: 'AUTH_STATE_LOST',
114
+ message: 'Room authentication state lost. Reconnect required.',
115
+ });
116
+ expect(room.safeSend).toHaveBeenNthCalledWith(2, ws, {
117
+ type: 'error',
118
+ code: 'AUTH_STATE_LOST',
119
+ message: 'Room authentication state lost. Reconnect required.',
120
+ });
121
+ expect(ws.close).toHaveBeenNthCalledWith(1, 4006, 'Room authentication state lost');
122
+ expect(ws.close).toHaveBeenNthCalledWith(2, 4006, 'Room authentication state lost');
123
+ });
124
+ });
@@ -35,10 +35,6 @@ const KNOWN_INDIRECT_RUNTIME_COVERAGE = new Map<string, string>([
35
35
  'durable-objects/logs-do.ts',
36
36
  'Covered indirectly via analytics/logging flows; keep explicit until a direct file reference lands in tests.',
37
37
  ],
38
- [
39
- 'durable-objects/room-runtime-base.ts',
40
- 'Covered through RoomsDO and room protocol/state tests, but the shared runtime base is not referenced by filename.',
41
- ],
42
38
  ]);
43
39
 
44
40
  function collectFiles(dir: string, predicate: (path: string) => boolean): string[] {
@@ -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
  });