@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,1102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL request handler for provider='neon'|'postgres'.
|
|
3
|
+
*
|
|
4
|
+
* Runs in Worker context (not DO) — handles:
|
|
5
|
+
* - Hyperdrive binding resolution
|
|
6
|
+
* - Lazy schema initialization (via postgres-schema-init)
|
|
7
|
+
* - CRUD operations (via query-engine + postgres-executor)
|
|
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 database-do.ts CRUD logic but uses PostgreSQL instead of SQLite.
|
|
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
|
+
DbBlock,
|
|
25
|
+
} from '@edge-base/shared';
|
|
26
|
+
import { EdgeBaseError, getTableAccess, getTableHooks } from '@edge-base/shared';
|
|
27
|
+
import { parseConfig } from './do-router.js';
|
|
28
|
+
import {
|
|
29
|
+
ensureLocalDevPostgresSchema,
|
|
30
|
+
getLocalDevPostgresExecOptions,
|
|
31
|
+
getProviderBindingName,
|
|
32
|
+
executePostgresQuery,
|
|
33
|
+
withPostgresConnection,
|
|
34
|
+
type PostgresExecutor,
|
|
35
|
+
} from './postgres-executor.js';
|
|
36
|
+
import { ensurePgSchema } from './postgres-schema-init.js';
|
|
37
|
+
import {
|
|
38
|
+
buildListQuery, buildCountQuery, buildGetQuery, buildSearchQuery,
|
|
39
|
+
parseQueryParams,
|
|
40
|
+
type FilterTuple,
|
|
41
|
+
} from './query-engine.js';
|
|
42
|
+
import { summarizeValidationErrors, validateInsert, validateUpdate } from './validation.js';
|
|
43
|
+
import { emitDbLiveEvent, emitDbLiveBatchEvent } from './database-live-emitter.js';
|
|
44
|
+
import { forbiddenError, hookRejectedError } from './errors.js';
|
|
45
|
+
import {
|
|
46
|
+
escapePgIdentifier,
|
|
47
|
+
preparePgInsertData,
|
|
48
|
+
preparePgUpdateData,
|
|
49
|
+
stripInternalPgFields,
|
|
50
|
+
} from './postgres-table-utils.js';
|
|
51
|
+
import { isTrustedInternalContext } from './internal-request.js';
|
|
52
|
+
import { executeDbTriggers } from './functions.js';
|
|
53
|
+
import { parseUpdateBody } from './op-parser.js';
|
|
54
|
+
|
|
55
|
+
// ─── Types ───
|
|
56
|
+
|
|
57
|
+
interface PgResolvedDb {
|
|
58
|
+
connectionString: string;
|
|
59
|
+
dbBlock: DbBlock;
|
|
60
|
+
namespace: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Main Handler ───
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Handle a request to a PostgreSQL-backed database table.
|
|
67
|
+
* Called from tables.ts when provider is 'neon' or 'postgres'.
|
|
68
|
+
*
|
|
69
|
+
* @param c - Hono context
|
|
70
|
+
* @param namespace - Database namespace (e.g. 'shared')
|
|
71
|
+
* @param tableName - Table name (e.g. 'posts')
|
|
72
|
+
* @param doPath - Internal path (e.g. '/tables/posts', '/tables/posts/abc123')
|
|
73
|
+
*/
|
|
74
|
+
export async function handlePgRequest(
|
|
75
|
+
c: Context<HonoEnv>,
|
|
76
|
+
namespace: string,
|
|
77
|
+
tableName: string,
|
|
78
|
+
doPath: string,
|
|
79
|
+
): Promise<Response> {
|
|
80
|
+
const resolved = resolvePgConnection(c.env, namespace);
|
|
81
|
+
const tableConfig = resolved.dbBlock.tables?.[tableName];
|
|
82
|
+
if (!tableConfig) {
|
|
83
|
+
return c.json({ code: 404, message: `Table '${tableName}' not found in database '${namespace}'.` }, 404);
|
|
84
|
+
}
|
|
85
|
+
const isServiceKey = checkServiceKey(c);
|
|
86
|
+
const auth = c.get('auth') as AuthContext | null | undefined ?? null;
|
|
87
|
+
const method = c.req.raw.method;
|
|
88
|
+
const pathSuffix = doPath.replace(`/tables/${tableName}`, '');
|
|
89
|
+
const localDevOptions = getLocalDevPostgresExecOptions(c.env as unknown as Record<string, unknown>, namespace);
|
|
90
|
+
|
|
91
|
+
if (localDevOptions) {
|
|
92
|
+
await ensureLocalDevPostgresSchema(localDevOptions);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return withPostgresConnection(
|
|
96
|
+
resolved.connectionString,
|
|
97
|
+
async (query) => {
|
|
98
|
+
if (!localDevOptions) {
|
|
99
|
+
await ensurePgSchema(
|
|
100
|
+
resolved.connectionString,
|
|
101
|
+
namespace,
|
|
102
|
+
resolved.dbBlock.tables ?? {},
|
|
103
|
+
query,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (method === 'GET') {
|
|
108
|
+
if (pathSuffix === '/count') {
|
|
109
|
+
return handleCount(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
|
|
110
|
+
}
|
|
111
|
+
if (pathSuffix === '/search') {
|
|
112
|
+
return handleSearch(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
|
|
113
|
+
}
|
|
114
|
+
if (pathSuffix && pathSuffix !== '/') {
|
|
115
|
+
const id = pathSuffix.slice(1);
|
|
116
|
+
return handleGet(c, resolved, tableName, tableConfig, id, auth, isServiceKey, query);
|
|
117
|
+
}
|
|
118
|
+
return handleList(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (method === 'POST') {
|
|
122
|
+
if (pathSuffix === '/batch') {
|
|
123
|
+
return handleBatch(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
|
|
124
|
+
}
|
|
125
|
+
if (pathSuffix === '/batch-by-filter') {
|
|
126
|
+
return handleBatchByFilter(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
|
|
127
|
+
}
|
|
128
|
+
return handleInsert(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (method === 'PATCH' || method === 'PUT') {
|
|
132
|
+
const id = pathSuffix.slice(1);
|
|
133
|
+
return handleUpdate(c, resolved, tableName, tableConfig, id, auth, isServiceKey, query);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (method === 'DELETE') {
|
|
137
|
+
const id = pathSuffix.slice(1);
|
|
138
|
+
return handleDelete(c, resolved, tableName, tableConfig, id, auth, isServiceKey, query);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return c.json({ code: 405, message: 'Method not allowed' }, 405);
|
|
142
|
+
},
|
|
143
|
+
localDevOptions,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Connection Resolution ───
|
|
148
|
+
|
|
149
|
+
function resolvePgConnection(env: Env, namespace: string): PgResolvedDb {
|
|
150
|
+
const config = parseConfig(env);
|
|
151
|
+
const dbBlock = config.databases?.[namespace];
|
|
152
|
+
if (!dbBlock) {
|
|
153
|
+
throw new EdgeBaseError(404, `Database '${namespace}' not found in config.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const bindingName = getProviderBindingName(namespace);
|
|
157
|
+
const envRecord = env as unknown as Record<string, unknown>;
|
|
158
|
+
|
|
159
|
+
// 1. Try Hyperdrive binding (production — object with .connectionString)
|
|
160
|
+
const hyperdrive = envRecord[bindingName] as { connectionString: string } | undefined;
|
|
161
|
+
if (hyperdrive?.connectionString) {
|
|
162
|
+
return { connectionString: hyperdrive.connectionString, dbBlock, namespace };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Fallback: direct connection string from env (local dev — {BINDING}_URL string)
|
|
166
|
+
const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
|
|
167
|
+
const directUrl = envRecord[envKey] as string | undefined;
|
|
168
|
+
if (directUrl) {
|
|
169
|
+
return { connectionString: directUrl, dbBlock, namespace };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new EdgeBaseError(500,
|
|
173
|
+
`PostgreSQL connection for '${namespace}' not found. ` +
|
|
174
|
+
`In production: run 'edgebase deploy' to auto-provision Hyperdrive. ` +
|
|
175
|
+
`In development: add ${envKey}=postgres://... to .env.development`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Service Key Check ───
|
|
180
|
+
|
|
181
|
+
function checkServiceKey(c: Context<HonoEnv>): boolean {
|
|
182
|
+
if (isTrustedInternalContext(c)) return true;
|
|
183
|
+
// Public request paths must be validated upstream (rules middleware / admin route)
|
|
184
|
+
// so provider-backed handlers observe the same scoped + constrained bypass result.
|
|
185
|
+
return c.get('isServiceKey' as never) === true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Rule Evaluation ───
|
|
189
|
+
|
|
190
|
+
async function evalRowRule(
|
|
191
|
+
rule: TableRules['read'],
|
|
192
|
+
auth: AuthContext | null,
|
|
193
|
+
row: Record<string, unknown>,
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
if (rule === undefined || rule === null) return true;
|
|
196
|
+
if (typeof rule === 'boolean') return rule;
|
|
197
|
+
if (typeof rule === 'function') {
|
|
198
|
+
try {
|
|
199
|
+
const result = await Promise.race([
|
|
200
|
+
Promise.resolve(rule(auth, row)),
|
|
201
|
+
new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
|
|
202
|
+
]);
|
|
203
|
+
return result;
|
|
204
|
+
} catch {
|
|
205
|
+
return false; // fail-closed
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function evalInsertRule(
|
|
212
|
+
rule: TableRules['insert'],
|
|
213
|
+
auth: AuthContext | null,
|
|
214
|
+
): Promise<boolean> {
|
|
215
|
+
if (rule === undefined || rule === null) return true;
|
|
216
|
+
if (typeof rule === 'boolean') return rule;
|
|
217
|
+
if (typeof rule === 'function') {
|
|
218
|
+
try {
|
|
219
|
+
const result = await Promise.race([
|
|
220
|
+
Promise.resolve((rule as (a: AuthContext | null) => boolean | Promise<boolean>)(auth)),
|
|
221
|
+
new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
|
|
222
|
+
]);
|
|
223
|
+
return result;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Hook Context Builder ───
|
|
232
|
+
|
|
233
|
+
function buildHookCtx(
|
|
234
|
+
connectionString: string,
|
|
235
|
+
tables: Record<string, TableConfig>,
|
|
236
|
+
executionCtx?: ExecutionContext,
|
|
237
|
+
queryExecutor?: PostgresExecutor,
|
|
238
|
+
): HookCtx {
|
|
239
|
+
const query =
|
|
240
|
+
queryExecutor ??
|
|
241
|
+
((sql: string, params: unknown[] = []) => executePostgresQuery(connectionString, sql, params));
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
db: {
|
|
245
|
+
async get(table: string, id: string): Promise<Record<string, unknown> | null> {
|
|
246
|
+
const { sql, params } = buildGetQuery(table, id, undefined, 'postgres');
|
|
247
|
+
const result = await query(sql, params);
|
|
248
|
+
return result.rows.length > 0
|
|
249
|
+
? stripInternalPgFields(result.rows[0] as Record<string, unknown>)
|
|
250
|
+
: null;
|
|
251
|
+
},
|
|
252
|
+
async list(table: string, filter?: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
|
|
253
|
+
let sql = `SELECT * FROM "${table.replace(/"/g, '""')}"`;
|
|
254
|
+
const params: unknown[] = [];
|
|
255
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
256
|
+
const conditions: string[] = [];
|
|
257
|
+
let idx = 1;
|
|
258
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
259
|
+
conditions.push(`"${key.replace(/"/g, '""')}" = $${idx++}`);
|
|
260
|
+
params.push(value);
|
|
261
|
+
}
|
|
262
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
263
|
+
}
|
|
264
|
+
sql += ' LIMIT 100';
|
|
265
|
+
const result = await query(sql, params);
|
|
266
|
+
return result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>));
|
|
267
|
+
},
|
|
268
|
+
async exists(table: string, filter: Record<string, unknown>): Promise<boolean> {
|
|
269
|
+
const conditions: string[] = [];
|
|
270
|
+
const params: unknown[] = [];
|
|
271
|
+
let idx = 1;
|
|
272
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
273
|
+
conditions.push(`"${key.replace(/"/g, '""')}" = $${idx++}`);
|
|
274
|
+
params.push(value);
|
|
275
|
+
}
|
|
276
|
+
const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
277
|
+
const sql = `SELECT 1 FROM "${table.replace(/"/g, '""')}"${where} LIMIT 1`;
|
|
278
|
+
const result = await query(sql, params);
|
|
279
|
+
return result.rows.length > 0;
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
databaseLive: {
|
|
283
|
+
async broadcast(_channel: string, _event: string, _data: unknown): Promise<void> {
|
|
284
|
+
// HookCtx broadcast — not implemented for PostgreSQL provider (no direct env access)
|
|
285
|
+
// Use database-live subscription from client SDK instead
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
push: {
|
|
289
|
+
async send(_userId: string, _payload: { title?: string; body: string }): Promise<void> {
|
|
290
|
+
// Push notifications — same mechanism as DO (via Worker env)
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
waitUntil(promise: Promise<unknown>): void {
|
|
294
|
+
if (executionCtx) {
|
|
295
|
+
executionCtx.waitUntil(promise);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function toFieldErrorData(
|
|
302
|
+
errors: Record<string, string>,
|
|
303
|
+
): Record<string, { code: string; message: string }> {
|
|
304
|
+
return Object.fromEntries(
|
|
305
|
+
Object.entries(errors).map(([key, message]) => [key, { code: 'invalid', message }]),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
310
|
+
// CRUD Operations
|
|
311
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
312
|
+
|
|
313
|
+
// ─── LIST ───
|
|
314
|
+
|
|
315
|
+
async function handleList(
|
|
316
|
+
c: Context<HonoEnv>,
|
|
317
|
+
resolved: PgResolvedDb,
|
|
318
|
+
tableName: string,
|
|
319
|
+
tableConfig: TableConfig,
|
|
320
|
+
auth: AuthContext | null,
|
|
321
|
+
isServiceKey: boolean,
|
|
322
|
+
query: PostgresExecutor,
|
|
323
|
+
): Promise<Response> {
|
|
324
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
325
|
+
if (!isServiceKey && tableAccess?.read === false) {
|
|
326
|
+
const error = forbiddenError('Access denied.');
|
|
327
|
+
return c.json(error.toJSON(), error.status as 403);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
331
|
+
const { sql, params, countSql, countParams } = buildListQuery(tableName, queryOpts, 'postgres');
|
|
332
|
+
const result = await query(sql, params);
|
|
333
|
+
|
|
334
|
+
// Apply read rules per row
|
|
335
|
+
let items = result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>));
|
|
336
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
337
|
+
if (!isServiceKey && tableAccess?.read !== undefined) {
|
|
338
|
+
const filtered: Record<string, unknown>[] = [];
|
|
339
|
+
for (const row of items) {
|
|
340
|
+
if (await evalRowRule(tableAccess.read, auth, row)) {
|
|
341
|
+
filtered.push(row);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
items = filtered;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Apply onEnrich hook
|
|
348
|
+
if (tableHooks?.onEnrich) {
|
|
349
|
+
const hookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
|
|
350
|
+
for (let i = 0; i < items.length; i++) {
|
|
351
|
+
try {
|
|
352
|
+
const enriched = await tableHooks.onEnrich(auth, items[i], hookCtx);
|
|
353
|
+
if (enriched && typeof enriched === 'object') items[i] = { ...items[i], ...enriched };
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Get total count
|
|
361
|
+
let total: number | null = null;
|
|
362
|
+
const includeTotal = !['0', 'false'].includes((c.req.query('includeTotal') ?? '').toLowerCase());
|
|
363
|
+
if (includeTotal && countSql && countParams) {
|
|
364
|
+
const countResult = await query(countSql, countParams);
|
|
365
|
+
total = Number(countResult.rows[0]?.total ?? 0);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
|
|
369
|
+
const page = queryOpts.pagination?.page ?? 1;
|
|
370
|
+
const hasMore = queryOpts.pagination?.after || queryOpts.pagination?.before
|
|
371
|
+
? items.length >= perPage
|
|
372
|
+
: null;
|
|
373
|
+
const cursor = hasMore && items.length > 0
|
|
374
|
+
? String((items[items.length - 1] as Record<string, unknown>).id ?? '')
|
|
375
|
+
: null;
|
|
376
|
+
|
|
377
|
+
return c.json({ items, total, hasMore, cursor, page: hasMore !== null ? null : page, perPage });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── COUNT ───
|
|
381
|
+
|
|
382
|
+
async function handleCount(
|
|
383
|
+
c: Context<HonoEnv>,
|
|
384
|
+
resolved: PgResolvedDb,
|
|
385
|
+
tableName: string,
|
|
386
|
+
tableConfig: TableConfig,
|
|
387
|
+
_auth: AuthContext | null,
|
|
388
|
+
isServiceKey: boolean,
|
|
389
|
+
query: PostgresExecutor,
|
|
390
|
+
): Promise<Response> {
|
|
391
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
392
|
+
if (!isServiceKey && tableAccess?.read === false) {
|
|
393
|
+
const error = forbiddenError('Access denied.');
|
|
394
|
+
return c.json(error.toJSON(), error.status as 403);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
398
|
+
const { sql, params } = buildCountQuery(tableName, queryOpts.filters, queryOpts.orFilters, 'postgres');
|
|
399
|
+
const result = await query(sql, params);
|
|
400
|
+
const total = result.rows[0]?.total ?? 0;
|
|
401
|
+
return c.json({ total });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ─── SEARCH ───
|
|
405
|
+
|
|
406
|
+
async function handleSearch(
|
|
407
|
+
c: Context<HonoEnv>,
|
|
408
|
+
resolved: PgResolvedDb,
|
|
409
|
+
tableName: string,
|
|
410
|
+
tableConfig: TableConfig,
|
|
411
|
+
auth: AuthContext | null,
|
|
412
|
+
isServiceKey: boolean,
|
|
413
|
+
query: PostgresExecutor,
|
|
414
|
+
): Promise<Response> {
|
|
415
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
416
|
+
if (!isServiceKey && tableAccess?.read === false) {
|
|
417
|
+
const error = forbiddenError('Access denied.');
|
|
418
|
+
return c.json(error.toJSON(), error.status as 403);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
|
|
422
|
+
const searchTerm = queryOpts.search || '';
|
|
423
|
+
if (!searchTerm) {
|
|
424
|
+
return c.json({ code: 400, message: 'Search term is required (use ?search=)' }, 400);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Use FTS fields from config, or fallback to text columns from schema
|
|
428
|
+
const ftsFields = tableConfig.fts?.length
|
|
429
|
+
? tableConfig.fts
|
|
430
|
+
: getTextFields(tableConfig);
|
|
431
|
+
const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
|
|
432
|
+
const offset = queryOpts.pagination?.offset ?? ((queryOpts.pagination?.page ?? 1) - 1) * limit;
|
|
433
|
+
const searchQuery = buildSearchQuery(tableName, searchTerm, {
|
|
434
|
+
pagination: queryOpts.pagination,
|
|
435
|
+
filters: queryOpts.filters,
|
|
436
|
+
orFilters: queryOpts.orFilters,
|
|
437
|
+
sort: queryOpts.sort,
|
|
438
|
+
ftsFields,
|
|
439
|
+
}, 'postgres');
|
|
440
|
+
|
|
441
|
+
const result = await query(searchQuery.sql, searchQuery.params);
|
|
442
|
+
let items = result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>));
|
|
443
|
+
let total = items.length;
|
|
444
|
+
if (searchQuery.countSql) {
|
|
445
|
+
const countResult = await query(searchQuery.countSql, searchQuery.countParams ?? []);
|
|
446
|
+
total = Number(countResult.rows[0]?.total ?? items.length);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Apply read rules
|
|
450
|
+
if (!isServiceKey && tableAccess?.read !== undefined) {
|
|
451
|
+
const filtered: Record<string, unknown>[] = [];
|
|
452
|
+
for (const row of items) {
|
|
453
|
+
if (await evalRowRule(tableAccess.read, auth, row)) {
|
|
454
|
+
filtered.push(row);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
items = filtered;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return c.json({ items, total, hasMore: total > offset + items.length, cursor: null, page: null, perPage: limit });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ─── GET ───
|
|
464
|
+
|
|
465
|
+
async function handleGet(
|
|
466
|
+
c: Context<HonoEnv>,
|
|
467
|
+
resolved: PgResolvedDb,
|
|
468
|
+
tableName: string,
|
|
469
|
+
tableConfig: TableConfig,
|
|
470
|
+
id: string,
|
|
471
|
+
auth: AuthContext | null,
|
|
472
|
+
isServiceKey: boolean,
|
|
473
|
+
query: PostgresExecutor,
|
|
474
|
+
): Promise<Response> {
|
|
475
|
+
const fieldsParam = new URL(c.req.url).searchParams.get('fields');
|
|
476
|
+
const fields = fieldsParam ? fieldsParam.split(',').map(f => f.trim()) : undefined;
|
|
477
|
+
|
|
478
|
+
const { sql, params } = buildGetQuery(tableName, id, fields, 'postgres');
|
|
479
|
+
const result = await query(sql, params);
|
|
480
|
+
|
|
481
|
+
if (result.rows.length === 0) {
|
|
482
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const row = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
|
|
486
|
+
|
|
487
|
+
// Check read rule
|
|
488
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
489
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
490
|
+
if (!isServiceKey && tableAccess?.read !== undefined) {
|
|
491
|
+
if (!(await evalRowRule(tableAccess.read, auth, row))) {
|
|
492
|
+
return c.json({ code: 403, message: 'Access denied.' }, 403);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Apply onEnrich hook
|
|
497
|
+
if (tableHooks?.onEnrich) {
|
|
498
|
+
const hookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
|
|
499
|
+
try {
|
|
500
|
+
const enriched = await tableHooks.onEnrich(auth, row, hookCtx);
|
|
501
|
+
if (enriched && typeof enriched === 'object') return c.json({ ...row, ...enriched });
|
|
502
|
+
} catch (err) {
|
|
503
|
+
console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return c.json(row);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── INSERT ───
|
|
511
|
+
|
|
512
|
+
async function handleInsert(
|
|
513
|
+
c: Context<HonoEnv>,
|
|
514
|
+
resolved: PgResolvedDb,
|
|
515
|
+
tableName: string,
|
|
516
|
+
tableConfig: TableConfig,
|
|
517
|
+
auth: AuthContext | null,
|
|
518
|
+
isServiceKey: boolean,
|
|
519
|
+
query: PostgresExecutor,
|
|
520
|
+
): Promise<Response> {
|
|
521
|
+
let body: Record<string, unknown>;
|
|
522
|
+
try {
|
|
523
|
+
body = await c.req.json();
|
|
524
|
+
} catch {
|
|
525
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check insert rule
|
|
529
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
530
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
531
|
+
if (!isServiceKey && tableAccess?.insert !== undefined) {
|
|
532
|
+
if (!(await evalInsertRule(tableAccess.insert, auth))) {
|
|
533
|
+
return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Validate against schema
|
|
538
|
+
const validation = validateInsert(body, tableConfig.schema);
|
|
539
|
+
if (!validation.valid) {
|
|
540
|
+
return c.json({
|
|
541
|
+
code: 400,
|
|
542
|
+
message: summarizeValidationErrors(validation.errors),
|
|
543
|
+
data: toFieldErrorData(validation.errors),
|
|
544
|
+
errors: validation.errors,
|
|
545
|
+
}, 400);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Run beforeInsert hook
|
|
549
|
+
const requestHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
|
|
550
|
+
if (tableHooks?.beforeInsert) {
|
|
551
|
+
try {
|
|
552
|
+
const transformed = await tableHooks.beforeInsert(auth, body, requestHookCtx);
|
|
553
|
+
if (transformed && typeof transformed === 'object') {
|
|
554
|
+
body = { ...body, ...transformed };
|
|
555
|
+
}
|
|
556
|
+
} catch (err) {
|
|
557
|
+
const hookError = hookRejectedError(err, 'Insert rejected by beforeInsert hook.');
|
|
558
|
+
return c.json(hookError.toJSON(), hookError.status as 400);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const { data } = preparePgInsertData(body, tableConfig);
|
|
563
|
+
|
|
564
|
+
// Check upsert mode
|
|
565
|
+
const url = new URL(c.req.url);
|
|
566
|
+
const isUpsert = url.searchParams.get('upsert') === 'true';
|
|
567
|
+
const conflictTarget = url.searchParams.get('conflictTarget') || 'id';
|
|
568
|
+
let isUpdate = false;
|
|
569
|
+
let upsertBeforeRow: Record<string, unknown> | null = null;
|
|
570
|
+
|
|
571
|
+
if (isUpsert && data[conflictTarget] !== undefined) {
|
|
572
|
+
const checkSql = `SELECT * FROM ${escapePgIdentifier(tableName)} WHERE ${escapePgIdentifier(conflictTarget)} = $1 LIMIT 1`;
|
|
573
|
+
const checkResult = await query(checkSql, [data[conflictTarget]]);
|
|
574
|
+
isUpdate = checkResult.rows.length > 0;
|
|
575
|
+
upsertBeforeRow = isUpdate
|
|
576
|
+
? stripInternalPgFields(checkResult.rows[0] as Record<string, unknown>)
|
|
577
|
+
: null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Build INSERT SQL
|
|
581
|
+
const columns = Object.keys(data);
|
|
582
|
+
const values = columns.map(col => data[col] ?? null);
|
|
583
|
+
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
|
584
|
+
|
|
585
|
+
let sql: string;
|
|
586
|
+
if (isUpsert) {
|
|
587
|
+
const setClauses = columns
|
|
588
|
+
.filter(col => col !== 'id' && col !== conflictTarget && col !== 'createdAt')
|
|
589
|
+
.map(col => `${escapePgIdentifier(col)} = EXCLUDED.${escapePgIdentifier(col)}`);
|
|
590
|
+
sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders})` +
|
|
591
|
+
` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO UPDATE SET ${setClauses.join(', ')}` +
|
|
592
|
+
` RETURNING *`;
|
|
593
|
+
} else {
|
|
594
|
+
sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const result = await query(sql, values);
|
|
598
|
+
const inserted = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
|
|
599
|
+
|
|
600
|
+
// Run afterInsert hook (fire-and-forget)
|
|
601
|
+
if (tableHooks?.afterInsert) {
|
|
602
|
+
const hook = tableHooks.afterInsert;
|
|
603
|
+
const backgroundHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx);
|
|
604
|
+
backgroundHookCtx.waitUntil(Promise.resolve(hook(inserted, backgroundHookCtx)).catch(() => {}));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Emit database-live event (fire-and-forget)
|
|
608
|
+
c.executionCtx.waitUntil(
|
|
609
|
+
emitDbLiveEvent(
|
|
610
|
+
c.env,
|
|
611
|
+
resolved.namespace,
|
|
612
|
+
tableName,
|
|
613
|
+
isUpsert && isUpdate ? 'modified' : 'added',
|
|
614
|
+
String(inserted.id ?? ''),
|
|
615
|
+
inserted,
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
c.executionCtx.waitUntil(
|
|
619
|
+
executeDbTriggers(
|
|
620
|
+
tableName,
|
|
621
|
+
isUpsert && isUpdate ? 'update' : 'insert',
|
|
622
|
+
isUpsert && isUpdate ? { before: upsertBeforeRow ?? inserted, after: inserted } : { after: inserted },
|
|
623
|
+
{
|
|
624
|
+
databaseNamespace: c.env.DATABASE,
|
|
625
|
+
authNamespace: c.env.AUTH,
|
|
626
|
+
kvNamespace: c.env.KV,
|
|
627
|
+
config: parseConfig(c.env),
|
|
628
|
+
env: c.env as never,
|
|
629
|
+
executionCtx: c.executionCtx as never,
|
|
630
|
+
},
|
|
631
|
+
{ namespace: resolved.namespace },
|
|
632
|
+
),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
if (isUpsert) {
|
|
636
|
+
const statusCode = isUpdate ? 200 : 201;
|
|
637
|
+
const action = isUpdate ? 'updated' : 'inserted';
|
|
638
|
+
return c.json({ ...inserted, action }, statusCode as 200);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return c.json(inserted, 201);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ─── UPDATE ───
|
|
645
|
+
|
|
646
|
+
async function handleUpdate(
|
|
647
|
+
c: Context<HonoEnv>,
|
|
648
|
+
resolved: PgResolvedDb,
|
|
649
|
+
tableName: string,
|
|
650
|
+
tableConfig: TableConfig,
|
|
651
|
+
id: string,
|
|
652
|
+
auth: AuthContext | null,
|
|
653
|
+
isServiceKey: boolean,
|
|
654
|
+
query: PostgresExecutor,
|
|
655
|
+
): Promise<Response> {
|
|
656
|
+
let body: Record<string, unknown>;
|
|
657
|
+
try {
|
|
658
|
+
body = await c.req.json();
|
|
659
|
+
} catch {
|
|
660
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Validate against schema
|
|
664
|
+
const validation = validateUpdate(body, tableConfig.schema);
|
|
665
|
+
if (!validation.valid) {
|
|
666
|
+
return c.json({
|
|
667
|
+
code: 400,
|
|
668
|
+
message: 'Validation failed.',
|
|
669
|
+
data: toFieldErrorData(validation.errors),
|
|
670
|
+
errors: validation.errors,
|
|
671
|
+
}, 400);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Fetch existing record to check rules
|
|
675
|
+
const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'postgres');
|
|
676
|
+
const existing = await query(getSql, getParams);
|
|
677
|
+
if (existing.rows.length === 0) {
|
|
678
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
679
|
+
}
|
|
680
|
+
const existingRow = existing.rows[0] as Record<string, unknown>;
|
|
681
|
+
|
|
682
|
+
// Check update rule
|
|
683
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
684
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
685
|
+
if (!isServiceKey && tableAccess?.update !== undefined) {
|
|
686
|
+
if (!(await evalRowRule(tableAccess.update, auth, existingRow))) {
|
|
687
|
+
return c.json({ code: 403, message: 'Update not allowed.' }, 403);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Run beforeUpdate hook
|
|
692
|
+
const requestHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
|
|
693
|
+
if (tableHooks?.beforeUpdate) {
|
|
694
|
+
try {
|
|
695
|
+
const transformed = await tableHooks.beforeUpdate(auth, existingRow, body, requestHookCtx);
|
|
696
|
+
if (transformed && typeof transformed === 'object') {
|
|
697
|
+
body = { ...body, ...transformed };
|
|
698
|
+
}
|
|
699
|
+
} catch (err) {
|
|
700
|
+
const hookError = hookRejectedError(err, 'Update rejected by beforeUpdate hook.');
|
|
701
|
+
return c.json(hookError.toJSON(), hookError.status as 400);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const { data } = preparePgUpdateData(body, tableConfig);
|
|
706
|
+
|
|
707
|
+
if (Object.keys(data).length === 0) {
|
|
708
|
+
return c.json({ code: 400, message: 'No valid fields to update.' }, 400);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const { setClauses, params, nextParamIndex } = parseUpdateBody(
|
|
712
|
+
data,
|
|
713
|
+
['id'],
|
|
714
|
+
{ dialect: 'postgres', startIndex: 1 },
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const sql = `UPDATE ${escapePgIdentifier(tableName)} SET ${setClauses.join(', ')} WHERE "id" = $${nextParamIndex} RETURNING *`;
|
|
718
|
+
const result = await query(sql, [...params, id]);
|
|
719
|
+
|
|
720
|
+
if (result.rows.length === 0) {
|
|
721
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const updated = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
|
|
725
|
+
|
|
726
|
+
// Run afterUpdate hook (fire-and-forget)
|
|
727
|
+
if (tableHooks?.afterUpdate) {
|
|
728
|
+
const hook = tableHooks.afterUpdate;
|
|
729
|
+
const backgroundHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx);
|
|
730
|
+
backgroundHookCtx.waitUntil(
|
|
731
|
+
Promise.resolve(hook(existingRow, updated, backgroundHookCtx)).catch(() => {}),
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Emit database-live event (fire-and-forget)
|
|
736
|
+
c.executionCtx.waitUntil(
|
|
737
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', id, updated),
|
|
738
|
+
);
|
|
739
|
+
c.executionCtx.waitUntil(
|
|
740
|
+
executeDbTriggers(
|
|
741
|
+
tableName,
|
|
742
|
+
'update',
|
|
743
|
+
{
|
|
744
|
+
before: existingRow,
|
|
745
|
+
after: updated,
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
databaseNamespace: c.env.DATABASE,
|
|
749
|
+
authNamespace: c.env.AUTH,
|
|
750
|
+
kvNamespace: c.env.KV,
|
|
751
|
+
config: parseConfig(c.env),
|
|
752
|
+
env: c.env as never,
|
|
753
|
+
executionCtx: c.executionCtx as never,
|
|
754
|
+
},
|
|
755
|
+
{ namespace: resolved.namespace },
|
|
756
|
+
),
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
return c.json(updated);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ─── DELETE ───
|
|
763
|
+
|
|
764
|
+
async function handleDelete(
|
|
765
|
+
c: Context<HonoEnv>,
|
|
766
|
+
resolved: PgResolvedDb,
|
|
767
|
+
tableName: string,
|
|
768
|
+
tableConfig: TableConfig,
|
|
769
|
+
id: string,
|
|
770
|
+
auth: AuthContext | null,
|
|
771
|
+
isServiceKey: boolean,
|
|
772
|
+
query: PostgresExecutor,
|
|
773
|
+
): Promise<Response> {
|
|
774
|
+
// Fetch existing record
|
|
775
|
+
const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'postgres');
|
|
776
|
+
const existing = await query(getSql, getParams);
|
|
777
|
+
if (existing.rows.length === 0) {
|
|
778
|
+
return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
|
|
779
|
+
}
|
|
780
|
+
const existingRow = existing.rows[0] as Record<string, unknown>;
|
|
781
|
+
|
|
782
|
+
// Check delete rule
|
|
783
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
784
|
+
const tableHooks = getTableHooks(tableConfig);
|
|
785
|
+
if (!isServiceKey && tableAccess?.delete !== undefined) {
|
|
786
|
+
if (!(await evalRowRule(tableAccess.delete, auth, existingRow))) {
|
|
787
|
+
return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Run beforeDelete hook
|
|
792
|
+
const requestHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
|
|
793
|
+
if (tableHooks?.beforeDelete) {
|
|
794
|
+
try {
|
|
795
|
+
await tableHooks.beforeDelete(auth, existingRow, requestHookCtx);
|
|
796
|
+
} catch (err) {
|
|
797
|
+
const hookError = hookRejectedError(err, 'Delete rejected by beforeDelete hook.');
|
|
798
|
+
return c.json(hookError.toJSON(), hookError.status as 400);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Execute DELETE
|
|
803
|
+
const sql = `DELETE FROM ${escapePgIdentifier(tableName)} WHERE "id" = $1 RETURNING *`;
|
|
804
|
+
const result = await query(sql, [id]);
|
|
805
|
+
|
|
806
|
+
if (result.rows.length === 0) {
|
|
807
|
+
return c.json({ code: 404, message: `Record '${id}' not found.` }, 404);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Run afterDelete hook (fire-and-forget)
|
|
811
|
+
if (tableHooks?.afterDelete) {
|
|
812
|
+
const hook = tableHooks.afterDelete;
|
|
813
|
+
const backgroundHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx);
|
|
814
|
+
backgroundHookCtx.waitUntil(
|
|
815
|
+
Promise.resolve(hook(existingRow, backgroundHookCtx)).catch(() => {}),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Emit database-live event (fire-and-forget)
|
|
820
|
+
c.executionCtx.waitUntil(
|
|
821
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', id, stripInternalPgFields(existingRow)),
|
|
822
|
+
);
|
|
823
|
+
c.executionCtx.waitUntil(
|
|
824
|
+
executeDbTriggers(
|
|
825
|
+
tableName,
|
|
826
|
+
'delete',
|
|
827
|
+
{ before: existingRow },
|
|
828
|
+
{
|
|
829
|
+
databaseNamespace: c.env.DATABASE,
|
|
830
|
+
authNamespace: c.env.AUTH,
|
|
831
|
+
kvNamespace: c.env.KV,
|
|
832
|
+
config: parseConfig(c.env),
|
|
833
|
+
env: c.env as never,
|
|
834
|
+
executionCtx: c.executionCtx as never,
|
|
835
|
+
},
|
|
836
|
+
{ namespace: resolved.namespace },
|
|
837
|
+
),
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
return c.json({ success: true, deleted: stripInternalPgFields(existingRow) });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ─── BATCH ───
|
|
844
|
+
|
|
845
|
+
async function handleBatch(
|
|
846
|
+
c: Context<HonoEnv>,
|
|
847
|
+
resolved: PgResolvedDb,
|
|
848
|
+
tableName: string,
|
|
849
|
+
tableConfig: TableConfig,
|
|
850
|
+
auth: AuthContext | null,
|
|
851
|
+
isServiceKey: boolean,
|
|
852
|
+
query: PostgresExecutor,
|
|
853
|
+
): Promise<Response> {
|
|
854
|
+
let body: {
|
|
855
|
+
items?: Record<string, unknown>[];
|
|
856
|
+
inserts?: Record<string, unknown>[];
|
|
857
|
+
updates?: { id: string; data: Record<string, unknown> }[];
|
|
858
|
+
deletes?: string[];
|
|
859
|
+
};
|
|
860
|
+
try {
|
|
861
|
+
body = await c.req.json();
|
|
862
|
+
} catch {
|
|
863
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const inserts = Array.isArray(body.inserts)
|
|
867
|
+
? body.inserts
|
|
868
|
+
: Array.isArray(body.items)
|
|
869
|
+
? body.items
|
|
870
|
+
: [];
|
|
871
|
+
const updates = Array.isArray(body.updates) ? body.updates : [];
|
|
872
|
+
const deletes = Array.isArray(body.deletes) ? body.deletes : [];
|
|
873
|
+
|
|
874
|
+
const totalOps = inserts.length + updates.length + deletes.length;
|
|
875
|
+
if (totalOps === 0) {
|
|
876
|
+
return c.json({ code: 400, message: 'items array is required and must not be empty' }, 400);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (totalOps > 500) {
|
|
880
|
+
return c.json({ code: 400, message: 'Batch size cannot exceed 500 items.' }, 400);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (updates.length > 0 || deletes.length > 0) {
|
|
884
|
+
return c.json({
|
|
885
|
+
code: 400,
|
|
886
|
+
message: 'PostgreSQL batch currently supports inserts/upserts only. Use batch-by-filter for updates/deletes.',
|
|
887
|
+
}, 400);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Check insert rule (table-level, once)
|
|
891
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
892
|
+
if (!isServiceKey && inserts.length > 0 && tableAccess?.insert !== undefined) {
|
|
893
|
+
if (!(await evalInsertRule(tableAccess.insert, auth))) {
|
|
894
|
+
return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const upsertMode = c.req.query('upsert') === 'true';
|
|
899
|
+
const conflictTarget = c.req.query('conflictTarget') || 'id';
|
|
900
|
+
const results: Record<string, unknown>[] = [];
|
|
901
|
+
|
|
902
|
+
for (const item of inserts) {
|
|
903
|
+
// Validate
|
|
904
|
+
const validation = validateInsert(item, tableConfig.schema);
|
|
905
|
+
if (!validation.valid) {
|
|
906
|
+
return c.json({
|
|
907
|
+
code: 400,
|
|
908
|
+
message: 'Validation failed.',
|
|
909
|
+
data: toFieldErrorData(validation.errors),
|
|
910
|
+
errors: validation.errors,
|
|
911
|
+
}, 400);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const { data } = preparePgInsertData(item, tableConfig);
|
|
915
|
+
|
|
916
|
+
const columns = Object.keys(data);
|
|
917
|
+
const values = columns.map(col => data[col] ?? null);
|
|
918
|
+
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
|
919
|
+
let sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders})`;
|
|
920
|
+
if (upsertMode) {
|
|
921
|
+
const updateCols = columns.filter((col) => col !== 'id' && col !== 'createdAt' && col !== conflictTarget);
|
|
922
|
+
if (updateCols.length > 0) {
|
|
923
|
+
const updateSet = updateCols
|
|
924
|
+
.map((col) => `${escapePgIdentifier(col)} = EXCLUDED.${escapePgIdentifier(col)}`)
|
|
925
|
+
.join(', ');
|
|
926
|
+
sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO UPDATE SET ${updateSet}`;
|
|
927
|
+
} else {
|
|
928
|
+
sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO NOTHING`;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
sql += ' RETURNING *';
|
|
932
|
+
|
|
933
|
+
const result = await query(sql, values);
|
|
934
|
+
if (result.rows.length > 0) {
|
|
935
|
+
results.push(stripInternalPgFields(result.rows[0] as Record<string, unknown>));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Emit batch database-live events
|
|
940
|
+
if (results.length > 0) {
|
|
941
|
+
const changes = results.map(r => ({
|
|
942
|
+
type: 'added' as const,
|
|
943
|
+
docId: String((r as Record<string, unknown>).id ?? ''),
|
|
944
|
+
data: r as Record<string, unknown>,
|
|
945
|
+
}));
|
|
946
|
+
if (changes.length >= 10) {
|
|
947
|
+
c.executionCtx.waitUntil(
|
|
948
|
+
emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, changes),
|
|
949
|
+
);
|
|
950
|
+
} else {
|
|
951
|
+
for (const ch of changes) {
|
|
952
|
+
c.executionCtx.waitUntil(
|
|
953
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return c.json({
|
|
960
|
+
inserted: results,
|
|
961
|
+
items: results,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ─── BATCH BY FILTER ───
|
|
966
|
+
|
|
967
|
+
async function handleBatchByFilter(
|
|
968
|
+
c: Context<HonoEnv>,
|
|
969
|
+
resolved: PgResolvedDb,
|
|
970
|
+
tableName: string,
|
|
971
|
+
tableConfig: TableConfig,
|
|
972
|
+
_auth: AuthContext | null,
|
|
973
|
+
isServiceKey: boolean,
|
|
974
|
+
query: PostgresExecutor,
|
|
975
|
+
): Promise<Response> {
|
|
976
|
+
let body: {
|
|
977
|
+
action?: string;
|
|
978
|
+
filter?: FilterTuple[];
|
|
979
|
+
orFilter?: FilterTuple[];
|
|
980
|
+
update?: Record<string, unknown>;
|
|
981
|
+
data?: Record<string, unknown>;
|
|
982
|
+
limit?: number;
|
|
983
|
+
};
|
|
984
|
+
try {
|
|
985
|
+
body = await c.req.json();
|
|
986
|
+
} catch {
|
|
987
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (!body.action || !['delete', 'update'].includes(body.action)) {
|
|
991
|
+
return c.json({ code: 400, message: "batch-by-filter requires 'action' to be 'delete' or 'update'." }, 400);
|
|
992
|
+
}
|
|
993
|
+
if (!body.filter || !Array.isArray(body.filter)) {
|
|
994
|
+
return c.json({ code: 400, message: "batch-by-filter requires 'filter' to be a non-empty array." }, 400);
|
|
995
|
+
}
|
|
996
|
+
const updateData = body.update ?? body.data;
|
|
997
|
+
if (body.action === 'update' && !updateData) {
|
|
998
|
+
return c.json({ code: 400, message: "batch-by-filter with action 'update' requires 'update' data." }, 400);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const limit = Math.min(body.limit ?? 500, 500);
|
|
1002
|
+
const { sql: selectSql, params: selectParams } = buildListQuery(tableName, {
|
|
1003
|
+
filters: body.filter,
|
|
1004
|
+
orFilters: body.orFilter,
|
|
1005
|
+
pagination: { limit },
|
|
1006
|
+
fields: ['id'],
|
|
1007
|
+
}, 'postgres');
|
|
1008
|
+
const selectResult = await query(selectSql, selectParams);
|
|
1009
|
+
const allRows = selectResult.rows;
|
|
1010
|
+
const processed = allRows.length;
|
|
1011
|
+
|
|
1012
|
+
if (allRows.length === 0) {
|
|
1013
|
+
return c.json({ processed: 0, succeeded: 0 });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const ids = allRows.map((row) => String((row as Record<string, unknown>).id));
|
|
1017
|
+
const idPlaceholders = ids.map((_, index) => `$${index + 1}`).join(', ');
|
|
1018
|
+
let succeeded = 0;
|
|
1019
|
+
|
|
1020
|
+
if (body.action === 'delete') {
|
|
1021
|
+
// Check delete rule at table level
|
|
1022
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
1023
|
+
if (!isServiceKey && tableAccess?.delete !== undefined) {
|
|
1024
|
+
if (typeof tableAccess.delete === 'boolean' && !tableAccess.delete) {
|
|
1025
|
+
return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const sql = `DELETE FROM ${escapePgIdentifier(tableName)} WHERE "id" IN (${idPlaceholders}) RETURNING *`;
|
|
1030
|
+
const result = await query(sql, ids);
|
|
1031
|
+
succeeded = result.rowCount;
|
|
1032
|
+
|
|
1033
|
+
if (succeeded > 0) {
|
|
1034
|
+
c.executionCtx.waitUntil(
|
|
1035
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', '_bulk', { action: 'delete', count: succeeded }),
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return c.json({
|
|
1040
|
+
processed,
|
|
1041
|
+
succeeded,
|
|
1042
|
+
deleted: result.rowCount,
|
|
1043
|
+
items: result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>)),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// action === 'update'
|
|
1048
|
+
if (!updateData || Object.keys(updateData).length === 0) {
|
|
1049
|
+
return c.json({ code: 400, message: 'data is required for update action.' }, 400);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Check update rule at table level
|
|
1053
|
+
const tableAccess = getTableAccess(tableConfig);
|
|
1054
|
+
if (!isServiceKey && tableAccess?.update !== undefined) {
|
|
1055
|
+
if (typeof tableAccess.update === 'boolean' && !tableAccess.update) {
|
|
1056
|
+
return c.json({ code: 403, message: 'Update not allowed.' }, 403);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const prepared = preparePgUpdateData(updateData, tableConfig).data;
|
|
1061
|
+
if (Object.keys(prepared).length === 0) {
|
|
1062
|
+
return c.json({ code: 400, message: 'No valid fields to update.' }, 400);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const { setClauses, params: updateValues } = parseUpdateBody(
|
|
1066
|
+
prepared,
|
|
1067
|
+
['id'],
|
|
1068
|
+
{ dialect: 'postgres', startIndex: ids.length + 1 },
|
|
1069
|
+
);
|
|
1070
|
+
const updateParams = [...ids, ...updateValues];
|
|
1071
|
+
|
|
1072
|
+
const sql = `UPDATE ${escapePgIdentifier(tableName)} SET ${setClauses.join(', ')} WHERE "id" IN (${idPlaceholders}) RETURNING *`;
|
|
1073
|
+
const result = await query(sql, updateParams);
|
|
1074
|
+
succeeded = result.rowCount;
|
|
1075
|
+
|
|
1076
|
+
if (succeeded > 0) {
|
|
1077
|
+
c.executionCtx.waitUntil(
|
|
1078
|
+
emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', '_bulk', { action: 'update', count: succeeded }),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return c.json({
|
|
1083
|
+
processed,
|
|
1084
|
+
succeeded,
|
|
1085
|
+
updated: result.rowCount,
|
|
1086
|
+
items: result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>)),
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ─── Helpers ───
|
|
1091
|
+
|
|
1092
|
+
function getTextFields(config: TableConfig): string[] {
|
|
1093
|
+
if (!config.schema) return ['id'];
|
|
1094
|
+
const fields: string[] = [];
|
|
1095
|
+
for (const [name, field] of Object.entries(config.schema)) {
|
|
1096
|
+
if (field === false) continue;
|
|
1097
|
+
if (field.type === 'string' || field.type === 'text') {
|
|
1098
|
+
fields.push(name);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return fields.length > 0 ? fields : ['id'];
|
|
1102
|
+
}
|