@cosmicdrift/kumiko-framework 0.367.0 → 0.369.0
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/AGENTS.md +1 -7
- package/README.md +1 -1
- package/agents/examples/access-control.md +2 -2
- package/agents/examples/anonymous-access-multitenant.md +2 -2
- package/agents/examples/anonymous-access.md +3 -3
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +6 -7
- package/agents/examples/api-worker-split.md +7 -8
- package/agents/examples/auth-claims.md +2 -1
- package/agents/examples/auth-foundation-providers.md +3 -3
- package/agents/examples/auth-mfa.md +3 -4
- package/agents/examples/basic-entity.md +3 -4
- package/agents/examples/chat-channels.md +4 -4
- package/agents/examples/compliance-profiles-demo.md +2 -3
- package/agents/examples/cross-feature-events.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +14 -9
- package/agents/examples/currencies-global.md +3 -4
- package/agents/examples/currencies-per-tenant.md +3 -4
- package/agents/examples/custom-fields-basic.md +2 -3
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/dashboard-stats.md +2 -3
- package/agents/examples/delivery-notifications.md +5 -6
- package/agents/examples/e2e-generator.md +1 -1
- package/agents/examples/embedded-entity-form.md +2 -3
- package/agents/examples/embedded.md +2 -3
- package/agents/examples/encrypted-tenant-config.md +5 -6
- package/agents/examples/error-contract.md +3 -4
- package/agents/examples/event-sourcing.md +19 -23
- package/agents/examples/feature-toggles.md +4 -4
- package/agents/examples/field-access.md +3 -4
- package/agents/examples/files-post-processing.md +12 -13
- package/agents/examples/golden-path.md +208 -0
- package/agents/examples/idempotency.md +2 -2
- package/agents/examples/inbound-mail-triage.md +7 -7
- package/agents/examples/lane-routing.md +3 -3
- package/agents/examples/ledger-banking.md +2 -3
- package/agents/examples/ledger-invoicing.md +2 -3
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +3 -3
- package/agents/examples/managed-config.md +3 -4
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +4 -5
- package/agents/examples/ownership.md +3 -5
- package/agents/examples/pdf-audit-export.md +2 -3
- package/agents/examples/pipeline-basics.md +2 -2
- package/agents/examples/public-share-token.md +3 -3
- package/agents/examples/rate-limiting.md +2 -1
- package/agents/examples/realtime-sse.md +2 -3
- package/agents/examples/record-detail-layout.md +1 -1
- package/agents/examples/reference-data.md +3 -3
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +1 -1
- package/agents/examples/search.md +2 -2
- package/agents/examples/secrets-demo.md +8 -8
- package/agents/examples/security-baseline.md +2 -2
- package/agents/examples/session-revocation.md +7 -8
- package/agents/examples/state-machine.md +181 -165
- package/agents/examples/tags-basic.md +2 -3
- package/agents/examples/tenant-isolation.md +2 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/tier-admin.md +3 -4
- package/agents/examples/timezones.md +3 -3
- package/agents/examples/user-data-rights.md +12 -8
- package/agents/examples/user-profile.md +3 -3
- package/agents/examples/webhook-step.md +2 -3
- package/agents/examples/wizard-form.md +4 -5
- package/agents/examples/workflow-engine.md +7 -3
- package/agents/examples/writeform-section.md +1 -1
- package/agents/examples.md +1 -0
- package/agents/features.md +4 -4
- package/dist/consumer-cli.js +2 -3
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.js +2 -0
- package/dist/crypto/pg-kms-adapter.js +1 -1
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +30 -15
- package/dist/db/bun-db-deprecated.d.ts +10 -0
- package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
- package/dist/db/config-seed.js +1 -1
- package/dist/db/connection.d.ts +23 -5
- package/dist/db/connection.js +10 -5
- package/dist/db/dialect.js +1 -1
- package/dist/db/entity-field-encryption.d.ts +5 -0
- package/dist/db/entity-field-encryption.js +29 -0
- package/dist/db/entity-table-meta.js +6 -5
- package/dist/db/event-store-executor-read.js +26 -5
- package/dist/db/index.d.ts +4 -4
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +1 -1
- package/dist/db/queries/backfill-pii.js +16 -9
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/event-store.d.ts +2 -0
- package/dist/db/queries/event-store.js +26 -0
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/query.d.ts +105 -1
- package/dist/db/query.js +1450 -3
- package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
- package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
- package/dist/db/tenant-db.d.ts +11 -0
- package/dist/db/tenant-db.js +51 -12
- package/dist/derivatives/derivatives-context.d.ts +1 -1
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/gdpr-storage.js +3 -3
- package/dist/engine/boot-validator/pii-retention.js +3 -3
- package/dist/engine/boot-validator/record-owned.js +3 -3
- package/dist/engine/boot-validator/screens.js +37 -1
- package/dist/engine/bridge-stub.d.ts +4 -0
- package/dist/engine/bridge-stub.js +62 -0
- package/dist/engine/build-config-feature-schema.js +197 -65
- package/dist/engine/event-helpers.d.ts +2 -1
- package/dist/engine/event-helpers.js +1 -0
- package/dist/engine/extension-selector-plugins.d.ts +3 -0
- package/dist/engine/extension-selector-plugins.js +14 -0
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -2
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/es-ops/operations-schema.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/archive.js +1 -1
- package/dist/event-store/errors.d.ts +2 -1
- package/dist/event-store/errors.js +6 -2
- package/dist/event-store/event-store.js +17 -9
- package/dist/event-store/events-schema.js +1 -1
- package/dist/event-store/rebuild-dead-letter.js +3 -3
- package/dist/event-store/snapshot.js +1 -1
- package/dist/event-store/upcaster-dead-letter.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +1 -1
- package/dist/files/file-routes.js +1 -1
- package/dist/i18n/settings-hub-keys.js +1 -2
- package/dist/internal/stack-support.d.ts +12 -0
- package/dist/internal/stack-support.js +12 -0
- package/dist/internal/testing.d.ts +4 -0
- package/dist/internal/testing.js +5 -0
- package/dist/jobs/job-runner.js +23 -11
- package/dist/migrations/pending-rebuilds.js +1 -1
- package/dist/migrations/pii-event-backfill.js +2 -2
- package/dist/pipeline/dispatch-shared.js +43 -23
- package/dist/pipeline/dispatch-write.js +6 -2
- package/dist/pipeline/dispatcher-utils.js +6 -2
- package/dist/pipeline/event-consumer-state.js +1 -1
- package/dist/pipeline/projection-rebuild.d.ts +1 -0
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/pipeline/projection-state.js +1 -1
- package/dist/pipeline/system-hooks.js +1 -1
- package/dist/schema-cli.js +3 -3
- package/dist/search/purge-subject.js +20 -10
- package/dist/seeding/index.d.ts +1 -0
- package/dist/seeding/index.js +1 -0
- package/dist/stack/index.d.ts +10 -9
- package/dist/stack/index.js +11 -19
- package/dist/testing/e2e-generator.d.ts +5 -49
- package/dist/testing/e2e-generator.js +5 -382
- package/dist/testing/handler-context.d.ts +5 -4
- package/dist/testing/handler-context.js +5 -69
- package/dist/testing/index.d.ts +10 -26
- package/dist/testing/index.js +10 -34
- package/dist/testing/real-providers.d.ts +5 -3
- package/dist/testing/real-providers.js +5 -21
- package/dist/upgrade-cli.js +6 -4
- package/package.json +37 -9
- package/src/changes.json +64 -0
- package/src/scripts/codemod/README.md +43 -0
- package/src/scripts/codemod/codemod-support.ts +276 -0
- package/src/scripts/codemod/db-factory.ts +810 -0
- package/src/scripts/codemod/feature-factory.ts +429 -0
- package/src/scripts/codemod/s3-imports.ts +274 -0
- package/src/scripts/codemod/tenant-db-options.ts +454 -0
- package/src/scripts/codemod/testing-move-targets.ts +161 -0
- package/src/scripts/codemod/testing-move.ts +520 -0
- package/dist/bun-db/connection.d.ts +0 -17
- package/dist/bun-db/connection.js +0 -40
- package/dist/bun-db/index.d.ts +0 -4
- package/dist/bun-db/query.d.ts +0 -106
- package/dist/bun-db/query.js +0 -1457
- package/dist/db/api.d.ts +0 -19
- package/dist/db/api.js +0 -23
- package/dist/db/bun-provider.d.ts +0 -2
- package/dist/db/bun-provider.js +0 -28
- package/dist/db/postgres-provider.d.ts +0 -2
- package/dist/db/postgres-provider.js +0 -21
- package/dist/db/query-api.d.ts +0 -2
- package/dist/db/query-api.js +0 -6
- package/dist/stack/db.d.ts +0 -22
- package/dist/stack/db.js +0 -67
- package/dist/stack/drain-event-consumers.d.ts +0 -4
- package/dist/stack/drain-event-consumers.js +0 -51
- package/dist/stack/drain-jobs.d.ts +0 -22
- package/dist/stack/drain-jobs.js +0 -141
- package/dist/stack/event-collector.d.ts +0 -9
- package/dist/stack/event-collector.js +0 -12
- package/dist/stack/push-entity-projection-tables.d.ts +0 -24
- package/dist/stack/push-entity-projection-tables.js +0 -74
- package/dist/stack/redis.d.ts +0 -17
- package/dist/stack/redis.js +0 -52
- package/dist/stack/request-helper.d.ts +0 -35
- package/dist/stack/request-helper.js +0 -144
- package/dist/stack/test-stack.d.ts +0 -192
- package/dist/stack/test-stack.js +0 -429
- package/dist/testing/access-assertions.d.ts +0 -2
- package/dist/testing/access-assertions.js +0 -13
- package/dist/testing/assertions.d.ts +0 -10
- package/dist/testing/assertions.js +0 -22
- package/dist/testing/boot-validator-fixture.d.ts +0 -3
- package/dist/testing/boot-validator-fixture.js +0 -101
- package/dist/testing/closed-connection-error.d.ts +0 -7
- package/dist/testing/closed-connection-error.js +0 -46
- package/dist/testing/db-cleanup.d.ts +0 -9
- package/dist/testing/db-cleanup.js +0 -31
- package/dist/testing/expect-error.d.ts +0 -2
- package/dist/testing/expect-error.js +0 -22
- package/dist/testing/file-provider-contract.d.ts +0 -2
- package/dist/testing/file-provider-contract.js +0 -102
- package/dist/testing/http-cookies.d.ts +0 -7
- package/dist/testing/http-cookies.js +0 -47
- package/dist/testing/late-bound.d.ts +0 -9
- package/dist/testing/late-bound.js +0 -31
- package/dist/testing/manual-clock.d.ts +0 -14
- package/dist/testing/manual-clock.js +0 -12
- package/dist/testing/multipart-helper.d.ts +0 -22
- package/dist/testing/multipart-helper.js +0 -77
- package/dist/testing/mutable-master-key-provider.d.ts +0 -7
- package/dist/testing/mutable-master-key-provider.js +0 -35
- package/dist/testing/observability-recorder.d.ts +0 -9
- package/dist/testing/observability-recorder.js +0 -29
- package/dist/testing/seed.d.ts +0 -9
- package/dist/testing/seed.js +0 -21
- package/dist/testing/shared-entities.d.ts +0 -286
- package/dist/testing/shared-entities.js +0 -56
- package/dist/testing/utils.d.ts +0 -1
- package/dist/testing/utils.js +0 -1
- package/dist/testing/wait-for.d.ts +0 -20
- package/dist/testing/wait-for.js +0 -47
- package/dist/testing/without-ambient-temporal.d.ts +0 -5
- package/dist/testing/without-ambient-temporal.js +0 -17
- package/dist/testing/zip-fixture.d.ts +0 -1
- package/dist/testing/zip-fixture.js +0 -33
- /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
- /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
package/dist/db/query.js
CHANGED
|
@@ -1,3 +1,1450 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Typed query helpers over the postgres-js driver: EntityTableMeta in, rows out.
|
|
2
|
+
//
|
|
3
|
+
// API:
|
|
4
|
+
// selectMany<T>(db, table, where?, opts?) → readonly T[]
|
|
5
|
+
// fetchOne<T>(db, table, where) → T | undefined
|
|
6
|
+
// insertOne<T>(db, table, values) → T | undefined
|
|
7
|
+
// updateMany<T>(db, table, set, where) → readonly T[]
|
|
8
|
+
// deleteMany(db, table, where) → void
|
|
9
|
+
// countWhere(db, table, where?) → number
|
|
10
|
+
// upsertByPk / upsertOnConflict / incrementCounter
|
|
11
|
+
// deleteManyBatched(db, table, where, { limit }) → { deleted, batches }
|
|
12
|
+
// transaction<T>(db, fn) → T
|
|
13
|
+
//
|
|
14
|
+
// `table` is an EntityTableMeta; the legacy table objects carrying it under a Symbol are accepted too.
|
|
15
|
+
// Static import (not getTemporal()): coercion works without an installed
|
|
16
|
+
// global and returns the same Temporal classes as the rest of the repo (#1480).
|
|
17
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
18
|
+
import { isOneOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
19
|
+
import { requestContext } from "../api/request-context.js";
|
|
20
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
|
|
21
|
+
import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
|
|
22
|
+
import { camelCase as envCamelCase } from "../env/index.js";
|
|
23
|
+
import { InternalError } from "../errors/index.js";
|
|
24
|
+
import { createFallbackLogger } from "../logging/index.js";
|
|
25
|
+
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
26
|
+
import { SQL_EXPR_BRAND } from "./dialect.js";
|
|
27
|
+
import { extractPgError } from "./pg-error.js";
|
|
28
|
+
import { toSnakeCase } from "./table-builder.js";
|
|
29
|
+
import { asEntityTableMeta, requireEntityTableMeta } from "./table-introspection.js";
|
|
30
|
+
// Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
|
|
31
|
+
// (designed for SHOUT_CASE input) — for already-camelCase keys (mock rows
|
|
32
|
+
// in tests, projection-aliased columns) it would silently produce "tenantid"
|
|
33
|
+
// instead of leaving "tenantId" alone. Guard with an underscore check so
|
|
34
|
+
// the conversion only fires when the key is actually snake-shaped.
|
|
35
|
+
function snakeToCamel(key) {
|
|
36
|
+
if (!key.includes("_"))
|
|
37
|
+
return key;
|
|
38
|
+
return envCamelCase(key);
|
|
39
|
+
}
|
|
40
|
+
// table() (dialect) stores the canonical, shadow-proof EntityTableMeta under
|
|
41
|
+
// `Symbol.for("kumiko:schema:Meta")`. The column handles on the table object are
|
|
42
|
+
// enumerable props, so an entity field named `source`/`columns`/`tableName`/…
|
|
43
|
+
// would overwrite the matching meta key — reading the meta from the symbol is
|
|
44
|
+
// the only collision-safe path.
|
|
45
|
+
export { asEntityTableMeta, requireEntityTableMeta };
|
|
46
|
+
export function hasBegin(client) {
|
|
47
|
+
// Bun.SQL tx/savepoint handles keep a begin() that throws; only savepoint() tells them apart from a pool.
|
|
48
|
+
return typeof client.begin === "function" && typeof client.savepoint !== "function";
|
|
49
|
+
}
|
|
50
|
+
function isRawClient(value) {
|
|
51
|
+
return typeof propertyOf(value, "unsafe") === "function";
|
|
52
|
+
}
|
|
53
|
+
export function asRawClient(db) {
|
|
54
|
+
// Direct: postgres-js Sql / TransactionSql. Both
|
|
55
|
+
// expose `.unsafe`; only Sql has `.begin` — TransactionSql uses
|
|
56
|
+
// `.savepoint` for nested-tx. We only require `.unsafe` here; callers that
|
|
57
|
+
// need `.begin` (e.g. `transaction()`) verify it themselves.
|
|
58
|
+
if (isRawClient(db))
|
|
59
|
+
return db;
|
|
60
|
+
// TenantDb (ctx.db) has no unfiltered escape via asRawClient anymore — fail
|
|
61
|
+
// closed instead of silently unwrapping a tenant-scoped handle.
|
|
62
|
+
if (isTenantDbShape(db)) {
|
|
63
|
+
throw new Error("asRawClient: received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the " +
|
|
64
|
+
"tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw() with escapeHatch: " +
|
|
65
|
+
"{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).");
|
|
66
|
+
}
|
|
67
|
+
// Wrapper handle: $client = postgres-js Sql.
|
|
68
|
+
const $client = propertyOf(db, "$client");
|
|
69
|
+
if (isRawClient($client))
|
|
70
|
+
return $client;
|
|
71
|
+
// Wrapper transaction handle: session.client = postgres-js Sql.
|
|
72
|
+
const sessionClient = propertyOf(propertyOf(db, "session"), "client");
|
|
73
|
+
if (isRawClient(sessionClient))
|
|
74
|
+
return sessionClient;
|
|
75
|
+
throw new Error("asRawClient: db argument has no .unsafe() — pass a postgres-js Sql or a transaction handle.");
|
|
76
|
+
}
|
|
77
|
+
export function runnerCanBeginTransaction(db) {
|
|
78
|
+
return hasBegin(asRawClient(db));
|
|
79
|
+
}
|
|
80
|
+
// postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
|
|
81
|
+
// it errored — even if the JS error was caught (the driver tracks per-block
|
|
82
|
+
// query failures). Error confinement therefore must go through the driver's
|
|
83
|
+
// own savepoint(), not manual SAVEPOINT statements. The callback receives
|
|
84
|
+
// the savepoint-scoped tx; run every confined statement on THAT handle.
|
|
85
|
+
export async function runInSavepoint(tx, fn) {
|
|
86
|
+
const raw = asRawClient(tx);
|
|
87
|
+
if (typeof raw.savepoint !== "function") {
|
|
88
|
+
throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql / Bun.SQL).");
|
|
89
|
+
}
|
|
90
|
+
return raw.savepoint(fn);
|
|
91
|
+
}
|
|
92
|
+
// Same error-confinement as runInSavepoint, but for call sites that don't
|
|
93
|
+
// know whether `db` is a bare pool connection or an active transaction
|
|
94
|
+
// (e.g. the CRUD executor, invoked both from a dispatcher tx and directly
|
|
95
|
+
// against the pool by seeds/tests). A pool connection has no savepoint() —
|
|
96
|
+
// and doesn't need one, since each statement there is its own auto-committed
|
|
97
|
+
// unit and a failed statement can't poison anything downstream.
|
|
98
|
+
//
|
|
99
|
+
// `.savepoint` being present isn't proof the transaction is still open: a
|
|
100
|
+
// committed TransactionSql-shaped db still exposes `.savepoint`, and calling
|
|
101
|
+
// it fails with PG 25P01 ("no active sql transaction") since there's no
|
|
102
|
+
// BEGIN left to nest into. That is now a programming error, not something
|
|
103
|
+
// to paper over by re-running `fn` without the savepoint's isolation.
|
|
104
|
+
export async function runInSavepointIfSupported(db, fn) {
|
|
105
|
+
const raw = asRawClient(db);
|
|
106
|
+
// @cast-boundary db-runner savepoint handle → DbRunner at the single seam
|
|
107
|
+
const asRunner = (sp) => sp;
|
|
108
|
+
if (typeof raw.savepoint !== "function")
|
|
109
|
+
return fn(asRunner(db));
|
|
110
|
+
// An error from inside fn must never be remapped, even if it carries a closed-connection code.
|
|
111
|
+
let fnStarted = false;
|
|
112
|
+
try {
|
|
113
|
+
return await raw.savepoint((sp) => {
|
|
114
|
+
fnStarted = true;
|
|
115
|
+
return fn(asRunner(sp));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
const code = extractPgError(e)?.code;
|
|
120
|
+
if (!fnStarted && (code === "25P01" || code === "ERR_POSTGRES_CONNECTION_CLOSED")) {
|
|
121
|
+
throw new InternalError({
|
|
122
|
+
message: code === "25P01"
|
|
123
|
+
? "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
|
|
124
|
+
"this is almost always database work in an afterCommit hook. afterCommit is the default " +
|
|
125
|
+
"phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
|
|
126
|
+
"roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback."
|
|
127
|
+
: "runInSavepointIfSupported: the transaction handle's connection is gone — the transaction " +
|
|
128
|
+
"already committed (almost always database work in an afterCommit hook) or the connection dropped. " +
|
|
129
|
+
"afterCommit is the default phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction " +
|
|
130
|
+
"for writes that must roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
|
|
131
|
+
cause: e instanceof Error ? e : undefined,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Duck-types TenantDb: five scoped methods + tenantId, no `.unsafe` of its own.
|
|
138
|
+
// A throwing systemScope guard Proxy fails closed here too — intended, not a bug.
|
|
139
|
+
function isTenantDbShape(db) {
|
|
140
|
+
if (typeof db !== "object" || db === null)
|
|
141
|
+
return false;
|
|
142
|
+
if (typeof propertyOf(db, "unsafe") === "function")
|
|
143
|
+
return false;
|
|
144
|
+
return (typeof propertyOf(db, "selectMany") === "function" &&
|
|
145
|
+
typeof propertyOf(db, "fetchOne") === "function" &&
|
|
146
|
+
typeof propertyOf(db, "insertOne") === "function" &&
|
|
147
|
+
typeof propertyOf(db, "updateMany") === "function" &&
|
|
148
|
+
typeof propertyOf(db, "deleteMany") === "function" &&
|
|
149
|
+
"tenantId" in db);
|
|
150
|
+
}
|
|
151
|
+
function tenantDbDelegate(db) {
|
|
152
|
+
return isTenantDbShape(db) ? db : undefined;
|
|
153
|
+
}
|
|
154
|
+
// Fails loudly for helpers that don't delegate to TenantDb (upsert/increment/insertMany) —
|
|
155
|
+
// passing one here would otherwise reach asRawClient and throw with a less specific message.
|
|
156
|
+
// @wrapper-known semantic-alias
|
|
157
|
+
function assertNotTenantScoped(db, fnName) {
|
|
158
|
+
if (tenantDbDelegate(db) !== undefined) {
|
|
159
|
+
throw new Error(`${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
|
|
160
|
+
"Pass ctx.db.unsafeRaw() (with escapeHatch: { reason } on the handler/hook) for an " +
|
|
161
|
+
"explicit cross-tenant op, or use a scoped TenantDb method.");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like", "jsonText"];
|
|
165
|
+
const _whereOperatorKeysExhaustive = true;
|
|
166
|
+
void _whereOperatorKeysExhaustive;
|
|
167
|
+
function isJsonTextMatchList(value) {
|
|
168
|
+
return Array.isArray(value);
|
|
169
|
+
}
|
|
170
|
+
function isWhereOperator(v) {
|
|
171
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
172
|
+
return false;
|
|
173
|
+
// Don't false-positive on Date / Temporal / other domain-objects.
|
|
174
|
+
// WhereOperator is plain object literal with at most these keys.
|
|
175
|
+
const keys = Object.keys(v);
|
|
176
|
+
if (keys.length === 0)
|
|
177
|
+
return false;
|
|
178
|
+
return keys.every((k) => isOneOf(WHERE_OPERATOR_KEYS, k));
|
|
179
|
+
}
|
|
180
|
+
export function extractTableInfo(table) {
|
|
181
|
+
const meta = asEntityTableMeta(table);
|
|
182
|
+
if (!meta) {
|
|
183
|
+
throw new Error("bun-db.extractTableInfo: table is not a kumiko EntityTableMeta — " +
|
|
184
|
+
"build it via buildEntityTable / deriveEntityTableMeta / table().");
|
|
185
|
+
}
|
|
186
|
+
const colByField = new Map();
|
|
187
|
+
const fieldByCol = new Map();
|
|
188
|
+
const typeByCol = new Map();
|
|
189
|
+
const bigintModeByCol = new Map();
|
|
190
|
+
for (const c of meta.columns) {
|
|
191
|
+
typeByCol.set(c.name, c.pgType);
|
|
192
|
+
if (c.bigintJsMode !== undefined)
|
|
193
|
+
bigintModeByCol.set(c.name, c.bigintJsMode);
|
|
194
|
+
// EntityTableMeta column names are snake_case. Map snake → snake AND
|
|
195
|
+
// derive a camelCase JS field-name so result rows can be renamed back
|
|
196
|
+
// to the API shape (`aggregate_id` → `aggregateId`).
|
|
197
|
+
colByField.set(c.name, c.name);
|
|
198
|
+
const camel = snakeToCamel(c.name);
|
|
199
|
+
if (camel !== c.name)
|
|
200
|
+
colByField.set(camel, c.name);
|
|
201
|
+
fieldByCol.set(c.name, camel === c.name ? c.name : camel);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
name: meta.tableName,
|
|
205
|
+
columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
|
|
206
|
+
pgTypeOf: (col) => typeByCol.get(col),
|
|
207
|
+
bigintJsModeOf: (col) => bigintModeByCol.get(col),
|
|
208
|
+
fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
|
|
209
|
+
hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function quoteIdent(name) {
|
|
213
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
214
|
+
}
|
|
215
|
+
// --- Value coercion at the driver boundary ------------------------------
|
|
216
|
+
//
|
|
217
|
+
// postgres-js returns timestamptz as JS Date (or ISO string depending on
|
|
218
|
+
// driver config). Framework contract says timestamptz surfaces as
|
|
219
|
+
// Temporal.Instant — without coercion every read hands callers a Date and
|
|
220
|
+
// downstream code that does .epochMilliseconds / .add(...) crashes.
|
|
221
|
+
//
|
|
222
|
+
// Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
|
|
223
|
+
// params — convert to ISO string when the column pgType is timestamptz.
|
|
224
|
+
function isTemporalInstant(v) {
|
|
225
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
|
|
226
|
+
}
|
|
227
|
+
// Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
|
|
228
|
+
// drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
|
|
229
|
+
// unmanaged columns). Uses BigInt equality check so 9007199254740993n never
|
|
230
|
+
// silently rounds to a safe integer.
|
|
231
|
+
function coerceBigIntFromDriver(value) {
|
|
232
|
+
let bi;
|
|
233
|
+
if (typeof value === "string") {
|
|
234
|
+
try {
|
|
235
|
+
bi = BigInt(value);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
bi = value;
|
|
243
|
+
}
|
|
244
|
+
const min = BigInt(Number.MIN_SAFE_INTEGER);
|
|
245
|
+
const max = BigInt(Number.MAX_SAFE_INTEGER);
|
|
246
|
+
if (bi >= min && bi <= max) {
|
|
247
|
+
const n = Number(bi);
|
|
248
|
+
if (BigInt(n) === bi)
|
|
249
|
+
return n;
|
|
250
|
+
}
|
|
251
|
+
return bi;
|
|
252
|
+
}
|
|
253
|
+
function instantFromDriver(value) {
|
|
254
|
+
if (value === null || value === undefined)
|
|
255
|
+
return null;
|
|
256
|
+
if (isTemporalInstant(value))
|
|
257
|
+
return value;
|
|
258
|
+
// Number coercion via +date — equivalent to .getTime() without a Date-API
|
|
259
|
+
// method (guard-no-date-api). Date → epoch milliseconds, then to Temporal.Instant.
|
|
260
|
+
if (value instanceof Date)
|
|
261
|
+
return Temporal.Instant.fromEpochMilliseconds(+value);
|
|
262
|
+
if (typeof value === "string") {
|
|
263
|
+
try {
|
|
264
|
+
return Temporal.Instant.from(value);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (typeof value === "number")
|
|
271
|
+
return Temporal.Instant.fromEpochMilliseconds(value);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
function isTemporalPlainDate(v) {
|
|
275
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
|
|
276
|
+
}
|
|
277
|
+
function isTemporalPlainDateTimeLike(v) {
|
|
278
|
+
const tag = propertyOf(v, Symbol.toStringTag);
|
|
279
|
+
return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
|
|
280
|
+
typeof v === "object" &&
|
|
281
|
+
v !== null &&
|
|
282
|
+
"toPlainDate" in v &&
|
|
283
|
+
typeof v.toPlainDate === "function");
|
|
284
|
+
}
|
|
285
|
+
// Verified empirically against Bun.SQL (kumiko-framework#1924): a `date`
|
|
286
|
+
// column comes back as a native JS Date, but ALWAYS anchored at UTC
|
|
287
|
+
// midnight regardless of process TZ — epochMilliseconds is stable across
|
|
288
|
+
// zones, only the local wall-clock read of that Date would drift the day
|
|
289
|
+
// (e.g. America/Los_Angeles reads back the 14th for a stored 15th). Routing
|
|
290
|
+
// through Instant→ZonedDateTimeISO("UTC")→PlainDate reads the calendar day
|
|
291
|
+
// back deterministically without ever touching a local Date getter — the
|
|
292
|
+
// same +value idiom instantFromDriver uses (guard-no-date-api: no
|
|
293
|
+
// `.getTime()`), just anchored to a fixed zone instead of process-local.
|
|
294
|
+
// A real `date` column always arrives as a UTC-midnight Date. Anything else means the column is
|
|
295
|
+
// still timestamptz (declared `date` before its ALTER ran); the UTC anchor then silently shifts
|
|
296
|
+
// the day, so say so once per column instead of returning the wrong day quietly.
|
|
297
|
+
const warnedNonMidnightDateColumns = new Set();
|
|
298
|
+
const MS_PER_DAY = 86_400_000;
|
|
299
|
+
const dateColumnDriftLog = createFallbackLogger("bun-db");
|
|
300
|
+
function warnIfNotDateColumn(epochMs, column) {
|
|
301
|
+
// skip: midnight value is a correct date column, and an already-warned column is deduped
|
|
302
|
+
if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
|
|
303
|
+
return;
|
|
304
|
+
warnedNonMidnightDateColumns.add(column);
|
|
305
|
+
dateColumnDriftLog.warn(`column "${column}" is declared as date but returned a non-midnight timestamp; it is probably still timestamptz. Run the ALTER ... TYPE date migration, otherwise days can shift.`);
|
|
306
|
+
}
|
|
307
|
+
function plainDateFromDriver(value, column) {
|
|
308
|
+
if (value === null || value === undefined)
|
|
309
|
+
return null;
|
|
310
|
+
if (isTemporalPlainDate(value))
|
|
311
|
+
return value;
|
|
312
|
+
if (typeof value === "string") {
|
|
313
|
+
const isoDay = /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0];
|
|
314
|
+
if (isoDay === undefined)
|
|
315
|
+
return null;
|
|
316
|
+
try {
|
|
317
|
+
return Temporal.PlainDate.from(isoDay);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (value instanceof Date) {
|
|
324
|
+
warnIfNotDateColumn(+value, column);
|
|
325
|
+
return Temporal.Instant.fromEpochMilliseconds(+value).toZonedDateTimeISO("UTC").toPlainDate();
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
// Walk the driver-row, applying three boundary-conversions per known column:
|
|
330
|
+
// - rename key snake_case → camelCase JS field-name (drizzle did this
|
|
331
|
+
// invisibly via its column-mapper; native dialect rebuild lost it)
|
|
332
|
+
// - parse jsonb string → object (postgres-js returns jsonb as text by
|
|
333
|
+
// default, not parsed JSON)
|
|
334
|
+
// - coerce timestamptz Date/string → Temporal.Instant
|
|
335
|
+
//
|
|
336
|
+
// Unknown columns (computed/aliased) pass through unchanged.
|
|
337
|
+
export function coerceRow(row, info) {
|
|
338
|
+
// @cast-boundary db-row — the driver row (raw column names/types) becomes the caller's entity row shape here, and only here
|
|
339
|
+
return coerceDriverRow(row, info);
|
|
340
|
+
}
|
|
341
|
+
function coerceDriverRow(row, info) {
|
|
342
|
+
const out = {};
|
|
343
|
+
let changed = false;
|
|
344
|
+
for (const key of Object.keys(row)) {
|
|
345
|
+
// Blind-index columns (#818) are pure lookup artifacts — the
|
|
346
|
+
// deterministic HMAC must never leak into caller/API responses.
|
|
347
|
+
if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
|
|
348
|
+
changed = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const pgType = info.pgTypeOf(key);
|
|
352
|
+
const value = row[key];
|
|
353
|
+
let coerced = value;
|
|
354
|
+
if (pgType === "timestamptz" || pgType === "timestamptz(3)") {
|
|
355
|
+
const t = instantFromDriver(value);
|
|
356
|
+
if (t !== null)
|
|
357
|
+
coerced = t;
|
|
358
|
+
}
|
|
359
|
+
else if (pgType === "date") {
|
|
360
|
+
const d = plainDateFromDriver(value, key);
|
|
361
|
+
if (d !== null)
|
|
362
|
+
coerced = d;
|
|
363
|
+
}
|
|
364
|
+
else if (pgType === "jsonb" && typeof value === "string") {
|
|
365
|
+
coerced = parseJsonSafe(value, value);
|
|
366
|
+
}
|
|
367
|
+
else if ((pgType === "bigint" || pgType === "bigserial") &&
|
|
368
|
+
(typeof value === "string" || typeof value === "bigint")) {
|
|
369
|
+
const jsMode = info.bigintJsModeOf(key);
|
|
370
|
+
if (jsMode === "number") {
|
|
371
|
+
coerced = coerceBigIntFromDriver(value);
|
|
372
|
+
}
|
|
373
|
+
else if (typeof value === "string") {
|
|
374
|
+
try {
|
|
375
|
+
coerced = BigInt(value);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// leave as string on parse error
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
coerced = value;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if ((pgType === "integer" || pgType === "int4" || pgType === "smallint" || pgType === "int2") &&
|
|
386
|
+
(typeof value === "bigint" || typeof value === "string")) {
|
|
387
|
+
// Bun.SQL / some drivers return int4 as bigint or numeric string.
|
|
388
|
+
// Drizzle coerced to number for numberField columns — match that.
|
|
389
|
+
const n = Number(value);
|
|
390
|
+
if (!Number.isNaN(n))
|
|
391
|
+
coerced = n;
|
|
392
|
+
}
|
|
393
|
+
else if (typeof pgType === "string" &&
|
|
394
|
+
pgType.startsWith("numeric") &&
|
|
395
|
+
(typeof value === "string" || typeof value === "bigint")) {
|
|
396
|
+
// pg returns numeric(p,s) as a string to preserve precision; the
|
|
397
|
+
// decimal-field contract surfaces it as JS number (safe ≤ 2^53). A
|
|
398
|
+
// non-numeric string would be DB corruption — leave it untouched so it
|
|
399
|
+
// surfaces loud downstream rather than becoming a silent NaN.
|
|
400
|
+
const n = Number(value);
|
|
401
|
+
if (!Number.isNaN(n))
|
|
402
|
+
coerced = n;
|
|
403
|
+
}
|
|
404
|
+
const fieldName = info.fieldOf(key);
|
|
405
|
+
if (fieldName !== key)
|
|
406
|
+
changed = true;
|
|
407
|
+
if (coerced !== value)
|
|
408
|
+
changed = true;
|
|
409
|
+
out[fieldName] = coerced;
|
|
410
|
+
}
|
|
411
|
+
return changed ? out : row;
|
|
412
|
+
}
|
|
413
|
+
function coerceRows(rows, info) {
|
|
414
|
+
return rows.map((r) => coerceRow(r, info));
|
|
415
|
+
}
|
|
416
|
+
// Checks the brand Symbol, not the `kind` string — a client-supplied jsonb
|
|
417
|
+
// value can fake `kind: "sql-expr"` over JSON but can never carry a Symbol,
|
|
418
|
+
// so request data can't be smuggled in as a raw SQL literal.
|
|
419
|
+
function isSqlExpression(v) {
|
|
420
|
+
return typeof v === "object" && v !== null && SQL_EXPR_BRAND in v;
|
|
421
|
+
}
|
|
422
|
+
// A `date` column takes a plain "yyyy-mm-dd" string (or PlainDate.toString())
|
|
423
|
+
// as-is — no Instant detour. Postgres parses a DATE literal without ever
|
|
424
|
+
// consulting the session timezone, so this is TZ-independent by construction
|
|
425
|
+
// (verified empirically — see plainDateFromDriver above for the read side).
|
|
426
|
+
// Compat cushion: pre-#1924 code (this field used to be an Instant) may still
|
|
427
|
+
// hand a Temporal.Instant into a where-filter or write on a `date` column —
|
|
428
|
+
// same UTC anchor as plainDateFromDriver's read side, so a filter built with
|
|
429
|
+
// `Temporal.Now.instant()` keeps working instead of binding a raw Instant the
|
|
430
|
+
// driver can't serialize.
|
|
431
|
+
function prepareDateValue(value) {
|
|
432
|
+
if (isTemporalPlainDate(value)) {
|
|
433
|
+
return { kind: "param", sql: "", bound: value.toString() };
|
|
434
|
+
}
|
|
435
|
+
if (isTemporalPlainDateTimeLike(value)) {
|
|
436
|
+
return { kind: "param", sql: "", bound: value.toPlainDate().toString() };
|
|
437
|
+
}
|
|
438
|
+
if (isTemporalInstant(value)) {
|
|
439
|
+
const day = value.toZonedDateTimeISO("UTC").toPlainDate();
|
|
440
|
+
return { kind: "param", sql: "", bound: day.toString() };
|
|
441
|
+
}
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
// structured values bind directly with an ::jsonb cast; JSON.stringify first
|
|
445
|
+
// would produce a JSON-string scalar instead of the structured value.
|
|
446
|
+
function prepareJsonbValue(value) {
|
|
447
|
+
if (typeof value === "boolean") {
|
|
448
|
+
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
449
|
+
}
|
|
450
|
+
if (typeof value !== "object" || isTemporalInstant(value)) {
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
if (!Array.isArray(value)) {
|
|
454
|
+
return { kind: "param", sql: "::jsonb", bound: value };
|
|
455
|
+
}
|
|
456
|
+
// All-boolean arrays are inferred as boolean[] by postgres-js; route via text::jsonb.
|
|
457
|
+
if (value.length > 0 && value.every((entry) => typeof entry === "boolean")) {
|
|
458
|
+
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
459
|
+
}
|
|
460
|
+
return { kind: "param", sql: "::jsonb", bound: value };
|
|
461
|
+
}
|
|
462
|
+
const INTEGER_PG_TYPES = new Set([
|
|
463
|
+
"integer",
|
|
464
|
+
"int4",
|
|
465
|
+
"smallint",
|
|
466
|
+
"int2",
|
|
467
|
+
"bigint",
|
|
468
|
+
"int8",
|
|
469
|
+
"serial",
|
|
470
|
+
"bigserial",
|
|
471
|
+
]);
|
|
472
|
+
// postgres.js truncates timestamptz params to ms via Date; Bun.SQL sends µs and Postgres
|
|
473
|
+
// rounds into timestamptz(3). Truncating here keeps both drivers storing the same instant.
|
|
474
|
+
export function instantToTimestamptzParam(instant) {
|
|
475
|
+
return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
|
|
476
|
+
}
|
|
477
|
+
function prepareDriverNeutralScalar(value, pgType) {
|
|
478
|
+
if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
|
|
479
|
+
return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
|
|
480
|
+
}
|
|
481
|
+
// Bun.SQL binds JS numbers typed, so Postgres would round 3.5 into an integer column; postgres.js sends text and rejects it.
|
|
482
|
+
if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
|
|
483
|
+
return { kind: "param", sql: "", bound: String(value) };
|
|
484
|
+
}
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
function prepareValue(value, pgType) {
|
|
488
|
+
if (isSqlExpression(value)) {
|
|
489
|
+
return { kind: "literal", literal: value.text };
|
|
490
|
+
}
|
|
491
|
+
// A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
|
|
492
|
+
// ::jsonb would be parsed as JSON text (or double-encoded under Bun.SQL).
|
|
493
|
+
if (pgType === "jsonb" && isPiiCiphertext(value)) {
|
|
494
|
+
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
495
|
+
}
|
|
496
|
+
if (pgType === "jsonb" && value !== null) {
|
|
497
|
+
const prepared = prepareJsonbValue(value);
|
|
498
|
+
if (prepared)
|
|
499
|
+
return prepared;
|
|
500
|
+
}
|
|
501
|
+
const scalar = prepareDriverNeutralScalar(value, pgType);
|
|
502
|
+
if (scalar)
|
|
503
|
+
return scalar;
|
|
504
|
+
if (pgType === "date") {
|
|
505
|
+
const prepared = prepareDateValue(value);
|
|
506
|
+
if (prepared)
|
|
507
|
+
return prepared;
|
|
508
|
+
}
|
|
509
|
+
return { kind: "param", sql: "", bound: value };
|
|
510
|
+
}
|
|
511
|
+
function buildWhereClause(info, where, startIndex, qualifier) {
|
|
512
|
+
const conditions = [];
|
|
513
|
+
const values = [];
|
|
514
|
+
let idx = startIndex;
|
|
515
|
+
// Single place that renders a column reference, so a join qualifier reaches
|
|
516
|
+
// every arm (blind index, jsonb containment, IS NULL, jsonText) alike.
|
|
517
|
+
const ref = (column) => qualifier === undefined ? quoteIdent(column) : `${quoteIdent(qualifier)}.${quoteIdent(column)}`;
|
|
518
|
+
// multiSelect (and any other jsonb-array-of-scalars column) stores an
|
|
519
|
+
// array — a filter value is one option, not the whole array, so eq/ne/in
|
|
520
|
+
// must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
|
|
521
|
+
// against the jsonb column, which Postgres rejects outright (fw#2490).
|
|
522
|
+
// Mirrors event-store-executor-read.ts's applyFilter — keep in lock-step.
|
|
523
|
+
function jsonbContainsAny(col, candidates) {
|
|
524
|
+
if (candidates.length === 0)
|
|
525
|
+
return "FALSE";
|
|
526
|
+
const parts = [];
|
|
527
|
+
for (const v of candidates) {
|
|
528
|
+
const p = prepareJsonbValue([v]);
|
|
529
|
+
// prepareJsonbValue never returns kind:"literal" (only prepareValue's
|
|
530
|
+
// isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
|
|
531
|
+
if (p && p.kind === "param") {
|
|
532
|
+
parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
|
|
533
|
+
values.push(p.bound);
|
|
534
|
+
}
|
|
535
|
+
// A jsonb column holding the scalar itself is never `@>` an array of it.
|
|
536
|
+
if (isJsonbScalar(v)) {
|
|
537
|
+
parts.push(`${ref(col)} = $${idx++}::text::jsonb`);
|
|
538
|
+
values.push(JSON.stringify(v));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
|
|
542
|
+
}
|
|
543
|
+
function isJsonbScalar(value) {
|
|
544
|
+
return value !== null && typeof value !== "object";
|
|
545
|
+
}
|
|
546
|
+
for (const [field, value] of Object.entries(where)) {
|
|
547
|
+
const col = info.columnOf(field);
|
|
548
|
+
const pgType = info.pgTypeOf(col);
|
|
549
|
+
if (value === null) {
|
|
550
|
+
conditions.push(`${ref(col)} IS NULL`);
|
|
551
|
+
}
|
|
552
|
+
else if (Array.isArray(value)) {
|
|
553
|
+
if (value.length === 0) {
|
|
554
|
+
conditions.push("FALSE");
|
|
555
|
+
}
|
|
556
|
+
else if (pgType === "jsonb") {
|
|
557
|
+
conditions.push(jsonbContainsAny(col, value));
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const parts = [];
|
|
561
|
+
for (const v of value) {
|
|
562
|
+
const p = prepareValue(v, pgType);
|
|
563
|
+
if (p.kind === "literal") {
|
|
564
|
+
parts.push(p.literal);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
parts.push(`$${idx++}${p.sql}`);
|
|
568
|
+
values.push(p.bound);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
else if (isWhereOperator(value)) {
|
|
575
|
+
if (value.jsonText !== undefined) {
|
|
576
|
+
if (Object.keys(value).length > 1) {
|
|
577
|
+
throw new Error(`where: jsonText on column "${col}" cannot be combined with other operators`);
|
|
578
|
+
}
|
|
579
|
+
if (pgType !== "jsonb") {
|
|
580
|
+
throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
|
|
581
|
+
}
|
|
582
|
+
const matches = isJsonTextMatchList(value.jsonText) ? value.jsonText : [value.jsonText];
|
|
583
|
+
if (matches.length === 0) {
|
|
584
|
+
conditions.push("FALSE");
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
for (const match of matches) {
|
|
588
|
+
if (match === null ||
|
|
589
|
+
typeof match !== "object" ||
|
|
590
|
+
!Array.isArray(match.keys) ||
|
|
591
|
+
match.keys.length === 0 ||
|
|
592
|
+
!match.keys.every((k) => typeof k === "string") ||
|
|
593
|
+
typeof match.eq !== "string") {
|
|
594
|
+
throw new Error(`where: jsonText on column "${col}" needs non-empty string keys and a string eq`);
|
|
595
|
+
}
|
|
596
|
+
const lookups = match.keys.map((key) => {
|
|
597
|
+
values.push(key);
|
|
598
|
+
return `${ref(col)}->>$${idx++}::text`;
|
|
599
|
+
});
|
|
600
|
+
const lhs = lookups.length === 1 ? lookups[0] : `COALESCE(${lookups.join(", ")})`;
|
|
601
|
+
values.push(match.eq);
|
|
602
|
+
conditions.push(`(${lhs} = $${idx++})`);
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (value.ne === null && Object.keys(value).length === 1) {
|
|
607
|
+
conditions.push(`${ref(col)} IS NOT NULL`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const opMap = {
|
|
611
|
+
gt: ">",
|
|
612
|
+
gte: ">=",
|
|
613
|
+
lt: "<",
|
|
614
|
+
lte: "<=",
|
|
615
|
+
ne: "<>",
|
|
616
|
+
like: "LIKE",
|
|
617
|
+
};
|
|
618
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
619
|
+
const opVal = Reflect.get(value, opKey);
|
|
620
|
+
if (opVal === undefined)
|
|
621
|
+
continue;
|
|
622
|
+
// Mirrors applyMultiSelectFilter: ordering/pattern ops have no meaning on a jsonb
|
|
623
|
+
// option array (`like` would crash as `jsonb ~~ jsonb`), so match nothing.
|
|
624
|
+
if (pgType === "jsonb" && opKey !== "ne") {
|
|
625
|
+
conditions.push("FALSE");
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
// Mirrors applyMultiSelectFilter: an array `ne` means "does not contain all of these".
|
|
629
|
+
if (opKey === "ne" && pgType === "jsonb" && Array.isArray(opVal)) {
|
|
630
|
+
if (opVal.length === 0) {
|
|
631
|
+
conditions.push("FALSE");
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const p = prepareJsonbValue(opVal);
|
|
635
|
+
if (p && p.kind === "param") {
|
|
636
|
+
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
|
|
637
|
+
values.push(p.bound);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
642
|
+
const p = prepareJsonbValue([opVal]);
|
|
643
|
+
if (p && p.kind === "param") {
|
|
644
|
+
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql} OR ${ref(col)} = $${idx++}::text::jsonb))`);
|
|
645
|
+
values.push(p.bound, JSON.stringify(opVal));
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const p = prepareValue(opVal, pgType);
|
|
650
|
+
if (p.kind === "literal") {
|
|
651
|
+
conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
conditions.push(`${ref(col)} ${opSym} $${idx++}${p.sql}`);
|
|
655
|
+
values.push(p.bound);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const inVal = value.in;
|
|
659
|
+
if (Array.isArray(inVal)) {
|
|
660
|
+
if (pgType === "jsonb") {
|
|
661
|
+
conditions.push(jsonbContainsAny(col, inVal));
|
|
662
|
+
}
|
|
663
|
+
else if (inVal.length === 0) {
|
|
664
|
+
conditions.push("FALSE");
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
const parts = [];
|
|
668
|
+
for (const v of inVal) {
|
|
669
|
+
const p = prepareValue(v, pgType);
|
|
670
|
+
if (p.kind === "literal") {
|
|
671
|
+
parts.push(p.literal);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
parts.push(`$${idx++}${p.sql}`);
|
|
675
|
+
values.push(p.bound);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
else if (pgType === "jsonb" && isJsonbScalar(value)) {
|
|
683
|
+
conditions.push(jsonbContainsAny(col, [value]));
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
const p = prepareValue(value, pgType);
|
|
687
|
+
if (p.kind === "literal") {
|
|
688
|
+
conditions.push(`${ref(col)} = ${p.literal}`);
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
// Blind-index OR rewrite (#818): if the column has a bidx
|
|
692
|
+
// counterpart (suffix convention `<col>_bidx`, framework-reserved)
|
|
693
|
+
// and a key is configured, equality matches both arms —
|
|
694
|
+
// plaintext legacy rows via the plaintext arm, encrypted rows
|
|
695
|
+
// via the HMAC. The `kumiko-pii:` prefix on ciphertexts makes
|
|
696
|
+
// a random match on the plaintext arm practically impossible.
|
|
697
|
+
const bidxKey = configuredBlindIndexKey();
|
|
698
|
+
const bidxCol = `${col}_bidx`;
|
|
699
|
+
if (bidxKey !== undefined &&
|
|
700
|
+
typeof value === "string" &&
|
|
701
|
+
!col.endsWith("_bidx") &&
|
|
702
|
+
info.hasColumn(bidxCol)) {
|
|
703
|
+
conditions.push(`(${ref(col)} = $${idx++} OR ${ref(bidxCol)} = $${idx++})`);
|
|
704
|
+
values.push(p.bound, computeBlindIndex(bidxKey, value));
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
conditions.push(`${ref(col)} = $${idx++}${p.sql}`);
|
|
708
|
+
values.push(p.bound);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return { sqlText: conditions.join(" AND "), values };
|
|
714
|
+
}
|
|
715
|
+
// A pool can briefly keep handing out connections the server just closed.
|
|
716
|
+
// Each failed attempt discards one, so retries are bounded by pool size.
|
|
717
|
+
const CLOSED_CONNECTION_CODES = new Set([
|
|
718
|
+
"CONNECTION_CLOSED", // postgres-js
|
|
719
|
+
"ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
|
|
720
|
+
"57P01", // PG SQLSTATE: admin_shutdown / terminated backend
|
|
721
|
+
]);
|
|
722
|
+
export function isClosedConnectionError(err) {
|
|
723
|
+
const code = extractPgError(err)?.code;
|
|
724
|
+
return code !== undefined && CLOSED_CONNECTION_CODES.has(code);
|
|
725
|
+
}
|
|
726
|
+
// Tx/reserved handles are pinned to one physical connection — a retry there
|
|
727
|
+
// would run on the same dead connection, not swap in a fresh one.
|
|
728
|
+
function isPooledClient(raw) {
|
|
729
|
+
if (raw === null || (typeof raw !== "object" && typeof raw !== "function"))
|
|
730
|
+
return false;
|
|
731
|
+
return (typeof propertyOf(raw, "begin") === "function" &&
|
|
732
|
+
typeof propertyOf(raw, "savepoint") !== "function" &&
|
|
733
|
+
typeof propertyOf(raw, "release") !== "function");
|
|
734
|
+
}
|
|
735
|
+
const closedConnectionLog = createFallbackLogger("bun-db");
|
|
736
|
+
// The driver rejects in-flight queries with this AbortError (no code) when the
|
|
737
|
+
// pool closes under them, so isClosedConnectionError can't see it.
|
|
738
|
+
const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
|
|
739
|
+
function isDriverClosedAbort(err) {
|
|
740
|
+
return (err instanceof Error && err.name === "AbortError" && err.message === DRIVER_CLOSED_ABORT_MESSAGE);
|
|
741
|
+
}
|
|
742
|
+
function isHandleLike(raw) {
|
|
743
|
+
return raw !== null && (typeof raw === "object" || typeof raw === "function");
|
|
744
|
+
}
|
|
745
|
+
// Bun.SQL is not instanceof-checkable (Bun.SQL.prototype is invalid) and its tx
|
|
746
|
+
// handles are plain function objects, so discriminate by members only one driver
|
|
747
|
+
// has: `flush` on Bun.SQL (pool and tx), `typed` on postgres-js (pool and tx).
|
|
748
|
+
function driverOf(raw) {
|
|
749
|
+
if (!isHandleLike(raw))
|
|
750
|
+
return "unknown";
|
|
751
|
+
if (typeof raw["flush"] === "function")
|
|
752
|
+
return "bun-sql";
|
|
753
|
+
if (typeof raw["typed"] === "function")
|
|
754
|
+
return "postgres-js";
|
|
755
|
+
return "unknown";
|
|
756
|
+
}
|
|
757
|
+
function positiveNumber(v) {
|
|
758
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
759
|
+
}
|
|
760
|
+
// postgres-js configures timeouts in seconds, Bun.SQL in milliseconds.
|
|
761
|
+
function timeoutOptionMs(options, driver, bunName, postgresJsName) {
|
|
762
|
+
if (driver === "postgres-js") {
|
|
763
|
+
const seconds = positiveNumber(options[postgresJsName]);
|
|
764
|
+
return seconds === undefined ? undefined : seconds * 1000;
|
|
765
|
+
}
|
|
766
|
+
return positiveNumber(options[bunName]);
|
|
767
|
+
}
|
|
768
|
+
function errorProp(err, name) {
|
|
769
|
+
return isHandleLike(err) ? err[name] : undefined;
|
|
770
|
+
}
|
|
771
|
+
// Deliberately excludes SQL text and parameters: app SQL can carry PII.
|
|
772
|
+
export function describeClosedConnectionFailure(raw, err, attempts) {
|
|
773
|
+
if (!isClosedConnectionError(err) && !isDriverClosedAbort(err))
|
|
774
|
+
return undefined;
|
|
775
|
+
const driver = driverOf(raw);
|
|
776
|
+
const rawOptions = isHandleLike(raw) ? raw["options"] : undefined;
|
|
777
|
+
const options = isHandleLike(rawOptions) ? rawOptions : {};
|
|
778
|
+
return {
|
|
779
|
+
driver,
|
|
780
|
+
pooled: isPooledClient(raw),
|
|
781
|
+
attempts,
|
|
782
|
+
poolMax: positiveNumber(options["max"]),
|
|
783
|
+
idleTimeoutMs: timeoutOptionMs(options, driver, "idleTimeout", "idle_timeout"),
|
|
784
|
+
maxLifetimeMs: timeoutOptionMs(options, driver, "maxLifetime", "max_lifetime"),
|
|
785
|
+
connectionTimeoutMs: timeoutOptionMs(options, driver, "connectionTimeout", "connect_timeout"),
|
|
786
|
+
errorName: errorProp(err, "name"),
|
|
787
|
+
errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
|
|
788
|
+
errorErrno: errorProp(err, "errno"),
|
|
789
|
+
errorMessage: errorProp(err, "message"),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function logClosedConnection(raw, err, attempts, context) {
|
|
793
|
+
const failure = describeClosedConnectionFailure(raw, err, attempts);
|
|
794
|
+
// skip: not a closed-connection error, nothing to diagnose
|
|
795
|
+
if (!failure)
|
|
796
|
+
return;
|
|
797
|
+
// Request identity lets this line be joined to the route's `handler failed` log.
|
|
798
|
+
const request = requestContext.get();
|
|
799
|
+
closedConnectionLog.warn("closed connection surfaced", {
|
|
800
|
+
...failure,
|
|
801
|
+
...context,
|
|
802
|
+
requestId: request?.requestId,
|
|
803
|
+
correlationId: request?.correlationId,
|
|
804
|
+
handler: request?.handler,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
// Write and other non-retried paths: log when a closed-connection error leaves
|
|
808
|
+
// bun-db, rethrow unchanged.
|
|
809
|
+
async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
|
|
810
|
+
const raw = asRawClient(db);
|
|
811
|
+
try {
|
|
812
|
+
return await raw.unsafe(sqlText, params);
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
logClosedConnection(raw, err, 1, context);
|
|
816
|
+
throw err;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const DEFAULT_POOL_MAX = 10;
|
|
820
|
+
function poolMaxOf(raw) {
|
|
821
|
+
const max = propertyOf(propertyOf(raw, "options"), "max");
|
|
822
|
+
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
|
|
823
|
+
}
|
|
824
|
+
// Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
|
|
825
|
+
// db/queries/*.ts) can opt into the same closed-connection retry instead of
|
|
826
|
+
// calling asRawClient(db).unsafe(...) directly and losing it.
|
|
827
|
+
// READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
|
|
828
|
+
export async function unsafeReadRetrying(db, sqlText, params) {
|
|
829
|
+
const raw = asRawClient(db);
|
|
830
|
+
try {
|
|
831
|
+
return await raw.unsafe(sqlText, params);
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
if (!isPooledClient(raw) || !isClosedConnectionError(err)) {
|
|
835
|
+
logClosedConnection(raw, err, 1);
|
|
836
|
+
throw err;
|
|
837
|
+
}
|
|
838
|
+
// Enough retries to flush every dead pooled connection plus one fresh one; total calls = maxRetries + 1.
|
|
839
|
+
const maxRetries = poolMaxOf(raw) + 1;
|
|
840
|
+
let lastErr = err;
|
|
841
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
842
|
+
try {
|
|
843
|
+
return await raw.unsafe(sqlText, params);
|
|
844
|
+
}
|
|
845
|
+
catch (retryErr) {
|
|
846
|
+
lastErr = retryErr;
|
|
847
|
+
if (!isClosedConnectionError(retryErr)) {
|
|
848
|
+
logClosedConnection(raw, retryErr, attempt + 2);
|
|
849
|
+
throw retryErr;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
logClosedConnection(raw, lastErr, maxRetries + 1);
|
|
854
|
+
throw lastErr;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function selectColumnList(info, fields) {
|
|
858
|
+
if (fields === undefined)
|
|
859
|
+
return "*";
|
|
860
|
+
if (fields.length === 0) {
|
|
861
|
+
throw new Error(`selectMany: fields must not be empty for "${info.name}"`);
|
|
862
|
+
}
|
|
863
|
+
return fields
|
|
864
|
+
.map((field) => {
|
|
865
|
+
if (!info.hasColumn(field)) {
|
|
866
|
+
throw new Error(`selectMany: fields entry "${field}" is not a column of "${info.name}"`);
|
|
867
|
+
}
|
|
868
|
+
return quoteIdent(info.columnOf(field));
|
|
869
|
+
})
|
|
870
|
+
.join(", ");
|
|
871
|
+
}
|
|
872
|
+
// biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
|
|
873
|
+
export async function selectMany(db, table, where, options) {
|
|
874
|
+
const scoped = tenantDbDelegate(db);
|
|
875
|
+
if (scoped) {
|
|
876
|
+
return scoped.selectMany(table, where, options);
|
|
877
|
+
}
|
|
878
|
+
const info = extractTableInfo(table);
|
|
879
|
+
let sqlText = `SELECT ${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
|
|
880
|
+
let values = [];
|
|
881
|
+
if (where && Object.keys(where).length > 0) {
|
|
882
|
+
const w = buildWhereClause(info, where, 1);
|
|
883
|
+
sqlText += ` WHERE ${w.sqlText}`;
|
|
884
|
+
values = w.values;
|
|
885
|
+
}
|
|
886
|
+
if (options?.orderBy) {
|
|
887
|
+
const clauses = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy];
|
|
888
|
+
const parts = clauses.map((c) => {
|
|
889
|
+
const col = info.columnOf(c.col);
|
|
890
|
+
const dir = c.direction === "desc" ? "DESC" : "ASC";
|
|
891
|
+
return `${quoteIdent(col)} ${dir}`;
|
|
892
|
+
});
|
|
893
|
+
if (parts.length > 0)
|
|
894
|
+
sqlText += ` ORDER BY ${parts.join(", ")}`;
|
|
895
|
+
}
|
|
896
|
+
if (options?.limit !== undefined) {
|
|
897
|
+
// Interpolated, not bound — guard against a non-integer slipping in via an
|
|
898
|
+
// `any`-typed call site and injecting SQL.
|
|
899
|
+
if (!Number.isInteger(options.limit) || options.limit < 0) {
|
|
900
|
+
throw new Error(`selectMany: limit must be a non-negative integer, got ${options.limit}`);
|
|
901
|
+
}
|
|
902
|
+
sqlText += ` LIMIT ${options.limit}`;
|
|
903
|
+
}
|
|
904
|
+
const raw = await unsafeReadRetrying(db, sqlText, values);
|
|
905
|
+
return coerceRows(raw, info);
|
|
906
|
+
}
|
|
907
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
908
|
+
export async function fetchOne(db, table, where) {
|
|
909
|
+
const rows = await selectMany(db, table, where, { limit: 1 });
|
|
910
|
+
return rows[0];
|
|
911
|
+
}
|
|
912
|
+
const JOIN_LEFT_ALIAS = "l";
|
|
913
|
+
const JOIN_RIGHT_ALIAS = "r";
|
|
914
|
+
function requireJoinColumn(info, field, role) {
|
|
915
|
+
if (!info.hasColumn(field)) {
|
|
916
|
+
throw new Error(`selectInnerJoin: ${role} "${field}" is not a column of "${info.name}"`);
|
|
917
|
+
}
|
|
918
|
+
return info.columnOf(field);
|
|
919
|
+
}
|
|
920
|
+
export async function selectInnerJoin(db, spec) {
|
|
921
|
+
assertNotTenantScoped(db, "selectInnerJoin");
|
|
922
|
+
const leftInfo = extractTableInfo(spec.left);
|
|
923
|
+
const rightInfo = extractTableInfo(spec.right);
|
|
924
|
+
if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
|
|
925
|
+
throw new Error(`selectInnerJoin: limit must be a non-negative integer, got ${spec.limit}`);
|
|
926
|
+
}
|
|
927
|
+
const leftColumns = (spec.leftFields ??
|
|
928
|
+
requireEntityTableMeta(spec.left, "selectInnerJoin").columns.map((c) => c.name)).map((field) => requireJoinColumn(leftInfo, field, "leftFields entry"));
|
|
929
|
+
const rightColumns = (spec.rightFields ?? []).map((field) => requireJoinColumn(rightInfo, field, "rightFields entry"));
|
|
930
|
+
// Positional aliases: a `<side>__<column>` scheme would hit Postgres' 63-byte
|
|
931
|
+
// identifier truncation for long column names.
|
|
932
|
+
const projected = [
|
|
933
|
+
...leftColumns.map((column) => ({ side: "left", column })),
|
|
934
|
+
...rightColumns.map((column) => ({ side: "right", column })),
|
|
935
|
+
];
|
|
936
|
+
if (projected.length === 0) {
|
|
937
|
+
throw new Error("selectInnerJoin: no columns selected");
|
|
938
|
+
}
|
|
939
|
+
const selectList = projected
|
|
940
|
+
.map(({ side, column }, index) => `${quoteIdent(side === "left" ? JOIN_LEFT_ALIAS : JOIN_RIGHT_ALIAS)}.${quoteIdent(column)} AS "c${index}"`)
|
|
941
|
+
.join(", ");
|
|
942
|
+
const onPairs = spec.on.map((pair) => {
|
|
943
|
+
const leftColumn = requireJoinColumn(leftInfo, pair.left, "on.left");
|
|
944
|
+
const rightColumn = requireJoinColumn(rightInfo, pair.right, "on.right");
|
|
945
|
+
const leftRef = `${quoteIdent(JOIN_LEFT_ALIAS)}.${quoteIdent(leftColumn)}`;
|
|
946
|
+
const rightRef = `${quoteIdent(JOIN_RIGHT_ALIAS)}.${quoteIdent(rightColumn)}`;
|
|
947
|
+
return leftInfo.pgTypeOf(leftColumn) === rightInfo.pgTypeOf(rightColumn)
|
|
948
|
+
? `${leftRef} = ${rightRef}`
|
|
949
|
+
: `${leftRef}::text = ${rightRef}::text`;
|
|
950
|
+
});
|
|
951
|
+
let sqlText = `SELECT ${selectList} FROM ${quoteIdent(leftInfo.name)} AS ${quoteIdent(JOIN_LEFT_ALIAS)} ` +
|
|
952
|
+
`INNER JOIN ${quoteIdent(rightInfo.name)} AS ${quoteIdent(JOIN_RIGHT_ALIAS)} ON ${onPairs.join(" AND ")}`;
|
|
953
|
+
const conditions = [];
|
|
954
|
+
const values = [];
|
|
955
|
+
if (spec.leftWhere && Object.keys(spec.leftWhere).length > 0) {
|
|
956
|
+
const w = buildWhereClause(leftInfo, spec.leftWhere, values.length + 1, JOIN_LEFT_ALIAS);
|
|
957
|
+
conditions.push(w.sqlText);
|
|
958
|
+
values.push(...w.values);
|
|
959
|
+
}
|
|
960
|
+
if (spec.rightWhere && Object.keys(spec.rightWhere).length > 0) {
|
|
961
|
+
const w = buildWhereClause(rightInfo, spec.rightWhere, values.length + 1, JOIN_RIGHT_ALIAS);
|
|
962
|
+
conditions.push(w.sqlText);
|
|
963
|
+
values.push(...w.values);
|
|
964
|
+
}
|
|
965
|
+
if (conditions.length > 0)
|
|
966
|
+
sqlText += ` WHERE ${conditions.join(" AND ")}`;
|
|
967
|
+
if (spec.limit !== undefined)
|
|
968
|
+
sqlText += ` LIMIT ${spec.limit}`;
|
|
969
|
+
const raw = await unsafeReadRetrying(db, sqlText, values);
|
|
970
|
+
return raw.map((row) => {
|
|
971
|
+
const leftRow = {};
|
|
972
|
+
const rightRow = {};
|
|
973
|
+
projected.forEach(({ side, column }, index) => {
|
|
974
|
+
(side === "left" ? leftRow : rightRow)[column] = row[`c${index}`];
|
|
975
|
+
});
|
|
976
|
+
return {
|
|
977
|
+
left: coerceRow(leftRow, leftInfo),
|
|
978
|
+
right: coerceRow(rightRow, rightInfo),
|
|
979
|
+
};
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
// Bulk INSERT — same shape as insertOne but takes an array of rows and
|
|
983
|
+
// produces one multi-VALUES statement. Mirrors drizzle's
|
|
984
|
+
// `db.insert(t).values(rows[])`. Empty input is a no-op.
|
|
985
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
986
|
+
export async function insertMany(db, table, rows) {
|
|
987
|
+
if (rows.length === 0)
|
|
988
|
+
return [];
|
|
989
|
+
assertNotTenantScoped(db, "insertMany");
|
|
990
|
+
const info = extractTableInfo(table);
|
|
991
|
+
// Use the column-set from the first row; assume all rows share keys.
|
|
992
|
+
const firstRow = rows[0];
|
|
993
|
+
if (firstRow === undefined)
|
|
994
|
+
return [];
|
|
995
|
+
// Filter out fields that don't exist as columns in the table (like drizzle did implicitly)
|
|
996
|
+
const fields = Object.keys(firstRow).filter((k) => info.hasColumn(k));
|
|
997
|
+
if (fields.length === 0)
|
|
998
|
+
throw new Error("insertMany: empty row object");
|
|
999
|
+
const cols = fields.map((k) => quoteIdent(info.columnOf(k))).join(", ");
|
|
1000
|
+
const params = [];
|
|
1001
|
+
const valuesClauses = [];
|
|
1002
|
+
for (const row of rows) {
|
|
1003
|
+
const placeholders = [];
|
|
1004
|
+
for (const f of fields) {
|
|
1005
|
+
const col = info.columnOf(f);
|
|
1006
|
+
const pgType = info.pgTypeOf(col);
|
|
1007
|
+
const p = prepareValue(row[f], pgType);
|
|
1008
|
+
if (p.kind === "literal") {
|
|
1009
|
+
placeholders.push(p.literal);
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
params.push(p.bound);
|
|
1013
|
+
placeholders.push(`$${params.length}${p.sql}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
valuesClauses.push(`(${placeholders.join(", ")})`);
|
|
1017
|
+
}
|
|
1018
|
+
const sqlText = `INSERT INTO ${quoteIdent(info.name)} (${cols}) VALUES ${valuesClauses.join(", ")} RETURNING *`;
|
|
1019
|
+
const raw = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1020
|
+
operation: "insertMany",
|
|
1021
|
+
table: info.name,
|
|
1022
|
+
});
|
|
1023
|
+
return coerceRows(raw, info);
|
|
1024
|
+
}
|
|
1025
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1026
|
+
export async function insertOne(db, table, values) {
|
|
1027
|
+
const scoped = tenantDbDelegate(db);
|
|
1028
|
+
if (scoped) {
|
|
1029
|
+
return scoped.insertOne(table, values);
|
|
1030
|
+
}
|
|
1031
|
+
const info = extractTableInfo(table);
|
|
1032
|
+
const entries = Object.entries(values)
|
|
1033
|
+
.filter(([k]) => info.hasColumn(k))
|
|
1034
|
+
.map(([k, v]) => {
|
|
1035
|
+
const col = info.columnOf(k);
|
|
1036
|
+
const pgType = info.pgTypeOf(col);
|
|
1037
|
+
return { col, prepared: prepareValue(v, pgType) };
|
|
1038
|
+
});
|
|
1039
|
+
if (entries.length === 0)
|
|
1040
|
+
throw new Error("insertOne: empty values object");
|
|
1041
|
+
const cols = entries.map((e) => quoteIdent(e.col)).join(", ");
|
|
1042
|
+
const params = [];
|
|
1043
|
+
const placeholders = entries
|
|
1044
|
+
.map((e) => {
|
|
1045
|
+
if (e.prepared.kind === "literal")
|
|
1046
|
+
return e.prepared.literal;
|
|
1047
|
+
params.push(e.prepared.bound);
|
|
1048
|
+
return `$${params.length}${e.prepared.sql}`;
|
|
1049
|
+
})
|
|
1050
|
+
.join(", ");
|
|
1051
|
+
const sqlText = `INSERT INTO ${quoteIdent(info.name)} (${cols}) VALUES (${placeholders}) RETURNING *`;
|
|
1052
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1053
|
+
operation: "insertOne",
|
|
1054
|
+
table: info.name,
|
|
1055
|
+
});
|
|
1056
|
+
const first = rows[0];
|
|
1057
|
+
if (!first)
|
|
1058
|
+
return undefined;
|
|
1059
|
+
return coerceRow(first, info);
|
|
1060
|
+
}
|
|
1061
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1062
|
+
export async function updateMany(db, table, set, where) {
|
|
1063
|
+
const scoped = tenantDbDelegate(db);
|
|
1064
|
+
if (scoped) {
|
|
1065
|
+
return scoped.updateMany(table, set, where);
|
|
1066
|
+
}
|
|
1067
|
+
const info = extractTableInfo(table);
|
|
1068
|
+
const setEntries = Object.entries(set).map(([k, v]) => {
|
|
1069
|
+
const col = info.columnOf(k);
|
|
1070
|
+
const pgType = info.pgTypeOf(col);
|
|
1071
|
+
return { col, prepared: prepareValue(v, pgType) };
|
|
1072
|
+
});
|
|
1073
|
+
if (setEntries.length === 0)
|
|
1074
|
+
throw new Error("updateMany: empty set object");
|
|
1075
|
+
const values = [];
|
|
1076
|
+
let idx = 1;
|
|
1077
|
+
const setParts = [];
|
|
1078
|
+
for (const e of setEntries) {
|
|
1079
|
+
if (e.prepared.kind === "literal") {
|
|
1080
|
+
setParts.push(`${quoteIdent(e.col)} = ${e.prepared.literal}`);
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
setParts.push(`${quoteIdent(e.col)} = $${idx++}${e.prepared.sql}`);
|
|
1084
|
+
values.push(e.prepared.bound);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const w = buildWhereClause(info, where, idx);
|
|
1088
|
+
let sqlText = `UPDATE ${quoteIdent(info.name)} SET ${setParts.join(", ")}`;
|
|
1089
|
+
if (w.sqlText) {
|
|
1090
|
+
sqlText += ` WHERE ${w.sqlText}`;
|
|
1091
|
+
for (const v of w.values)
|
|
1092
|
+
values.push(v);
|
|
1093
|
+
}
|
|
1094
|
+
sqlText += " RETURNING *";
|
|
1095
|
+
const raw = await unsafeLoggingClosedConnection(db, sqlText, values, {
|
|
1096
|
+
operation: "updateMany",
|
|
1097
|
+
table: info.name,
|
|
1098
|
+
});
|
|
1099
|
+
return coerceRows(raw, info);
|
|
1100
|
+
}
|
|
1101
|
+
export async function deleteMany(db, table, where) {
|
|
1102
|
+
const scoped = tenantDbDelegate(db);
|
|
1103
|
+
if (scoped) {
|
|
1104
|
+
return scoped.deleteMany(table, where);
|
|
1105
|
+
}
|
|
1106
|
+
const info = extractTableInfo(table);
|
|
1107
|
+
const w = buildWhereClause(info, where, 1);
|
|
1108
|
+
let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
|
|
1109
|
+
if (w.sqlText)
|
|
1110
|
+
sqlText += ` WHERE ${w.sqlText}`;
|
|
1111
|
+
await unsafeLoggingClosedConnection(db, sqlText, w.values, {
|
|
1112
|
+
operation: "deleteMany",
|
|
1113
|
+
table: info.name,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
function insertEntries(info, values) {
|
|
1117
|
+
return Object.entries(values)
|
|
1118
|
+
.filter(([k]) => info.hasColumn(k))
|
|
1119
|
+
.map(([k, v]) => {
|
|
1120
|
+
const col = info.columnOf(k);
|
|
1121
|
+
return { field: k, col, prepared: prepareValue(v, info.pgTypeOf(col)) };
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
// Exported for the shadow-proof regression test — `table.columns` would be the
|
|
1125
|
+
// drizzle handle (not the PK list) when an entity has a field named `columns`.
|
|
1126
|
+
export function resolveConflictColumns(table, info, conflictKeys) {
|
|
1127
|
+
if (conflictKeys !== undefined && conflictKeys.length > 0) {
|
|
1128
|
+
return conflictKeys.map((field) => info.columnOf(field));
|
|
1129
|
+
}
|
|
1130
|
+
// Read the shadow-proof meta — `table.columns` directly would be the handle
|
|
1131
|
+
// object when an entity has a field named `columns`.
|
|
1132
|
+
const meta = asEntityTableMeta(table);
|
|
1133
|
+
if (meta) {
|
|
1134
|
+
const pks = meta.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
1135
|
+
if (pks.length > 0)
|
|
1136
|
+
return pks;
|
|
1137
|
+
}
|
|
1138
|
+
if (info.hasColumn("id"))
|
|
1139
|
+
return [info.columnOf("id")];
|
|
1140
|
+
throw new Error("upsert: cannot infer conflict keys — pass conflictKeys explicitly");
|
|
1141
|
+
}
|
|
1142
|
+
function buildInsertSql(info, entries) {
|
|
1143
|
+
if (entries.length === 0)
|
|
1144
|
+
throw new Error("insert: empty values object");
|
|
1145
|
+
const cols = entries.map((e) => quoteIdent(e.col)).join(", ");
|
|
1146
|
+
const params = [];
|
|
1147
|
+
const placeholders = entries
|
|
1148
|
+
.map((e) => {
|
|
1149
|
+
if (e.prepared.kind === "literal")
|
|
1150
|
+
return e.prepared.literal;
|
|
1151
|
+
params.push(e.prepared.bound);
|
|
1152
|
+
return `$${params.length}${e.prepared.sql}`;
|
|
1153
|
+
})
|
|
1154
|
+
.join(", ");
|
|
1155
|
+
const sqlPrefix = `INSERT INTO ${quoteIdent(info.name)} (${cols}) VALUES (${placeholders})`;
|
|
1156
|
+
return { sqlPrefix, params };
|
|
1157
|
+
}
|
|
1158
|
+
export async function countWhere(db, table, where = {}) {
|
|
1159
|
+
const info = extractTableInfo(table);
|
|
1160
|
+
let sqlText = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(info.name)}`;
|
|
1161
|
+
let values = [];
|
|
1162
|
+
if (Object.keys(where).length > 0) {
|
|
1163
|
+
const w = buildWhereClause(info, where, 1);
|
|
1164
|
+
sqlText += ` WHERE ${w.sqlText}`;
|
|
1165
|
+
values = w.values;
|
|
1166
|
+
}
|
|
1167
|
+
const rows = await unsafeReadRetrying(db, sqlText, values);
|
|
1168
|
+
return rows[0]?.count ?? 0;
|
|
1169
|
+
}
|
|
1170
|
+
const AGGREGATE_BUCKET_UNITS = ["hour", "day"];
|
|
1171
|
+
const AGGREGATE_ORDER_DIRECTIONS = ["asc", "desc"];
|
|
1172
|
+
function requireAggregateColumn(info, field, role) {
|
|
1173
|
+
if (!info.hasColumn(field)) {
|
|
1174
|
+
throw new Error(`aggregateWhere: ${role} "${field}" is not a column of ${info.name}`);
|
|
1175
|
+
}
|
|
1176
|
+
return quoteIdent(info.columnOf(field));
|
|
1177
|
+
}
|
|
1178
|
+
export function isTimestamptzType(pgType) {
|
|
1179
|
+
return pgType === "timestamptz" || pgType === "timestamptz(3)";
|
|
1180
|
+
}
|
|
1181
|
+
function aggregateKeyOf(raw) {
|
|
1182
|
+
if (raw === null || raw === undefined)
|
|
1183
|
+
return null;
|
|
1184
|
+
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean")
|
|
1185
|
+
return raw;
|
|
1186
|
+
if (typeof raw === "bigint")
|
|
1187
|
+
return Number(raw);
|
|
1188
|
+
if (raw instanceof Date)
|
|
1189
|
+
return raw.toISOString();
|
|
1190
|
+
return String(raw);
|
|
1191
|
+
}
|
|
1192
|
+
function aggregateMeasureSql(info, measure) {
|
|
1193
|
+
switch (measure.fn) {
|
|
1194
|
+
case "count":
|
|
1195
|
+
return "COUNT(*)";
|
|
1196
|
+
case "countDistinct":
|
|
1197
|
+
return `COUNT(DISTINCT ${requireAggregateColumn(info, measure.field, "measure field")})`;
|
|
1198
|
+
case "sum":
|
|
1199
|
+
return `COALESCE(SUM(${requireAggregateColumn(info, measure.field, "measure field")}), 0)`;
|
|
1200
|
+
case "avg":
|
|
1201
|
+
return `AVG(${requireAggregateColumn(info, measure.field, "measure field")})`;
|
|
1202
|
+
default:
|
|
1203
|
+
throw new Error(`aggregateWhere: unknown measure fn "${String(Reflect.get(measure, "fn"))}"`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function aggregateDimensionSql(info, dimension, index, params) {
|
|
1207
|
+
const column = requireAggregateColumn(info, dimension.field, "groupBy field");
|
|
1208
|
+
if (!("bucket" in dimension))
|
|
1209
|
+
return `${column} AS k${index}`;
|
|
1210
|
+
if (!AGGREGATE_BUCKET_UNITS.includes(dimension.bucket)) {
|
|
1211
|
+
throw new Error(`aggregateWhere: unknown bucket "${String(dimension.bucket)}"`);
|
|
1212
|
+
}
|
|
1213
|
+
if (!isTimestamptzType(info.pgTypeOf(info.columnOf(dimension.field)))) {
|
|
1214
|
+
throw new Error(`aggregateWhere: bucket on "${dimension.field}" requires a timestamptz column`);
|
|
1215
|
+
}
|
|
1216
|
+
params.push(dimension.timeZone);
|
|
1217
|
+
return `(EXTRACT(EPOCH FROM date_trunc('${dimension.bucket}', ${column}, $${params.length}::text)) * 1000)::float8 AS k${index}`;
|
|
1218
|
+
}
|
|
1219
|
+
function assertValidAggregateLimits(spec) {
|
|
1220
|
+
if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
|
|
1221
|
+
throw new Error(`aggregateWhere: limit must be a non-negative integer, got ${spec.limit}`);
|
|
1222
|
+
}
|
|
1223
|
+
if (spec.orderByValue !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByValue)) {
|
|
1224
|
+
throw new Error(`aggregateWhere: unknown orderByValue "${String(spec.orderByValue)}"`);
|
|
1225
|
+
}
|
|
1226
|
+
if (spec.orderByKeys !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByKeys)) {
|
|
1227
|
+
throw new Error(`aggregateWhere: unknown orderByKeys "${String(spec.orderByKeys)}"`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function aggregateGroupOrderLimitSql(spec, dimensionCount) {
|
|
1231
|
+
let sqlText = "";
|
|
1232
|
+
if (dimensionCount > 0) {
|
|
1233
|
+
const positions = Array.from({ length: dimensionCount }, (_, index) => String(index + 1));
|
|
1234
|
+
sqlText += ` GROUP BY ${positions.join(", ")}`;
|
|
1235
|
+
const keyDirection = spec.orderByKeys === "desc" ? "DESC" : "ASC";
|
|
1236
|
+
const keyParts = positions.map((position) => `${position} ${keyDirection}`);
|
|
1237
|
+
const orderParts = spec.orderByValue === undefined
|
|
1238
|
+
? keyParts
|
|
1239
|
+
: [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...keyParts];
|
|
1240
|
+
sqlText += ` ORDER BY ${orderParts.join(", ")}`;
|
|
1241
|
+
}
|
|
1242
|
+
if (spec.limit !== undefined)
|
|
1243
|
+
sqlText += ` LIMIT ${spec.limit}`;
|
|
1244
|
+
return sqlText;
|
|
1245
|
+
}
|
|
1246
|
+
export async function aggregateWhere(db, table, spec, where = {}) {
|
|
1247
|
+
const scoped = tenantDbDelegate(db);
|
|
1248
|
+
if (scoped) {
|
|
1249
|
+
return scoped.aggregate(table, spec, where);
|
|
1250
|
+
}
|
|
1251
|
+
const info = extractTableInfo(table);
|
|
1252
|
+
const params = [];
|
|
1253
|
+
const measureSql = aggregateMeasureSql(info, spec.measure);
|
|
1254
|
+
const dimensions = spec.groupBy ?? [];
|
|
1255
|
+
const dimensionSql = dimensions.map((dimension, index) => aggregateDimensionSql(info, dimension, index, params));
|
|
1256
|
+
assertValidAggregateLimits(spec);
|
|
1257
|
+
const selectList = [...dimensionSql, `(${measureSql})::float8 AS value`].join(", ");
|
|
1258
|
+
let sqlText = `SELECT ${selectList} FROM ${quoteIdent(info.name)}`;
|
|
1259
|
+
if (Object.keys(where).length > 0) {
|
|
1260
|
+
const w = buildWhereClause(info, where, params.length + 1);
|
|
1261
|
+
sqlText += ` WHERE ${w.sqlText}`;
|
|
1262
|
+
params.push(...w.values);
|
|
1263
|
+
}
|
|
1264
|
+
sqlText += aggregateGroupOrderLimitSql(spec, dimensions.length);
|
|
1265
|
+
const raw = await unsafeReadRetrying(db, sqlText, params);
|
|
1266
|
+
return raw.map((row) => {
|
|
1267
|
+
const value = row["value"];
|
|
1268
|
+
return {
|
|
1269
|
+
keys: dimensions.map((_, index) => aggregateKeyOf(row[`k${index}`])),
|
|
1270
|
+
value: value === null || value === undefined ? null : Number(value),
|
|
1271
|
+
};
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1275
|
+
export async function upsertOnConflict(db, table, values, options) {
|
|
1276
|
+
assertNotTenantScoped(db, "upsertOnConflict");
|
|
1277
|
+
const info = extractTableInfo(table);
|
|
1278
|
+
const entries = insertEntries(info, values);
|
|
1279
|
+
const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
|
|
1280
|
+
const conflictSet = new Set(conflictCols);
|
|
1281
|
+
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
1282
|
+
const updateParts = [];
|
|
1283
|
+
let idx = params.length + 1;
|
|
1284
|
+
if (options.update !== undefined) {
|
|
1285
|
+
for (const [field, value] of Object.entries(options.update)) {
|
|
1286
|
+
const col = info.columnOf(field);
|
|
1287
|
+
const p = prepareValue(value, info.pgTypeOf(col));
|
|
1288
|
+
if (p.kind === "literal") {
|
|
1289
|
+
updateParts.push(`${quoteIdent(col)} = ${p.literal}`);
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
updateParts.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
|
|
1293
|
+
params.push(p.bound);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
for (const e of entries) {
|
|
1299
|
+
if (conflictSet.has(e.col))
|
|
1300
|
+
continue;
|
|
1301
|
+
updateParts.push(`${quoteIdent(e.col)} = EXCLUDED.${quoteIdent(e.col)}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
if (updateParts.length === 0) {
|
|
1305
|
+
throw new Error("upsertOnConflict: nothing to update on conflict");
|
|
1306
|
+
}
|
|
1307
|
+
const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
|
|
1308
|
+
const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO UPDATE SET ${updateParts.join(", ")} RETURNING *`;
|
|
1309
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1310
|
+
operation: "upsertOnConflict",
|
|
1311
|
+
table: info.name,
|
|
1312
|
+
});
|
|
1313
|
+
const first = rows[0];
|
|
1314
|
+
if (!first)
|
|
1315
|
+
return undefined;
|
|
1316
|
+
return coerceRow(first, info);
|
|
1317
|
+
}
|
|
1318
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1319
|
+
export async function upsertByPk(db, table, values, updateOnConflict) {
|
|
1320
|
+
const info = extractTableInfo(table);
|
|
1321
|
+
const conflictKeys = resolveConflictColumns(table, info, undefined);
|
|
1322
|
+
const fieldKeys = conflictKeys.map((col) => info.fieldOf(col));
|
|
1323
|
+
return upsertOnConflict(db, table, values, {
|
|
1324
|
+
conflictKeys: fieldKeys,
|
|
1325
|
+
...(updateOnConflict !== undefined ? { update: updateOnConflict } : {}),
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1329
|
+
export async function insertOnConflictDoNothing(db, table, values, options) {
|
|
1330
|
+
assertNotTenantScoped(db, "insertOnConflictDoNothing");
|
|
1331
|
+
const info = extractTableInfo(table);
|
|
1332
|
+
const entries = insertEntries(info, values);
|
|
1333
|
+
const conflictCols = resolveConflictColumns(table, info, options?.conflictKeys);
|
|
1334
|
+
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
1335
|
+
const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
|
|
1336
|
+
const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO NOTHING RETURNING *`;
|
|
1337
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1338
|
+
operation: "insertOnConflictDoNothing",
|
|
1339
|
+
table: info.name,
|
|
1340
|
+
});
|
|
1341
|
+
const first = rows[0];
|
|
1342
|
+
if (!first)
|
|
1343
|
+
return undefined;
|
|
1344
|
+
return coerceRow(first, info);
|
|
1345
|
+
}
|
|
1346
|
+
// biome-ignore lint/suspicious/noExplicitAny: see selectMany default
|
|
1347
|
+
export async function incrementCounter(db, table, values, increments, options = {}) {
|
|
1348
|
+
assertNotTenantScoped(db, "incrementCounter");
|
|
1349
|
+
const info = extractTableInfo(table);
|
|
1350
|
+
const entries = insertEntries(info, values);
|
|
1351
|
+
const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
|
|
1352
|
+
const conflictSet = new Set(conflictCols);
|
|
1353
|
+
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
1354
|
+
const tableQ = quoteIdent(info.name);
|
|
1355
|
+
const updateParts = [];
|
|
1356
|
+
let idx = params.length + 1;
|
|
1357
|
+
for (const [field, delta] of Object.entries(increments)) {
|
|
1358
|
+
const col = info.columnOf(field);
|
|
1359
|
+
updateParts.push(`${quoteIdent(col)} = ${tableQ}.${quoteIdent(col)} + $${idx++}`);
|
|
1360
|
+
params.push(delta);
|
|
1361
|
+
}
|
|
1362
|
+
if (options.set !== undefined) {
|
|
1363
|
+
for (const [field, value] of Object.entries(options.set)) {
|
|
1364
|
+
const col = info.columnOf(field);
|
|
1365
|
+
const p = prepareValue(value, info.pgTypeOf(col));
|
|
1366
|
+
if (p.kind === "literal") {
|
|
1367
|
+
updateParts.push(`${quoteIdent(col)} = ${p.literal}`);
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
updateParts.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
|
|
1371
|
+
params.push(p.bound);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
for (const e of entries) {
|
|
1376
|
+
if (conflictSet.has(e.col))
|
|
1377
|
+
continue;
|
|
1378
|
+
if (Object.hasOwn(increments, e.field))
|
|
1379
|
+
continue;
|
|
1380
|
+
if (options.set !== undefined && Object.hasOwn(options.set, e.field))
|
|
1381
|
+
continue;
|
|
1382
|
+
updateParts.push(`${quoteIdent(e.col)} = EXCLUDED.${quoteIdent(e.col)}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (updateParts.length === 0) {
|
|
1385
|
+
throw new Error("incrementCounter: nothing to update on conflict");
|
|
1386
|
+
}
|
|
1387
|
+
const conflictList = conflictCols.map((c) => quoteIdent(c)).join(", ");
|
|
1388
|
+
const sqlText = `${sqlPrefix} ON CONFLICT (${conflictList}) DO UPDATE SET ${updateParts.join(", ")} RETURNING *`;
|
|
1389
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1390
|
+
operation: "incrementCounter",
|
|
1391
|
+
table: info.name,
|
|
1392
|
+
});
|
|
1393
|
+
const first = rows[0];
|
|
1394
|
+
if (!first)
|
|
1395
|
+
return undefined;
|
|
1396
|
+
return coerceRow(first, info);
|
|
1397
|
+
}
|
|
1398
|
+
export async function deleteManyBatched(db, table, where, options) {
|
|
1399
|
+
const info = extractTableInfo(table);
|
|
1400
|
+
const w = buildWhereClause(info, where, 1);
|
|
1401
|
+
if (!w.sqlText) {
|
|
1402
|
+
throw new Error("deleteManyBatched: where clause required — refusing unbounded batch delete");
|
|
1403
|
+
}
|
|
1404
|
+
const limit = options.limit;
|
|
1405
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
1406
|
+
throw new Error("deleteManyBatched: limit must be a positive number");
|
|
1407
|
+
}
|
|
1408
|
+
const idField = options.idColumn ?? "id";
|
|
1409
|
+
const idCol = info.columnOf(idField);
|
|
1410
|
+
const tableQ = quoteIdent(info.name);
|
|
1411
|
+
const idQ = quoteIdent(idCol);
|
|
1412
|
+
const maxBatches = options.maxBatches ?? Number.POSITIVE_INFINITY;
|
|
1413
|
+
let deleted = 0;
|
|
1414
|
+
let batches = 0;
|
|
1415
|
+
while (batches < maxBatches) {
|
|
1416
|
+
const limitIdx = w.values.length + 1;
|
|
1417
|
+
const params = [...w.values, limit];
|
|
1418
|
+
const sqlText = `DELETE FROM ${tableQ} WHERE ${idQ} IN (
|
|
1419
|
+
SELECT ${idQ} FROM ${tableQ} WHERE ${w.sqlText} LIMIT $${limitIdx}
|
|
1420
|
+
) RETURNING ${idQ}`;
|
|
1421
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
1422
|
+
operation: "deleteManyBatched",
|
|
1423
|
+
table: info.name,
|
|
1424
|
+
});
|
|
1425
|
+
batches++;
|
|
1426
|
+
deleted += rows.length;
|
|
1427
|
+
if (rows.length < limit)
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
return { deleted, batches };
|
|
1431
|
+
}
|
|
1432
|
+
export async function transaction(db, fn) {
|
|
1433
|
+
const raw = asRawClient(db);
|
|
1434
|
+
if (!hasBegin(raw)) {
|
|
1435
|
+
throw new InternalError({
|
|
1436
|
+
message: "transaction: runner has no begin() — already inside a transaction or savepoint.",
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
return raw.begin(fn);
|
|
1440
|
+
}
|
|
1441
|
+
// A tx/savepoint handle has no begin(); fail closed instead of nesting silently or TypeError-ing.
|
|
1442
|
+
export async function runInNewTransaction(db, fn) {
|
|
1443
|
+
const raw = asRawClient(db);
|
|
1444
|
+
if (!hasBegin(raw)) {
|
|
1445
|
+
throw new InternalError({
|
|
1446
|
+
message: "runInNewTransaction: runner has no begin() — already inside a transaction or savepoint.",
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
return raw.begin(fn);
|
|
1450
|
+
}
|