@edge-base/server 0.2.1 → 0.2.3

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 (81) hide show
  1. package/admin-build/_app/immutable/chunks/{DjOEv9M9.js → A_3UuvCe.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{Dqk2TGNU.js → B-_-hJ9o.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{BFs_qStz.js → B5Nwfelm.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{B0QyxC2M.js → BxoNtYHK.js} +3 -3
  5. package/admin-build/_app/immutable/chunks/{BsFiK_FJ.js → CZ0TVkCa.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{k0CIJkw4.js → CzSAxmuj.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{D-x55wdW.js → DCKcAiQH.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{CSGrwS7E.js → DCvwWZrm.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{BTJcQFEp.js → DRqPU3wD.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{CqUxCvs_.js → Dc1-6Po6.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{D755Tqat.js → DiyBpamp.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{BcIUK2sk.js → Dlty5069.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/{BY07qVPA.js → DpVAayDG.js} +1 -1
  14. package/admin-build/_app/immutable/chunks/{BCKr7yKd.js → Du5vWVa2.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{m9QZTyVV.js → byv2rTy8.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{DnLqc9L1.js → nZvorU8i.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.BTsq3_xq.js → app.CfrmEXPD.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.BZ00WDYH.js → 0.Cn2BZ4da.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.RzSJ3yyr.js → 1.Dv4LX_Co.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.D-rsiquF.js → 10.DPVv3kat.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.l7-bgtFD.js → 11.CiCb6Ayu.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.Dkq0H7B5.js → 12.CIPyeekF.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.DtK_4oRz.js → 13.Z15Lt36e.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.BKo7-AMx.js → 14.s0l5bAq3.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.CQAj_6lq.js → 15.UwSSNO76.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.XVIG-Ffr.js → 16.qiD8i883.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.g6raZLCM.js → 17.Dy3dcSvu.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.IQz6a3T6.js → 18.DeXyPYsO.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.CAAZ8i8h.js → 19.CAbuyS6w.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.BPcX3KPj.js → 20.Bec0T7un.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.DuDYelMY.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.Br5AG_5Z.js → 22.CdVprrv2.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.KjbrdXoE.js → 23.Y8RzVLoF.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.C3n2-hgw.js → 24.CWhHYFBx.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.SFDSBzHd.js → 25.wCBplOVt.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.D95vui6E.js → 26.Cod_JRFK.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.FgLgdjwB.js → 27.BO2HVMu9.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.B9sYYm1F.js → 28.DxG-FBVQ.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.DyqZ_wbN.js → 29.CjGqWGvE.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.Bzo2yVIO.js → 3.By3_OmdZ.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.c1CiNwiS.js → 30.M_H7Htpq.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.CXty66Vh.js → 31.DEU18izM.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.BgQaXZ27.js → 4.DeYhKtzJ.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.BuJrHvxH.js → 5.9WLgxhrD.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.CkBBC94k.js → 6.BdT2i_dd.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.D2YBvNFM.js → 7.CHq0s4K6.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.D8qQWo_z.js → 8.DuvRw-XZ.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.BLDLX5hV.js → 9.C2Ub82wn.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 -2
  53. package/src/__tests__/d1-live-broadcast-verification.test.ts +271 -0
  54. package/src/__tests__/database-live-do.test.ts +50 -0
  55. package/src/__tests__/database-live-emitter.test.ts +116 -1
  56. package/src/__tests__/error-format.test.ts +63 -0
  57. package/src/__tests__/functions-context.test.ts +592 -35
  58. package/src/__tests__/meta-export-coverage.test.ts +1 -0
  59. package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
  60. package/src/__tests__/provider-aware-sql.test.ts +157 -0
  61. package/src/__tests__/room-auth-state-loss.test.ts +124 -0
  62. package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
  63. package/src/__tests__/sql-route.test.ts +187 -76
  64. package/src/durable-objects/database-live-do.ts +46 -1
  65. package/src/durable-objects/room-runtime-base.ts +26 -2
  66. package/src/durable-objects/rooms-do.ts +1 -1
  67. package/src/lib/admin-db-target.ts +30 -74
  68. package/src/lib/d1-handler.ts +45 -14
  69. package/src/lib/database-live-emitter.ts +57 -16
  70. package/src/lib/functions.ts +332 -454
  71. package/src/lib/internal-transport.ts +316 -0
  72. package/src/lib/plugin-migrations.ts +39 -39
  73. package/src/lib/postgres-handler.ts +39 -11
  74. package/src/lib/provider-aware-sql.ts +827 -0
  75. package/src/routes/admin.ts +7 -1
  76. package/src/routes/auth.ts +11 -12
  77. package/src/routes/sql.ts +51 -76
  78. package/src/routes/storage.ts +11 -12
  79. package/src/types.ts +2 -0
  80. package/admin-build/_app/immutable/entry/start.zXCirpgY.js +0 -1
  81. package/admin-build/_app/immutable/nodes/21.DoPabrY_.js +0 -1
@@ -18,22 +18,28 @@ describe('sql route', () => {
18
18
  });
19
19
 
20
20
  it('rejects unconfigured shared namespace instead of treating it as implicit', async () => {
21
- setConfig(defineConfig({
22
- databases: {
23
- app: {
24
- tables: {
25
- posts: { schema: { title: { type: 'string' } } },
21
+ setConfig(
22
+ defineConfig({
23
+ databases: {
24
+ app: {
25
+ tables: {
26
+ posts: { schema: { title: { type: 'string' } } },
27
+ },
26
28
  },
27
29
  },
28
- },
29
- }));
30
+ }),
31
+ );
30
32
 
31
33
  const app = createApp();
32
- const response = await app.request('/api/sql', {
33
- method: 'POST',
34
- headers: { 'Content-Type': 'application/json' },
35
- body: JSON.stringify({ namespace: 'shared', sql: 'SELECT 1' }),
36
- }, {} as Env);
34
+ const response = await app.request(
35
+ '/api/sql',
36
+ {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ namespace: 'shared', sql: 'SELECT 1' }),
40
+ },
41
+ {} as Env,
42
+ );
37
43
 
38
44
  expect(response.status).toBe(404);
39
45
  await expect(response.json()).resolves.toMatchObject({
@@ -43,30 +49,33 @@ describe('sql route', () => {
43
49
  });
44
50
 
45
51
  it('retries dynamic DO SQL after create handshake and forwards the DO name', async () => {
46
- setConfig(defineConfig({
47
- databases: {
48
- workspace: {
49
- instance: true,
50
- tables: {
51
- members: { schema: { userId: { type: 'string' } } },
52
+ setConfig(
53
+ defineConfig({
54
+ databases: {
55
+ workspace: {
56
+ instance: true,
57
+ tables: {
58
+ members: { schema: { userId: { type: 'string' } } },
59
+ },
52
60
  },
53
61
  },
54
- },
55
- serviceKeys: {
56
- keys: [
57
- {
58
- kid: 'root',
59
- tier: 'root',
60
- scopes: ['*'],
61
- secretSource: 'inline',
62
- inlineSecret: 'sk-root',
63
- },
64
- ],
65
- },
66
- }));
62
+ serviceKeys: {
63
+ keys: [
64
+ {
65
+ kid: 'root',
66
+ tier: 'root',
67
+ scopes: ['*'],
68
+ secretSource: 'inline',
69
+ inlineSecret: 'sk-root',
70
+ },
71
+ ],
72
+ },
73
+ }),
74
+ );
67
75
 
68
76
  const stub = {
69
- fetch: vi.fn()
77
+ fetch: vi
78
+ .fn()
70
79
  .mockResolvedValueOnce(
71
80
  new Response(JSON.stringify({ needsCreate: true, namespace: 'workspace', id: 'ws-1' }), {
72
81
  status: 201,
@@ -88,19 +97,23 @@ describe('sql route', () => {
88
97
  } as unknown as Env;
89
98
 
90
99
  const app = createApp();
91
- const response = await app.request('/api/sql', {
92
- method: 'POST',
93
- headers: {
94
- 'Content-Type': 'application/json',
95
- 'X-EdgeBase-Service-Key': 'sk-root',
100
+ const response = await app.request(
101
+ '/api/sql',
102
+ {
103
+ method: 'POST',
104
+ headers: {
105
+ 'Content-Type': 'application/json',
106
+ 'X-EdgeBase-Service-Key': 'sk-root',
107
+ },
108
+ body: JSON.stringify({
109
+ namespace: 'workspace',
110
+ id: 'ws-1',
111
+ sql: 'SELECT COUNT(*) AS total FROM members',
112
+ params: [],
113
+ }),
96
114
  },
97
- body: JSON.stringify({
98
- namespace: 'workspace',
99
- id: 'ws-1',
100
- sql: 'SELECT COUNT(*) AS total FROM members',
101
- params: [],
102
- }),
103
- }, env);
115
+ env,
116
+ );
104
117
 
105
118
  expect(response.status).toBe(200);
106
119
  await expect(response.json()).resolves.toMatchObject({
@@ -122,26 +135,28 @@ describe('sql route', () => {
122
135
  });
123
136
 
124
137
  it('uses D1 run() for non-SELECT SQL so schema mutations actually execute', async () => {
125
- setConfig(defineConfig({
126
- databases: {
127
- shared: {
128
- tables: {
129
- posts: { schema: { title: { type: 'string' } } },
138
+ setConfig(
139
+ defineConfig({
140
+ databases: {
141
+ shared: {
142
+ tables: {
143
+ posts: { schema: { title: { type: 'string' } } },
144
+ },
130
145
  },
131
146
  },
132
- },
133
- serviceKeys: {
134
- keys: [
135
- {
136
- kid: 'root',
137
- tier: 'root',
138
- scopes: ['*'],
139
- secretSource: 'inline',
140
- inlineSecret: 'sk-root',
141
- },
142
- ],
143
- },
144
- }));
147
+ serviceKeys: {
148
+ keys: [
149
+ {
150
+ kid: 'root',
151
+ tier: 'root',
152
+ scopes: ['*'],
153
+ secretSource: 'inline',
154
+ inlineSecret: 'sk-root',
155
+ },
156
+ ],
157
+ },
158
+ }),
159
+ );
145
160
 
146
161
  const stmt: {
147
162
  bind: ReturnType<typeof vi.fn>;
@@ -159,33 +174,129 @@ describe('sql route', () => {
159
174
  } as unknown as Env;
160
175
 
161
176
  const app = createApp();
162
- const response = await app.request('/api/sql', {
163
- method: 'POST',
164
- headers: {
165
- 'Content-Type': 'application/json',
166
- 'X-EdgeBase-Service-Key': 'sk-root',
177
+ const response = await app.request(
178
+ '/api/sql',
179
+ {
180
+ method: 'POST',
181
+ headers: {
182
+ 'Content-Type': 'application/json',
183
+ 'X-EdgeBase-Service-Key': 'sk-root',
184
+ },
185
+ body: JSON.stringify({
186
+ namespace: 'shared',
187
+ sql: 'ALTER TABLE "posts" RENAME TO "articles"',
188
+ }),
167
189
  },
168
- body: JSON.stringify({
169
- namespace: 'shared',
170
- sql: 'ALTER TABLE "posts" RENAME TO "articles"',
171
- }),
172
- }, env);
190
+ env,
191
+ );
173
192
 
174
193
  expect(response.status).toBe(200);
175
194
  await expect(response.json()).resolves.toMatchObject({
176
195
  rows: [],
177
196
  rowCount: 0,
178
197
  });
179
- expect((env as unknown as { DB_D1_SHARED: { prepare: ReturnType<typeof vi.fn> } }).DB_D1_SHARED.prepare).toHaveBeenCalledWith(
180
- 'ALTER TABLE "posts" RENAME TO "articles"',
181
- );
198
+ expect(
199
+ (env as unknown as { DB_D1_SHARED: { prepare: ReturnType<typeof vi.fn> } }).DB_D1_SHARED
200
+ .prepare,
201
+ ).toHaveBeenCalledWith('ALTER TABLE "posts" RENAME TO "articles"');
182
202
  expect(stmt.run).toHaveBeenCalledTimes(1);
183
203
  expect(stmt.all).not.toHaveBeenCalled();
184
204
  });
185
205
 
206
+ it.each(['postgres', 'neon'] as const)(
207
+ 'routes %s raw SQL through the provider-aware executor and normalizes ? placeholders',
208
+ async (provider) => {
209
+ const fetchMock = vi
210
+ .fn()
211
+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
212
+ .mockResolvedValueOnce(
213
+ new Response(
214
+ JSON.stringify({
215
+ columns: ['literal', 'total'],
216
+ rows: [{ literal: '?', total: 3 }],
217
+ rowCount: 1,
218
+ }),
219
+ {
220
+ status: 200,
221
+ headers: { 'Content-Type': 'application/json' },
222
+ },
223
+ ),
224
+ );
225
+ vi.stubGlobal('fetch', fetchMock);
226
+
227
+ setConfig(
228
+ defineConfig({
229
+ databases: {
230
+ shared: {
231
+ provider,
232
+ tables: {
233
+ posts: { schema: { title: { type: 'string' } } },
234
+ },
235
+ },
236
+ },
237
+ serviceKeys: {
238
+ keys: [
239
+ {
240
+ kid: 'root',
241
+ tier: 'root',
242
+ scopes: ['*'],
243
+ secretSource: 'inline',
244
+ inlineSecret: 'sk-root',
245
+ },
246
+ ],
247
+ },
248
+ }),
249
+ );
250
+
251
+ const env = {
252
+ DATABASE: {
253
+ idFromName: vi.fn().mockReturnValue('do-id'),
254
+ get: vi.fn(),
255
+ },
256
+ EDGEBASE_DEV_SIDECAR_PORT: '8788',
257
+ JWT_ADMIN_SECRET: 'jwt-secret',
258
+ DB_POSTGRES_SHARED_URL: 'postgres://edgebase:test@localhost/shared',
259
+ } as unknown as Env;
260
+
261
+ const app = createApp();
262
+ const response = await app.request(
263
+ '/api/sql',
264
+ {
265
+ method: 'POST',
266
+ headers: {
267
+ 'Content-Type': 'application/json',
268
+ 'X-EdgeBase-Service-Key': 'sk-root',
269
+ },
270
+ body: JSON.stringify({
271
+ namespace: 'shared',
272
+ sql: "SELECT '?' AS literal, COUNT(*) AS total FROM posts WHERE title = ?",
273
+ params: ['owner'],
274
+ }),
275
+ },
276
+ env,
277
+ );
278
+
279
+ expect(response.status).toBe(200);
280
+ await expect(response.json()).resolves.toMatchObject({
281
+ rows: [{ literal: '?', total: 3 }],
282
+ rowCount: 1,
283
+ });
284
+ expect(fetchMock).toHaveBeenCalledTimes(2);
285
+ expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? '{}'))).toEqual({
286
+ namespace: 'shared',
287
+ sql: "SELECT '?' AS literal, COUNT(*) AS total FROM posts WHERE title = $1",
288
+ params: ['owner'],
289
+ });
290
+ expect(
291
+ (env as unknown as { DATABASE: { get: ReturnType<typeof vi.fn> } }).DATABASE.get,
292
+ ).not.toHaveBeenCalled();
293
+ },
294
+ );
295
+
186
296
  it('executeDoSql retries the create handshake before returning rows', async () => {
187
297
  const stub = {
188
- fetch: vi.fn()
298
+ fetch: vi
299
+ .fn()
189
300
  .mockResolvedValueOnce(
190
301
  new Response(JSON.stringify({ needsCreate: true, namespace: 'workspace', id: 'ws-2' }), {
191
302
  status: 201,
@@ -19,6 +19,7 @@ interface DatabaseLiveEvent {
19
19
  docId: string;
20
20
  data: Record<string, unknown> | null;
21
21
  timestamp: string;
22
+ deliveryId?: string;
22
23
  }
23
24
 
24
25
  export type DatabaseLiveFilterCondition = [
@@ -42,6 +43,8 @@ interface WSMeta {
42
43
  }
43
44
 
44
45
  const MAX_FILTER_CONDITIONS = 5;
46
+ const RECENT_DELIVERY_TTL_MS = 60_000;
47
+ const MAX_RECENT_DELIVERIES = 2048;
45
48
  const VALID_FILTER_OPERATORS = new Set<FilterOperator>([
46
49
  '==',
47
50
  '!=',
@@ -138,6 +141,7 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
138
141
  private filterRecoveryNeeded = true;
139
142
  private pendingAuth = new Map<string, ReturnType<typeof setTimeout>>();
140
143
  private metaCache = new Map<WebSocket, WSMeta>();
144
+ private recentDeliveryIds = new Map<string, number>();
141
145
 
142
146
  constructor(ctx: DurableObjectState, env: DOEnv) {
143
147
  super(ctx, env);
@@ -520,6 +524,10 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
520
524
  return Response.json({ error: 'Invalid event body' }, { status: 400 });
521
525
  }
522
526
 
527
+ if (this.isDuplicateDelivery(event.deliveryId)) {
528
+ return Response.json({ ok: true, duplicate: true });
529
+ }
530
+
523
531
  await this.broadcastWithFilters({
524
532
  type: 'db_change',
525
533
  channel: event.channel,
@@ -540,6 +548,7 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
540
548
  table: string;
541
549
  changes: Array<{ type: string; docId: string; data: Record<string, unknown> | null; timestamp: string }>;
542
550
  total: number;
551
+ deliveryId?: string;
543
552
  };
544
553
  try {
545
554
  batch = await request.json() as typeof batch;
@@ -547,6 +556,10 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
547
556
  return Response.json({ error: 'Invalid batch event body' }, { status: 400 });
548
557
  }
549
558
 
559
+ if (this.isDuplicateDelivery(batch.deliveryId)) {
560
+ return Response.json({ ok: true, duplicate: true });
561
+ }
562
+
550
563
  const sockets = this.ctx.getWebSockets();
551
564
  const batchMsg = {
552
565
  type: 'batch_changes' as const,
@@ -646,13 +659,17 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
646
659
  * Sends a custom broadcast_event to all clients subscribed to matching DB channels.
647
660
  */
648
661
  private async handleInternalBroadcast(request: Request): Promise<Response> {
649
- let body: { channel?: string; event?: string; payload?: Record<string, unknown> };
662
+ let body: { channel?: string; event?: string; payload?: Record<string, unknown>; deliveryId?: string };
650
663
  try {
651
664
  body = await request.json() as typeof body;
652
665
  } catch {
653
666
  return Response.json({ error: 'Invalid broadcast body' }, { status: 400 });
654
667
  }
655
668
 
669
+ if (this.isDuplicateDelivery(body.deliveryId)) {
670
+ return Response.json({ ok: true, duplicate: true });
671
+ }
672
+
656
673
  const { channel, event, payload } = body;
657
674
  if (!channel || typeof channel !== 'string') {
658
675
  return Response.json({ error: 'channel is required' }, { status: 400 });
@@ -791,6 +808,34 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
791
808
  this.metaCache.set(ws, meta);
792
809
  }
793
810
 
811
+ private isDuplicateDelivery(deliveryId?: string): boolean {
812
+ if (!deliveryId) return false;
813
+
814
+ const now = Date.now();
815
+ this.pruneRecentDeliveries(now);
816
+ if (this.recentDeliveryIds.has(deliveryId)) {
817
+ return true;
818
+ }
819
+
820
+ this.recentDeliveryIds.set(deliveryId, now);
821
+ while (this.recentDeliveryIds.size > MAX_RECENT_DELIVERIES) {
822
+ const oldest = this.recentDeliveryIds.keys().next().value;
823
+ if (!oldest) break;
824
+ this.recentDeliveryIds.delete(oldest);
825
+ }
826
+
827
+ return false;
828
+ }
829
+
830
+ private pruneRecentDeliveries(now: number): void {
831
+ for (const [deliveryId, seenAt] of this.recentDeliveryIds) {
832
+ if (now - seenAt <= RECENT_DELIVERY_TTL_MS) {
833
+ break;
834
+ }
835
+ this.recentDeliveryIds.delete(deliveryId);
836
+ }
837
+ }
838
+
794
839
  private getTableReadRule(tableName: string):
795
840
  | ((auth: Record<string, unknown> | null, row: Record<string, unknown>) => boolean | Promise<boolean>)
796
841
  | boolean
@@ -48,6 +48,7 @@ export interface RoomDOEnv {
48
48
 
49
49
  export interface RoomWSMeta {
50
50
  authenticated: boolean;
51
+ authStateLost?: boolean;
51
52
  userId?: string;
52
53
  role?: string;
53
54
  auth?: SharedAuthContext;
@@ -70,6 +71,8 @@ const DEFAULT_DELTA_BATCH_MS = 50;
70
71
  const DEFAULT_RATE_LIMIT_ACTIONS = 10;
71
72
  const DEFAULT_RECONNECT_TIMEOUT_MS = 30000;
72
73
  const ROOM_CLIENT_LEAVE_CLOSE_CODE = 4005;
74
+ const ROOM_AUTH_STATE_LOST_CLOSE_CODE = 4006;
75
+ const ROOM_AUTH_STATE_LOST_CLOSE_REASON = 'Room authentication state lost';
73
76
  const EMPTY_ROOM_CLEANUP_DELAY_MS = 100;
74
77
  const DEFAULT_IDLE_TIMEOUT_SEC = 300;
75
78
  const ACTION_TIMEOUT_MS = 5000;
@@ -330,6 +333,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
330
333
  const connectionId = crypto.randomUUID();
331
334
  const meta: RoomWSMeta = {
332
335
  authenticated: false,
336
+ authStateLost: false,
333
337
  connectionId,
334
338
  ip: request.headers.get('CF-Connecting-IP')
335
339
  || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
@@ -407,7 +411,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
407
411
 
408
412
  // Everything else requires authentication
409
413
  if (!meta.authenticated) {
410
- this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
414
+ this.handleUnauthenticatedSocket(ws, meta);
411
415
  return;
412
416
  }
413
417
 
@@ -593,6 +597,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
593
597
  new Request('http://internal/api/room/auth', { headers }),
594
598
  );
595
599
  meta.authenticated = true;
600
+ meta.authStateLost = false;
596
601
  meta.userId = auth.id;
597
602
  meta.role = auth.role;
598
603
  meta.auth = {
@@ -656,7 +661,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
656
661
  msg: Record<string, unknown>,
657
662
  ): Promise<void> {
658
663
  if (!meta.authenticated || !meta.userId) {
659
- this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
664
+ this.handleUnauthenticatedSocket(ws, meta);
660
665
  return;
661
666
  }
662
667
 
@@ -1582,6 +1587,7 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1582
1587
 
1583
1588
  const meta: RoomWSMeta = {
1584
1589
  authenticated: false, // Must re-auth after hibernation
1590
+ authStateLost: true,
1585
1591
  connectionId,
1586
1592
  ip,
1587
1593
  };
@@ -1596,6 +1602,24 @@ export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
1596
1602
  this._metaCache.set(ws, meta);
1597
1603
  }
1598
1604
 
1605
+ protected handleUnauthenticatedSocket(ws: WebSocket, meta: RoomWSMeta): void {
1606
+ if (meta.authStateLost) {
1607
+ this.safeSend(ws, {
1608
+ type: 'error',
1609
+ code: 'AUTH_STATE_LOST',
1610
+ message: 'Room authentication state lost. Reconnect required.',
1611
+ });
1612
+ try {
1613
+ ws.close(ROOM_AUTH_STATE_LOST_CLOSE_CODE, ROOM_AUTH_STATE_LOST_CLOSE_REASON);
1614
+ } catch {
1615
+ // Socket may already be closing.
1616
+ }
1617
+ return;
1618
+ }
1619
+
1620
+ this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
1621
+ }
1622
+
1599
1623
  // ─── Config ───
1600
1624
 
1601
1625
  private parseConfig(env: RoomDOEnv): EdgeBaseConfig {
@@ -2359,7 +2359,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2359
2359
  }
2360
2360
 
2361
2361
  if (!meta.authenticated) {
2362
- this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
2362
+ this.handleUnauthenticatedSocket(ws, meta);
2363
2363
  return null;
2364
2364
  }
2365
2365
 
@@ -4,16 +4,7 @@ import type {
4
4
  AdminInstanceDiscoveryOption,
5
5
  EdgeBaseConfig,
6
6
  } from '@edge-base/shared';
7
- import { executeD1Sql } from './d1-sql.js';
8
- import { executeDoSql } from './do-sql.js';
9
- import { getD1BindingName, shouldRouteToD1 } from './do-router.js';
10
- import {
11
- ensureLocalDevPostgresSchema,
12
- getLocalDevPostgresExecOptions,
13
- getProviderBindingName,
14
- withPostgresConnection,
15
- } from './postgres-executor.js';
16
- import { ensurePgSchema } from './postgres-schema-init.js';
7
+ import { executeProviderAwareSql } from './provider-aware-sql.js';
17
8
  import type { Env } from '../types.js';
18
9
 
19
10
  export interface AdminDbQueryResult {
@@ -47,13 +38,15 @@ interface ResolveAdminInstanceOptions {
47
38
  }
48
39
 
49
40
  function isDynamicDbBlock(
50
- dbBlock: {
51
- instance?: boolean;
52
- access?: {
53
- canCreate?: unknown;
54
- access?: unknown;
55
- };
56
- } | undefined,
41
+ dbBlock:
42
+ | {
43
+ instance?: boolean;
44
+ access?: {
45
+ canCreate?: unknown;
46
+ access?: unknown;
47
+ };
48
+ }
49
+ | undefined,
57
50
  ): boolean {
58
51
  if (!dbBlock) return false;
59
52
  return !!(dbBlock.instance || dbBlock.access?.canCreate || dbBlock.access?.access);
@@ -108,7 +101,9 @@ function uniqueStrings(values: Array<string | undefined>): string[] {
108
101
  return result;
109
102
  }
110
103
 
111
- function normalizeDiscoveryItems(items: AdminInstanceDiscoveryOption[]): AdminInstanceDiscoveryOption[] {
104
+ function normalizeDiscoveryItems(
105
+ items: AdminInstanceDiscoveryOption[],
106
+ ): AdminInstanceDiscoveryOption[] {
112
107
  const seen = new Set<string>();
113
108
  const normalized: AdminInstanceDiscoveryOption[] = [];
114
109
  for (const item of items) {
@@ -132,60 +127,17 @@ export async function executeAdminDbQuery({
132
127
  sql,
133
128
  params = [],
134
129
  }: ExecuteAdminDbQueryOptions): Promise<AdminDbQueryResult> {
135
- const dbBlock = config.databases?.[namespace];
136
- if (!dbBlock) {
137
- throw new Error(`Namespace not found: ${namespace}`);
138
- }
139
-
140
- if (!id && (dbBlock.provider === 'neon' || dbBlock.provider === 'postgres')) {
141
- const bindingName = getProviderBindingName(namespace);
142
- const envRecord = env as unknown as Record<string, unknown>;
143
- const hyperdrive = envRecord[bindingName] as { connectionString?: string } | undefined;
144
- const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
145
- const connectionString = hyperdrive?.connectionString ?? (envRecord[envKey] as string | undefined);
146
- if (!connectionString) {
147
- throw new Error(`PostgreSQL connection '${envKey}' not found.`);
148
- }
149
-
150
- const localDevOptions = getLocalDevPostgresExecOptions(env as unknown as Record<string, unknown>, namespace);
151
- if (localDevOptions) {
152
- await ensureLocalDevPostgresSchema(localDevOptions);
153
- }
154
- return withPostgresConnection(connectionString, async (query) => {
155
- if (!localDevOptions) {
156
- await ensurePgSchema(connectionString, namespace, dbBlock.tables ?? {}, query);
157
- }
158
- return query(sql, params);
159
- }, localDevOptions);
160
- }
161
-
162
- if (!id && shouldRouteToD1(namespace, config)) {
163
- const bindingName = getD1BindingName(namespace);
164
- const d1 = (env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
165
- if (!d1) {
166
- throw new Error(`D1 binding '${bindingName}' not found.`);
167
- }
168
- const result = await executeD1Sql(d1, sql, params);
169
- const rows = result.rows;
170
- return {
171
- columns: rows.length > 0 ? Object.keys(rows[0]) : [],
172
- rows,
173
- rowCount: result.rowCount,
174
- };
175
- }
176
-
177
- const rows = await executeDoSql({
178
- databaseNamespace: env.DATABASE,
130
+ return executeProviderAwareSql(
131
+ {
132
+ env,
133
+ config,
134
+ databaseNamespace: env.DATABASE,
135
+ },
179
136
  namespace,
180
137
  id,
181
- query: sql,
138
+ sql,
182
139
  params,
183
- });
184
- return {
185
- columns: rows.length > 0 ? Object.keys(rows[0]) : [],
186
- rows,
187
- rowCount: rows.length,
188
- };
140
+ );
189
141
  }
190
142
 
191
143
  export async function resolveAdminInstanceOptions({
@@ -245,7 +197,11 @@ export async function resolveAdminInstanceOptions({
245
197
  const sourceLimit = clampLimit(discovery.limit, 12);
246
198
  const effectiveLimit = Math.min(requestedLimit, sourceLimit);
247
199
  const sourceDbBlock = config.databases?.[sourceNamespace];
248
- const usesPostgres = Boolean(sourceDbBlock && !isDynamicDbBlock(sourceDbBlock) && (sourceDbBlock.provider === 'neon' || sourceDbBlock.provider === 'postgres'));
200
+ const usesPostgres = Boolean(
201
+ sourceDbBlock &&
202
+ !isDynamicDbBlock(sourceDbBlock) &&
203
+ (sourceDbBlock.provider === 'neon' || sourceDbBlock.provider === 'postgres'),
204
+ );
249
205
  const idField = discovery.idField ?? 'id';
250
206
  const labelField = discovery.labelField;
251
207
  const descriptionField = discovery.descriptionField;
@@ -254,14 +210,14 @@ export async function resolveAdminInstanceOptions({
254
210
  const aliasId = '__edgebase_id';
255
211
  const aliasLabel = '__edgebase_label';
256
212
  const aliasDescription = '__edgebase_description';
257
- const selectParts = [
258
- `${quoteIdentifier(idField)} AS ${quoteIdentifier(aliasId)}`,
259
- ];
213
+ const selectParts = [`${quoteIdentifier(idField)} AS ${quoteIdentifier(aliasId)}`];
260
214
  if (labelField) {
261
215
  selectParts.push(`${quoteIdentifier(labelField)} AS ${quoteIdentifier(aliasLabel)}`);
262
216
  }
263
217
  if (descriptionField) {
264
- selectParts.push(`${quoteIdentifier(descriptionField)} AS ${quoteIdentifier(aliasDescription)}`);
218
+ selectParts.push(
219
+ `${quoteIdentifier(descriptionField)} AS ${quoteIdentifier(aliasDescription)}`,
220
+ );
265
221
  }
266
222
 
267
223
  const params: unknown[] = [];