@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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.
Files changed (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
package/dist/db/query.js CHANGED
@@ -12,33 +12,19 @@
12
12
  // transaction<T>(db, fn) → T
13
13
  //
14
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.
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";
15
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
16
+ import { quoteIdent } from "../crypto/ciphertext-pattern.js";
23
17
  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";
18
+ import { aggregateDimensionSql, aggregateGroupOrderLimitSql, aggregateKeyOf, aggregateMeasureSql, assertValidAggregateLimits, } from "./aggregate-sql.js";
19
+ import { isClosedConnectionError, isPooledClient, logClosedConnection, poolMaxOf, } from "./closed-connection.js";
27
20
  import { buildDistinctOnClause } from "./distinct-on.js";
21
+ import { coerceDriverRow } from "./driver-row-coercion.js";
22
+ import { prepareValue } from "./driver-value-preparation.js";
28
23
  import { executorManagedWriteDenied } from "./executor-managed-tables.js";
29
24
  import { extractPgError } from "./pg-error.js";
30
- import { toSnakeCase } from "./table-builder.js";
25
+ import { extractTableInfo } from "./table-info.js";
31
26
  import { asEntityTableMeta, requireEntityTableMeta } from "./table-introspection.js";
32
- // Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
33
- // (designed for SHOUT_CASE input) — for already-camelCase keys (mock rows
34
- // in tests, projection-aliased columns) it would silently produce "tenantid"
35
- // instead of leaving "tenantId" alone. Guard with an underscore check so
36
- // the conversion only fires when the key is actually snake-shaped.
37
- function snakeToCamel(key) {
38
- if (!key.includes("_"))
39
- return key;
40
- return envCamelCase(key);
41
- }
27
+ import { buildWhereClause } from "./where-clause.js";
42
28
  // table() (dialect) stores the canonical, shadow-proof EntityTableMeta under
43
29
  // `Symbol.for("kumiko:schema:Meta")`. The column handles on the table object are
44
30
  // enumerable props, so an entity field named `source`/`columns`/`tableName`/…
@@ -148,671 +134,13 @@ function assertNotTenantScoped(db, fnName) {
148
134
  "explicit cross-tenant op, or use a scoped TenantDb method.");
149
135
  }
150
136
  }
151
- const WHERE_OPERATOR_KEYS = [
152
- "gt",
153
- "gte",
154
- "lt",
155
- "lte",
156
- "ne",
157
- "in",
158
- "notIn",
159
- "like",
160
- "jsonText",
161
- ];
162
- const _whereOperatorKeysExhaustive = true;
163
- void _whereOperatorKeysExhaustive;
164
- function isJsonTextMatchList(value) {
165
- return Array.isArray(value);
166
- }
167
- function isWhereOperator(v) {
168
- if (v === null || typeof v !== "object" || Array.isArray(v))
169
- return false;
170
- // Don't false-positive on Date / Temporal / other domain-objects.
171
- // WhereOperator is plain object literal with at most these keys.
172
- const keys = Object.keys(v);
173
- if (keys.length === 0)
174
- return false;
175
- return keys.every((k) => isOneOf(WHERE_OPERATOR_KEYS, k));
176
- }
177
- export function extractTableInfo(table) {
178
- const meta = asEntityTableMeta(table);
179
- if (!meta) {
180
- throw new Error("extractTableInfo: table is not a kumiko EntityTableMeta — " +
181
- "build it via buildEntityTable / deriveEntityTableMeta / table().");
182
- }
183
- const colByField = new Map();
184
- const fieldByCol = new Map();
185
- const typeByCol = new Map();
186
- const bigintModeByCol = new Map();
187
- for (const c of meta.columns) {
188
- typeByCol.set(c.name, c.pgType);
189
- if (c.bigintJsMode !== undefined)
190
- bigintModeByCol.set(c.name, c.bigintJsMode);
191
- // EntityTableMeta column names are snake_case. Map snake → snake AND
192
- // derive a camelCase JS field-name so result rows can be renamed back
193
- // to the API shape (`aggregate_id` → `aggregateId`).
194
- colByField.set(c.name, c.name);
195
- const camel = snakeToCamel(c.name);
196
- if (camel !== c.name)
197
- colByField.set(camel, c.name);
198
- fieldByCol.set(c.name, camel === c.name ? c.name : camel);
199
- }
200
- return {
201
- name: meta.tableName,
202
- columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
203
- pgTypeOf: (col) => typeByCol.get(col),
204
- bigintJsModeOf: (col) => bigintModeByCol.get(col),
205
- fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
206
- hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
207
- };
208
- }
209
- function quoteIdent(name) {
210
- return `"${name.replace(/"/g, '""')}"`;
211
- }
212
- // --- Value coercion at the driver boundary ------------------------------
213
- //
214
- // postgres-js returns timestamptz as JS Date (or ISO string depending on
215
- // driver config). Framework contract says timestamptz surfaces as
216
- // Temporal.Instant — without coercion every read hands callers a Date and
217
- // downstream code that does .epochMilliseconds / .add(...) crashes.
218
- //
219
- // Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
220
- // params — convert to ISO string when the column pgType is timestamptz.
221
- function isTemporalInstant(v) {
222
- return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
223
- }
224
- // Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
225
- // drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
226
- // unmanaged columns). Uses BigInt equality check so 9007199254740993n never
227
- // silently rounds to a safe integer.
228
- function coerceBigIntFromDriver(value) {
229
- let bi;
230
- if (typeof value === "string") {
231
- try {
232
- bi = BigInt(value);
233
- }
234
- catch {
235
- return value;
236
- }
237
- }
238
- else {
239
- bi = value;
240
- }
241
- const min = BigInt(Number.MIN_SAFE_INTEGER);
242
- const max = BigInt(Number.MAX_SAFE_INTEGER);
243
- if (bi >= min && bi <= max) {
244
- const n = Number(bi);
245
- if (BigInt(n) === bi)
246
- return n;
247
- }
248
- return bi;
249
- }
250
- function instantFromDriver(value) {
251
- if (value === null || value === undefined)
252
- return null;
253
- if (isTemporalInstant(value))
254
- return value;
255
- // Number coercion via +date — equivalent to .getTime() without a Date-API
256
- // method (guard-no-date-api). Date → epoch milliseconds, then to Temporal.Instant.
257
- if (value instanceof Date)
258
- return Temporal.Instant.fromEpochMilliseconds(+value);
259
- if (typeof value === "string") {
260
- try {
261
- return Temporal.Instant.from(value);
262
- }
263
- catch {
264
- return null;
265
- }
266
- }
267
- if (typeof value === "number")
268
- return Temporal.Instant.fromEpochMilliseconds(value);
269
- return null;
270
- }
271
- function isTemporalPlainDate(v) {
272
- return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
273
- }
274
- function isTemporalPlainDateTimeLike(v) {
275
- const tag = propertyOf(v, Symbol.toStringTag);
276
- return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
277
- typeof v === "object" &&
278
- v !== null &&
279
- "toPlainDate" in v &&
280
- typeof v.toPlainDate === "function");
281
- }
282
- // Verified empirically: a `date`
283
- // column comes back as a native JS Date, but ALWAYS anchored at UTC
284
- // midnight regardless of process TZ — epochMilliseconds is stable across
285
- // zones, only the local wall-clock read of that Date would drift the day
286
- // (e.g. America/Los_Angeles reads back the 14th for a stored 15th). Routing
287
- // through Instant→ZonedDateTimeISO("UTC")→PlainDate reads the calendar day
288
- // back deterministically without ever touching a local Date getter — the
289
- // same +value idiom instantFromDriver uses (guard-no-date-api: no
290
- // `.getTime()`), just anchored to a fixed zone instead of process-local.
291
- // A real `date` column always arrives as a UTC-midnight Date. Anything else means the column is
292
- // still timestamptz (declared `date` before its ALTER ran); the UTC anchor then silently shifts
293
- // the day, so say so once per column instead of returning the wrong day quietly.
294
- const warnedNonMidnightDateColumns = new Set();
295
- const MS_PER_DAY = 86_400_000;
296
- const dateColumnDriftLog = createFallbackLogger("postgres-js");
297
- function warnIfNotDateColumn(epochMs, column) {
298
- // skip: midnight value is a correct date column, and an already-warned column is deduped
299
- if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
300
- return;
301
- warnedNonMidnightDateColumns.add(column);
302
- 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.`);
303
- }
304
- function plainDateFromDriver(value, column) {
305
- if (value === null || value === undefined)
306
- return null;
307
- if (isTemporalPlainDate(value))
308
- return value;
309
- if (typeof value === "string") {
310
- const isoDay = /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0];
311
- if (isoDay === undefined)
312
- return null;
313
- try {
314
- return Temporal.PlainDate.from(isoDay);
315
- }
316
- catch {
317
- return null;
318
- }
319
- }
320
- if (value instanceof Date) {
321
- warnIfNotDateColumn(+value, column);
322
- return Temporal.Instant.fromEpochMilliseconds(+value).toZonedDateTimeISO("UTC").toPlainDate();
323
- }
324
- return null;
325
- }
326
- // Walk the driver-row, applying three boundary-conversions per known column:
327
- // - rename key snake_case → camelCase JS field-name (drizzle did this
328
- // invisibly via its column-mapper; native dialect rebuild lost it)
329
- // - parse jsonb string → object (postgres-js returns jsonb as text by
330
- // default, not parsed JSON)
331
- // - coerce timestamptz Date/string → Temporal.Instant
332
- //
333
- // Unknown columns (computed/aliased) pass through unchanged.
334
137
  export function coerceRow(row, info) {
335
138
  // @cast-boundary db-row — the driver row (raw column names/types) becomes the caller's entity row shape here, and only here
336
139
  return coerceDriverRow(row, info);
337
140
  }
338
- function coerceDriverRow(row, info) {
339
- const out = {};
340
- let changed = false;
341
- for (const key of Object.keys(row)) {
342
- // Blind-index columns are pure lookup artifacts — the
343
- // deterministic HMAC must never leak into caller/API responses.
344
- if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
345
- changed = true;
346
- continue;
347
- }
348
- const pgType = info.pgTypeOf(key);
349
- const value = row[key];
350
- let coerced = value;
351
- if (pgType === "timestamptz" || pgType === "timestamptz(3)") {
352
- const t = instantFromDriver(value);
353
- if (t !== null)
354
- coerced = t;
355
- }
356
- else if (pgType === "date") {
357
- const d = plainDateFromDriver(value, key);
358
- if (d !== null)
359
- coerced = d;
360
- }
361
- else if (pgType === "jsonb" && typeof value === "string") {
362
- coerced = parseJsonSafe(value, value);
363
- }
364
- else if ((pgType === "bigint" || pgType === "bigserial") &&
365
- (typeof value === "string" || typeof value === "bigint")) {
366
- const jsMode = info.bigintJsModeOf(key);
367
- if (jsMode === "number") {
368
- coerced = coerceBigIntFromDriver(value);
369
- }
370
- else if (typeof value === "string") {
371
- try {
372
- coerced = BigInt(value);
373
- }
374
- catch {
375
- // leave as string on parse error
376
- }
377
- }
378
- else {
379
- coerced = value;
380
- }
381
- }
382
- else if ((pgType === "integer" || pgType === "int4" || pgType === "smallint" || pgType === "int2") &&
383
- (typeof value === "bigint" || typeof value === "string")) {
384
- // Drivers may return int4 as bigint or numeric string.
385
- // Drizzle coerced to number for numberField columns — match that.
386
- const n = Number(value);
387
- if (!Number.isNaN(n))
388
- coerced = n;
389
- }
390
- else if (typeof pgType === "string" &&
391
- pgType.startsWith("numeric") &&
392
- (typeof value === "string" || typeof value === "bigint")) {
393
- // pg returns numeric(p,s) as a string to preserve precision; the
394
- // decimal-field contract surfaces it as JS number (safe ≤ 2^53). A
395
- // non-numeric string would be DB corruption — leave it untouched so it
396
- // surfaces loud downstream rather than becoming a silent NaN.
397
- const n = Number(value);
398
- if (!Number.isNaN(n))
399
- coerced = n;
400
- }
401
- const fieldName = info.fieldOf(key);
402
- if (fieldName !== key)
403
- changed = true;
404
- if (coerced !== value)
405
- changed = true;
406
- out[fieldName] = coerced;
407
- }
408
- return changed ? out : row;
409
- }
410
141
  function coerceRows(rows, info) {
411
142
  return rows.map((r) => coerceRow(r, info));
412
143
  }
413
- // Checks the brand Symbol, not the `kind` string — a client-supplied jsonb
414
- // value can fake `kind: "sql-expr"` over JSON but can never carry a Symbol,
415
- // so request data can't be smuggled in as a raw SQL literal.
416
- function isSqlExpression(v) {
417
- return typeof v === "object" && v !== null && SQL_EXPR_BRAND in v;
418
- }
419
- // A `date` column takes a plain "yyyy-mm-dd" string (or PlainDate.toString())
420
- // as-is — no Instant detour. Postgres parses a DATE literal without ever
421
- // consulting the session timezone, so this is TZ-independent by construction
422
- // (verified empirically — see plainDateFromDriver above for the read side).
423
- // Compat cushion: code (this field used to be an Instant) may still
424
- // hand a Temporal.Instant into a where-filter or write on a `date` column —
425
- // same UTC anchor as plainDateFromDriver's read side, so a filter built with
426
- // `Temporal.Now.instant()` keeps working instead of binding a raw Instant the
427
- // driver can't serialize.
428
- function prepareDateValue(value) {
429
- if (isTemporalPlainDate(value)) {
430
- return { kind: "param", sql: "", bound: value.toString() };
431
- }
432
- if (isTemporalPlainDateTimeLike(value)) {
433
- return { kind: "param", sql: "", bound: value.toPlainDate().toString() };
434
- }
435
- if (isTemporalInstant(value)) {
436
- const day = value.toZonedDateTimeISO("UTC").toPlainDate();
437
- return { kind: "param", sql: "", bound: day.toString() };
438
- }
439
- return undefined;
440
- }
441
- // structured values bind directly with an ::jsonb cast; JSON.stringify first
442
- // would produce a JSON-string scalar instead of the structured value.
443
- function prepareJsonbValue(value) {
444
- if (typeof value === "boolean") {
445
- return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
446
- }
447
- if (typeof value !== "object" || isTemporalInstant(value)) {
448
- return undefined;
449
- }
450
- if (!Array.isArray(value)) {
451
- return { kind: "param", sql: "::jsonb", bound: value };
452
- }
453
- // All-boolean arrays are inferred as boolean[] by postgres-js; route via text::jsonb.
454
- if (value.length > 0 && value.every((entry) => typeof entry === "boolean")) {
455
- return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
456
- }
457
- return { kind: "param", sql: "::jsonb", bound: value };
458
- }
459
- const INTEGER_PG_TYPES = new Set([
460
- "integer",
461
- "int4",
462
- "smallint",
463
- "int2",
464
- "bigint",
465
- "int8",
466
- "serial",
467
- "bigserial",
468
- ]);
469
- // Postgres rounds sub-millisecond params into timestamptz(3); truncating to ms here keeps the
470
- // stored instant identical across drivers and equal to what postgres.js sends via Date.
471
- export function instantToTimestamptzParam(instant) {
472
- return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
473
- }
474
- function prepareDriverNeutralScalar(value, pgType) {
475
- if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
476
- return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
477
- }
478
- // postgres.js sends text, so Postgres rejects 3.5 for an integer column instead of rounding it.
479
- if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
480
- return { kind: "param", sql: "", bound: String(value) };
481
- }
482
- return undefined;
483
- }
484
- function prepareValue(value, pgType) {
485
- if (isSqlExpression(value)) {
486
- return { kind: "literal", literal: value.text };
487
- }
488
- // A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
489
- // ::jsonb would be parsed as JSON text (or double-encoded).
490
- if (pgType === "jsonb" && isPiiCiphertext(value)) {
491
- return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
492
- }
493
- if (pgType === "jsonb" && value !== null) {
494
- const prepared = prepareJsonbValue(value);
495
- if (prepared)
496
- return prepared;
497
- }
498
- const scalar = prepareDriverNeutralScalar(value, pgType);
499
- if (scalar)
500
- return scalar;
501
- if (pgType === "date") {
502
- const prepared = prepareDateValue(value);
503
- if (prepared)
504
- return prepared;
505
- }
506
- return { kind: "param", sql: "", bound: value };
507
- }
508
- function buildWhereClause(info, where, startIndex, qualifier) {
509
- const conditions = [];
510
- const values = [];
511
- let idx = startIndex;
512
- // Single place that renders a column reference, so a join qualifier reaches
513
- // every arm (blind index, jsonb containment, IS NULL, jsonText) alike.
514
- const ref = (column) => qualifier === undefined ? quoteIdent(column) : `${quoteIdent(qualifier)}.${quoteIdent(column)}`;
515
- // multiSelect (and any other jsonb-array-of-scalars column) stores an
516
- // array — a filter value is one option, not the whole array, so eq/ne/in
517
- // must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
518
- // against the jsonb column, which Postgres rejects outright.
519
- // Mirrors event-store-executor-read.ts's applyFilter — keep in lock-step.
520
- function jsonbContainsAny(col, candidates) {
521
- if (candidates.length === 0)
522
- return "FALSE";
523
- const parts = [];
524
- for (const v of candidates) {
525
- const p = prepareJsonbValue([v]);
526
- // prepareJsonbValue never returns kind:"literal" (only prepareValue's
527
- // isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
528
- if (p && p.kind === "param") {
529
- parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
530
- values.push(p.bound);
531
- }
532
- // A jsonb column holding the scalar itself is never `@>` an array of it.
533
- if (isJsonbScalar(v)) {
534
- parts.push(`${ref(col)} = $${idx++}::text::jsonb`);
535
- values.push(JSON.stringify(v));
536
- }
537
- }
538
- return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
539
- }
540
- function isJsonbScalar(value) {
541
- return value !== null && typeof value !== "object";
542
- }
543
- for (const [field, value] of Object.entries(where)) {
544
- const col = info.columnOf(field);
545
- const pgType = info.pgTypeOf(col);
546
- if (value === null) {
547
- conditions.push(`${ref(col)} IS NULL`);
548
- }
549
- else if (Array.isArray(value)) {
550
- if (value.length === 0) {
551
- conditions.push("FALSE");
552
- }
553
- else if (pgType === "jsonb") {
554
- conditions.push(jsonbContainsAny(col, value));
555
- }
556
- else {
557
- const parts = [];
558
- for (const v of value) {
559
- const p = prepareValue(v, pgType);
560
- if (p.kind === "literal") {
561
- parts.push(p.literal);
562
- }
563
- else {
564
- parts.push(`$${idx++}${p.sql}`);
565
- values.push(p.bound);
566
- }
567
- }
568
- conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
569
- }
570
- }
571
- else if (isWhereOperator(value)) {
572
- if (value.jsonText !== undefined) {
573
- if (Object.keys(value).length > 1) {
574
- throw new Error(`where: jsonText on column "${col}" cannot be combined with other operators`);
575
- }
576
- if (pgType !== "jsonb") {
577
- throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
578
- }
579
- const matches = isJsonTextMatchList(value.jsonText) ? value.jsonText : [value.jsonText];
580
- if (matches.length === 0) {
581
- conditions.push("FALSE");
582
- continue;
583
- }
584
- for (const match of matches) {
585
- if (match === null ||
586
- typeof match !== "object" ||
587
- !Array.isArray(match.keys) ||
588
- match.keys.length === 0 ||
589
- !match.keys.every((k) => typeof k === "string") ||
590
- typeof match.eq !== "string") {
591
- throw new Error(`where: jsonText on column "${col}" needs non-empty string keys and a string eq`);
592
- }
593
- const lookups = match.keys.map((key) => {
594
- values.push(key);
595
- return `${ref(col)}->>$${idx++}::text`;
596
- });
597
- const lhs = lookups.length === 1 ? lookups[0] : `COALESCE(${lookups.join(", ")})`;
598
- values.push(match.eq);
599
- conditions.push(`(${lhs} = $${idx++})`);
600
- }
601
- continue;
602
- }
603
- if (value.ne === null && Object.keys(value).length === 1) {
604
- conditions.push(`${ref(col)} IS NOT NULL`);
605
- continue;
606
- }
607
- const opMap = {
608
- gt: ">",
609
- gte: ">=",
610
- lt: "<",
611
- lte: "<=",
612
- ne: "<>",
613
- like: "LIKE",
614
- };
615
- for (const [opKey, opSym] of Object.entries(opMap)) {
616
- const opVal = Reflect.get(value, opKey);
617
- if (opVal === undefined)
618
- continue;
619
- // Mirrors applyMultiSelectFilter: ordering/pattern ops have no meaning on a jsonb
620
- // option array (`like` would crash as `jsonb ~~ jsonb`), so match nothing.
621
- if (pgType === "jsonb" && opKey !== "ne") {
622
- conditions.push("FALSE");
623
- continue;
624
- }
625
- // Mirrors applyMultiSelectFilter: an array `ne` means "does not contain all of these".
626
- if (opKey === "ne" && pgType === "jsonb" && Array.isArray(opVal)) {
627
- if (opVal.length === 0) {
628
- conditions.push("FALSE");
629
- continue;
630
- }
631
- const p = prepareJsonbValue(opVal);
632
- if (p && p.kind === "param") {
633
- conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
634
- values.push(p.bound);
635
- continue;
636
- }
637
- }
638
- if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
639
- const p = prepareJsonbValue([opVal]);
640
- if (p && p.kind === "param") {
641
- conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql} OR ${ref(col)} = $${idx++}::text::jsonb))`);
642
- values.push(p.bound, JSON.stringify(opVal));
643
- continue;
644
- }
645
- }
646
- const p = prepareValue(opVal, pgType);
647
- if (p.kind === "literal") {
648
- conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
649
- }
650
- else {
651
- conditions.push(`${ref(col)} ${opSym} $${idx++}${p.sql}`);
652
- values.push(p.bound);
653
- }
654
- }
655
- const inVal = value.in;
656
- if (Array.isArray(inVal)) {
657
- if (pgType === "jsonb") {
658
- conditions.push(jsonbContainsAny(col, inVal));
659
- }
660
- else if (inVal.length === 0) {
661
- conditions.push("FALSE");
662
- }
663
- else {
664
- const parts = [];
665
- for (const v of inVal) {
666
- const p = prepareValue(v, pgType);
667
- if (p.kind === "literal") {
668
- parts.push(p.literal);
669
- }
670
- else {
671
- parts.push(`$${idx++}${p.sql}`);
672
- values.push(p.bound);
673
- }
674
- }
675
- conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
676
- }
677
- }
678
- const notInVal = value.notIn;
679
- if (Array.isArray(notInVal)) {
680
- if (pgType === "jsonb") {
681
- throw new Error(`where: notIn is not supported on jsonb column "${col}"`);
682
- }
683
- const parts = [];
684
- for (const v of notInVal) {
685
- const p = prepareValue(v, pgType);
686
- if (p.kind === "literal") {
687
- parts.push(p.literal);
688
- }
689
- else {
690
- parts.push(`$${idx++}${p.sql}`);
691
- values.push(p.bound);
692
- }
693
- }
694
- conditions.push(parts.length === 0 ? "TRUE" : `${ref(col)} NOT IN (${parts.join(", ")})`);
695
- }
696
- }
697
- else if (pgType === "jsonb" && isJsonbScalar(value)) {
698
- conditions.push(jsonbContainsAny(col, [value]));
699
- }
700
- else {
701
- const p = prepareValue(value, pgType);
702
- if (p.kind === "literal") {
703
- conditions.push(`${ref(col)} = ${p.literal}`);
704
- }
705
- else {
706
- // Blind-index OR rewrite: if the column has a bidx
707
- // counterpart (suffix convention `<col>_bidx`, framework-reserved)
708
- // and a key is configured, equality matches both arms —
709
- // plaintext legacy rows via the plaintext arm, encrypted rows
710
- // via the HMAC. The `kumiko-pii:` prefix on ciphertexts makes
711
- // a random match on the plaintext arm practically impossible.
712
- const bidxKey = configuredBlindIndexKey();
713
- const bidxCol = `${col}_bidx`;
714
- if (bidxKey !== undefined &&
715
- typeof value === "string" &&
716
- !col.endsWith("_bidx") &&
717
- info.hasColumn(bidxCol)) {
718
- conditions.push(`(${ref(col)} = $${idx++} OR ${ref(bidxCol)} = $${idx++})`);
719
- values.push(p.bound, computeBlindIndex(bidxKey, value));
720
- }
721
- else {
722
- conditions.push(`${ref(col)} = $${idx++}${p.sql}`);
723
- values.push(p.bound);
724
- }
725
- }
726
- }
727
- }
728
- return { sqlText: conditions.join(" AND "), values };
729
- }
730
- // A pool can briefly keep handing out connections the server just closed.
731
- // Each failed attempt discards one, so retries are bounded by pool size.
732
- const CLOSED_CONNECTION_CODES = new Set([
733
- "CONNECTION_CLOSED", // postgres-js
734
- "57P01", // PG SQLSTATE: admin_shutdown / terminated backend
735
- ]);
736
- export function isClosedConnectionError(err) {
737
- const code = extractPgError(err)?.code;
738
- return code !== undefined && CLOSED_CONNECTION_CODES.has(code);
739
- }
740
- // Tx/reserved handles are pinned to one physical connection — a retry there
741
- // would run on the same dead connection, not swap in a fresh one.
742
- function isPooledClient(raw) {
743
- if (raw === null || (typeof raw !== "object" && typeof raw !== "function"))
744
- return false;
745
- return (typeof propertyOf(raw, "begin") === "function" &&
746
- typeof propertyOf(raw, "savepoint") !== "function" &&
747
- typeof propertyOf(raw, "release") !== "function");
748
- }
749
- const closedConnectionLog = createFallbackLogger("postgres-js");
750
- // The driver rejects in-flight queries with this AbortError (no code) when the
751
- // pool closes under them, so isClosedConnectionError can't see it.
752
- const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
753
- function isDriverClosedAbort(err) {
754
- return (err instanceof Error && err.name === "AbortError" && err.message === DRIVER_CLOSED_ABORT_MESSAGE);
755
- }
756
- function isHandleLike(raw) {
757
- return raw !== null && (typeof raw === "object" || typeof raw === "function");
758
- }
759
- // postgres-js pool and tx handles both carry `typed`.
760
- function driverOf(raw) {
761
- if (!isHandleLike(raw))
762
- return "unknown";
763
- if (typeof raw["typed"] === "function")
764
- return "postgres-js";
765
- return "unknown";
766
- }
767
- function positiveNumber(v) {
768
- return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
769
- }
770
- // postgres-js configures timeouts in seconds.
771
- function timeoutOptionMs(options, driver, optionName) {
772
- if (driver !== "postgres-js")
773
- return undefined;
774
- const seconds = positiveNumber(options[optionName]);
775
- return seconds === undefined ? undefined : seconds * 1000;
776
- }
777
- function errorProp(err, name) {
778
- return isHandleLike(err) ? err[name] : undefined;
779
- }
780
- // Deliberately excludes SQL text and parameters: app SQL can carry PII.
781
- export function describeClosedConnectionFailure(raw, err, attempts) {
782
- if (!isClosedConnectionError(err) && !isDriverClosedAbort(err))
783
- return undefined;
784
- const driver = driverOf(raw);
785
- const rawOptions = isHandleLike(raw) ? raw["options"] : undefined;
786
- const options = isHandleLike(rawOptions) ? rawOptions : {};
787
- return {
788
- driver,
789
- pooled: isPooledClient(raw),
790
- attempts,
791
- poolMax: positiveNumber(options["max"]),
792
- idleTimeoutMs: timeoutOptionMs(options, driver, "idle_timeout"),
793
- maxLifetimeMs: timeoutOptionMs(options, driver, "max_lifetime"),
794
- connectionTimeoutMs: timeoutOptionMs(options, driver, "connect_timeout"),
795
- errorName: errorProp(err, "name"),
796
- errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
797
- errorErrno: errorProp(err, "errno"),
798
- errorMessage: errorProp(err, "message"),
799
- };
800
- }
801
- function logClosedConnection(raw, err, attempts, context) {
802
- const failure = describeClosedConnectionFailure(raw, err, attempts);
803
- // skip: not a closed-connection error, nothing to diagnose
804
- if (!failure)
805
- return;
806
- // Request identity lets this line be joined to the route's `handler failed` log.
807
- const request = requestContext.get();
808
- closedConnectionLog.warn("closed connection surfaced", {
809
- ...failure,
810
- ...context,
811
- requestId: request?.requestId,
812
- correlationId: request?.correlationId,
813
- handler: request?.handler,
814
- });
815
- }
816
144
  // Write and other non-retried paths: log when a closed-connection error leaves
817
145
  // the db layer, rethrow unchanged.
818
146
  async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
@@ -825,11 +153,6 @@ async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
825
153
  throw err;
826
154
  }
827
155
  }
828
- const DEFAULT_POOL_MAX = 10;
829
- function poolMaxOf(raw) {
830
- const max = propertyOf(propertyOf(raw, "options"), "max");
831
- return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
832
- }
833
156
  // Exported so raw-SQL query modules outside the db layer (e.g. bundled-features'
834
157
  // db/queries/*.ts) can opt into the same closed-connection retry instead of
835
158
  // calling asRawClient(db).unsafe(...) directly and losing it.
@@ -1191,82 +514,6 @@ export async function countWhere(db, table, where = {}) {
1191
514
  const rows = await unsafeReadRetrying(db, sqlText, values);
1192
515
  return rows[0]?.count ?? 0;
1193
516
  }
1194
- const AGGREGATE_BUCKET_UNITS = ["hour", "day"];
1195
- const AGGREGATE_ORDER_DIRECTIONS = ["asc", "desc"];
1196
- function requireAggregateColumn(info, field, role) {
1197
- if (!info.hasColumn(field)) {
1198
- throw new Error(`aggregateWhere: ${role} "${field}" is not a column of ${info.name}`);
1199
- }
1200
- return quoteIdent(info.columnOf(field));
1201
- }
1202
- export function isTimestamptzType(pgType) {
1203
- return pgType === "timestamptz" || pgType === "timestamptz(3)";
1204
- }
1205
- function aggregateKeyOf(raw) {
1206
- if (raw === null || raw === undefined)
1207
- return null;
1208
- if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean")
1209
- return raw;
1210
- if (typeof raw === "bigint")
1211
- return Number(raw);
1212
- if (raw instanceof Date)
1213
- return raw.toISOString();
1214
- return String(raw);
1215
- }
1216
- function aggregateMeasureSql(info, measure) {
1217
- switch (measure.fn) {
1218
- case "count":
1219
- return "COUNT(*)";
1220
- case "countDistinct":
1221
- return `COUNT(DISTINCT ${requireAggregateColumn(info, measure.field, "measure field")})`;
1222
- case "sum":
1223
- return `COALESCE(SUM(${requireAggregateColumn(info, measure.field, "measure field")}), 0)`;
1224
- case "avg":
1225
- return `AVG(${requireAggregateColumn(info, measure.field, "measure field")})`;
1226
- default:
1227
- throw new Error(`aggregateWhere: unknown measure fn "${String(Reflect.get(measure, "fn"))}"`);
1228
- }
1229
- }
1230
- function aggregateDimensionSql(info, dimension, index, params) {
1231
- const column = requireAggregateColumn(info, dimension.field, "groupBy field");
1232
- if (!("bucket" in dimension))
1233
- return `${column} AS k${index}`;
1234
- if (!AGGREGATE_BUCKET_UNITS.includes(dimension.bucket)) {
1235
- throw new Error(`aggregateWhere: unknown bucket "${String(dimension.bucket)}"`);
1236
- }
1237
- if (!isTimestamptzType(info.pgTypeOf(info.columnOf(dimension.field)))) {
1238
- throw new Error(`aggregateWhere: bucket on "${dimension.field}" requires a timestamptz column`);
1239
- }
1240
- params.push(dimension.timeZone);
1241
- return `(EXTRACT(EPOCH FROM date_trunc('${dimension.bucket}', ${column}, $${params.length}::text)) * 1000)::float8 AS k${index}`;
1242
- }
1243
- function assertValidAggregateLimits(spec) {
1244
- if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
1245
- throw new Error(`aggregateWhere: limit must be a non-negative integer, got ${spec.limit}`);
1246
- }
1247
- if (spec.orderByValue !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByValue)) {
1248
- throw new Error(`aggregateWhere: unknown orderByValue "${String(spec.orderByValue)}"`);
1249
- }
1250
- if (spec.orderByKeys !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByKeys)) {
1251
- throw new Error(`aggregateWhere: unknown orderByKeys "${String(spec.orderByKeys)}"`);
1252
- }
1253
- }
1254
- function aggregateGroupOrderLimitSql(spec, dimensionCount) {
1255
- let sqlText = "";
1256
- if (dimensionCount > 0) {
1257
- const positions = Array.from({ length: dimensionCount }, (_, index) => String(index + 1));
1258
- sqlText += ` GROUP BY ${positions.join(", ")}`;
1259
- const keyDirection = spec.orderByKeys === "desc" ? "DESC" : "ASC";
1260
- const keyParts = positions.map((position) => `${position} ${keyDirection}`);
1261
- const orderParts = spec.orderByValue === undefined
1262
- ? keyParts
1263
- : [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...keyParts];
1264
- sqlText += ` ORDER BY ${orderParts.join(", ")}`;
1265
- }
1266
- if (spec.limit !== undefined)
1267
- sqlText += ` LIMIT ${spec.limit}`;
1268
- return sqlText;
1269
- }
1270
517
  export async function aggregateWhere(db, table, spec, where = {}) {
1271
518
  const scoped = tenantDbDelegate(db);
1272
519
  if (scoped) {