@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
@@ -54,15 +54,12 @@ import {
54
54
  getRegisteredFunctions,
55
55
  buildFunctionContext,
56
56
  } from '../lib/functions.js';
57
- import { parseDbDoName, parseConfig as getGlobalConfig } from '../lib/do-router.js';
57
+ import { parseDbDoName, parseConfig as getGlobalConfig, isDynamicDbBlock } from '../lib/do-router.js';
58
58
  import { parseDuration } from '../lib/jwt.js';
59
- import { createPushProvider } from '../lib/push-provider.js';
60
- import { getDevicesForUser } from '../lib/push-token.js';
61
- import { ensureAuthSchema } from '../lib/auth-d1.js';
62
- import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
63
59
  import { buildDbLiveChannel, DATABASE_LIVE_HUB_DO_NAME } from '../lib/database-live-emitter.js';
64
60
  import { resolveRootServiceKey } from '../lib/service-key.js';
65
61
  import { resolveDbLiveBatchThreshold } from '../lib/database-live-config.js';
62
+ import { buildTableHookRuntimeServices } from '../lib/table-hook-runtime.js';
66
63
  import type { Env } from '../types.js';
67
64
 
68
65
  // ─── Types ───
@@ -104,8 +101,36 @@ export class DatabaseDO extends DurableObject<DOEnv> {
104
101
  if (!this.initialized) {
105
102
  // §36: Newly created DO must be authorized before initialization.
106
103
  // If X-DO-Create-Authorized header is absent, signal Worker to evaluate canCreate.
107
- // Shared/static DOs (doName === 'shared' or system) skip this gate.
108
- const isStaticDO = !this.doName || this.doName === 'shared' || this.doName.startsWith('_');
104
+ // Single-instance DB blocks and internal/system DOs skip this gate.
105
+ const parsedDoName = this.doName ? parseDbDoName(this.doName) : null;
106
+ const dbBlock = parsedDoName ? this.config.databases?.[parsedDoName.namespace] : undefined;
107
+ const dynamicDbBlock = isDynamicDbBlock(dbBlock);
108
+ if (parsedDoName && dbBlock) {
109
+ if (dynamicDbBlock && !parsedDoName.id) {
110
+ return Response.json(
111
+ {
112
+ code: 400,
113
+ message: `instanceId is required for dynamic namespace '${parsedDoName.namespace}'`,
114
+ error: 'INVALID_DB_INSTANCE_ID',
115
+ },
116
+ { status: 400 },
117
+ );
118
+ }
119
+ if (!dynamicDbBlock && parsedDoName.id) {
120
+ return Response.json(
121
+ {
122
+ code: 400,
123
+ message: `instanceId is not allowed for single-instance namespace '${parsedDoName.namespace}'`,
124
+ error: 'INVALID_DB_INSTANCE_ID',
125
+ },
126
+ { status: 400 },
127
+ );
128
+ }
129
+ }
130
+ const isStaticDO =
131
+ !this.doName
132
+ || this.doName.startsWith('_')
133
+ || (!!dbBlock && !dynamicDbBlock && !parsedDoName?.id);
109
134
  if (!isStaticDO && !request.headers.get('X-DO-Create-Authorized')) {
110
135
  // Check if _meta table already exists (i.e., DO was previously initialized)
111
136
  let alreadyExists = false;
@@ -118,9 +143,8 @@ export class DatabaseDO extends DurableObject<DOEnv> {
118
143
 
119
144
  if (!alreadyExists) {
120
145
  // Signal Worker: this DO needs canCreate evaluation before init
121
- const parsed = this.doName ? parseDbDoName(this.doName) : null;
122
146
  return Response.json(
123
- { needsCreate: true, namespace: parsed?.namespace ?? 'shared', id: parsed?.id },
147
+ { needsCreate: true, namespace: parsedDoName?.namespace ?? 'shared', id: parsedDoName?.id },
124
148
  { status: 201 },
125
149
  );
126
150
  }
@@ -173,6 +197,8 @@ export class DatabaseDO extends DurableObject<DOEnv> {
173
197
  * db.get/list/exists use local SQL; databaseLive.broadcast uses emitDbLiveEvent.
174
198
  */
175
199
  private buildHookCtx(_table: string): HookCtx {
200
+ const runtimeServices = buildTableHookRuntimeServices(this.config, this.env as unknown as Env);
201
+
176
202
  return {
177
203
  db: {
178
204
  get: (tbl: string, id: string) => {
@@ -201,42 +227,7 @@ export class DatabaseDO extends DurableObject<DOEnv> {
201
227
  return Promise.resolve(rows.length > 0);
202
228
  },
203
229
  },
204
- databaseLive: {
205
- broadcast: (channel: string, event: string, data: unknown) => {
206
- return this.sendBroadcastToDatabaseLiveDO(
207
- channel,
208
- { channel, event, payload: data ?? {} },
209
- );
210
- },
211
- },
212
- push: {
213
- // Push from hooks — direct FCM via push-provider + KV device tokens
214
- send: async (userId: string, payload: { title?: string; body: string }) => {
215
- // Fire-and-forget — hooks are non-critical side effects
216
- try {
217
- if (!this.env.KV) return;
218
- const provider = createPushProvider(this.config.push, this.env as unknown as Env);
219
- if (!provider) return;
220
- let tokenStore: KVNamespace | { kv: KVNamespace; authDb?: AuthDb | null } = this.env.KV;
221
- try {
222
- const authDb = resolveAuthDb(this.env as unknown as Record<string, unknown>);
223
- await ensureAuthSchema(authDb);
224
- tokenStore = { kv: this.env.KV, authDb };
225
- } catch {
226
- tokenStore = this.env.KV;
227
- }
228
- const devices = await getDevicesForUser(tokenStore, userId);
229
- if (devices.length === 0) return;
230
- await Promise.allSettled(
231
- devices.map((device) =>
232
- provider.send({ token: device.token, platform: device.platform, payload }),
233
- ),
234
- );
235
- } catch {
236
- /* best-effort */
237
- }
238
- },
239
- },
230
+ ...runtimeServices,
240
231
  waitUntil: (p: Promise<unknown>) => this.ctx.waitUntil(p),
241
232
  };
242
233
  }
@@ -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
 
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ import { createAdminAssetRequest } from './lib/admin-assets.js';
35
35
  import { resolveAdminFaviconTarget, resolveAdminRedirectTarget } from './lib/admin-routing.js';
36
36
  import { zodDefaultHook } from './lib/schemas.js';
37
37
  import { executePluginMigrations } from './lib/plugin-migrations.js';
38
+ import { shouldRunPluginMigrationsForRequestPath } from './lib/plugin-migration-routing.js';
38
39
  import { getFunctionsByTrigger, buildFunctionContext, getWorkerUrl } from './lib/functions.js';
39
40
  import { parseCron, matchesCron } from './lib/cron.js';
40
41
  import { parseDuration } from './lib/jwt.js';
@@ -104,15 +105,12 @@ app.use('*', corsMiddleware);
104
105
  // middleware below rejects any request with this header unless it comes via internal
105
106
  // stub.fetch (which is allowed by the x-internal whitelist). No stripping needed.
106
107
 
107
- // Plugin migration middleware — lazy, runs once per cold-start
108
+ // Plugin migration middleware — lazily reconciles plugin control state before
109
+ // routes that can execute plugin code or touch plugin-managed tables.
108
110
  app.use('*', async (c, next) => {
109
111
  const config = parseConfig(c.env);
110
112
  const requestPath = new URL(c.req.url).pathname;
111
- const shouldSkipPluginMigrations =
112
- requestPath.startsWith('/admin/api/backup/') ||
113
- requestPath.startsWith('/admin/api/data/backup/') ||
114
- requestPath.startsWith('/internal/backup/');
115
- if (!shouldSkipPluginMigrations && config?.plugins?.length) {
113
+ if (config?.plugins?.length && shouldRunPluginMigrationsForRequestPath(requestPath)) {
116
114
  await executePluginMigrations(config.plugins, c.env, config, getWorkerUrl(c.req.url, c.env));
117
115
  }
118
116
  return next();
@@ -306,6 +304,14 @@ export default {
306
304
  */
307
305
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
308
306
  const config = parseConfig(env);
307
+ if (config.plugins?.length) {
308
+ await executePluginMigrations(
309
+ config.plugins,
310
+ env,
311
+ config,
312
+ getWorkerUrl('http://internal/scheduled', env),
313
+ );
314
+ }
309
315
  const scheduleFns = getFunctionsByTrigger('schedule');
310
316
 
311
317
  const now = new Date(event.scheduledTime);
@@ -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[] = [];