@edge-base/server 0.1.1
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/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup/Restore Admin API routes *
|
|
3
|
+
* Service Key–protected endpoints for portable backup/recovery.
|
|
4
|
+
* All routes require X-Service-Key header with valid SERVICE_KEY.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* POST /admin/api/backup/list-dos — enumerate all DO instances from config
|
|
8
|
+
* POST /admin/api/backup/dump-do — dump a specific DO's data
|
|
9
|
+
* POST /admin/api/backup/restore-do — restore a specific DO's data
|
|
10
|
+
* POST /admin/api/backup/dump-d1 — dump auth database tables
|
|
11
|
+
* POST /admin/api/backup/restore-d1 — restore auth database tables
|
|
12
|
+
* POST /admin/api/backup/dump-control-d1 — dump internal control-plane tables
|
|
13
|
+
* POST /admin/api/backup/restore-control-d1 — restore internal control-plane tables
|
|
14
|
+
* POST /admin/api/backup/dump-data — dump data namespace tables (D1 or PostgreSQL)
|
|
15
|
+
* POST /admin/api/backup/restore-data — restore data namespace tables (D1 or PostgreSQL)
|
|
16
|
+
* POST /admin/api/backup/dump-storage — list/download R2 objects
|
|
17
|
+
* POST /admin/api/backup/restore-storage — wipe/upload R2 objects
|
|
18
|
+
* GET /admin/api/backup/export/:name — export table data as JSON
|
|
19
|
+
*/
|
|
20
|
+
import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
|
|
21
|
+
import type { Env } from '../types.js';
|
|
22
|
+
import { EdgeBaseError } from '@edge-base/shared';
|
|
23
|
+
import { validateKey, buildConstraintCtx, resolveServiceKeyCandidate } from '../lib/service-key.js';
|
|
24
|
+
import {
|
|
25
|
+
ensureAuthSchema,
|
|
26
|
+
upsertUserPublic,
|
|
27
|
+
type UserPublicData,
|
|
28
|
+
} from '../lib/auth-d1.js';
|
|
29
|
+
import {
|
|
30
|
+
parseConfig,
|
|
31
|
+
callDO,
|
|
32
|
+
callDOByHexId,
|
|
33
|
+
getDbDoName,
|
|
34
|
+
shouldRouteToD1,
|
|
35
|
+
getD1BindingName,
|
|
36
|
+
} from '../lib/do-router.js';
|
|
37
|
+
import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
|
|
38
|
+
import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
|
|
39
|
+
import { ensureControlSchema, resolveControlDb, type ControlDb } from '../lib/control-db.js';
|
|
40
|
+
import { ensureD1Schema } from '../lib/d1-schema-init.js';
|
|
41
|
+
import { dumpNamespaceTables } from '../lib/namespace-dump.js';
|
|
42
|
+
import { ensurePgSchema } from '../lib/postgres-schema-init.js';
|
|
43
|
+
import { executePostgresQuery } from '../lib/postgres-executor.js';
|
|
44
|
+
import {
|
|
45
|
+
ensureLocalDevPostgresSchema,
|
|
46
|
+
getLocalDevPostgresExecOptions,
|
|
47
|
+
getProviderBindingName,
|
|
48
|
+
withPostgresConnection,
|
|
49
|
+
} from '../lib/postgres-executor.js';
|
|
50
|
+
|
|
51
|
+
/** Resolve AuthDb from Hono context. Defaults to D1 (AUTH_DB binding). */
|
|
52
|
+
function getAuthDb(c: { env: unknown }): AuthDb {
|
|
53
|
+
return resolveAuthDb(c.env as Record<string, unknown>);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getControlDb(c: { env: unknown }): ControlDb {
|
|
57
|
+
return resolveControlDb(c.env as Record<string, unknown>);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const backupRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
|
|
61
|
+
|
|
62
|
+
// ─── Service Key Auth Middleware ───
|
|
63
|
+
backupRoute.use('*', async (c, next) => {
|
|
64
|
+
const config = parseConfig(c.env);
|
|
65
|
+
const provided = resolveServiceKeyCandidate(c.req);
|
|
66
|
+
const { result } = validateKey(
|
|
67
|
+
provided,
|
|
68
|
+
'backup:*:*:exec',
|
|
69
|
+
config,
|
|
70
|
+
c.env,
|
|
71
|
+
undefined,
|
|
72
|
+
buildConstraintCtx(c.env, c.req),
|
|
73
|
+
);
|
|
74
|
+
if (result === 'missing') {
|
|
75
|
+
throw new EdgeBaseError(403, 'Service Key required for backup operations.');
|
|
76
|
+
}
|
|
77
|
+
if (result === 'invalid') {
|
|
78
|
+
throw new EdgeBaseError(401, 'Invalid or missing Service Key.');
|
|
79
|
+
}
|
|
80
|
+
await next();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Error handler
|
|
84
|
+
backupRoute.onError((err, c) => {
|
|
85
|
+
if (err instanceof EdgeBaseError) {
|
|
86
|
+
return c.json(err.toJSON(), err.code as 400);
|
|
87
|
+
}
|
|
88
|
+
console.error('Backup API error:', err);
|
|
89
|
+
return c.json({ code: 500, message: 'Internal server error.' }, 500);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── DO Name Helpers ───
|
|
93
|
+
|
|
94
|
+
interface DOInfo {
|
|
95
|
+
doName: string;
|
|
96
|
+
type: 'database' | 'auth';
|
|
97
|
+
namespace: 'DATABASE' | 'AUTH';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface PluginCleanupResult {
|
|
101
|
+
tables: string[];
|
|
102
|
+
metaKeys: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isSqliteFtsCompanionTable(name: string): boolean {
|
|
106
|
+
return /_fts(?:_.+)?$/.test(name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function quoteSqliteIdent(name: string): string {
|
|
110
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function quotePgIdent(name: string): string {
|
|
114
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildPluginSqliteCleanupPlan(
|
|
118
|
+
tableNames: string[],
|
|
119
|
+
prefix: string,
|
|
120
|
+
): PluginCleanupResult & { dropTables: string[] } {
|
|
121
|
+
const basePrefix = `${prefix}/`;
|
|
122
|
+
const tables = tableNames
|
|
123
|
+
.filter(
|
|
124
|
+
(name) => name !== '_meta' && name.startsWith(basePrefix) && !isSqliteFtsCompanionTable(name),
|
|
125
|
+
)
|
|
126
|
+
.sort();
|
|
127
|
+
|
|
128
|
+
const dropTables = tables.flatMap((table) => [`${table}_fts`, table]);
|
|
129
|
+
const metaKeys = tables.flatMap((table) => [`schemaHash:${table}`, `migration_version:${table}`]);
|
|
130
|
+
|
|
131
|
+
return { tables, dropTables, metaKeys };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildPluginPgCleanupPlan(tableNames: string[], prefix: string): PluginCleanupResult {
|
|
135
|
+
const basePrefix = `${prefix}/`;
|
|
136
|
+
const tables = tableNames
|
|
137
|
+
.filter((name) => name !== '_meta' && name.startsWith(basePrefix))
|
|
138
|
+
.sort();
|
|
139
|
+
const metaKeys = tables.flatMap((table) => [`schemaHash:${table}`, `migration_version:${table}`]);
|
|
140
|
+
return { tables, metaKeys };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function clearPluginVersionMeta(db: ControlDb, prefix: string): Promise<void> {
|
|
144
|
+
await ensureControlSchema(db);
|
|
145
|
+
await db.run('DELETE FROM _meta WHERE key = ?', [`plugin_version:${prefix}`]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cleanupPluginTablesInD1(
|
|
149
|
+
db: D1Database,
|
|
150
|
+
prefix: string,
|
|
151
|
+
): Promise<PluginCleanupResult> {
|
|
152
|
+
const rows = await db
|
|
153
|
+
.prepare(
|
|
154
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' ORDER BY name`,
|
|
155
|
+
)
|
|
156
|
+
.all();
|
|
157
|
+
const tableNames = (rows.results ?? []).map((row) =>
|
|
158
|
+
String((row as Record<string, unknown>).name),
|
|
159
|
+
);
|
|
160
|
+
const plan = buildPluginSqliteCleanupPlan(tableNames, prefix);
|
|
161
|
+
|
|
162
|
+
for (const tableName of plan.dropTables) {
|
|
163
|
+
await db.prepare(`DROP TABLE IF EXISTS ${quoteSqliteIdent(tableName)}`).run();
|
|
164
|
+
}
|
|
165
|
+
for (const metaKey of plan.metaKeys) {
|
|
166
|
+
await db.prepare(`DELETE FROM "_meta" WHERE "key" = ?`).bind(metaKey).run();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { tables: plan.tables, metaKeys: plan.metaKeys };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function cleanupPluginTablesInPostgres(
|
|
173
|
+
connectionString: string,
|
|
174
|
+
prefix: string,
|
|
175
|
+
): Promise<PluginCleanupResult> {
|
|
176
|
+
const result = await executePostgresQuery(
|
|
177
|
+
connectionString,
|
|
178
|
+
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`,
|
|
179
|
+
[],
|
|
180
|
+
);
|
|
181
|
+
const tableNames = result.rows.map((row) => String((row as Record<string, unknown>).table_name));
|
|
182
|
+
const plan = buildPluginPgCleanupPlan(tableNames, prefix);
|
|
183
|
+
|
|
184
|
+
for (const tableName of plan.tables) {
|
|
185
|
+
await executePostgresQuery(
|
|
186
|
+
connectionString,
|
|
187
|
+
`DROP TRIGGER IF EXISTS ${quotePgIdent(`${tableName}_fts_update`)} ON ${quotePgIdent(tableName)}`,
|
|
188
|
+
[],
|
|
189
|
+
);
|
|
190
|
+
await executePostgresQuery(
|
|
191
|
+
connectionString,
|
|
192
|
+
`DROP TABLE IF EXISTS ${quotePgIdent(tableName)} CASCADE`,
|
|
193
|
+
[],
|
|
194
|
+
);
|
|
195
|
+
await executePostgresQuery(
|
|
196
|
+
connectionString,
|
|
197
|
+
`DROP FUNCTION IF EXISTS ${quotePgIdent(`${tableName}_fts_trigger`)}() CASCADE`,
|
|
198
|
+
[],
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
for (const metaKey of plan.metaKeys) {
|
|
202
|
+
await executePostgresQuery(connectionString, `DELETE FROM "_meta" WHERE "key" = $1`, [metaKey]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return plan;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function callDatabaseDoSql(
|
|
209
|
+
env: Env,
|
|
210
|
+
doName: string,
|
|
211
|
+
query: string,
|
|
212
|
+
params: unknown[] = [],
|
|
213
|
+
): Promise<Record<string, unknown>[]> {
|
|
214
|
+
const resp = await callDO(env.DATABASE, doName, '/internal/sql', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'X-DO-Name': doName },
|
|
217
|
+
body: { query, params },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const data = (await resp.json().catch(() => ({}))) as {
|
|
221
|
+
rows?: Record<string, unknown>[];
|
|
222
|
+
message?: string;
|
|
223
|
+
};
|
|
224
|
+
if (!resp.ok) {
|
|
225
|
+
throw new EdgeBaseError(resp.status as 500, data.message || 'DO SQL execution failed.');
|
|
226
|
+
}
|
|
227
|
+
return data.rows ?? [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function cleanupPluginTablesInDO(
|
|
231
|
+
env: Env,
|
|
232
|
+
doName: string,
|
|
233
|
+
prefix: string,
|
|
234
|
+
): Promise<PluginCleanupResult> {
|
|
235
|
+
const rows = await callDatabaseDoSql(
|
|
236
|
+
env,
|
|
237
|
+
doName,
|
|
238
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' ORDER BY name`,
|
|
239
|
+
);
|
|
240
|
+
const tableNames = rows.map((row) => String(row.name));
|
|
241
|
+
const plan = buildPluginSqliteCleanupPlan(tableNames, prefix);
|
|
242
|
+
|
|
243
|
+
for (const tableName of plan.dropTables) {
|
|
244
|
+
await callDatabaseDoSql(env, doName, `DROP TABLE IF EXISTS ${quoteSqliteIdent(tableName)}`);
|
|
245
|
+
}
|
|
246
|
+
for (const metaKey of plan.metaKeys) {
|
|
247
|
+
await callDatabaseDoSql(env, doName, `DELETE FROM "_meta" WHERE "key" = ?`, [metaKey]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { tables: plan.tables, metaKeys: plan.metaKeys };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Enumerate all DO instance names from config (§1/§2).
|
|
255
|
+
* Covers: namespace DOs from databases block (as 'namespace' name),
|
|
256
|
+
* auth:shard-{0..15}
|
|
257
|
+
* Isolation is at the namespace DO level — individual tenant DOs are
|
|
258
|
+
* dynamically created (namespace:id) and cannot be statically enumerated.
|
|
259
|
+
* Use doName = namespace for static DBs, or discover via app-level listing.
|
|
260
|
+
*/
|
|
261
|
+
async function enumerateDOs(env: Env): Promise<DOInfo[]> {
|
|
262
|
+
const dos: DOInfo[] = [];
|
|
263
|
+
const seenNames = new Set<string>();
|
|
264
|
+
|
|
265
|
+
// 1. Parse config for database-namespace DOs (§1/§2)
|
|
266
|
+
const config = parseConfig(env);
|
|
267
|
+
|
|
268
|
+
if (env.DATABASE) {
|
|
269
|
+
// Static shared DB
|
|
270
|
+
const sharedDoName = getDbDoName('shared');
|
|
271
|
+
if (!seenNames.has(sharedDoName)) {
|
|
272
|
+
dos.push({ doName: sharedDoName, type: 'database', namespace: 'DATABASE' });
|
|
273
|
+
seenNames.add(sharedDoName);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// databases block namespaces (static DOs only — dynamic namespace:id DOs need app-level discovery)
|
|
277
|
+
for (const ns of Object.keys(config.databases ?? {})) {
|
|
278
|
+
const doName = getDbDoName(ns);
|
|
279
|
+
if (!seenNames.has(doName)) {
|
|
280
|
+
dos.push({ doName, type: 'database', namespace: 'DATABASE' });
|
|
281
|
+
seenNames.add(doName);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return dos;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Routes ───
|
|
290
|
+
|
|
291
|
+
// POST /admin/api/backup/list-dos — enumerate all DO instances
|
|
292
|
+
const listDOs = createRoute({
|
|
293
|
+
operationId: 'backupListDOs',
|
|
294
|
+
method: 'post',
|
|
295
|
+
path: '/list-dos',
|
|
296
|
+
tags: ['admin'],
|
|
297
|
+
summary: 'List all DO instances',
|
|
298
|
+
request: {
|
|
299
|
+
body: {
|
|
300
|
+
content: {
|
|
301
|
+
'application/json': {
|
|
302
|
+
schema: z
|
|
303
|
+
.object({
|
|
304
|
+
hexIds: z.array(z.string()).optional(),
|
|
305
|
+
})
|
|
306
|
+
.passthrough(),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
required: false,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
responses: {
|
|
313
|
+
200: {
|
|
314
|
+
description: 'DO list',
|
|
315
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
316
|
+
},
|
|
317
|
+
400: {
|
|
318
|
+
description: 'Bad request',
|
|
319
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
backupRoute.openapi(listDOs, async (c) => {
|
|
325
|
+
const body = await c.req.json<{ hexIds?: string[] }>().catch(() => ({}) as { hexIds?: string[] });
|
|
326
|
+
|
|
327
|
+
if (body.hexIds && body.hexIds.length > 0) {
|
|
328
|
+
// Edge mode: resolve hex IDs to DO names
|
|
329
|
+
const dos: DOInfo[] = [];
|
|
330
|
+
|
|
331
|
+
for (const hexId of body.hexIds) {
|
|
332
|
+
// Database DO: call dump to get _meta doName
|
|
333
|
+
if (c.env.DATABASE) {
|
|
334
|
+
try {
|
|
335
|
+
const resp = await callDOByHexId(c.env.DATABASE, hexId, '/internal/backup/dump');
|
|
336
|
+
if (resp.ok) {
|
|
337
|
+
const data = (await resp.json()) as { doName?: string };
|
|
338
|
+
if (data.doName) {
|
|
339
|
+
dos.push({ doName: data.doName, type: 'database', namespace: 'DATABASE' });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
/* not a Database DO */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Try as Auth DO (non-shard, e.g. future expansion)
|
|
349
|
+
if (c.env.AUTH) {
|
|
350
|
+
try {
|
|
351
|
+
const resp = await callDOByHexId(c.env.AUTH, hexId, '/internal/backup/dump');
|
|
352
|
+
if (resp.ok) {
|
|
353
|
+
const data = (await resp.json()) as { doName?: string };
|
|
354
|
+
if (data.doName) {
|
|
355
|
+
dos.push({ doName: data.doName, type: 'auth', namespace: 'AUTH' });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
/* not an Auth DO */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return c.json({ dos, total: dos.length });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Config-scan mode: enumerate via config + membership
|
|
369
|
+
const dos = await enumerateDOs(c.env);
|
|
370
|
+
return c.json({
|
|
371
|
+
dos,
|
|
372
|
+
total: dos.length,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// GET /admin/api/backup/config — return parsed config snapshot
|
|
377
|
+
const getConfig = createRoute({
|
|
378
|
+
operationId: 'backupGetConfig',
|
|
379
|
+
method: 'get',
|
|
380
|
+
path: '/config',
|
|
381
|
+
tags: ['admin'],
|
|
382
|
+
summary: 'Return parsed config snapshot',
|
|
383
|
+
responses: {
|
|
384
|
+
200: {
|
|
385
|
+
description: 'Config snapshot',
|
|
386
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
backupRoute.openapi(getConfig, (c) => {
|
|
392
|
+
const config = parseConfig(c.env);
|
|
393
|
+
return c.json(config);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// POST /admin/api/backup/cleanup-plugin — remove plugin-prefixed tables from a namespace/DO
|
|
397
|
+
const cleanupPlugin = createRoute({
|
|
398
|
+
operationId: 'backupCleanupPlugin',
|
|
399
|
+
method: 'post',
|
|
400
|
+
path: '/cleanup-plugin',
|
|
401
|
+
tags: ['admin'],
|
|
402
|
+
summary: 'Remove plugin-prefixed tables and migration metadata',
|
|
403
|
+
request: {
|
|
404
|
+
body: {
|
|
405
|
+
content: {
|
|
406
|
+
'application/json': {
|
|
407
|
+
schema: z
|
|
408
|
+
.object({
|
|
409
|
+
prefix: z.string(),
|
|
410
|
+
namespace: z.string().optional(),
|
|
411
|
+
id: z.string().optional(),
|
|
412
|
+
doName: z.string().optional(),
|
|
413
|
+
})
|
|
414
|
+
.passthrough(),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
required: true,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
responses: {
|
|
421
|
+
200: {
|
|
422
|
+
description: 'Cleanup result',
|
|
423
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
424
|
+
},
|
|
425
|
+
400: {
|
|
426
|
+
description: 'Bad request',
|
|
427
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
backupRoute.openapi(cleanupPlugin, async (c) => {
|
|
433
|
+
const body = await c.req.json<{
|
|
434
|
+
prefix: string;
|
|
435
|
+
namespace?: string;
|
|
436
|
+
id?: string;
|
|
437
|
+
doName?: string;
|
|
438
|
+
}>();
|
|
439
|
+
|
|
440
|
+
const prefix = body.prefix.trim().replace(/\/+$/, '');
|
|
441
|
+
if (!prefix) throw new EdgeBaseError(400, 'prefix is required.');
|
|
442
|
+
|
|
443
|
+
let removed: PluginCleanupResult = { tables: [], metaKeys: [] };
|
|
444
|
+
|
|
445
|
+
if (body.doName) {
|
|
446
|
+
removed = await cleanupPluginTablesInDO(c.env, body.doName, prefix);
|
|
447
|
+
} else if (body.namespace) {
|
|
448
|
+
const config = parseConfig(c.env);
|
|
449
|
+
const dbBlock = config.databases?.[body.namespace];
|
|
450
|
+
const envRecord = c.env as unknown as Record<string, unknown>;
|
|
451
|
+
const d1BindingName = getD1BindingName(body.namespace);
|
|
452
|
+
const d1 = envRecord[d1BindingName] as D1Database | undefined;
|
|
453
|
+
const pgBindingName = getProviderBindingName(body.namespace);
|
|
454
|
+
const hyperdrive = envRecord[pgBindingName] as { connectionString?: string } | undefined;
|
|
455
|
+
const envKey = dbBlock?.connectionString ?? `${pgBindingName}_URL`;
|
|
456
|
+
const directUrl = envRecord[envKey] as string | undefined;
|
|
457
|
+
const connectionString = hyperdrive?.connectionString ?? directUrl;
|
|
458
|
+
const wantsPostgres = dbBlock?.provider === 'neon' || dbBlock?.provider === 'postgres';
|
|
459
|
+
const wantsD1 = !!dbBlock && !body.id && shouldRouteToD1(body.namespace, config);
|
|
460
|
+
|
|
461
|
+
if (wantsPostgres || (!dbBlock && connectionString)) {
|
|
462
|
+
if (!connectionString) {
|
|
463
|
+
throw new EdgeBaseError(500, `PostgreSQL binding '${pgBindingName}' not found.`);
|
|
464
|
+
}
|
|
465
|
+
removed = await cleanupPluginTablesInPostgres(connectionString, prefix);
|
|
466
|
+
} else if (wantsD1 || (!dbBlock && !body.id && d1)) {
|
|
467
|
+
if (!d1) {
|
|
468
|
+
throw new EdgeBaseError(500, `D1 binding '${d1BindingName}' not found.`);
|
|
469
|
+
}
|
|
470
|
+
removed = await cleanupPluginTablesInD1(d1, prefix);
|
|
471
|
+
} else {
|
|
472
|
+
removed = await cleanupPluginTablesInDO(c.env, getDbDoName(body.namespace, body.id), prefix);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await clearPluginVersionMeta(getControlDb(c), prefix);
|
|
477
|
+
const metaKeys = [...removed.metaKeys, `plugin_version:${prefix}`];
|
|
478
|
+
|
|
479
|
+
return c.json({
|
|
480
|
+
ok: true,
|
|
481
|
+
prefix,
|
|
482
|
+
target: body.doName
|
|
483
|
+
? { doName: body.doName }
|
|
484
|
+
: body.namespace
|
|
485
|
+
? { namespace: body.namespace, ...(body.id ? { id: body.id } : {}) }
|
|
486
|
+
: null,
|
|
487
|
+
removed: {
|
|
488
|
+
tables: removed.tables,
|
|
489
|
+
metaKeys,
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// POST /admin/api/backup/wipe-do — wipe a specific DO's data (orphan cleanup)
|
|
495
|
+
const wipeDO = createRoute({
|
|
496
|
+
operationId: 'backupWipeDO',
|
|
497
|
+
method: 'post',
|
|
498
|
+
path: '/wipe-do',
|
|
499
|
+
tags: ['admin'],
|
|
500
|
+
summary: "Wipe a specific DO's data",
|
|
501
|
+
request: {
|
|
502
|
+
body: {
|
|
503
|
+
content: {
|
|
504
|
+
'application/json': {
|
|
505
|
+
schema: z
|
|
506
|
+
.object({
|
|
507
|
+
doName: z.string(),
|
|
508
|
+
type: z.enum(['database', 'auth']),
|
|
509
|
+
})
|
|
510
|
+
.passthrough(),
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
required: true,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
responses: {
|
|
517
|
+
200: {
|
|
518
|
+
description: 'Wipe result',
|
|
519
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
520
|
+
},
|
|
521
|
+
400: {
|
|
522
|
+
description: 'Bad request',
|
|
523
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
backupRoute.openapi(wipeDO, async (c) => {
|
|
529
|
+
const { doName, type } = await c.req.json<{ doName: string; type: 'database' | 'auth' }>();
|
|
530
|
+
if (!doName) throw new EdgeBaseError(400, 'doName is required.');
|
|
531
|
+
|
|
532
|
+
const namespace = type === 'auth' ? c.env.AUTH : c.env.DATABASE;
|
|
533
|
+
let lastError: string | null = null;
|
|
534
|
+
|
|
535
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
536
|
+
const resp = await callDO(namespace, doName, '/internal/drop-all', {
|
|
537
|
+
method: 'POST',
|
|
538
|
+
headers: { 'X-DO-Name': doName },
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (resp.ok) {
|
|
542
|
+
return c.json({ ok: true, doName });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
lastError = await resp.text();
|
|
546
|
+
const lockError = lastError.includes('SQLITE_LOCKED') || lastError.includes('table is locked');
|
|
547
|
+
if (!lockError || attempt === 10) {
|
|
548
|
+
throw new EdgeBaseError(resp.status as 500, `DO wipe failed: ${lastError}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 200));
|
|
552
|
+
}
|
|
553
|
+
throw new EdgeBaseError(500, `DO wipe failed: ${lastError ?? 'unknown error'}`);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// POST /admin/api/backup/dump-do — dump a specific DO
|
|
557
|
+
const dumpDO = createRoute({
|
|
558
|
+
operationId: 'backupDumpDO',
|
|
559
|
+
method: 'post',
|
|
560
|
+
path: '/dump-do',
|
|
561
|
+
tags: ['admin'],
|
|
562
|
+
summary: "Dump a specific DO's data",
|
|
563
|
+
request: {
|
|
564
|
+
body: {
|
|
565
|
+
content: {
|
|
566
|
+
'application/json': {
|
|
567
|
+
schema: z
|
|
568
|
+
.object({
|
|
569
|
+
doName: z.string(),
|
|
570
|
+
type: z.enum(['database', 'auth']),
|
|
571
|
+
})
|
|
572
|
+
.passthrough(),
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
required: true,
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
responses: {
|
|
579
|
+
200: {
|
|
580
|
+
description: 'DO dump data',
|
|
581
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
582
|
+
},
|
|
583
|
+
400: {
|
|
584
|
+
description: 'Bad request',
|
|
585
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
backupRoute.openapi(dumpDO, async (c) => {
|
|
591
|
+
const { doName, type } = await c.req.json<{ doName: string; type: 'database' | 'auth' }>();
|
|
592
|
+
if (!doName) throw new EdgeBaseError(400, 'doName is required.');
|
|
593
|
+
|
|
594
|
+
const namespace = type === 'auth' ? c.env.AUTH : c.env.DATABASE;
|
|
595
|
+
const resp = await callDO(namespace, doName, '/internal/backup/dump', {
|
|
596
|
+
headers: { 'X-DO-Name': doName },
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
if (!resp.ok) {
|
|
600
|
+
const error = await resp.text();
|
|
601
|
+
throw new EdgeBaseError(resp.status as 500, `DO dump failed: ${error}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const data = await resp.json();
|
|
605
|
+
return c.json(data);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// POST /admin/api/backup/restore-do — restore a specific DO
|
|
609
|
+
const restoreDO = createRoute({
|
|
610
|
+
operationId: 'backupRestoreDO',
|
|
611
|
+
method: 'post',
|
|
612
|
+
path: '/restore-do',
|
|
613
|
+
tags: ['admin'],
|
|
614
|
+
summary: "Restore a specific DO's data",
|
|
615
|
+
request: {
|
|
616
|
+
body: {
|
|
617
|
+
content: {
|
|
618
|
+
'application/json': {
|
|
619
|
+
schema: z
|
|
620
|
+
.object({
|
|
621
|
+
doName: z.string(),
|
|
622
|
+
type: z.enum(['database', 'auth']),
|
|
623
|
+
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
|
|
624
|
+
})
|
|
625
|
+
.passthrough(),
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
required: true,
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
responses: {
|
|
632
|
+
200: {
|
|
633
|
+
description: 'Restore result',
|
|
634
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
635
|
+
},
|
|
636
|
+
400: {
|
|
637
|
+
description: 'Bad request',
|
|
638
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
backupRoute.openapi(restoreDO, async (c) => {
|
|
644
|
+
const body = await c.req.json<{
|
|
645
|
+
doName: string;
|
|
646
|
+
type: 'database' | 'auth';
|
|
647
|
+
tables: Record<string, Array<Record<string, unknown>>>;
|
|
648
|
+
}>();
|
|
649
|
+
|
|
650
|
+
if (!body.doName) throw new EdgeBaseError(400, 'doName is required.');
|
|
651
|
+
if (!body.tables) throw new EdgeBaseError(400, 'tables data is required.');
|
|
652
|
+
|
|
653
|
+
const namespace = body.type === 'auth' ? c.env.AUTH : c.env.DATABASE;
|
|
654
|
+
const resp = await callDO(namespace, body.doName, '/internal/backup/restore', {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
body: { tables: body.tables },
|
|
657
|
+
headers: { 'X-DO-Name': body.doName },
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
if (!resp.ok) {
|
|
661
|
+
const error = await resp.text();
|
|
662
|
+
throw new EdgeBaseError(resp.status as 500, `DO restore failed: ${error}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const data = await resp.json();
|
|
666
|
+
return c.json(data);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// POST /admin/api/backup/dump-d1 — dump auth database tables
|
|
670
|
+
const dumpD1 = createRoute({
|
|
671
|
+
operationId: 'backupDumpD1',
|
|
672
|
+
method: 'post',
|
|
673
|
+
path: '/dump-d1',
|
|
674
|
+
tags: ['admin'],
|
|
675
|
+
summary: 'Dump auth database tables',
|
|
676
|
+
responses: {
|
|
677
|
+
200: {
|
|
678
|
+
description: 'D1 dump data',
|
|
679
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
backupRoute.openapi(dumpD1, async (c) => {
|
|
685
|
+
const authDb = getAuthDb(c);
|
|
686
|
+
await ensureAuthSchema(authDb);
|
|
687
|
+
|
|
688
|
+
const D1_TABLES = [
|
|
689
|
+
'_email_index',
|
|
690
|
+
'_oauth_index',
|
|
691
|
+
'_anon_index',
|
|
692
|
+
'_phone_index',
|
|
693
|
+
'_passkey_index',
|
|
694
|
+
'_admins',
|
|
695
|
+
'_admin_sessions',
|
|
696
|
+
'_users_public',
|
|
697
|
+
'_meta',
|
|
698
|
+
// Auth tables (Phase 3: Auth DO → D1)
|
|
699
|
+
'_users',
|
|
700
|
+
'_sessions',
|
|
701
|
+
'_oauth_accounts',
|
|
702
|
+
'_email_tokens',
|
|
703
|
+
'_mfa_factors',
|
|
704
|
+
'_mfa_recovery_codes',
|
|
705
|
+
'_webauthn_credentials',
|
|
706
|
+
];
|
|
707
|
+
const tables: Record<string, unknown[]> = {};
|
|
708
|
+
|
|
709
|
+
for (const tableName of D1_TABLES) {
|
|
710
|
+
try {
|
|
711
|
+
tables[tableName] = await authDb.query(`SELECT * FROM "${tableName}"`);
|
|
712
|
+
} catch {
|
|
713
|
+
tables[tableName] = [];
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return c.json({
|
|
718
|
+
type: 'd1',
|
|
719
|
+
tables,
|
|
720
|
+
timestamp: new Date().toISOString(),
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// POST /admin/api/backup/restore-d1 — restore auth database tables
|
|
725
|
+
const restoreD1 = createRoute({
|
|
726
|
+
operationId: 'backupRestoreD1',
|
|
727
|
+
method: 'post',
|
|
728
|
+
path: '/restore-d1',
|
|
729
|
+
tags: ['admin'],
|
|
730
|
+
summary: 'Restore auth database tables',
|
|
731
|
+
request: {
|
|
732
|
+
body: {
|
|
733
|
+
content: {
|
|
734
|
+
'application/json': {
|
|
735
|
+
schema: z
|
|
736
|
+
.object({
|
|
737
|
+
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
|
|
738
|
+
})
|
|
739
|
+
.passthrough(),
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
required: true,
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
responses: {
|
|
746
|
+
200: {
|
|
747
|
+
description: 'Restore result',
|
|
748
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
749
|
+
},
|
|
750
|
+
400: {
|
|
751
|
+
description: 'Bad request',
|
|
752
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
backupRoute.openapi(restoreD1, async (c) => {
|
|
758
|
+
const body = await c.req.json<{
|
|
759
|
+
tables: Record<string, Array<Record<string, unknown>>>;
|
|
760
|
+
skipWipe?: boolean;
|
|
761
|
+
}>();
|
|
762
|
+
|
|
763
|
+
if (!body.tables) throw new EdgeBaseError(400, 'tables data is required.');
|
|
764
|
+
|
|
765
|
+
const authDb = getAuthDb(c);
|
|
766
|
+
await ensureAuthSchema(authDb);
|
|
767
|
+
|
|
768
|
+
// 1. Wipe existing data (child tables first for FK safety) — skip if skipWipe is true
|
|
769
|
+
if (!body.skipWipe) {
|
|
770
|
+
const WIPE_ORDER = [
|
|
771
|
+
'_admin_sessions',
|
|
772
|
+
'_webauthn_credentials',
|
|
773
|
+
'_mfa_recovery_codes',
|
|
774
|
+
'_mfa_factors',
|
|
775
|
+
'_email_tokens',
|
|
776
|
+
'_oauth_accounts',
|
|
777
|
+
'_sessions',
|
|
778
|
+
'_anon_index',
|
|
779
|
+
'_oauth_index',
|
|
780
|
+
'_email_index',
|
|
781
|
+
'_phone_index',
|
|
782
|
+
'_passkey_index',
|
|
783
|
+
'_users_public',
|
|
784
|
+
'_meta',
|
|
785
|
+
'_admins',
|
|
786
|
+
'_users',
|
|
787
|
+
];
|
|
788
|
+
const wipeStatements: { sql: string; params?: unknown[] }[] = [];
|
|
789
|
+
|
|
790
|
+
for (const tableName of WIPE_ORDER) {
|
|
791
|
+
wipeStatements.push({ sql: `DELETE FROM "${tableName}"` });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
await authDb.batch(wipeStatements);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// 2. Insert backup data (parent tables first for FK constraints)
|
|
798
|
+
const INSERT_ORDER = [
|
|
799
|
+
'_users',
|
|
800
|
+
'_admins',
|
|
801
|
+
'_meta',
|
|
802
|
+
'_users_public',
|
|
803
|
+
'_email_index',
|
|
804
|
+
'_oauth_index',
|
|
805
|
+
'_anon_index',
|
|
806
|
+
'_phone_index',
|
|
807
|
+
'_passkey_index',
|
|
808
|
+
'_sessions',
|
|
809
|
+
'_oauth_accounts',
|
|
810
|
+
'_email_tokens',
|
|
811
|
+
'_mfa_factors',
|
|
812
|
+
'_mfa_recovery_codes',
|
|
813
|
+
'_webauthn_credentials',
|
|
814
|
+
'_admin_sessions',
|
|
815
|
+
];
|
|
816
|
+
for (const tableName of INSERT_ORDER) {
|
|
817
|
+
const rows = body.tables[tableName];
|
|
818
|
+
if (!rows || rows.length === 0) continue;
|
|
819
|
+
|
|
820
|
+
// Build batch statements for efficient inserts
|
|
821
|
+
const insertStmts: { sql: string; params?: unknown[] }[] = [];
|
|
822
|
+
for (const row of rows) {
|
|
823
|
+
const columns = Object.keys(row);
|
|
824
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
825
|
+
const escId = (n: string) => `"${n.replace(/"/g, '""')}"`;
|
|
826
|
+
const colStr = columns.map((col) => escId(col)).join(', ');
|
|
827
|
+
const values = columns.map((col) => row[col]);
|
|
828
|
+
insertStmts.push({
|
|
829
|
+
sql: `INSERT OR REPLACE INTO ${escId(tableName)} (${colStr}) VALUES (${placeholders})`,
|
|
830
|
+
params: values,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Batch limit is 100 — chunk if needed
|
|
835
|
+
const BATCH_SIZE = 100;
|
|
836
|
+
for (let i = 0; i < insertStmts.length; i += BATCH_SIZE) {
|
|
837
|
+
const chunk = insertStmts.slice(i, i + BATCH_SIZE);
|
|
838
|
+
await authDb.batch(chunk);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return c.json({ ok: true, restored: Object.keys(body.tables).length });
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const dumpControlD1 = createRoute({
|
|
846
|
+
operationId: 'backupDumpControlD1',
|
|
847
|
+
method: 'post',
|
|
848
|
+
path: '/dump-control-d1',
|
|
849
|
+
tags: ['admin'],
|
|
850
|
+
summary: 'Dump control-plane D1 tables',
|
|
851
|
+
responses: {
|
|
852
|
+
200: {
|
|
853
|
+
description: 'Control-plane D1 dump data',
|
|
854
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
backupRoute.openapi(dumpControlD1, async (c) => {
|
|
860
|
+
const controlDb = getControlDb(c);
|
|
861
|
+
await ensureControlSchema(controlDb);
|
|
862
|
+
|
|
863
|
+
const tables: Record<string, unknown[]> = {};
|
|
864
|
+
try {
|
|
865
|
+
tables._meta = await controlDb.query('SELECT * FROM "_meta"');
|
|
866
|
+
} catch {
|
|
867
|
+
tables._meta = [];
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return c.json({
|
|
871
|
+
type: 'd1',
|
|
872
|
+
tables,
|
|
873
|
+
timestamp: new Date().toISOString(),
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const restoreControlD1 = createRoute({
|
|
878
|
+
operationId: 'backupRestoreControlD1',
|
|
879
|
+
method: 'post',
|
|
880
|
+
path: '/restore-control-d1',
|
|
881
|
+
tags: ['admin'],
|
|
882
|
+
summary: 'Restore control-plane D1 tables',
|
|
883
|
+
request: {
|
|
884
|
+
body: {
|
|
885
|
+
content: {
|
|
886
|
+
'application/json': {
|
|
887
|
+
schema: z
|
|
888
|
+
.object({
|
|
889
|
+
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
|
|
890
|
+
})
|
|
891
|
+
.passthrough(),
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
required: true,
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
responses: {
|
|
898
|
+
200: {
|
|
899
|
+
description: 'Restore result',
|
|
900
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
901
|
+
},
|
|
902
|
+
400: {
|
|
903
|
+
description: 'Bad request',
|
|
904
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
backupRoute.openapi(restoreControlD1, async (c) => {
|
|
910
|
+
const body = await c.req.json<{
|
|
911
|
+
tables: Record<string, Array<Record<string, unknown>>>;
|
|
912
|
+
skipWipe?: boolean;
|
|
913
|
+
}>();
|
|
914
|
+
|
|
915
|
+
if (!body.tables) throw new EdgeBaseError(400, 'tables data is required.');
|
|
916
|
+
|
|
917
|
+
const controlDb = getControlDb(c);
|
|
918
|
+
await ensureControlSchema(controlDb);
|
|
919
|
+
|
|
920
|
+
if (!body.skipWipe) {
|
|
921
|
+
await controlDb.batch([{ sql: 'DELETE FROM "_meta"' }]);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const metaRows = body.tables._meta ?? [];
|
|
925
|
+
if (metaRows.length > 0) {
|
|
926
|
+
const insertStmts = metaRows.map((row) => {
|
|
927
|
+
const columns = Object.keys(row);
|
|
928
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
929
|
+
const escId = (name: string) => `"${name.replace(/"/g, '""')}"`;
|
|
930
|
+
const colStr = columns.map((col) => escId(col)).join(', ');
|
|
931
|
+
return {
|
|
932
|
+
sql: `INSERT OR REPLACE INTO "_meta" (${colStr}) VALUES (${placeholders})`,
|
|
933
|
+
params: columns.map((col) => row[col]),
|
|
934
|
+
};
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const batchSize = 100;
|
|
938
|
+
for (let i = 0; i < insertStmts.length; i += batchSize) {
|
|
939
|
+
await controlDb.batch(insertStmts.slice(i, i + batchSize));
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return c.json({ ok: true, restored: metaRows.length > 0 ? 1 : 0 });
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// ─── Data Namespace Dump/Restore — Phase 5 ───
|
|
947
|
+
|
|
948
|
+
// POST /admin/api/backup/dump-data — dump data namespace tables (D1 or PostgreSQL)
|
|
949
|
+
const dumpData = createRoute({
|
|
950
|
+
operationId: 'backupDumpData',
|
|
951
|
+
method: 'post',
|
|
952
|
+
path: '/dump-data',
|
|
953
|
+
tags: ['admin'],
|
|
954
|
+
summary: 'Dump all tables from a data namespace',
|
|
955
|
+
request: {
|
|
956
|
+
body: {
|
|
957
|
+
content: {
|
|
958
|
+
'application/json': {
|
|
959
|
+
schema: z
|
|
960
|
+
.object({
|
|
961
|
+
namespace: z.string(),
|
|
962
|
+
})
|
|
963
|
+
.passthrough(),
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
required: true,
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
responses: {
|
|
970
|
+
200: {
|
|
971
|
+
description: 'Data namespace dump',
|
|
972
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
973
|
+
},
|
|
974
|
+
400: {
|
|
975
|
+
description: 'Bad request',
|
|
976
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
977
|
+
},
|
|
978
|
+
404: {
|
|
979
|
+
description: 'Namespace not found',
|
|
980
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
backupRoute.openapi(dumpData, async (c) => {
|
|
986
|
+
const { namespace } = await c.req.json<{ namespace: string }>();
|
|
987
|
+
if (!namespace) throw new EdgeBaseError(400, 'namespace is required.');
|
|
988
|
+
|
|
989
|
+
const config = parseConfig(c.env);
|
|
990
|
+
const dbBlock = config.databases?.[namespace];
|
|
991
|
+
if (!dbBlock) throw new EdgeBaseError(404, `Namespace '${namespace}' not found in config.`);
|
|
992
|
+
|
|
993
|
+
const tableNames = Object.keys(dbBlock.tables ?? {});
|
|
994
|
+
const tables = await dumpNamespaceTables(c.env, config, namespace, {
|
|
995
|
+
includeMeta: true,
|
|
996
|
+
tableNames,
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
return c.json({
|
|
1000
|
+
type: 'data',
|
|
1001
|
+
namespace,
|
|
1002
|
+
tables,
|
|
1003
|
+
tableOrder: tableNames,
|
|
1004
|
+
timestamp: new Date().toISOString(),
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// POST /admin/api/backup/restore-data — restore data namespace tables (D1 or PostgreSQL)
|
|
1009
|
+
const restoreData = createRoute({
|
|
1010
|
+
operationId: 'backupRestoreData',
|
|
1011
|
+
method: 'post',
|
|
1012
|
+
path: '/restore-data',
|
|
1013
|
+
tags: ['admin'],
|
|
1014
|
+
summary: 'Restore all tables into a data namespace',
|
|
1015
|
+
request: {
|
|
1016
|
+
body: {
|
|
1017
|
+
content: {
|
|
1018
|
+
'application/json': {
|
|
1019
|
+
schema: z
|
|
1020
|
+
.object({
|
|
1021
|
+
namespace: z.string(),
|
|
1022
|
+
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
|
|
1023
|
+
})
|
|
1024
|
+
.passthrough(),
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
required: true,
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
responses: {
|
|
1031
|
+
200: {
|
|
1032
|
+
description: 'Restore result',
|
|
1033
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
1034
|
+
},
|
|
1035
|
+
400: {
|
|
1036
|
+
description: 'Bad request',
|
|
1037
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1038
|
+
},
|
|
1039
|
+
404: {
|
|
1040
|
+
description: 'Namespace not found',
|
|
1041
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
backupRoute.openapi(restoreData, async (c) => {
|
|
1047
|
+
const body = await c.req.json<{
|
|
1048
|
+
namespace: string;
|
|
1049
|
+
tables: Record<string, Array<Record<string, unknown>>>;
|
|
1050
|
+
skipWipe?: boolean;
|
|
1051
|
+
}>();
|
|
1052
|
+
|
|
1053
|
+
if (!body.namespace) throw new EdgeBaseError(400, 'namespace is required.');
|
|
1054
|
+
if (!body.tables) throw new EdgeBaseError(400, 'tables data is required.');
|
|
1055
|
+
|
|
1056
|
+
const config = parseConfig(c.env);
|
|
1057
|
+
const dbBlock = config.databases?.[body.namespace];
|
|
1058
|
+
if (!dbBlock) throw new EdgeBaseError(404, `Namespace '${body.namespace}' not found in config.`);
|
|
1059
|
+
|
|
1060
|
+
const userTableNames = Object.keys(dbBlock.tables ?? {});
|
|
1061
|
+
const provider = dbBlock.provider;
|
|
1062
|
+
const BATCH_SIZE = 100;
|
|
1063
|
+
|
|
1064
|
+
if (provider === 'neon' || provider === 'postgres') {
|
|
1065
|
+
// PostgreSQL path
|
|
1066
|
+
const bindingName = getProviderBindingName(body.namespace);
|
|
1067
|
+
const envRecord = c.env as unknown as Record<string, unknown>;
|
|
1068
|
+
const hyperdrive = envRecord[bindingName] as { connectionString: string } | undefined;
|
|
1069
|
+
const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
|
|
1070
|
+
const connStr =
|
|
1071
|
+
hyperdrive?.connectionString ?? (envRecord[envKey] as string | undefined);
|
|
1072
|
+
if (!connStr)
|
|
1073
|
+
throw new EdgeBaseError(500, `PostgreSQL connection not available for '${body.namespace}'.`);
|
|
1074
|
+
|
|
1075
|
+
const localDevOptions = getLocalDevPostgresExecOptions(c.env as unknown as Record<string, unknown>, body.namespace);
|
|
1076
|
+
if (localDevOptions) {
|
|
1077
|
+
await ensureLocalDevPostgresSchema(localDevOptions);
|
|
1078
|
+
}
|
|
1079
|
+
await withPostgresConnection(connStr, async (query) => {
|
|
1080
|
+
if (!localDevOptions) {
|
|
1081
|
+
await ensurePgSchema(connStr, body.namespace, dbBlock.tables ?? {}, query);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (!body.skipWipe) {
|
|
1085
|
+
for (const tableName of [...userTableNames, '_meta']) {
|
|
1086
|
+
try {
|
|
1087
|
+
await query(`DELETE FROM "${tableName}"`, []);
|
|
1088
|
+
} catch {
|
|
1089
|
+
/* table may not exist */
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
for (const tableName of [...userTableNames, '_meta']) {
|
|
1095
|
+
const rows = body.tables[tableName];
|
|
1096
|
+
if (!rows || rows.length === 0) continue;
|
|
1097
|
+
|
|
1098
|
+
const escId = (n: string) => `"${n.replace(/"/g, '""')}"`;
|
|
1099
|
+
for (const row of rows) {
|
|
1100
|
+
const columns = Object.keys(row);
|
|
1101
|
+
const colStr = columns.map((col) => escId(col)).join(', ');
|
|
1102
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
|
1103
|
+
const values = columns.map((col) => row[col]);
|
|
1104
|
+
await query(
|
|
1105
|
+
`INSERT INTO ${escId(tableName)} (${colStr}) VALUES (${placeholders})`,
|
|
1106
|
+
values,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
for (const tableName of userTableNames) {
|
|
1112
|
+
try {
|
|
1113
|
+
await query(
|
|
1114
|
+
`SELECT setval(pg_get_serial_sequence('"${tableName}"', 'id'), COALESCE((SELECT MAX(CAST(id AS BIGINT)) FROM "${tableName}"), 0) + 1, false)`,
|
|
1115
|
+
[],
|
|
1116
|
+
);
|
|
1117
|
+
} catch {
|
|
1118
|
+
/* no sequence or non-numeric id — skip */
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}, localDevOptions);
|
|
1122
|
+
} else {
|
|
1123
|
+
// D1 path
|
|
1124
|
+
const bindingName = getD1BindingName(body.namespace);
|
|
1125
|
+
const db = (c.env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
|
|
1126
|
+
if (!db)
|
|
1127
|
+
throw new EdgeBaseError(
|
|
1128
|
+
500,
|
|
1129
|
+
`D1 binding '${bindingName}' not available for '${body.namespace}'.`,
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
// 1. Ensure schema
|
|
1133
|
+
await ensureD1Schema(db, body.namespace, dbBlock.tables ?? {});
|
|
1134
|
+
|
|
1135
|
+
// 2. Wipe existing data — skip if skipWipe is true
|
|
1136
|
+
if (!body.skipWipe) {
|
|
1137
|
+
const wipeStmts = [...userTableNames, '_meta'].map((t) => db.prepare(`DELETE FROM "${t}"`));
|
|
1138
|
+
if (wipeStmts.length > 0) {
|
|
1139
|
+
await db.batch(wipeStmts);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// 3. Insert data in batches
|
|
1144
|
+
for (const tableName of [...userTableNames, '_meta']) {
|
|
1145
|
+
const rows = body.tables[tableName];
|
|
1146
|
+
if (!rows || rows.length === 0) continue;
|
|
1147
|
+
|
|
1148
|
+
const escId = (n: string) => `"${n.replace(/"/g, '""')}"`;
|
|
1149
|
+
const insertStmts: D1PreparedStatement[] = [];
|
|
1150
|
+
for (const row of rows) {
|
|
1151
|
+
const columns = Object.keys(row);
|
|
1152
|
+
const colStr = columns.map((col) => escId(col)).join(', ');
|
|
1153
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
1154
|
+
const values = columns.map((col) => row[col]);
|
|
1155
|
+
insertStmts.push(
|
|
1156
|
+
db
|
|
1157
|
+
.prepare(
|
|
1158
|
+
`INSERT OR REPLACE INTO ${escId(tableName)} (${colStr}) VALUES (${placeholders})`,
|
|
1159
|
+
)
|
|
1160
|
+
.bind(...values),
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Batch limit is 100
|
|
1165
|
+
for (let i = 0; i < insertStmts.length; i += BATCH_SIZE) {
|
|
1166
|
+
const chunk = insertStmts.slice(i, i + BATCH_SIZE);
|
|
1167
|
+
await db.batch(chunk);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return c.json({
|
|
1173
|
+
ok: true,
|
|
1174
|
+
namespace: body.namespace,
|
|
1175
|
+
restored: Object.keys(body.tables).length,
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
// ─── R2 Storage Backup/Restore — Phase 3 ───
|
|
1180
|
+
|
|
1181
|
+
// POST /admin/api/backup/dump-storage — dump R2 storage
|
|
1182
|
+
// ?action=list → list all R2 objects with cursor pagination
|
|
1183
|
+
// ?action=get&key=... → download a specific file as binary stream
|
|
1184
|
+
const dumpStorage = createRoute({
|
|
1185
|
+
operationId: 'backupDumpStorage',
|
|
1186
|
+
method: 'post',
|
|
1187
|
+
path: '/dump-storage',
|
|
1188
|
+
tags: ['admin'],
|
|
1189
|
+
summary: 'Dump R2 storage (list or download)',
|
|
1190
|
+
request: {
|
|
1191
|
+
query: z.object({
|
|
1192
|
+
action: z.enum(['list', 'get']).optional(),
|
|
1193
|
+
key: z.string().optional(),
|
|
1194
|
+
}),
|
|
1195
|
+
},
|
|
1196
|
+
responses: {
|
|
1197
|
+
200: {
|
|
1198
|
+
description: 'Storage dump data',
|
|
1199
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
1200
|
+
},
|
|
1201
|
+
400: {
|
|
1202
|
+
description: 'Bad request',
|
|
1203
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
backupRoute.openapi(dumpStorage, async (c) => {
|
|
1209
|
+
const action = c.req.query('action');
|
|
1210
|
+
|
|
1211
|
+
if (action === 'list') {
|
|
1212
|
+
// Paginate through all R2 objects
|
|
1213
|
+
const objects: Array<{ key: string; size: number; etag: string; contentType: string }> = [];
|
|
1214
|
+
let cursor: string | undefined;
|
|
1215
|
+
|
|
1216
|
+
do {
|
|
1217
|
+
const listed = await c.env.STORAGE.list({
|
|
1218
|
+
cursor,
|
|
1219
|
+
limit: 1000,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
for (const obj of listed.objects) {
|
|
1223
|
+
objects.push({
|
|
1224
|
+
key: obj.key,
|
|
1225
|
+
size: obj.size,
|
|
1226
|
+
etag: obj.etag,
|
|
1227
|
+
contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
1232
|
+
} while (cursor);
|
|
1233
|
+
|
|
1234
|
+
return c.json({
|
|
1235
|
+
objects,
|
|
1236
|
+
total: objects.length,
|
|
1237
|
+
timestamp: new Date().toISOString(),
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (action === 'get') {
|
|
1242
|
+
const key = c.req.query('key');
|
|
1243
|
+
if (!key) throw new EdgeBaseError(400, 'key query parameter is required.');
|
|
1244
|
+
|
|
1245
|
+
const object = await c.env.STORAGE.get(key);
|
|
1246
|
+
if (!object) {
|
|
1247
|
+
throw new EdgeBaseError(404, `Object not found: ${key}`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return new Response(object.body, {
|
|
1251
|
+
headers: {
|
|
1252
|
+
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
|
|
1253
|
+
'Content-Length': String(object.size),
|
|
1254
|
+
'X-R2-Key': key,
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
throw new EdgeBaseError(400, 'action query parameter must be "list" or "get".');
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
// POST /admin/api/backup/restore-storage — restore R2 storage
|
|
1263
|
+
// ?action=wipe → delete all R2 objects
|
|
1264
|
+
// ?action=put&key=... → upload a single file (body = binary data)
|
|
1265
|
+
const restoreStorage = createRoute({
|
|
1266
|
+
operationId: 'backupRestoreStorage',
|
|
1267
|
+
method: 'post',
|
|
1268
|
+
path: '/restore-storage',
|
|
1269
|
+
tags: ['admin'],
|
|
1270
|
+
summary: 'Restore R2 storage (wipe or upload)',
|
|
1271
|
+
request: {
|
|
1272
|
+
query: z.object({
|
|
1273
|
+
action: z.enum(['wipe', 'put']).optional(),
|
|
1274
|
+
key: z.string().optional(),
|
|
1275
|
+
}),
|
|
1276
|
+
},
|
|
1277
|
+
responses: {
|
|
1278
|
+
200: {
|
|
1279
|
+
description: 'Restore result',
|
|
1280
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
1281
|
+
},
|
|
1282
|
+
400: {
|
|
1283
|
+
description: 'Bad request',
|
|
1284
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
backupRoute.openapi(restoreStorage, async (c) => {
|
|
1290
|
+
const action = c.req.query('action');
|
|
1291
|
+
|
|
1292
|
+
if (action === 'wipe') {
|
|
1293
|
+
// Delete all objects in R2 bucket
|
|
1294
|
+
let deleted = 0;
|
|
1295
|
+
let cursor: string | undefined;
|
|
1296
|
+
|
|
1297
|
+
do {
|
|
1298
|
+
const listed = await c.env.STORAGE.list({
|
|
1299
|
+
cursor,
|
|
1300
|
+
limit: 1000,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
if (listed.objects.length > 0) {
|
|
1304
|
+
const keys = listed.objects.map((obj) => obj.key);
|
|
1305
|
+
await c.env.STORAGE.delete(keys);
|
|
1306
|
+
deleted += keys.length;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
1310
|
+
} while (cursor);
|
|
1311
|
+
|
|
1312
|
+
return c.json({ ok: true, deleted });
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (action === 'put') {
|
|
1316
|
+
const key = c.req.query('key');
|
|
1317
|
+
if (!key) throw new EdgeBaseError(400, 'key query parameter is required.');
|
|
1318
|
+
|
|
1319
|
+
const body = await c.req.arrayBuffer();
|
|
1320
|
+
const contentType = c.req.header('Content-Type') || 'application/octet-stream';
|
|
1321
|
+
|
|
1322
|
+
await c.env.STORAGE.put(key, body, {
|
|
1323
|
+
httpMetadata: { contentType },
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
return c.json({ ok: true, key, size: body.byteLength });
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
throw new EdgeBaseError(400, 'action query parameter must be "wipe" or "put".');
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// ─── _users_public Resync — Step 8 ───
|
|
1333
|
+
|
|
1334
|
+
// POST /admin/api/backup/resync-users-public
|
|
1335
|
+
// Resync _users_public from _users table in AUTH_DB D1.
|
|
1336
|
+
// Phase 3: Auth data now lives in D1 — reads _users directly instead of shard iteration.
|
|
1337
|
+
const resyncUsersPublic = createRoute({
|
|
1338
|
+
operationId: 'backupResyncUsersPublic',
|
|
1339
|
+
method: 'post',
|
|
1340
|
+
path: '/resync-users-public',
|
|
1341
|
+
tags: ['admin'],
|
|
1342
|
+
summary: 'Resync _users_public from _users in AUTH_DB D1',
|
|
1343
|
+
responses: {
|
|
1344
|
+
200: {
|
|
1345
|
+
description: 'Resync result',
|
|
1346
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
backupRoute.openapi(resyncUsersPublic, async (c) => {
|
|
1352
|
+
const authDb = getAuthDb(c);
|
|
1353
|
+
await ensureAuthSchema(authDb);
|
|
1354
|
+
|
|
1355
|
+
// Auth data is now in AUTH_DB D1 — read _users directly instead of shard iteration
|
|
1356
|
+
let totalSynced = 0;
|
|
1357
|
+
|
|
1358
|
+
try {
|
|
1359
|
+
const users = await authDb.query(
|
|
1360
|
+
'SELECT id, email, displayName, avatarUrl, role, isAnonymous, createdAt, updatedAt FROM _users',
|
|
1361
|
+
);
|
|
1362
|
+
|
|
1363
|
+
// Batch upsert to _users_public
|
|
1364
|
+
for (const user of users) {
|
|
1365
|
+
try {
|
|
1366
|
+
const now = new Date().toISOString();
|
|
1367
|
+
await upsertUserPublic(
|
|
1368
|
+
authDb,
|
|
1369
|
+
user.id as string,
|
|
1370
|
+
{
|
|
1371
|
+
email: (user.email as string) ?? null,
|
|
1372
|
+
displayName: (user.displayName as string) ?? null,
|
|
1373
|
+
avatarUrl: (user.avatarUrl as string) ?? null,
|
|
1374
|
+
role: (user.role as string) ?? 'user',
|
|
1375
|
+
isAnonymous: user.isAnonymous ? 1 : 0,
|
|
1376
|
+
createdAt: (user.createdAt as string) ?? now,
|
|
1377
|
+
updatedAt: now,
|
|
1378
|
+
} as UserPublicData,
|
|
1379
|
+
);
|
|
1380
|
+
totalSynced++;
|
|
1381
|
+
} catch (err) {
|
|
1382
|
+
console.error(`Failed to sync user ${user.id}:`, err);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return c.json({
|
|
1387
|
+
ok: true,
|
|
1388
|
+
totalSynced,
|
|
1389
|
+
source: 'd1',
|
|
1390
|
+
});
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
console.error('Failed to resync _users_public:', err);
|
|
1393
|
+
throw new EdgeBaseError(500, `Resync failed: ${(err as Error).message}`);
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// ─── Table Export — ───
|
|
1398
|
+
|
|
1399
|
+
// GET /admin/api/backup/export/:name?format=json
|
|
1400
|
+
// Exports a single table's data as JSON array.
|
|
1401
|
+
// Reuses dump-do infrastructure; filters to the requested table.
|
|
1402
|
+
// For user-namespaced DB blocks (#133 §1), each user has a dedicated DO — export merges all.
|
|
1403
|
+
const exportTable = createRoute({
|
|
1404
|
+
operationId: 'backupExportTable',
|
|
1405
|
+
method: 'get',
|
|
1406
|
+
path: '/export/{name}',
|
|
1407
|
+
tags: ['admin'],
|
|
1408
|
+
summary: 'Export a single table as JSON',
|
|
1409
|
+
request: {
|
|
1410
|
+
params: z.object({ name: z.string() }),
|
|
1411
|
+
query: z.object({
|
|
1412
|
+
format: z.string().optional(),
|
|
1413
|
+
}),
|
|
1414
|
+
},
|
|
1415
|
+
responses: {
|
|
1416
|
+
200: {
|
|
1417
|
+
description: 'Exported table data',
|
|
1418
|
+
content: { 'application/json': { schema: jsonResponseSchema } },
|
|
1419
|
+
},
|
|
1420
|
+
400: {
|
|
1421
|
+
description: 'Bad request',
|
|
1422
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1423
|
+
},
|
|
1424
|
+
404: {
|
|
1425
|
+
description: 'Table not found',
|
|
1426
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1427
|
+
},
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
backupRoute.openapi(exportTable, async (c) => {
|
|
1432
|
+
const name = c.req.param('name')!;
|
|
1433
|
+
const format = c.req.query('format') || 'json';
|
|
1434
|
+
|
|
1435
|
+
if (format !== 'json') {
|
|
1436
|
+
throw new EdgeBaseError(400, `Unsupported export format: ${format}. Only "json" is supported.`);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Validate table exists in databases config (§1)
|
|
1440
|
+
const config = parseConfig(c.env);
|
|
1441
|
+
let tableNamespace = 'shared';
|
|
1442
|
+
let found = false;
|
|
1443
|
+
for (const [ns, dbBlock] of Object.entries(config.databases ?? {})) {
|
|
1444
|
+
if (dbBlock.tables?.[name]) {
|
|
1445
|
+
tableNamespace = ns;
|
|
1446
|
+
found = true;
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (!found) {
|
|
1451
|
+
throw new EdgeBaseError(404, `Table not found: ${name}`);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const responseHeaders: Record<string, string> = {
|
|
1455
|
+
'Content-Type': 'application/json',
|
|
1456
|
+
'Content-Disposition': `attachment; filename="${name}-export.json"`,
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
const tables = await dumpNamespaceTables(c.env, config, tableNamespace, {
|
|
1460
|
+
includeMeta: false,
|
|
1461
|
+
tableNames: [name],
|
|
1462
|
+
});
|
|
1463
|
+
const records = tables[name] || [];
|
|
1464
|
+
|
|
1465
|
+
return new Response(JSON.stringify(records, null, 2), { headers: responseHeaders });
|
|
1466
|
+
});
|