@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,1425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1 request handler for single-instance (non-multi-tenant) databases.
|
|
3
|
+
*
|
|
4
|
+
* Runs in Worker context (not DO) — handles:
|
|
5
|
+
* - D1 binding resolution (dynamic per namespace)
|
|
6
|
+
* - Lazy schema initialization (via d1-schema-init)
|
|
7
|
+
* - CRUD operations (via query-engine SQLite dialect)
|
|
8
|
+
* - Rules evaluation (Worker level)
|
|
9
|
+
* - Hooks execution (Worker level)
|
|
10
|
+
*
|
|
11
|
+
* Database Live: After successful writes, emits events to DatabaseLiveDO
|
|
12
|
+
* via fire-and-forget stub.fetch() — same pattern as database-do.ts.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors postgres-handler.ts structurally but uses D1 API + SQLite dialect.
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from 'hono';
|
|
17
|
+
import type { HonoEnv } from './hono.js';
|
|
18
|
+
import type { Env } from '../types.js';
|
|
19
|
+
import type {
|
|
20
|
+
AuthContext,
|
|
21
|
+
TableConfig,
|
|
22
|
+
TableRules,
|
|
23
|
+
HookCtx,
|
|
24
|
+
SchemaField,
|
|
25
|
+
DbBlock,
|
|
26
|
+
} from '@edge-base/shared';
|
|
27
|
+
import { EdgeBaseError, getTableAccess, getTableHooks } from '@edge-base/shared';
|
|
28
|
+
import { parseConfig, getD1BindingName } from './do-router.js';
|
|
29
|
+
import { ensureD1Schema } from './d1-schema-init.js';
|
|
30
|
+
import {
|
|
31
|
+
buildListQuery, buildCountQuery, buildGetQuery, buildSearchQuery, buildSubstringSearchQuery,
|
|
32
|
+
parseQueryParams,
|
|
33
|
+
type FilterTuple,
|
|
34
|
+
} from './query-engine.js';
|
|
35
|
+
import { summarizeValidationErrors, validateInsert, validateUpdate } from './validation.js';
|
|
36
|
+
import { buildEffectiveSchema } from './schema.js';
|
|
37
|
+
import { generateId } from './uuid.js';
|
|
38
|
+
import { parseUpdateBody } from './op-parser.js';
|
|
39
|
+
import { emitDbLiveEvent, emitDbLiveBatchEvent, sendToDatabaseLiveDO } from './database-live-emitter.js';
|
|
40
|
+
import { isTrustedInternalContext } from './internal-request.js';
|
|
41
|
+
import { executeDbTriggers } from './functions.js';
|
|
42
|
+
import { forbiddenError, hookRejectedError, normalizeDatabaseError } from './errors.js';
|
|
43
|
+
|
|
44
|
+
// ─── Types ───
|
|
45
|
+
|
|
46
|
+
interface D1ResolvedDb {
|
|
47
|
+
db: D1Database;
|
|
48
|
+
dbBlock: DbBlock;
|
|
49
|
+
namespace: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Main Handler ───
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle a request to a D1-backed database table.
|
|
56
|
+
* Called from tables.ts when shouldRouteToD1() returns true.
|
|
57
|
+
*/
|
|
58
|
+
export async function handleD1Request(
|
|
59
|
+
c: Context<HonoEnv>,
|
|
60
|
+
namespace: string,
|
|
61
|
+
tableName: string,
|
|
62
|
+
doPath: string,
|
|
63
|
+
): Promise<Response> {
|
|
64
|
+
// 1. Resolve D1 binding
|
|
65
|
+
const resolved = resolveD1Binding(c.env, namespace);
|
|
66
|
+
|
|
67
|
+
// 2. Validate table exists in config
|
|
68
|
+
const tableConfig = resolved.dbBlock.tables?.[tableName];
|
|
69
|
+
if (!tableConfig) {
|
|
70
|
+
return c.json({ code: 404, message: `Table '${tableName}' not found in database '${namespace}'.` }, 404);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 3. Lazy schema init
|
|
74
|
+
await ensureD1Schema(
|
|
75
|
+
resolved.db,
|
|
76
|
+
namespace,
|
|
77
|
+
resolved.dbBlock.tables ?? {},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// 4. Check if this is a service key request
|
|
81
|
+
const isServiceKey = checkServiceKey(c);
|
|
82
|
+
|
|
83
|
+
// 5. Get auth context
|
|
84
|
+
const auth = c.get('auth') as AuthContext | null | undefined ?? null;
|
|
85
|
+
|
|
86
|
+
// 6. Parse operation from path + method
|
|
87
|
+
const method = c.req.raw.method;
|
|
88
|
+
const pathSuffix = doPath.replace(`/tables/${tableName}`, '');
|
|
89
|
+
|
|
90
|
+
// 7. Route to operation
|
|
91
|
+
if (method === 'GET') {
|
|
92
|
+
if (pathSuffix === '/count') {
|
|
93
|
+
return handleCount(c, resolved, tableName, tableConfig, auth, isServiceKey);
|
|
94
|
+
}
|
|
95
|
+
if (pathSuffix === '/search') {
|
|
96
|
+
return handleSearch(c, resolved, tableName, tableConfig, auth, isServiceKey);
|
|
97
|
+
}
|
|
98
|
+
if (pathSuffix && pathSuffix !== '/') {
|
|
99
|
+
const id = pathSuffix.slice(1);
|
|
100
|
+
return handleGet(c, resolved, tableName, tableConfig, id, auth, isServiceKey);
|
|
101
|
+
}
|
|
102
|
+
return handleList(c, resolved, tableName, tableConfig, auth, isServiceKey);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (method === 'POST') {
|
|
106
|
+
if (pathSuffix === '/batch') {
|
|
107
|
+
return handleBatch(c, resolved, tableName, tableConfig, auth, isServiceKey);
|
|
108
|
+
}
|
|
109
|
+
if (pathSuffix === '/batch-by-filter') {
|
|
110
|
+
return handleBatchByFilter(c, resolved, tableName, tableConfig, auth, isServiceKey);
|
|
111
|
+
}
|
|
112
|
+
return handleInsert(c, resolved, tableName, tableConfig, auth, isServiceKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (method === 'PATCH' || method === 'PUT') {
|
|
116
|
+
const id = pathSuffix.slice(1);
|
|
117
|
+
return handleUpdate(c, resolved, tableName, tableConfig, id, auth, isServiceKey);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (method === 'DELETE') {
|
|
121
|
+
const id = pathSuffix.slice(1);
|
|
122
|
+
return handleDelete(c, resolved, tableName, tableConfig, id, auth, isServiceKey);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return c.json({ code: 405, message: 'Method not allowed' }, 405);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── D1 Binding Resolution ───
|
|
129
|
+
|
|
130
|
+
function resolveD1Binding(env: Env, namespace: string): D1ResolvedDb {
|
|
131
|
+
const config = parseConfig(env);
|
|
132
|
+
const dbBlock = config.databases?.[namespace];
|
|
133
|
+
if (!dbBlock) {
|
|
134
|
+
throw new EdgeBaseError(404, `Database '${namespace}' not found in config.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const bindingName = getD1BindingName(namespace);
|
|
138
|
+
const envRecord = env as unknown as Record<string, unknown>;
|
|
139
|
+
const db = envRecord[bindingName] as D1Database | undefined;
|
|
140
|
+
|
|
141
|
+
if (!db) {
|
|
142
|
+
throw new EdgeBaseError(500,
|
|
143
|
+
`D1 binding '${bindingName}' not found for namespace '${namespace}'. ` +
|
|
144
|
+
`Run 'edgebase deploy' to auto-provision D1 databases, ` +
|
|
145
|
+
`or add [[d1_databases]] binding = "${bindingName}" to wrangler.toml for local dev.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { db, dbBlock, namespace };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Service Key Check ───
|
|
153
|
+
|
|
154
|
+
function checkServiceKey(c: Context<HonoEnv>): boolean {
|
|
155
|
+
if (isTrustedInternalContext(c)) return true;
|
|
156
|
+
// Public request paths must be validated upstream (rules middleware / admin route)
|
|
157
|
+
// so provider-backed handlers observe the same scoped + constrained bypass result.
|
|
158
|
+
return c.get('isServiceKey' as never) === true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── D1 Query Helpers ───
|
|
162
|
+
|
|
163
|
+
async function executeD1Query(
|
|
164
|
+
db: D1Database,
|
|
165
|
+
sql: string,
|
|
166
|
+
params: unknown[],
|
|
167
|
+
): Promise<{ rows: Record<string, unknown>[]; rowCount: number }> {
|
|
168
|
+
try {
|
|
169
|
+
const stmt = db.prepare(sql);
|
|
170
|
+
const bound = params.length > 0 ? stmt.bind(...params) : stmt;
|
|
171
|
+
const result = await bound.all();
|
|
172
|
+
return {
|
|
173
|
+
rows: (result.results ?? []) as Record<string, unknown>[],
|
|
174
|
+
rowCount: result.meta?.changes ?? 0,
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const normalized = normalizeDatabaseError(error);
|
|
178
|
+
if (normalized) throw normalized;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Rule Evaluation ───
|
|
184
|
+
|
|
185
|
+
async function evalRowRule(
|
|
186
|
+
rule: TableRules['read'],
|
|
187
|
+
auth: AuthContext | null,
|
|
188
|
+
row: Record<string, unknown>,
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
if (rule === undefined || rule === null) return true;
|
|
191
|
+
if (typeof rule === 'boolean') return rule;
|
|
192
|
+
if (typeof rule === 'function') {
|
|
193
|
+
try {
|
|
194
|
+
const result = await Promise.race([
|
|
195
|
+
Promise.resolve(rule(auth, row)),
|
|
196
|
+
new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
|
|
197
|
+
]);
|
|
198
|
+
return result;
|
|
199
|
+
} catch {
|
|
200
|
+
return false; // fail-closed
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function evalInsertRule(
|
|
207
|
+
rule: TableRules['insert'],
|
|
208
|
+
auth: AuthContext | null,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
if (rule === undefined || rule === null) return true;
|
|
211
|
+
if (typeof rule === 'boolean') return rule;
|
|
212
|
+
if (typeof rule === 'function') {
|
|
213
|
+
try {
|
|
214
|
+
const result = await Promise.race([
|
|
215
|
+
Promise.resolve((rule as (a: AuthContext | null) => boolean | Promise<boolean>)(auth)),
|
|
216
|
+
new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
|
|
217
|
+
]);
|
|
218
|
+
return result;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Hook Context Builder ───
|
|
227
|
+
|
|
228
|
+
function buildHookCtx(
|
|
229
|
+
db: D1Database,
|
|
230
|
+
tables: Record<string, TableConfig>,
|
|
231
|
+
env: Env,
|
|
232
|
+
executionCtx?: ExecutionContext,
|
|
233
|
+
): HookCtx {
|
|
234
|
+
return {
|
|
235
|
+
db: {
|
|
236
|
+
async get(table: string, id: string): Promise<Record<string, unknown> | null> {
|
|
237
|
+
const { sql, params } = buildGetQuery(table, id, undefined, 'sqlite');
|
|
238
|
+
const result = await executeD1Query(db, sql, params);
|
|
239
|
+
return result.rows.length > 0 ? stripInternalFields(result.rows[0]) : null;
|
|
240
|
+
},
|
|
241
|
+
async list(table: string, filter?: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
|
|
242
|
+
let sql = `SELECT * FROM "${table.replace(/"/g, '""')}"`;
|
|
243
|
+
const params: unknown[] = [];
|
|
244
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
245
|
+
const conditions: string[] = [];
|
|
246
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
247
|
+
conditions.push(`"${key.replace(/"/g, '""')}" = ?`);
|
|
248
|
+
params.push(value);
|
|
249
|
+
}
|
|
250
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
251
|
+
}
|
|
252
|
+
sql += ' LIMIT 100';
|
|
253
|
+
const result = await executeD1Query(db, sql, params);
|
|
254
|
+
return result.rows.map(r => stripInternalFields(r));
|
|
255
|
+
},
|
|
256
|
+
async exists(table: string, filter: Record<string, unknown>): Promise<boolean> {
|
|
257
|
+
const conditions: string[] = [];
|
|
258
|
+
const params: unknown[] = [];
|
|
259
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
260
|
+
conditions.push(`"${key.replace(/"/g, '""')}" = ?`);
|
|
261
|
+
params.push(value);
|
|
262
|
+
}
|
|
263
|
+
const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
264
|
+
const sql = `SELECT 1 FROM "${table.replace(/"/g, '""')}"${where} LIMIT 1`;
|
|
265
|
+
const result = await executeD1Query(db, sql, params);
|
|
266
|
+
return result.rows.length > 0;
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
databaseLive: {
|
|
270
|
+
async broadcast(channel: string, event: string, data: unknown): Promise<void> {
|
|
271
|
+
await sendToDatabaseLiveDO(
|
|
272
|
+
env,
|
|
273
|
+
{ channel, event, payload: data ?? {} },
|
|
274
|
+
'/internal/broadcast',
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
push: {
|
|
279
|
+
async send(_userId: string, _payload: { title?: string; body: string }): Promise<void> {
|
|
280
|
+
// Push notifications — same mechanism as DO (via Worker env)
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
waitUntil(promise: Promise<unknown>): void {
|
|
284
|
+
if (executionCtx) {
|
|
285
|
+
executionCtx.waitUntil(promise);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Utility ───
|
|
292
|
+
|
|
293
|
+
function esc(name: string): string {
|
|
294
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function stripInternalFields(row: Record<string, unknown>): Record<string, unknown> {
|
|
298
|
+
const cleaned = { ...row };
|
|
299
|
+
delete cleaned._fts;
|
|
300
|
+
return cleaned;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Normalize D1/SQLite row values:
|
|
305
|
+
* - Boolean fields: 0/1 → true/false
|
|
306
|
+
* - JSON fields: parse string → object
|
|
307
|
+
* - Number fields: string → number (SQLite may return strings)
|
|
308
|
+
* Mirrors database-do.ts normalizeRow().
|
|
309
|
+
*/
|
|
310
|
+
function normalizeRow(
|
|
311
|
+
row: Record<string, unknown>,
|
|
312
|
+
tableConfig: TableConfig,
|
|
313
|
+
): Record<string, unknown> {
|
|
314
|
+
if (!tableConfig.schema) return row;
|
|
315
|
+
const result = { ...row };
|
|
316
|
+
|
|
317
|
+
for (const [key, fieldDef] of Object.entries(tableConfig.schema)) {
|
|
318
|
+
if (fieldDef === false) continue;
|
|
319
|
+
const value = result[key];
|
|
320
|
+
if (value === undefined) continue;
|
|
321
|
+
|
|
322
|
+
if (fieldDef.type === 'boolean') {
|
|
323
|
+
if (value === 1 || value === '1' || value === 'true' || value === true) {
|
|
324
|
+
result[key] = true;
|
|
325
|
+
} else if (value === 0 || value === '0' || value === 'false' || value === false) {
|
|
326
|
+
result[key] = false;
|
|
327
|
+
} else if (value === null) {
|
|
328
|
+
result[key] = null;
|
|
329
|
+
} else {
|
|
330
|
+
result[key] = Boolean(value);
|
|
331
|
+
}
|
|
332
|
+
} else if (fieldDef.type === 'json') {
|
|
333
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
334
|
+
try {
|
|
335
|
+
result[key] = JSON.parse(value);
|
|
336
|
+
} catch {
|
|
337
|
+
// Keep as string if not valid JSON
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} else if (fieldDef.type === 'number') {
|
|
341
|
+
if (typeof value === 'string') {
|
|
342
|
+
const num = Number(value);
|
|
343
|
+
if (!Number.isNaN(num)) result[key] = num;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function serializeJsonFields(
|
|
352
|
+
data: Record<string, unknown>,
|
|
353
|
+
schema: Record<string, SchemaField>,
|
|
354
|
+
): void {
|
|
355
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
356
|
+
if (field.type === 'json' && data[key] !== undefined && data[key] !== null) {
|
|
357
|
+
if (typeof data[key] !== 'string') {
|
|
358
|
+
data[key] = JSON.stringify(data[key]);
|
|
359
|
+
}
|
|
360
|
+
} else if (field.type === 'boolean' && data[key] !== undefined && data[key] !== null) {
|
|
361
|
+
data[key] = data[key] === true || data[key] === 'true' || data[key] === 1 || data[key] === '1'
|
|
362
|
+
? 1
|
|
363
|
+
: 0;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function filterToSchemaColumns(
|
|
369
|
+
data: Record<string, unknown>,
|
|
370
|
+
effectiveSchema: Record<string, SchemaField>,
|
|
371
|
+
): Record<string, unknown> {
|
|
372
|
+
const filtered: Record<string, unknown> = {};
|
|
373
|
+
for (const key of Object.keys(data)) {
|
|
374
|
+
if (key in effectiveSchema) {
|
|
375
|
+
filtered[key] = data[key];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return filtered;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
382
|
+
// CRUD Operations
|
|
383
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
384
|
+
|
|
385
|
+
// ─── LIST ───
|
|
386
|
+
|
|
387
|
+
async function handleList(
|
|
388
|
+
c: Context<HonoEnv>,
|
|
389
|
+
resolved: D1ResolvedDb,
|
|
390
|
+
tableName: string,
|
|
391
|
+
tableConfig: TableConfig,
|
|
392
|
+
auth: AuthContext | null,
|
|
393
|
+
isServiceKey: boolean,
|
|
394
|
+
): Promise<Response> {
|
|
395
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
396
|
+
if (!isServiceKey && tableAccess?.read === false) {
|
|
397
|
+
const error = forbiddenError('Access denied.');
|
|
398
|
+
return c.json(error.toJSON(), error.status as 403);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
402
|
+
const { sql, params, countSql, countParams } = buildListQuery(tableName, queryOpts, 'sqlite');
|
|
403
|
+
const result = await executeD1Query(resolved.db, sql, params);
|
|
404
|
+
|
|
405
|
+
// Apply read rules per row + normalize booleans/JSON
|
|
406
|
+
let items = result.rows.map(r => normalizeRow(stripInternalFields(r), tableConfig));
|
|
407
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
408
|
+
if (!isServiceKey && tableAccess?.read !== undefined) {
|
|
409
|
+
const filtered: Record<string, unknown>[] = [];
|
|
410
|
+
for (const row of items) {
|
|
411
|
+
if (await evalRowRule(tableAccess.read, auth, row)) {
|
|
412
|
+
filtered.push(row);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
items = filtered;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Apply onEnrich hook
|
|
419
|
+
if (tableHooks?.onEnrich) {
|
|
420
|
+
const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
|
|
421
|
+
for (let i = 0; i < items.length; i++) {
|
|
422
|
+
try {
|
|
423
|
+
const enriched = await tableHooks.onEnrich(auth, items[i], hookCtx);
|
|
424
|
+
if (enriched && typeof enriched === 'object') items[i] = { ...items[i], ...enriched };
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Get total count
|
|
432
|
+
let total: number | null = null;
|
|
433
|
+
if (countSql && countParams) {
|
|
434
|
+
const countResult = await executeD1Query(resolved.db, countSql, countParams);
|
|
435
|
+
total = Number(countResult.rows[0]?.total ?? 0);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
|
|
439
|
+
const page = queryOpts.pagination?.page ?? 1;
|
|
440
|
+
// Always include cursor/hasMore like DO does — clients can start cursor pagination from any page
|
|
441
|
+
const hasMore = items.length === perPage;
|
|
442
|
+
const cursor = hasMore && items.length > 0
|
|
443
|
+
? String((items[items.length - 1]).id ?? '')
|
|
444
|
+
: null;
|
|
445
|
+
|
|
446
|
+
return c.json({ items, total, hasMore, cursor, page, perPage });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ─── COUNT ───
|
|
450
|
+
|
|
451
|
+
async function handleCount(
|
|
452
|
+
c: Context<HonoEnv>,
|
|
453
|
+
resolved: D1ResolvedDb,
|
|
454
|
+
tableName: string,
|
|
455
|
+
tableConfig: TableConfig,
|
|
456
|
+
_auth: AuthContext | null,
|
|
457
|
+
isServiceKey: boolean,
|
|
458
|
+
): Promise<Response> {
|
|
459
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
460
|
+
if (!isServiceKey && tableAccess?.read === false) {
|
|
461
|
+
const error = forbiddenError('Access denied.');
|
|
462
|
+
return c.json(error.toJSON(), error.status as 403);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
466
|
+
const { sql, params } = buildCountQuery(tableName, queryOpts.filters, queryOpts.orFilters, 'sqlite');
|
|
467
|
+
const result = await executeD1Query(resolved.db, sql, params);
|
|
468
|
+
const total = result.rows[0]?.total ?? 0;
|
|
469
|
+
return c.json({ total });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── SEARCH ───
|
|
473
|
+
|
|
474
|
+
async function handleSearch(
|
|
475
|
+
c: Context<HonoEnv>,
|
|
476
|
+
resolved: D1ResolvedDb,
|
|
477
|
+
tableName: string,
|
|
478
|
+
tableConfig: TableConfig,
|
|
479
|
+
auth: AuthContext | null,
|
|
480
|
+
isServiceKey: boolean,
|
|
481
|
+
): Promise<Response> {
|
|
482
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
483
|
+
if (!isServiceKey && tableAccess?.read === false) {
|
|
484
|
+
const error = forbiddenError('Access denied.');
|
|
485
|
+
return c.json(error.toJSON(), error.status as 403);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
489
|
+
const searchTerm = queryOpts.search || '';
|
|
490
|
+
if (!searchTerm) {
|
|
491
|
+
return c.json({ items: [] });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const ftsFields = tableConfig.fts?.length
|
|
495
|
+
? tableConfig.fts
|
|
496
|
+
: getTextFields(tableConfig);
|
|
497
|
+
|
|
498
|
+
let items: Record<string, unknown>[];
|
|
499
|
+
let total = 0;
|
|
500
|
+
const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
|
|
501
|
+
const offset = queryOpts.pagination?.offset ?? ((queryOpts.pagination?.page ?? 1) - 1) * limit;
|
|
502
|
+
const searchQuery = buildSearchQuery(tableName, searchTerm, {
|
|
503
|
+
pagination: queryOpts.pagination,
|
|
504
|
+
filters: queryOpts.filters,
|
|
505
|
+
orFilters: queryOpts.orFilters,
|
|
506
|
+
sort: queryOpts.sort,
|
|
507
|
+
ftsFields,
|
|
508
|
+
}, 'sqlite');
|
|
509
|
+
try {
|
|
510
|
+
const result = await executeD1Query(resolved.db, searchQuery.sql, searchQuery.params);
|
|
511
|
+
items = result.rows.map(r => normalizeRow(stripInternalFields(r), tableConfig));
|
|
512
|
+
if (searchQuery.countSql) {
|
|
513
|
+
const countResult = await executeD1Query(resolved.db, searchQuery.countSql, searchQuery.countParams ?? []);
|
|
514
|
+
total = Number(countResult.rows[0]?.total ?? items.length);
|
|
515
|
+
}
|
|
516
|
+
} catch {
|
|
517
|
+
items = [];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (items.length === 0 && ftsFields.length > 0) {
|
|
521
|
+
const fallback = buildSubstringSearchQuery(tableName, searchTerm, {
|
|
522
|
+
pagination: queryOpts.pagination,
|
|
523
|
+
filters: queryOpts.filters,
|
|
524
|
+
orFilters: queryOpts.orFilters,
|
|
525
|
+
sort: queryOpts.sort,
|
|
526
|
+
fields: ftsFields,
|
|
527
|
+
}, 'sqlite');
|
|
528
|
+
const result = await executeD1Query(resolved.db, fallback.sql, fallback.params);
|
|
529
|
+
items = result.rows.map((row) => normalizeRow(stripInternalFields(row), tableConfig));
|
|
530
|
+
if (fallback.countSql) {
|
|
531
|
+
const countResult = await executeD1Query(resolved.db, fallback.countSql, fallback.countParams ?? []);
|
|
532
|
+
total = Number(countResult.rows[0]?.total ?? items.length);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Apply read rules
|
|
537
|
+
if (!isServiceKey && tableAccess?.read !== undefined) {
|
|
538
|
+
const filtered: Record<string, unknown>[] = [];
|
|
539
|
+
for (const row of items) {
|
|
540
|
+
if (await evalRowRule(tableAccess.read, auth, row)) {
|
|
541
|
+
filtered.push(row);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
items = filtered;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return c.json({ items, total, hasMore: total > offset + items.length, cursor: null, page: null, perPage: limit });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ─── GET ───
|
|
551
|
+
|
|
552
|
+
async function handleGet(
|
|
553
|
+
c: Context<HonoEnv>,
|
|
554
|
+
resolved: D1ResolvedDb,
|
|
555
|
+
tableName: string,
|
|
556
|
+
tableConfig: TableConfig,
|
|
557
|
+
id: string,
|
|
558
|
+
auth: AuthContext | null,
|
|
559
|
+
isServiceKey: boolean,
|
|
560
|
+
): Promise<Response> {
|
|
561
|
+
const fieldsParam = new URL(c.req.url).searchParams.get('fields');
|
|
562
|
+
const fields = fieldsParam ? fieldsParam.split(',').map(f => f.trim()) : undefined;
|
|
563
|
+
|
|
564
|
+
const { sql, params } = buildGetQuery(tableName, id, fields, 'sqlite');
|
|
565
|
+
const result = await executeD1Query(resolved.db, sql, params);
|
|
566
|
+
|
|
567
|
+
if (result.rows.length === 0) {
|
|
568
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const row = normalizeRow(stripInternalFields(result.rows[0]), tableConfig);
|
|
572
|
+
|
|
573
|
+
// Check read rule
|
|
574
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
575
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
576
|
+
if (!isServiceKey && tableAccess?.read !== undefined) {
|
|
577
|
+
if (!(await evalRowRule(tableAccess.read, auth, row))) {
|
|
578
|
+
return c.json({ code: 403, message: 'Access denied.' }, 403);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Apply onEnrich hook
|
|
583
|
+
if (tableHooks?.onEnrich) {
|
|
584
|
+
const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
|
|
585
|
+
try {
|
|
586
|
+
const enriched = await tableHooks.onEnrich(auth, row, hookCtx);
|
|
587
|
+
if (enriched && typeof enriched === 'object') return c.json({ ...row, ...enriched });
|
|
588
|
+
} catch (err) {
|
|
589
|
+
console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return c.json(row);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ─── INSERT ───
|
|
597
|
+
|
|
598
|
+
async function handleInsert(
|
|
599
|
+
c: Context<HonoEnv>,
|
|
600
|
+
resolved: D1ResolvedDb,
|
|
601
|
+
tableName: string,
|
|
602
|
+
tableConfig: TableConfig,
|
|
603
|
+
auth: AuthContext | null,
|
|
604
|
+
isServiceKey: boolean,
|
|
605
|
+
): Promise<Response> {
|
|
606
|
+
let body: Record<string, unknown>;
|
|
607
|
+
try {
|
|
608
|
+
body = await c.req.json();
|
|
609
|
+
} catch {
|
|
610
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
611
|
+
}
|
|
612
|
+
body = applySchemaFieldAliases(body, tableConfig.schema);
|
|
613
|
+
|
|
614
|
+
// Check insert rule
|
|
615
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
616
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
617
|
+
if (!isServiceKey && tableAccess?.insert !== undefined) {
|
|
618
|
+
if (!(await evalInsertRule(tableAccess.insert, auth))) {
|
|
619
|
+
return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Validate against schema
|
|
624
|
+
const validation = validateInsert(body, tableConfig.schema);
|
|
625
|
+
if (!validation.valid) {
|
|
626
|
+
return c.json({
|
|
627
|
+
code: 400,
|
|
628
|
+
message: summarizeValidationErrors(validation.errors),
|
|
629
|
+
data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])),
|
|
630
|
+
}, 400);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Build effective schema
|
|
634
|
+
const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
|
|
635
|
+
|
|
636
|
+
// Auto-fields
|
|
637
|
+
if (!body.id) body.id = generateId();
|
|
638
|
+
const now = new Date().toISOString();
|
|
639
|
+
if (effectiveSchema.createdAt) body.createdAt = now;
|
|
640
|
+
if (effectiveSchema.updatedAt) body.updatedAt = now;
|
|
641
|
+
|
|
642
|
+
// Apply defaults for missing fields
|
|
643
|
+
for (const [name, field] of Object.entries(effectiveSchema)) {
|
|
644
|
+
if (body[name] === undefined && field.default !== undefined) {
|
|
645
|
+
body[name] = field.default;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Run beforeInsert hook
|
|
650
|
+
const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
|
|
651
|
+
if (tableHooks?.beforeInsert) {
|
|
652
|
+
try {
|
|
653
|
+
const transformed = await tableHooks.beforeInsert(auth, body, hookCtx);
|
|
654
|
+
if (transformed && typeof transformed === 'object') {
|
|
655
|
+
body = { ...body, ...transformed };
|
|
656
|
+
}
|
|
657
|
+
} catch (err) {
|
|
658
|
+
const hookError = hookRejectedError(err, 'Insert rejected by beforeInsert hook.');
|
|
659
|
+
return c.json(hookError.toJSON(), hookError.status as 400);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Filter to schema columns + serialize JSON
|
|
664
|
+
const data = filterToSchemaColumns(body, effectiveSchema);
|
|
665
|
+
serializeJsonFields(data, effectiveSchema);
|
|
666
|
+
|
|
667
|
+
// Check upsert mode
|
|
668
|
+
const url = new URL(c.req.url);
|
|
669
|
+
const isUpsert = url.searchParams.get('upsert') === 'true';
|
|
670
|
+
const conflictTarget = url.searchParams.get('conflictTarget') || 'id';
|
|
671
|
+
|
|
672
|
+
// Validate conflictTarget for upserts
|
|
673
|
+
if (isUpsert) {
|
|
674
|
+
// Check field exists in schema
|
|
675
|
+
if (!effectiveSchema[conflictTarget]) {
|
|
676
|
+
return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' does not exist in schema.` }, 400);
|
|
677
|
+
}
|
|
678
|
+
// Check field is unique (or 'id')
|
|
679
|
+
if (conflictTarget !== 'id') {
|
|
680
|
+
const fieldDef = tableConfig.schema?.[conflictTarget];
|
|
681
|
+
if (!fieldDef || !(fieldDef as SchemaField).unique) {
|
|
682
|
+
return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' must be a unique field.` }, 400);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// For upsert: check if record already exists to determine action
|
|
688
|
+
let isUpdate = false;
|
|
689
|
+
let upsertBeforeRow: Record<string, unknown> | null = null;
|
|
690
|
+
if (isUpsert && data[conflictTarget] !== undefined) {
|
|
691
|
+
const checkSql = `SELECT * FROM ${esc(tableName)} WHERE ${esc(conflictTarget)} = ? LIMIT 1`;
|
|
692
|
+
const checkResult = await executeD1Query(resolved.db, checkSql, [data[conflictTarget]]);
|
|
693
|
+
isUpdate = checkResult.rows.length > 0;
|
|
694
|
+
upsertBeforeRow = isUpdate ? stripInternalFields(checkResult.rows[0]) : null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Build INSERT SQL (SQLite uses ? params)
|
|
698
|
+
const columns = Object.keys(data);
|
|
699
|
+
const values = columns.map(col => data[col] ?? null);
|
|
700
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
701
|
+
|
|
702
|
+
let sql: string;
|
|
703
|
+
if (isUpsert) {
|
|
704
|
+
const setClauses = columns
|
|
705
|
+
.filter(col => col !== 'id' && col !== conflictTarget && col !== 'createdAt')
|
|
706
|
+
.map(col => `${esc(col)} = excluded.${esc(col)}`);
|
|
707
|
+
sql = `INSERT INTO ${esc(tableName)} (${columns.map(esc).join(', ')}) VALUES (${placeholders})` +
|
|
708
|
+
` ON CONFLICT (${esc(conflictTarget)}) DO UPDATE SET ${setClauses.join(', ')}`;
|
|
709
|
+
} else {
|
|
710
|
+
sql = `INSERT INTO ${esc(tableName)} (${columns.map(esc).join(', ')}) VALUES (${placeholders})`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
await executeD1Query(resolved.db, sql, values);
|
|
714
|
+
|
|
715
|
+
// D1 doesn't support RETURNING * — re-fetch the inserted row using the stable conflict target.
|
|
716
|
+
const fetchField = isUpsert && conflictTarget !== 'id' ? conflictTarget : 'id';
|
|
717
|
+
const fetchValue = data[fetchField];
|
|
718
|
+
const fetchResult = await executeD1Query(
|
|
719
|
+
resolved.db,
|
|
720
|
+
`SELECT * FROM ${esc(tableName)} WHERE ${esc(String(fetchField))} = ? LIMIT 1`,
|
|
721
|
+
[fetchValue],
|
|
722
|
+
);
|
|
723
|
+
const rawRow = fetchResult.rows.length > 0 ? stripInternalFields(fetchResult.rows[0]) : data;
|
|
724
|
+
const inserted = normalizeRow(rawRow, tableConfig);
|
|
725
|
+
|
|
726
|
+
// Run afterInsert hook (fire-and-forget)
|
|
727
|
+
if (tableHooks?.afterInsert) {
|
|
728
|
+
const hook = tableHooks.afterInsert;
|
|
729
|
+
hookCtx.waitUntil(Promise.resolve(hook(inserted, hookCtx)).catch(() => {}));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Emit database-live event (fire-and-forget)
|
|
733
|
+
const eventType = isUpsert && isUpdate ? 'modified' : 'added';
|
|
734
|
+
c.executionCtx.waitUntil(
|
|
735
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, eventType, String(inserted.id ?? ''), inserted),
|
|
736
|
+
);
|
|
737
|
+
c.executionCtx.waitUntil(
|
|
738
|
+
executeDbTriggers(
|
|
739
|
+
tableName,
|
|
740
|
+
isUpsert && isUpdate ? 'update' : 'insert',
|
|
741
|
+
isUpsert && isUpdate ? { before: upsertBeforeRow ?? inserted, after: inserted } : { after: inserted },
|
|
742
|
+
{
|
|
743
|
+
databaseNamespace: c.env.DATABASE,
|
|
744
|
+
authNamespace: c.env.AUTH,
|
|
745
|
+
kvNamespace: c.env.KV,
|
|
746
|
+
config: parseConfig(c.env),
|
|
747
|
+
env: c.env as never,
|
|
748
|
+
executionCtx: c.executionCtx as never,
|
|
749
|
+
},
|
|
750
|
+
{ namespace: resolved.namespace },
|
|
751
|
+
),
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
// Upsert response includes action field
|
|
755
|
+
if (isUpsert) {
|
|
756
|
+
const statusCode = isUpdate ? 200 : 201;
|
|
757
|
+
const action = isUpdate ? 'updated' : 'inserted';
|
|
758
|
+
return c.json({ ...inserted, action }, statusCode as 200);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return c.json(inserted, 201);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ─── UPDATE ───
|
|
765
|
+
|
|
766
|
+
async function handleUpdate(
|
|
767
|
+
c: Context<HonoEnv>,
|
|
768
|
+
resolved: D1ResolvedDb,
|
|
769
|
+
tableName: string,
|
|
770
|
+
tableConfig: TableConfig,
|
|
771
|
+
id: string,
|
|
772
|
+
auth: AuthContext | null,
|
|
773
|
+
isServiceKey: boolean,
|
|
774
|
+
): Promise<Response> {
|
|
775
|
+
let body: Record<string, unknown>;
|
|
776
|
+
try {
|
|
777
|
+
body = await c.req.json();
|
|
778
|
+
} catch {
|
|
779
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
780
|
+
}
|
|
781
|
+
body = applySchemaFieldAliases(body, tableConfig.schema);
|
|
782
|
+
|
|
783
|
+
// Validate against schema
|
|
784
|
+
const validation = validateUpdate(body, tableConfig.schema);
|
|
785
|
+
if (!validation.valid) {
|
|
786
|
+
return c.json({ code: 400, message: 'Request body failed validation. See data for field-level errors.', data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])) }, 400);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Fetch existing record to check rules
|
|
790
|
+
const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'sqlite');
|
|
791
|
+
const existing = await executeD1Query(resolved.db, getSql, getParams);
|
|
792
|
+
if (existing.rows.length === 0) {
|
|
793
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
794
|
+
}
|
|
795
|
+
const existingRow = existing.rows[0];
|
|
796
|
+
|
|
797
|
+
// Check update rule
|
|
798
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
799
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
800
|
+
if (!isServiceKey && tableAccess?.update !== undefined) {
|
|
801
|
+
if (!(await evalRowRule(tableAccess.update, auth, existingRow))) {
|
|
802
|
+
return c.json({ code: 403, message: `Access denied: 'update' rule blocked record "${id}" in table "${tableName}".` }, 403);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Build effective schema
|
|
807
|
+
const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
|
|
808
|
+
|
|
809
|
+
// Auto-field: updatedAt
|
|
810
|
+
if (effectiveSchema.updatedAt) {
|
|
811
|
+
body.updatedAt = new Date().toISOString();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Run beforeUpdate hook
|
|
815
|
+
const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
|
|
816
|
+
if (tableHooks?.beforeUpdate) {
|
|
817
|
+
try {
|
|
818
|
+
const transformed = await tableHooks.beforeUpdate(auth, existingRow, body, hookCtx);
|
|
819
|
+
if (transformed && typeof transformed === 'object') {
|
|
820
|
+
body = { ...body, ...transformed };
|
|
821
|
+
}
|
|
822
|
+
} catch (err) {
|
|
823
|
+
const hookError = hookRejectedError(err, 'Update rejected by beforeUpdate hook.');
|
|
824
|
+
return c.json(hookError.toJSON(), hookError.status as 400);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Filter to schema columns + serialize JSON (skip $op objects from serialization)
|
|
829
|
+
delete body.id;
|
|
830
|
+
delete body.createdAt;
|
|
831
|
+
const data = filterToSchemaColumns(body, effectiveSchema);
|
|
832
|
+
// Serialize JSON fields (but skip $op objects)
|
|
833
|
+
for (const [key, val] of Object.entries(data)) {
|
|
834
|
+
if (val && typeof val === 'object' && '$op' in (val as Record<string, unknown>)) continue;
|
|
835
|
+
const fieldDef = effectiveSchema[key];
|
|
836
|
+
if (fieldDef && typeof fieldDef === 'object' && fieldDef.type === 'json' && val !== null && val !== undefined) {
|
|
837
|
+
data[key] = JSON.stringify(val);
|
|
838
|
+
} else if (fieldDef && typeof fieldDef === 'object' && fieldDef.type === 'boolean' && val !== null && val !== undefined) {
|
|
839
|
+
data[key] = val === true || val === 'true' || val === 1 || val === '1'
|
|
840
|
+
? 1
|
|
841
|
+
: 0;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Use parseUpdateBody to handle both regular values and $op field operators
|
|
846
|
+
const { setClauses, params } = parseUpdateBody(data);
|
|
847
|
+
|
|
848
|
+
if (setClauses.length === 0) {
|
|
849
|
+
// Empty update body — return existing record as-is (same as DO handler)
|
|
850
|
+
return c.json(existingRow);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Build UPDATE SQL with WHERE id = ?
|
|
854
|
+
params.push(id);
|
|
855
|
+
const sql = `UPDATE ${esc(tableName)} SET ${setClauses.join(', ')} WHERE "id" = ?`;
|
|
856
|
+
const updateResult = await executeD1Query(resolved.db, sql, params);
|
|
857
|
+
|
|
858
|
+
if (updateResult.rowCount === 0) {
|
|
859
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Re-fetch updated row (D1 doesn't support RETURNING *)
|
|
863
|
+
const fetchResult = await executeD1Query(resolved.db, getSql, getParams);
|
|
864
|
+
const rawUpdated = fetchResult.rows.length > 0 ? stripInternalFields(fetchResult.rows[0]) : { id, ...data };
|
|
865
|
+
const updated = normalizeRow(rawUpdated, tableConfig);
|
|
866
|
+
|
|
867
|
+
// Run afterUpdate hook (fire-and-forget)
|
|
868
|
+
if (tableHooks?.afterUpdate) {
|
|
869
|
+
const hook = tableHooks.afterUpdate;
|
|
870
|
+
hookCtx.waitUntil(
|
|
871
|
+
Promise.resolve(hook(existingRow, updated, hookCtx)).catch(() => {}),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Emit database-live event (fire-and-forget)
|
|
876
|
+
c.executionCtx.waitUntil(
|
|
877
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', id, updated),
|
|
878
|
+
);
|
|
879
|
+
c.executionCtx.waitUntil(
|
|
880
|
+
executeDbTriggers(
|
|
881
|
+
tableName,
|
|
882
|
+
'update',
|
|
883
|
+
{
|
|
884
|
+
before: existingRow as Record<string, unknown>,
|
|
885
|
+
after: updated,
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
databaseNamespace: c.env.DATABASE,
|
|
889
|
+
authNamespace: c.env.AUTH,
|
|
890
|
+
kvNamespace: c.env.KV,
|
|
891
|
+
config: parseConfig(c.env),
|
|
892
|
+
env: c.env as never,
|
|
893
|
+
executionCtx: c.executionCtx as never,
|
|
894
|
+
},
|
|
895
|
+
{ namespace: resolved.namespace },
|
|
896
|
+
),
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
return c.json(updated);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ─── DELETE ───
|
|
903
|
+
|
|
904
|
+
async function handleDelete(
|
|
905
|
+
c: Context<HonoEnv>,
|
|
906
|
+
resolved: D1ResolvedDb,
|
|
907
|
+
tableName: string,
|
|
908
|
+
tableConfig: TableConfig,
|
|
909
|
+
id: string,
|
|
910
|
+
auth: AuthContext | null,
|
|
911
|
+
isServiceKey: boolean,
|
|
912
|
+
): Promise<Response> {
|
|
913
|
+
// Fetch existing record
|
|
914
|
+
const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'sqlite');
|
|
915
|
+
const existing = await executeD1Query(resolved.db, getSql, getParams);
|
|
916
|
+
if (existing.rows.length === 0) {
|
|
917
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
918
|
+
}
|
|
919
|
+
const existingRow = existing.rows[0];
|
|
920
|
+
|
|
921
|
+
// Check delete rule
|
|
922
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
923
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
924
|
+
if (!isServiceKey && tableAccess?.delete !== undefined) {
|
|
925
|
+
if (!(await evalRowRule(tableAccess.delete, auth, existingRow))) {
|
|
926
|
+
return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Run beforeDelete hook
|
|
931
|
+
const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
|
|
932
|
+
if (tableHooks?.beforeDelete) {
|
|
933
|
+
try {
|
|
934
|
+
await tableHooks.beforeDelete(auth, existingRow, hookCtx);
|
|
935
|
+
} catch (err) {
|
|
936
|
+
const hookError = hookRejectedError(err, 'Delete rejected by beforeDelete hook.');
|
|
937
|
+
return c.json(hookError.toJSON(), hookError.status as 400);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Execute DELETE
|
|
942
|
+
const sql = `DELETE FROM ${esc(tableName)} WHERE "id" = ?`;
|
|
943
|
+
await executeD1Query(resolved.db, sql, [id]);
|
|
944
|
+
|
|
945
|
+
// Run afterDelete hook (fire-and-forget)
|
|
946
|
+
if (tableHooks?.afterDelete) {
|
|
947
|
+
const hook = tableHooks.afterDelete;
|
|
948
|
+
hookCtx.waitUntil(
|
|
949
|
+
Promise.resolve(hook(existingRow, hookCtx)).catch(() => {}),
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Emit database-live event (fire-and-forget)
|
|
954
|
+
c.executionCtx.waitUntil(
|
|
955
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', id, stripInternalFields(existingRow)),
|
|
956
|
+
);
|
|
957
|
+
c.executionCtx.waitUntil(
|
|
958
|
+
executeDbTriggers(
|
|
959
|
+
tableName,
|
|
960
|
+
'delete',
|
|
961
|
+
{ before: existingRow as Record<string, unknown> },
|
|
962
|
+
{
|
|
963
|
+
databaseNamespace: c.env.DATABASE,
|
|
964
|
+
authNamespace: c.env.AUTH,
|
|
965
|
+
kvNamespace: c.env.KV,
|
|
966
|
+
config: parseConfig(c.env),
|
|
967
|
+
env: c.env as never,
|
|
968
|
+
executionCtx: c.executionCtx as never,
|
|
969
|
+
},
|
|
970
|
+
{ namespace: resolved.namespace },
|
|
971
|
+
),
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
return c.json({ deleted: true });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ─── BATCH ───
|
|
978
|
+
|
|
979
|
+
async function handleBatch(
|
|
980
|
+
c: Context<HonoEnv>,
|
|
981
|
+
resolved: D1ResolvedDb,
|
|
982
|
+
tableName: string,
|
|
983
|
+
tableConfig: TableConfig,
|
|
984
|
+
auth: AuthContext | null,
|
|
985
|
+
isServiceKey: boolean,
|
|
986
|
+
): Promise<Response> {
|
|
987
|
+
let body: {
|
|
988
|
+
inserts?: Record<string, unknown>[];
|
|
989
|
+
updates?: { id: string; data: Record<string, unknown> }[];
|
|
990
|
+
deletes?: string[];
|
|
991
|
+
};
|
|
992
|
+
try {
|
|
993
|
+
body = await c.req.json();
|
|
994
|
+
} catch {
|
|
995
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Batch size limit: 500 total ops
|
|
999
|
+
const MAX_BATCH_SIZE = 500;
|
|
1000
|
+
const totalOps = (body.inserts?.length ?? 0) + (body.updates?.length ?? 0) + (body.deletes?.length ?? 0);
|
|
1001
|
+
if (totalOps > MAX_BATCH_SIZE) {
|
|
1002
|
+
return c.json({ code: 400, message: `Batch limit exceeded: ${totalOps} operations (max ${MAX_BATCH_SIZE}).` }, 400);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Check insert rule (table-level, once)
|
|
1006
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
1007
|
+
if (!isServiceKey && body.inserts?.length && tableAccess?.insert !== undefined) {
|
|
1008
|
+
if (!(await evalInsertRule(tableAccess.insert, auth))) {
|
|
1009
|
+
return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Upsert mode support
|
|
1014
|
+
const upsertMode = c.req.query('upsert') === 'true';
|
|
1015
|
+
const conflictTarget = c.req.query('conflictTarget') || 'id';
|
|
1016
|
+
|
|
1017
|
+
if (upsertMode && conflictTarget !== 'id') {
|
|
1018
|
+
const eff = buildEffectiveSchema(tableConfig.schema);
|
|
1019
|
+
const targetField = eff[conflictTarget];
|
|
1020
|
+
if (!targetField) {
|
|
1021
|
+
return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' does not exist in schema.` }, 400);
|
|
1022
|
+
}
|
|
1023
|
+
if (!targetField.unique) {
|
|
1024
|
+
return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' must be a unique field.` }, 400);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ── Pre-validate ALL operations before executing any (ensures atomicity) ──
|
|
1029
|
+
const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
|
|
1030
|
+
const now = new Date().toISOString();
|
|
1031
|
+
|
|
1032
|
+
// Validate all inserts
|
|
1033
|
+
if (body.inserts?.length) {
|
|
1034
|
+
body.inserts = body.inserts.map((item) => applySchemaFieldAliases(item, tableConfig.schema));
|
|
1035
|
+
for (const item of body.inserts) {
|
|
1036
|
+
const validation = validateInsert(item, tableConfig.schema);
|
|
1037
|
+
if (!validation.valid) {
|
|
1038
|
+
return c.json({ code: 400, message: 'Batch insert request failed validation. See data for field-level errors.', data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])) }, 400);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Validate all updates
|
|
1044
|
+
if (body.updates?.length) {
|
|
1045
|
+
body.updates = body.updates.map((entry) => ({
|
|
1046
|
+
...entry,
|
|
1047
|
+
data: applySchemaFieldAliases(entry.data, tableConfig.schema),
|
|
1048
|
+
}));
|
|
1049
|
+
for (const entry of body.updates) {
|
|
1050
|
+
if (!entry.id) {
|
|
1051
|
+
return c.json({ code: 400, message: 'Each batch update entry must include an id.' }, 400);
|
|
1052
|
+
}
|
|
1053
|
+
if (!entry.data || typeof entry.data !== 'object') {
|
|
1054
|
+
return c.json({ code: 400, message: 'Each batch update entry must include a data object.' }, 400);
|
|
1055
|
+
}
|
|
1056
|
+
const validation = validateUpdate(entry.data, tableConfig.schema);
|
|
1057
|
+
if (!validation.valid) {
|
|
1058
|
+
return c.json({ code: 400, message: 'Batch update request failed validation. See data for field-level errors.', data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])) }, 400);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Check delete rules (table-level)
|
|
1064
|
+
if (!isServiceKey && body.deletes?.length && tableAccess?.delete !== undefined) {
|
|
1065
|
+
if (!(await evalRowRule(tableAccess.delete, auth, {}))) {
|
|
1066
|
+
return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ── All validation passed — now execute ──
|
|
1071
|
+
const results: Record<string, unknown> = {};
|
|
1072
|
+
const allChanges: Array<{ type: 'added' | 'modified' | 'removed'; docId: string; data: Record<string, unknown> | null }> = [];
|
|
1073
|
+
|
|
1074
|
+
// ── Inserts ──
|
|
1075
|
+
if (body.inserts) results.inserted = [];
|
|
1076
|
+
if (body.inserts?.length) {
|
|
1077
|
+
const stmts: D1PreparedStatement[] = [];
|
|
1078
|
+
const insertedRecords: Record<string, unknown>[] = [];
|
|
1079
|
+
|
|
1080
|
+
for (const item of body.inserts) {
|
|
1081
|
+
const id = (item.id as string) || generateId();
|
|
1082
|
+
const record: Record<string, unknown> = { ...item, id };
|
|
1083
|
+
if (effectiveSchema.createdAt) record.createdAt = now;
|
|
1084
|
+
if (effectiveSchema.updatedAt) record.updatedAt = now;
|
|
1085
|
+
|
|
1086
|
+
for (const [fname, field] of Object.entries(effectiveSchema)) {
|
|
1087
|
+
if (record[fname] === undefined && field.default !== undefined) {
|
|
1088
|
+
record[fname] = field.default;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const data = filterToSchemaColumns(record, effectiveSchema);
|
|
1093
|
+
serializeJsonFields(data, effectiveSchema);
|
|
1094
|
+
|
|
1095
|
+
const columns = Object.keys(data);
|
|
1096
|
+
const values = columns.map(col => data[col] ?? null);
|
|
1097
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
1098
|
+
const colStr = columns.map(esc).join(', ');
|
|
1099
|
+
|
|
1100
|
+
let sql: string;
|
|
1101
|
+
if (upsertMode) {
|
|
1102
|
+
const updateCols = columns.filter(k => k !== 'id' && k !== 'createdAt' && k !== conflictTarget);
|
|
1103
|
+
const updateSet = updateCols.map(k => `${esc(k)} = excluded.${esc(k)}`).join(', ');
|
|
1104
|
+
sql = updateSet
|
|
1105
|
+
? `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO UPDATE SET ${updateSet}`
|
|
1106
|
+
: `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO NOTHING`;
|
|
1107
|
+
} else {
|
|
1108
|
+
sql = `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders})`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const stmt = resolved.db.prepare(sql);
|
|
1112
|
+
stmts.push(values.length > 0 ? stmt.bind(...values) : stmt);
|
|
1113
|
+
insertedRecords.push(data);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Execute all inserts atomically via db.batch()
|
|
1117
|
+
await resolved.db.batch(stmts);
|
|
1118
|
+
|
|
1119
|
+
// Re-fetch all inserted rows
|
|
1120
|
+
const inserted = results.inserted as Record<string, unknown>[];
|
|
1121
|
+
for (const rec of insertedRecords) {
|
|
1122
|
+
const fetchField = upsertMode && conflictTarget !== 'id' ? conflictTarget : 'id';
|
|
1123
|
+
const fetchValue = rec[fetchField];
|
|
1124
|
+
const fetchResult = await executeD1Query(resolved.db, `SELECT * FROM ${esc(tableName)} WHERE ${esc(String(fetchField))} = ?`, [fetchValue]);
|
|
1125
|
+
if (fetchResult.rows.length > 0) {
|
|
1126
|
+
const row = normalizeRow(stripInternalFields(fetchResult.rows[0]), tableConfig);
|
|
1127
|
+
inserted.push(row);
|
|
1128
|
+
allChanges.push({ type: 'added', docId: String(row.id ?? ''), data: row });
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ── Updates ──
|
|
1134
|
+
if (body.updates) results.updated = [];
|
|
1135
|
+
if (body.updates?.length) {
|
|
1136
|
+
const updated = results.updated as Record<string, unknown>[];
|
|
1137
|
+
for (const entry of body.updates) {
|
|
1138
|
+
const updateData = { ...entry.data };
|
|
1139
|
+
delete updateData.id;
|
|
1140
|
+
delete updateData.createdAt;
|
|
1141
|
+
if (effectiveSchema.updatedAt?.onUpdate === 'now') {
|
|
1142
|
+
updateData.updatedAt = now;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Serialize json-type fields
|
|
1146
|
+
for (const [key, value] of Object.entries(updateData)) {
|
|
1147
|
+
if (effectiveSchema[key]?.type === 'json' && value !== null && value !== undefined && typeof value === 'object' && !('$op' in (value as Record<string, unknown>))) {
|
|
1148
|
+
updateData[key] = JSON.stringify(value);
|
|
1149
|
+
} else if (effectiveSchema[key]?.type === 'boolean' && value !== null && value !== undefined && (typeof value !== 'object' || !('$op' in value))) {
|
|
1150
|
+
updateData[key] = value === true || value === 'true' || value === 1 || value === '1'
|
|
1151
|
+
? 1
|
|
1152
|
+
: 0;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const { setClauses, params } = parseUpdateBody(updateData);
|
|
1157
|
+
if (setClauses.length > 0) {
|
|
1158
|
+
params.push(entry.id);
|
|
1159
|
+
await executeD1Query(resolved.db, `UPDATE ${esc(tableName)} SET ${setClauses.join(', ')} WHERE "id" = ?`, params);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Re-fetch the updated row
|
|
1163
|
+
const fetchResult = await executeD1Query(resolved.db, `SELECT * FROM ${esc(tableName)} WHERE "id" = ?`, [entry.id]);
|
|
1164
|
+
const row = fetchResult.rows.length > 0
|
|
1165
|
+
? normalizeRow(stripInternalFields(fetchResult.rows[0]), tableConfig)
|
|
1166
|
+
: { id: entry.id, ...entry.data };
|
|
1167
|
+
updated.push(row);
|
|
1168
|
+
allChanges.push({ type: 'modified', docId: String(row.id ?? entry.id), data: row });
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ── Deletes ──
|
|
1173
|
+
if (body.deletes) results.deleted = 0;
|
|
1174
|
+
if (body.deletes?.length) {
|
|
1175
|
+
for (const id of body.deletes) {
|
|
1176
|
+
await executeD1Query(resolved.db, `DELETE FROM ${esc(tableName)} WHERE "id" = ?`, [id]);
|
|
1177
|
+
}
|
|
1178
|
+
results.deleted = body.deletes.length;
|
|
1179
|
+
for (const id of body.deletes) {
|
|
1180
|
+
allChanges.push({ type: 'removed', docId: id, data: null });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Emit database-live events
|
|
1185
|
+
if (allChanges.length > 0) {
|
|
1186
|
+
if (allChanges.length >= 10) {
|
|
1187
|
+
c.executionCtx.waitUntil(
|
|
1188
|
+
emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, allChanges),
|
|
1189
|
+
);
|
|
1190
|
+
} else {
|
|
1191
|
+
for (const ch of allChanges) {
|
|
1192
|
+
c.executionCtx.waitUntil(
|
|
1193
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
return c.json(results);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ─── BATCH BY FILTER ───
|
|
1203
|
+
|
|
1204
|
+
async function handleBatchByFilter(
|
|
1205
|
+
c: Context<HonoEnv>,
|
|
1206
|
+
resolved: D1ResolvedDb,
|
|
1207
|
+
tableName: string,
|
|
1208
|
+
tableConfig: TableConfig,
|
|
1209
|
+
_auth: AuthContext | null,
|
|
1210
|
+
_isServiceKey: boolean,
|
|
1211
|
+
): Promise<Response> {
|
|
1212
|
+
let body: {
|
|
1213
|
+
action?: string;
|
|
1214
|
+
filter?: FilterTuple[];
|
|
1215
|
+
orFilter?: FilterTuple[];
|
|
1216
|
+
update?: Record<string, unknown>;
|
|
1217
|
+
limit?: number;
|
|
1218
|
+
};
|
|
1219
|
+
try {
|
|
1220
|
+
body = await c.req.json();
|
|
1221
|
+
} catch {
|
|
1222
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (!body.action || !['delete', 'update'].includes(body.action)) {
|
|
1226
|
+
return c.json({ code: 400, message: "batch-by-filter requires 'action' to be 'delete' or 'update'." }, 400);
|
|
1227
|
+
}
|
|
1228
|
+
if (!body.filter || !Array.isArray(body.filter)) {
|
|
1229
|
+
return c.json({ code: 400, message: "batch-by-filter requires 'filter' to be a non-empty array." }, 400);
|
|
1230
|
+
}
|
|
1231
|
+
if (body.action === 'update' && !body.update) {
|
|
1232
|
+
return c.json({ code: 400, message: "batch-by-filter with action 'update' requires 'update' data." }, 400);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const limit = Math.min(body.limit ?? 500, 500);
|
|
1236
|
+
|
|
1237
|
+
// Find matching records using buildListQuery
|
|
1238
|
+
const { sql: selectSql, params: selectParams } = buildListQuery(tableName, {
|
|
1239
|
+
filters: body.filter,
|
|
1240
|
+
orFilters: body.orFilter,
|
|
1241
|
+
pagination: { limit },
|
|
1242
|
+
}, 'sqlite');
|
|
1243
|
+
const selectResult = await executeD1Query(resolved.db, selectSql, selectParams);
|
|
1244
|
+
const allRows = selectResult.rows;
|
|
1245
|
+
const processed = allRows.length;
|
|
1246
|
+
|
|
1247
|
+
if (allRows.length === 0) {
|
|
1248
|
+
return c.json({ processed: 0, succeeded: 0 });
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const ids = allRows.map(r => r.id as string);
|
|
1252
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
1253
|
+
let succeeded = 0;
|
|
1254
|
+
|
|
1255
|
+
if (body.action === 'delete') {
|
|
1256
|
+
await executeD1Query(resolved.db, `DELETE FROM ${esc(tableName)} WHERE "id" IN (${placeholders})`, ids);
|
|
1257
|
+
succeeded = ids.length;
|
|
1258
|
+
} else if (body.action === 'update' && body.update) {
|
|
1259
|
+
const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
|
|
1260
|
+
const updateData = { ...body.update };
|
|
1261
|
+
if (effectiveSchema.updatedAt?.onUpdate === 'now') {
|
|
1262
|
+
updateData.updatedAt = new Date().toISOString();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Serialize json-type fields
|
|
1266
|
+
for (const [key, value] of Object.entries(updateData)) {
|
|
1267
|
+
if (effectiveSchema[key]?.type === 'json' && value !== null && value !== undefined && typeof value === 'object' && !('$op' in (value as Record<string, unknown>))) {
|
|
1268
|
+
updateData[key] = JSON.stringify(value);
|
|
1269
|
+
} else if (effectiveSchema[key]?.type === 'boolean' && value !== null && value !== undefined && (typeof value !== 'object' || !('$op' in value))) {
|
|
1270
|
+
updateData[key] = value === true || value === 'true' || value === 1 || value === '1'
|
|
1271
|
+
? 1
|
|
1272
|
+
: 0;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const { setClauses, params } = parseUpdateBody(updateData);
|
|
1277
|
+
if (setClauses.length > 0) {
|
|
1278
|
+
await executeD1Query(
|
|
1279
|
+
resolved.db,
|
|
1280
|
+
`UPDATE ${esc(tableName)} SET ${setClauses.join(', ')} WHERE "id" IN (${placeholders})`,
|
|
1281
|
+
[...params, ...ids],
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
succeeded = ids.length;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Emit database-live events
|
|
1288
|
+
if (succeeded > 0) {
|
|
1289
|
+
const eventType = body.action === 'delete' ? 'removed' : 'modified';
|
|
1290
|
+
c.executionCtx.waitUntil(
|
|
1291
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, eventType as 'modified' | 'removed', '_bulk', { action: body.action, count: succeeded }),
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return c.json({ processed, succeeded });
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ─── Helpers ───
|
|
1299
|
+
|
|
1300
|
+
function getTextFields(config: TableConfig): string[] {
|
|
1301
|
+
if (!config.schema) return ['id'];
|
|
1302
|
+
const fields: string[] = [];
|
|
1303
|
+
for (const [name, field] of Object.entries(config.schema)) {
|
|
1304
|
+
if (field === false) continue;
|
|
1305
|
+
if (field.type === 'string' || field.type === 'text') {
|
|
1306
|
+
fields.push(name);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return fields.length > 0 ? fields : ['id'];
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function toSnakeCase(value: string): string {
|
|
1313
|
+
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function toCamelCase(value: string): string {
|
|
1317
|
+
return value.replace(/_([a-z0-9])/g, (_match, char: string) => char.toUpperCase());
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function applySchemaFieldAliases<T extends Record<string, unknown> | null | undefined>(
|
|
1321
|
+
record: T,
|
|
1322
|
+
schema?: Record<string, SchemaField | false>,
|
|
1323
|
+
): T {
|
|
1324
|
+
if (!schema || !record || typeof record !== 'object' || Array.isArray(record)) return record;
|
|
1325
|
+
|
|
1326
|
+
const effectiveSchema = buildEffectiveSchema(schema);
|
|
1327
|
+
const normalized: Record<string, unknown> = { ...record };
|
|
1328
|
+
|
|
1329
|
+
for (const key of Object.keys(effectiveSchema)) {
|
|
1330
|
+
const snake = toSnakeCase(key);
|
|
1331
|
+
const camel = toCamelCase(key);
|
|
1332
|
+
|
|
1333
|
+
if (effectiveSchema[snake] && normalized[snake] === undefined && normalized[key] !== undefined) {
|
|
1334
|
+
normalized[snake] = normalized[key];
|
|
1335
|
+
}
|
|
1336
|
+
if (effectiveSchema[camel] && normalized[camel] === undefined && normalized[key] !== undefined) {
|
|
1337
|
+
normalized[camel] = normalized[key];
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return normalized as T;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ─── Exported batch import for admin routes ───
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Batch import records into D1 directly (bypasses rules, for admin use).
|
|
1348
|
+
* Returns { imported, errors }.
|
|
1349
|
+
*/
|
|
1350
|
+
export async function d1BatchImport(
|
|
1351
|
+
env: Env,
|
|
1352
|
+
namespace: string,
|
|
1353
|
+
tableName: string,
|
|
1354
|
+
records: Record<string, unknown>[],
|
|
1355
|
+
options?: { upsert?: boolean; conflictTarget?: string },
|
|
1356
|
+
): Promise<{ imported: number; errors: Array<{ row: number; message: string }> }> {
|
|
1357
|
+
const resolved = resolveD1Binding(env, namespace);
|
|
1358
|
+
const tableConfig = resolved.dbBlock.tables?.[tableName];
|
|
1359
|
+
if (!tableConfig) {
|
|
1360
|
+
throw new EdgeBaseError(404, `Table '${tableName}' not found in database '${namespace}'.`);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
await ensureD1Schema(resolved.db, namespace, resolved.dbBlock.tables ?? {});
|
|
1364
|
+
|
|
1365
|
+
const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
|
|
1366
|
+
const now = new Date().toISOString();
|
|
1367
|
+
const upsertMode = options?.upsert ?? false;
|
|
1368
|
+
const conflictTarget = options?.conflictTarget ?? 'id';
|
|
1369
|
+
|
|
1370
|
+
const stmts: D1PreparedStatement[] = [];
|
|
1371
|
+
const errors: Array<{ row: number; message: string }> = [];
|
|
1372
|
+
|
|
1373
|
+
for (let i = 0; i < records.length; i++) {
|
|
1374
|
+
const item = applySchemaFieldAliases(records[i], tableConfig.schema);
|
|
1375
|
+
const validation = validateInsert(item, tableConfig.schema);
|
|
1376
|
+
if (!validation.valid) {
|
|
1377
|
+
errors.push({ row: i, message: Object.values(validation.errors).join('; ') });
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const id = (item.id as string) || generateId();
|
|
1382
|
+
const record: Record<string, unknown> = { ...item, id };
|
|
1383
|
+
if (effectiveSchema.createdAt) record.createdAt = now;
|
|
1384
|
+
if (effectiveSchema.updatedAt) record.updatedAt = now;
|
|
1385
|
+
|
|
1386
|
+
for (const [fname, field] of Object.entries(effectiveSchema)) {
|
|
1387
|
+
if (record[fname] === undefined && field.default !== undefined) {
|
|
1388
|
+
record[fname] = field.default;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const data = filterToSchemaColumns(record, effectiveSchema);
|
|
1393
|
+
serializeJsonFields(data, effectiveSchema);
|
|
1394
|
+
|
|
1395
|
+
const columns = Object.keys(data);
|
|
1396
|
+
const values = columns.map(col => data[col] ?? null);
|
|
1397
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
1398
|
+
const colStr = columns.map(esc).join(', ');
|
|
1399
|
+
|
|
1400
|
+
let sql: string;
|
|
1401
|
+
if (upsertMode) {
|
|
1402
|
+
const updateCols = columns.filter(k => k !== 'id' && k !== 'createdAt' && k !== conflictTarget);
|
|
1403
|
+
const updateSet = updateCols.map(k => `${esc(k)} = excluded.${esc(k)}`).join(', ');
|
|
1404
|
+
sql = updateSet
|
|
1405
|
+
? `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO UPDATE SET ${updateSet}`
|
|
1406
|
+
: `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO NOTHING`;
|
|
1407
|
+
} else {
|
|
1408
|
+
sql = `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders})`;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const stmt = resolved.db.prepare(sql);
|
|
1412
|
+
stmts.push(values.length > 0 ? stmt.bind(...values) : stmt);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (stmts.length === 0) {
|
|
1416
|
+
return { imported: 0, errors };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
try {
|
|
1420
|
+
await resolved.db.batch(stmts);
|
|
1421
|
+
return { imported: stmts.length, errors };
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
return { imported: 0, errors: [{ row: 0, message: err instanceof Error ? err.message : 'Batch insert failed' }] };
|
|
1424
|
+
}
|
|
1425
|
+
}
|