@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.
- package/admin-build/_app/immutable/chunks/{DjOEv9M9.js → A_3UuvCe.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dqk2TGNU.js → B-_-hJ9o.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BFs_qStz.js → B5Nwfelm.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B0QyxC2M.js → BxoNtYHK.js} +3 -3
- package/admin-build/_app/immutable/chunks/{BsFiK_FJ.js → CZ0TVkCa.js} +1 -1
- package/admin-build/_app/immutable/chunks/{k0CIJkw4.js → CzSAxmuj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D-x55wdW.js → DCKcAiQH.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CSGrwS7E.js → DCvwWZrm.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BTJcQFEp.js → DRqPU3wD.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CqUxCvs_.js → Dc1-6Po6.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D755Tqat.js → DiyBpamp.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BcIUK2sk.js → Dlty5069.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BY07qVPA.js → DpVAayDG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BCKr7yKd.js → Du5vWVa2.js} +1 -1
- package/admin-build/_app/immutable/chunks/{m9QZTyVV.js → byv2rTy8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DnLqc9L1.js → nZvorU8i.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.BTsq3_xq.js → app.CfrmEXPD.js} +2 -2
- package/admin-build/_app/immutable/entry/start.l1WvHznQ.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.BZ00WDYH.js → 0.Cn2BZ4da.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.RzSJ3yyr.js → 1.Dv4LX_Co.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.D-rsiquF.js → 10.DPVv3kat.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.l7-bgtFD.js → 11.CiCb6Ayu.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.Dkq0H7B5.js → 12.CIPyeekF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.DtK_4oRz.js → 13.Z15Lt36e.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.BKo7-AMx.js → 14.s0l5bAq3.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.CQAj_6lq.js → 15.UwSSNO76.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.XVIG-Ffr.js → 16.qiD8i883.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.g6raZLCM.js → 17.Dy3dcSvu.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.IQz6a3T6.js → 18.DeXyPYsO.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CAAZ8i8h.js → 19.CAbuyS6w.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.BPcX3KPj.js → 20.Bec0T7un.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DuDYelMY.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.Br5AG_5Z.js → 22.CdVprrv2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.KjbrdXoE.js → 23.Y8RzVLoF.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.C3n2-hgw.js → 24.CWhHYFBx.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.SFDSBzHd.js → 25.wCBplOVt.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.D95vui6E.js → 26.Cod_JRFK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.FgLgdjwB.js → 27.BO2HVMu9.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.B9sYYm1F.js → 28.DxG-FBVQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.DyqZ_wbN.js → 29.CjGqWGvE.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.Bzo2yVIO.js → 3.By3_OmdZ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.c1CiNwiS.js → 30.M_H7Htpq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.CXty66Vh.js → 31.DEU18izM.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.BgQaXZ27.js → 4.DeYhKtzJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BuJrHvxH.js → 5.9WLgxhrD.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.CkBBC94k.js → 6.BdT2i_dd.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.D2YBvNFM.js → 7.CHq0s4K6.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.D8qQWo_z.js → 8.DuvRw-XZ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.BLDLX5hV.js → 9.C2Ub82wn.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/package.json +3 -2
- package/src/__tests__/d1-live-broadcast-verification.test.ts +271 -0
- package/src/__tests__/database-live-do.test.ts +50 -0
- package/src/__tests__/database-live-emitter.test.ts +116 -1
- package/src/__tests__/error-format.test.ts +63 -0
- package/src/__tests__/functions-context.test.ts +592 -35
- package/src/__tests__/meta-export-coverage.test.ts +1 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +110 -0
- package/src/__tests__/provider-aware-sql.test.ts +157 -0
- package/src/__tests__/room-auth-state-loss.test.ts +124 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +0 -4
- package/src/__tests__/sql-route.test.ts +187 -76
- package/src/durable-objects/database-live-do.ts +46 -1
- package/src/durable-objects/room-runtime-base.ts +26 -2
- package/src/durable-objects/rooms-do.ts +1 -1
- package/src/lib/admin-db-target.ts +30 -74
- package/src/lib/d1-handler.ts +45 -14
- package/src/lib/database-live-emitter.ts +57 -16
- package/src/lib/functions.ts +332 -454
- package/src/lib/internal-transport.ts +316 -0
- package/src/lib/plugin-migrations.ts +39 -39
- package/src/lib/postgres-handler.ts +39 -11
- package/src/lib/provider-aware-sql.ts +827 -0
- package/src/routes/admin.ts +7 -1
- package/src/routes/auth.ts +11 -12
- package/src/routes/sql.ts +51 -76
- package/src/routes/storage.ts +11 -12
- package/src/types.ts +2 -0
- package/admin-build/_app/immutable/entry/start.zXCirpgY.js +0 -1
- 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(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
setConfig(
|
|
139
|
+
defineConfig({
|
|
140
|
+
databases: {
|
|
141
|
+
shared: {
|
|
142
|
+
tables: {
|
|
143
|
+
posts: { schema: { title: { type: 'string' } } },
|
|
144
|
+
},
|
|
130
145
|
},
|
|
131
146
|
},
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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(
|
|
180
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
218
|
+
selectParts.push(
|
|
219
|
+
`${quoteIdentifier(descriptionField)} AS ${quoteIdentifier(aliasDescription)}`,
|
|
220
|
+
);
|
|
265
221
|
}
|
|
266
222
|
|
|
267
223
|
const params: unknown[] = [];
|