@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.
- package/AGENTS.md +6 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +1 -0
- package/dist/api/client-error-route.js +8 -2
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +203 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +293 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +236 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.js +16 -12
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +21 -0
- package/dist/db/aggregate-sql.js +155 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +95 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +99 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +112 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +203 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-steps.d.ts +20 -0
- package/dist/db/event-store-executor-write-steps.js +100 -0
- package/dist/db/event-store-executor-write.js +395 -499
- package/dist/db/index.d.ts +6 -2
- package/dist/db/index.js +5 -1
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +4 -1
- package/dist/db/query.d.ts +2 -15
- package/dist/db/query.js +8 -761
- package/dist/db/reference-data.js +6 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db.js +39 -9
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +12 -9
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.js +56 -96
- package/dist/engine/define-feature.js +9 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/patch.js +4 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
- package/dist/engine/feature-ast/patterns.d.ts +9 -0
- package/dist/engine/feature-ast/patterns.js +43 -51
- package/dist/engine/feature-ast/render.js +48 -87
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +4 -1
- package/dist/entrypoint/index.js +7 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.js +10 -4
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +4 -1
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.js +11 -8
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.js +7 -0
- package/dist/i18n/required-surface-keys.js +182 -206
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +47 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +12 -1
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +58 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -5
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
- package/dist/pipeline/handler-context-dispatch.js +89 -0
- package/dist/pipeline/handler-context-events.d.ts +43 -0
- package/dist/pipeline/handler-context-events.js +237 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/upgrade-cli.js +149 -121
- package/package.json +4 -4
- package/src/changes.json +310 -0
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
|
@@ -53,10 +53,10 @@ export function expireLegacySessionCookieVariants(c, cookies) {
|
|
|
53
53
|
// skip: the unprefixed names are the only legacy variant when the config writes __Host- names.
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
56
|
+
// Sent even with secureCookies:false: TLS may still terminate at a proxy, and over plain HTTP the
|
|
57
|
+
// browser drops the Secure delete without effect.
|
|
58
|
+
deleteCookie(c, sessionCookieName(AUTH_COOKIE_BASE, { secure: true }), hostPrefixedDelete);
|
|
59
|
+
deleteCookie(c, sessionCookieName(CSRF_COOKIE_BASE, { secure: true }), hostPrefixedDelete);
|
|
60
60
|
}
|
|
61
61
|
const hostPrefixedDelete = { path: "/", secure: true };
|
|
62
62
|
// Expire every variant: the Domain cookie (current) and the host-only one
|
package/dist/api/sse-route.js
CHANGED
|
@@ -136,6 +136,20 @@ export function createSseRoute(broker, options) {
|
|
|
136
136
|
let pendingWrites = 0;
|
|
137
137
|
let lastProgressAt = now();
|
|
138
138
|
const isStalled = () => pendingWrites > 0 && now() - lastProgressAt > SSE_STALLED_CLIENT_TIMEOUT_MS;
|
|
139
|
+
// Pings go through the same accounting: an awaited ping would block on a client that
|
|
140
|
+
// stopped reading, and the stall check below would never run again.
|
|
141
|
+
const writeTracked = (event, data) => {
|
|
142
|
+
if (pendingWrites === 0)
|
|
143
|
+
lastProgressAt = now();
|
|
144
|
+
pendingWrites += 1;
|
|
145
|
+
stream
|
|
146
|
+
.writeSSE({ event, data })
|
|
147
|
+
.catch(closeStream)
|
|
148
|
+
.finally(() => {
|
|
149
|
+
pendingWrites -= 1;
|
|
150
|
+
lastProgressAt = now();
|
|
151
|
+
});
|
|
152
|
+
};
|
|
139
153
|
const clientId = broker.addClient(channel, (event) => {
|
|
140
154
|
// skip: the stream is already being torn down
|
|
141
155
|
if (stream.aborted || stream.closed)
|
|
@@ -152,17 +166,7 @@ export function createSseRoute(broker, options) {
|
|
|
152
166
|
// skip: this client is gone, the frame has no audience
|
|
153
167
|
return;
|
|
154
168
|
}
|
|
155
|
-
|
|
156
|
-
if (pendingWrites === 0)
|
|
157
|
-
lastProgressAt = now();
|
|
158
|
-
pendingWrites += 1;
|
|
159
|
-
stream
|
|
160
|
-
.writeSSE({ event: frame.name, data })
|
|
161
|
-
.catch(closeStream)
|
|
162
|
-
.finally(() => {
|
|
163
|
-
pendingWrites -= 1;
|
|
164
|
-
lastProgressAt = now();
|
|
165
|
-
});
|
|
169
|
+
writeTracked(frame.name, JSON.stringify(frame.data));
|
|
166
170
|
}
|
|
167
171
|
catch {
|
|
168
172
|
// skip: an unserializable frame ends this client's stream, not the broadcast
|
|
@@ -192,7 +196,7 @@ export function createSseRoute(broker, options) {
|
|
|
192
196
|
stream.abort();
|
|
193
197
|
break;
|
|
194
198
|
}
|
|
195
|
-
|
|
199
|
+
writeTracked("ping", "");
|
|
196
200
|
await Promise.race([stream.sleep(SSE_HEARTBEAT_INTERVAL_MS), ended]);
|
|
197
201
|
}
|
|
198
202
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/fields.ts";
|
|
2
|
-
export declare function isPlainUserOwnedField(field: EntityDefinition["fields"][string]):
|
|
2
|
+
export declare function isPlainUserOwnedField(field: EntityDefinition["fields"][string]): field is EntityDefinition["fields"][string] & {
|
|
3
|
+
readonly userOwned: {
|
|
4
|
+
readonly ownerField: string;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
3
7
|
export declare function personalFieldsRekeyedOnOwnerTransition(entity: EntityDefinition, previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -17,7 +17,7 @@ export function isPlainUserOwnedField(field) {
|
|
|
17
17
|
export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
|
|
18
18
|
const rekeyed = {};
|
|
19
19
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
20
|
-
if (!isPlainUserOwnedField(field)
|
|
20
|
+
if (!isPlainUserOwnedField(field))
|
|
21
21
|
continue;
|
|
22
22
|
if (name in submittedChanges)
|
|
23
23
|
continue;
|
|
@@ -7,13 +7,17 @@ export declare function piiCiphertextSubject(value: string): SubjectId;
|
|
|
7
7
|
export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
8
8
|
export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
9
9
|
export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
10
|
+
export declare const ErasedSubjectHandlings: {
|
|
11
|
+
readonly sentinel: "sentinel";
|
|
12
|
+
};
|
|
13
|
+
export type ErasedSubjectHandling = (typeof ErasedSubjectHandlings)[keyof typeof ErasedSubjectHandlings];
|
|
10
14
|
export interface EncryptPiiOptions {
|
|
11
15
|
readonly onlyKeys?: Iterable<string>;
|
|
12
16
|
readonly tenantId?: TenantId;
|
|
13
17
|
readonly subjectSource?: Record<string, unknown>;
|
|
14
18
|
readonly entityName: string;
|
|
15
19
|
readonly ownerFallback?: Record<string, unknown>;
|
|
16
|
-
readonly onErasedSubject?:
|
|
20
|
+
readonly onErasedSubject?: ErasedSubjectHandling;
|
|
17
21
|
}
|
|
18
22
|
export type PiiEncryptedRow<TRow extends Record<string, unknown>> = {
|
|
19
23
|
[K in keyof TRow]: TRow[K] extends string ? string : TRow[K] | string;
|
|
@@ -130,6 +130,7 @@ export async function decryptPiiValueForSubject(kms, value, kmsCtx, field) {
|
|
|
130
130
|
return PII_ERASED_SENTINEL;
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
export const ErasedSubjectHandlings = { sentinel: "sentinel" };
|
|
133
134
|
export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx, opts) {
|
|
134
135
|
if (piiFields.length === 0)
|
|
135
136
|
return row;
|
|
@@ -166,7 +167,8 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
|
|
|
166
167
|
out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
|
|
167
168
|
}
|
|
168
169
|
catch (e) {
|
|
169
|
-
if (!(e instanceof KeyErasedError) ||
|
|
170
|
+
if (!(e instanceof KeyErasedError) ||
|
|
171
|
+
opts.onErasedSubject !== ErasedSubjectHandlings.sentinel)
|
|
170
172
|
throw e;
|
|
171
173
|
out[name] = PII_ERASED_SENTINEL;
|
|
172
174
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AggregateDimension, AggregateKey, AggregateSpec } from "@cosmicdrift/kumiko-types/aggregate-types";
|
|
2
|
+
import type { FieldDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
3
|
+
import type { ListAggregateMeasureKind, ListAggregateValue } from "@cosmicdrift/kumiko-types/list-aggregate-types";
|
|
4
|
+
import type { TableInfo } from "./table-info.ts";
|
|
5
|
+
export declare function isTimestamptzType(pgType: string | undefined): boolean;
|
|
6
|
+
export declare function aggregateKeyOf(raw: unknown): AggregateKey;
|
|
7
|
+
export type AggregateFunction = "count" | "countDistinct" | "sum" | "avg" | "min" | "max";
|
|
8
|
+
export declare function aggregateFunctionSql(fn: AggregateFunction, columnSql: string | undefined, filterSql?: string): string;
|
|
9
|
+
export declare function aggregateMeasureSql(info: TableInfo, measure: AggregateSpec["measure"]): string;
|
|
10
|
+
type FieldType = FieldDefinition["type"];
|
|
11
|
+
export declare function isListAggregateMeasureSupported(kind: ListAggregateMeasureKind, fieldType: FieldType): boolean;
|
|
12
|
+
export declare function isListAggregateGroupable(fieldType: FieldType, multiple: boolean): boolean;
|
|
13
|
+
export declare function listAggregateKeySql(fieldType: FieldType, columnSql: string, alias: string): string;
|
|
14
|
+
export declare function listAggregateMeasureSql(kind: ListAggregateMeasureKind, fieldType: FieldType, columnSql: string, filterSql?: string): string;
|
|
15
|
+
export declare function groupedKeyOrderLimitSql(limit: number): string;
|
|
16
|
+
export declare function decodeListAggregateValue(raw: unknown): ListAggregateValue;
|
|
17
|
+
export declare function emptyListAggregateValue(kind: ListAggregateMeasureKind): ListAggregateValue;
|
|
18
|
+
export declare function aggregateDimensionSql(info: TableInfo, dimension: AggregateDimension, index: number, params: unknown[]): string;
|
|
19
|
+
export declare function assertValidAggregateLimits(spec: AggregateSpec): void;
|
|
20
|
+
export declare function aggregateGroupOrderLimitSql(spec: AggregateSpec, dimensionCount: number): string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { quoteIdent } from "../crypto/ciphertext-pattern.js";
|
|
2
|
+
const AGGREGATE_BUCKET_UNITS = ["hour", "day"];
|
|
3
|
+
const AGGREGATE_ORDER_DIRECTIONS = ["asc", "desc"];
|
|
4
|
+
function requireAggregateColumn(info, field, role) {
|
|
5
|
+
if (!info.hasColumn(field)) {
|
|
6
|
+
throw new Error(`aggregateWhere: ${role} "${field}" is not a column of ${info.name}`);
|
|
7
|
+
}
|
|
8
|
+
return quoteIdent(info.columnOf(field));
|
|
9
|
+
}
|
|
10
|
+
export function isTimestamptzType(pgType) {
|
|
11
|
+
return pgType === "timestamptz" || pgType === "timestamptz(3)";
|
|
12
|
+
}
|
|
13
|
+
export function aggregateKeyOf(raw) {
|
|
14
|
+
if (raw === null || raw === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean")
|
|
17
|
+
return raw;
|
|
18
|
+
if (typeof raw === "bigint")
|
|
19
|
+
return Number(raw);
|
|
20
|
+
if (raw instanceof Date)
|
|
21
|
+
return raw.toISOString();
|
|
22
|
+
return String(raw);
|
|
23
|
+
}
|
|
24
|
+
// FILTER sits inside the aggregate so a row-dependent read clause restricts only
|
|
25
|
+
// this measure; COALESCE stays outside to keep an all-filtered SUM at 0.
|
|
26
|
+
export function aggregateFunctionSql(fn, columnSql, filterSql) {
|
|
27
|
+
const filter = filterSql === undefined ? "" : ` FILTER (WHERE ${filterSql})`;
|
|
28
|
+
const column = columnSql ?? "*";
|
|
29
|
+
switch (fn) {
|
|
30
|
+
case "count":
|
|
31
|
+
return `COUNT(${column})${filter}`;
|
|
32
|
+
case "countDistinct":
|
|
33
|
+
return `COUNT(DISTINCT ${column})${filter}`;
|
|
34
|
+
case "sum":
|
|
35
|
+
return `COALESCE(SUM(${column})${filter}, 0)`;
|
|
36
|
+
case "avg":
|
|
37
|
+
return `AVG(${column})${filter}`;
|
|
38
|
+
case "min":
|
|
39
|
+
return `MIN(${column})${filter}`;
|
|
40
|
+
case "max":
|
|
41
|
+
return `MAX(${column})${filter}`;
|
|
42
|
+
default:
|
|
43
|
+
throw new Error(`aggregateWhere: unknown aggregate fn "${String(fn)}"`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function aggregateMeasureSql(info, measure) {
|
|
47
|
+
if (measure.fn === "count")
|
|
48
|
+
return aggregateFunctionSql("count", undefined);
|
|
49
|
+
return aggregateFunctionSql(measure.fn, requireAggregateColumn(info, measure.field, "measure field"));
|
|
50
|
+
}
|
|
51
|
+
const NUMERIC_MEASURE_TYPES = ["number", "bigInt", "decimal", "money"];
|
|
52
|
+
const ORDERED_MEASURE_TYPES = [...NUMERIC_MEASURE_TYPES, "date", "timestamp"];
|
|
53
|
+
// Group keys must round-trip as a plain `field eq key` filter, which rules out
|
|
54
|
+
// composite, multi-valued, currency-paired and zone-paired types.
|
|
55
|
+
const GROUPABLE_TYPES = [
|
|
56
|
+
"text",
|
|
57
|
+
"longText",
|
|
58
|
+
"select",
|
|
59
|
+
"boolean",
|
|
60
|
+
"number",
|
|
61
|
+
"bigInt",
|
|
62
|
+
"decimal",
|
|
63
|
+
"date",
|
|
64
|
+
"timestamp",
|
|
65
|
+
];
|
|
66
|
+
export function isListAggregateMeasureSupported(kind, fieldType) {
|
|
67
|
+
if (kind === "count")
|
|
68
|
+
return true;
|
|
69
|
+
const allowed = kind === "sum" || kind === "avg" ? NUMERIC_MEASURE_TYPES : ORDERED_MEASURE_TYPES;
|
|
70
|
+
return allowed.includes(fieldType);
|
|
71
|
+
}
|
|
72
|
+
export function isListAggregateGroupable(fieldType, multiple) {
|
|
73
|
+
return fieldType === "reference" ? !multiple : GROUPABLE_TYPES.includes(fieldType);
|
|
74
|
+
}
|
|
75
|
+
export function listAggregateKeySql(fieldType, columnSql, alias) {
|
|
76
|
+
// A date column must not pass through a JS Date, so it travels as text.
|
|
77
|
+
return fieldType === "date" ? `${columnSql}::text AS ${alias}` : `${columnSql} AS ${alias}`;
|
|
78
|
+
}
|
|
79
|
+
const ISO_UTC_TIMESTAMP_FORMAT = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`;
|
|
80
|
+
// Numbers leave PG as float8 and dates/timestamps as text, so the driver never
|
|
81
|
+
// hands back a bigint or numeric string that JSON could not carry.
|
|
82
|
+
export function listAggregateMeasureSql(kind, fieldType, columnSql, filterSql) {
|
|
83
|
+
const fn = aggregateFunctionSql(kind, columnSql, filterSql);
|
|
84
|
+
if (kind === "count")
|
|
85
|
+
return `(${fn})::float8`;
|
|
86
|
+
if (kind === "min" || kind === "max") {
|
|
87
|
+
if (fieldType === "date")
|
|
88
|
+
return `(${fn})::text`;
|
|
89
|
+
if (fieldType === "timestamp") {
|
|
90
|
+
return `to_char((${fn}) AT TIME ZONE 'UTC', ${ISO_UTC_TIMESTAMP_FORMAT})`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Money is integer minor units, so an average rounds to one.
|
|
94
|
+
if (kind === "avg" && fieldType === "money")
|
|
95
|
+
return `ROUND(${fn})::float8`;
|
|
96
|
+
return `(${fn})::float8`;
|
|
97
|
+
}
|
|
98
|
+
export function groupedKeyOrderLimitSql(limit) {
|
|
99
|
+
return ` GROUP BY 1 ORDER BY 1 ASC NULLS LAST LIMIT ${limit}`;
|
|
100
|
+
}
|
|
101
|
+
export function decodeListAggregateValue(raw) {
|
|
102
|
+
if (raw === null || raw === undefined)
|
|
103
|
+
return null;
|
|
104
|
+
if (typeof raw === "number" || typeof raw === "string")
|
|
105
|
+
return raw;
|
|
106
|
+
if (typeof raw === "bigint")
|
|
107
|
+
return Number(raw);
|
|
108
|
+
if (raw instanceof Date)
|
|
109
|
+
return raw.toISOString();
|
|
110
|
+
return String(raw);
|
|
111
|
+
}
|
|
112
|
+
// What the measure reports over zero rows, mirroring the SQL (SUM coalesces, COUNT is 0).
|
|
113
|
+
export function emptyListAggregateValue(kind) {
|
|
114
|
+
return kind === "sum" || kind === "count" ? 0 : null;
|
|
115
|
+
}
|
|
116
|
+
export function aggregateDimensionSql(info, dimension, index, params) {
|
|
117
|
+
const column = requireAggregateColumn(info, dimension.field, "groupBy field");
|
|
118
|
+
if (!("bucket" in dimension))
|
|
119
|
+
return `${column} AS k${index}`;
|
|
120
|
+
if (!AGGREGATE_BUCKET_UNITS.includes(dimension.bucket)) {
|
|
121
|
+
throw new Error(`aggregateWhere: unknown bucket "${String(dimension.bucket)}"`);
|
|
122
|
+
}
|
|
123
|
+
if (!isTimestamptzType(info.pgTypeOf(info.columnOf(dimension.field)))) {
|
|
124
|
+
throw new Error(`aggregateWhere: bucket on "${dimension.field}" requires a timestamptz column`);
|
|
125
|
+
}
|
|
126
|
+
params.push(dimension.timeZone);
|
|
127
|
+
return `(EXTRACT(EPOCH FROM date_trunc('${dimension.bucket}', ${column}, $${params.length}::text)) * 1000)::float8 AS k${index}`;
|
|
128
|
+
}
|
|
129
|
+
export function assertValidAggregateLimits(spec) {
|
|
130
|
+
if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
|
|
131
|
+
throw new Error(`aggregateWhere: limit must be a non-negative integer, got ${spec.limit}`);
|
|
132
|
+
}
|
|
133
|
+
if (spec.orderByValue !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByValue)) {
|
|
134
|
+
throw new Error(`aggregateWhere: unknown orderByValue "${String(spec.orderByValue)}"`);
|
|
135
|
+
}
|
|
136
|
+
if (spec.orderByKeys !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByKeys)) {
|
|
137
|
+
throw new Error(`aggregateWhere: unknown orderByKeys "${String(spec.orderByKeys)}"`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function aggregateGroupOrderLimitSql(spec, dimensionCount) {
|
|
141
|
+
let sqlText = "";
|
|
142
|
+
if (dimensionCount > 0) {
|
|
143
|
+
const positions = Array.from({ length: dimensionCount }, (_, index) => String(index + 1));
|
|
144
|
+
sqlText += ` GROUP BY ${positions.join(", ")}`;
|
|
145
|
+
const keyDirection = spec.orderByKeys === "desc" ? "DESC" : "ASC";
|
|
146
|
+
const keyParts = positions.map((position) => `${position} ${keyDirection}`);
|
|
147
|
+
const orderParts = spec.orderByValue === undefined
|
|
148
|
+
? keyParts
|
|
149
|
+
: [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...keyParts];
|
|
150
|
+
sqlText += ` ORDER BY ${orderParts.join(", ")}`;
|
|
151
|
+
}
|
|
152
|
+
if (spec.limit !== undefined)
|
|
153
|
+
sqlText += ` LIMIT ${spec.limit}`;
|
|
154
|
+
return sqlText;
|
|
155
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function isClosedConnectionError(err: unknown): boolean;
|
|
2
|
+
export declare function isPooledClient(raw: unknown): boolean;
|
|
3
|
+
export declare function describeClosedConnectionFailure(raw: unknown, err: unknown, attempts: number): Record<string, unknown> | undefined;
|
|
4
|
+
export type ClosedConnectionContext = {
|
|
5
|
+
readonly operation: string;
|
|
6
|
+
readonly table?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function logClosedConnection(raw: unknown, err: unknown, attempts: number, context?: ClosedConnectionContext): void;
|
|
9
|
+
export declare function poolMaxOf(raw: unknown): number;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { requestContext } from "../api/request-context.js";
|
|
3
|
+
import { createFallbackLogger } from "../logging/index.js";
|
|
4
|
+
import { extractPgError } from "./pg-error.js";
|
|
5
|
+
// A pool can briefly keep handing out connections the server just closed.
|
|
6
|
+
// Each failed attempt discards one, so retries are bounded by pool size.
|
|
7
|
+
const CLOSED_CONNECTION_CODES = new Set([
|
|
8
|
+
"CONNECTION_CLOSED", // postgres-js
|
|
9
|
+
"57P01", // PG SQLSTATE: admin_shutdown / terminated backend
|
|
10
|
+
]);
|
|
11
|
+
export function isClosedConnectionError(err) {
|
|
12
|
+
const code = extractPgError(err)?.code;
|
|
13
|
+
return code !== undefined && CLOSED_CONNECTION_CODES.has(code);
|
|
14
|
+
}
|
|
15
|
+
// Tx/reserved handles are pinned to one physical connection — a retry there
|
|
16
|
+
// would run on the same dead connection, not swap in a fresh one.
|
|
17
|
+
export function isPooledClient(raw) {
|
|
18
|
+
if (raw === null || (typeof raw !== "object" && typeof raw !== "function"))
|
|
19
|
+
return false;
|
|
20
|
+
return (typeof propertyOf(raw, "begin") === "function" &&
|
|
21
|
+
typeof propertyOf(raw, "savepoint") !== "function" &&
|
|
22
|
+
typeof propertyOf(raw, "release") !== "function");
|
|
23
|
+
}
|
|
24
|
+
const closedConnectionLog = createFallbackLogger("postgres-js");
|
|
25
|
+
// The driver rejects in-flight queries with this AbortError (no code) when the
|
|
26
|
+
// pool closes under them, so isClosedConnectionError can't see it.
|
|
27
|
+
const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
|
|
28
|
+
function isDriverClosedAbort(err) {
|
|
29
|
+
return (err instanceof Error && err.name === "AbortError" && err.message === DRIVER_CLOSED_ABORT_MESSAGE);
|
|
30
|
+
}
|
|
31
|
+
function isHandleLike(raw) {
|
|
32
|
+
return raw !== null && (typeof raw === "object" || typeof raw === "function");
|
|
33
|
+
}
|
|
34
|
+
// postgres-js pool and tx handles both carry `typed`.
|
|
35
|
+
function driverOf(raw) {
|
|
36
|
+
if (!isHandleLike(raw))
|
|
37
|
+
return "unknown";
|
|
38
|
+
if (typeof raw["typed"] === "function")
|
|
39
|
+
return "postgres-js";
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
function positiveNumber(v) {
|
|
43
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
44
|
+
}
|
|
45
|
+
// postgres-js configures timeouts in seconds.
|
|
46
|
+
function timeoutOptionMs(options, driver, optionName) {
|
|
47
|
+
if (driver !== "postgres-js")
|
|
48
|
+
return undefined;
|
|
49
|
+
const seconds = positiveNumber(options[optionName]);
|
|
50
|
+
return seconds === undefined ? undefined : seconds * 1000;
|
|
51
|
+
}
|
|
52
|
+
function errorProp(err, name) {
|
|
53
|
+
return isHandleLike(err) ? err[name] : undefined;
|
|
54
|
+
}
|
|
55
|
+
// Deliberately excludes SQL text and parameters: app SQL can carry PII.
|
|
56
|
+
export function describeClosedConnectionFailure(raw, err, attempts) {
|
|
57
|
+
if (!isClosedConnectionError(err) && !isDriverClosedAbort(err))
|
|
58
|
+
return undefined;
|
|
59
|
+
const driver = driverOf(raw);
|
|
60
|
+
const rawOptions = isHandleLike(raw) ? raw["options"] : undefined;
|
|
61
|
+
const options = isHandleLike(rawOptions) ? rawOptions : {};
|
|
62
|
+
return {
|
|
63
|
+
driver,
|
|
64
|
+
pooled: isPooledClient(raw),
|
|
65
|
+
attempts,
|
|
66
|
+
poolMax: positiveNumber(options["max"]),
|
|
67
|
+
idleTimeoutMs: timeoutOptionMs(options, driver, "idle_timeout"),
|
|
68
|
+
maxLifetimeMs: timeoutOptionMs(options, driver, "max_lifetime"),
|
|
69
|
+
connectionTimeoutMs: timeoutOptionMs(options, driver, "connect_timeout"),
|
|
70
|
+
errorName: errorProp(err, "name"),
|
|
71
|
+
errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
|
|
72
|
+
errorErrno: errorProp(err, "errno"),
|
|
73
|
+
errorMessage: errorProp(err, "message"),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function logClosedConnection(raw, err, attempts, context) {
|
|
77
|
+
const failure = describeClosedConnectionFailure(raw, err, attempts);
|
|
78
|
+
// skip: not a closed-connection error, nothing to diagnose
|
|
79
|
+
if (!failure)
|
|
80
|
+
return;
|
|
81
|
+
// Request identity lets this line be joined to the route's `handler failed` log.
|
|
82
|
+
const request = requestContext.get();
|
|
83
|
+
closedConnectionLog.warn("closed connection surfaced", {
|
|
84
|
+
...failure,
|
|
85
|
+
...context,
|
|
86
|
+
requestId: request?.requestId,
|
|
87
|
+
correlationId: request?.correlationId,
|
|
88
|
+
handler: request?.handler,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const DEFAULT_POOL_MAX = 10;
|
|
92
|
+
export function poolMaxOf(raw) {
|
|
93
|
+
const max = propertyOf(propertyOf(raw, "options"), "max");
|
|
94
|
+
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
|
|
95
|
+
}
|
package/dist/db/dialect.js
CHANGED
|
@@ -251,7 +251,7 @@ export function primaryKey(opts) {
|
|
|
251
251
|
// Limits: no nested SqlExpression composition (drizzle's recursive
|
|
252
252
|
// `sql\`${other}\``) — schema-files use single-level expressions only.
|
|
253
253
|
// Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
|
|
254
|
-
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (db/
|
|
254
|
+
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (db/driver-value-preparation.ts)
|
|
255
255
|
// can't be tricked into treating request data as a raw SQL literal. The same
|
|
256
256
|
// brand gate is enforced here in the schema DSL (sql`...` interpolation +
|
|
257
257
|
// literalDefault) and in entity-table-meta's sqlExpressionText — a duck-typed
|
package/dist/db/distinct-on.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OrderByClause } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
2
|
-
import type { TableInfo } from "./
|
|
2
|
+
import type { TableInfo } from "./table-info.ts";
|
|
3
3
|
type DistinctOnClause = {
|
|
4
4
|
/** Physical column names, unquoted. */
|
|
5
5
|
readonly columns: readonly string[];
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
+
import { createFallbackLogger } from "../logging/index.js";
|
|
3
|
+
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
4
|
+
import { isTemporalInstant, isTemporalPlainDate } from "./temporal-value-guards.js";
|
|
5
|
+
// --- Value coercion at the driver boundary ------------------------------
|
|
6
|
+
//
|
|
7
|
+
// postgres-js returns timestamptz as JS Date (or ISO string depending on
|
|
8
|
+
// driver config). Framework contract says timestamptz surfaces as
|
|
9
|
+
// Temporal.Instant — without coercion every read hands callers a Date and
|
|
10
|
+
// downstream code that does .epochMilliseconds / .add(...) crashes.
|
|
11
|
+
//
|
|
12
|
+
// Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
|
|
13
|
+
// params — convert to ISO string when the column pgType is timestamptz.
|
|
14
|
+
// Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
|
|
15
|
+
// drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
|
|
16
|
+
// unmanaged columns). Uses BigInt equality check so 9007199254740993n never
|
|
17
|
+
// silently rounds to a safe integer.
|
|
18
|
+
function coerceBigIntFromDriver(value) {
|
|
19
|
+
let bi;
|
|
20
|
+
if (typeof value === "string") {
|
|
21
|
+
try {
|
|
22
|
+
bi = BigInt(value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
bi = value;
|
|
30
|
+
}
|
|
31
|
+
const min = BigInt(Number.MIN_SAFE_INTEGER);
|
|
32
|
+
const max = BigInt(Number.MAX_SAFE_INTEGER);
|
|
33
|
+
if (bi >= min && bi <= max) {
|
|
34
|
+
const n = Number(bi);
|
|
35
|
+
if (BigInt(n) === bi)
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
return bi;
|
|
39
|
+
}
|
|
40
|
+
function instantFromDriver(value) {
|
|
41
|
+
if (value === null || value === undefined)
|
|
42
|
+
return null;
|
|
43
|
+
if (isTemporalInstant(value))
|
|
44
|
+
return value;
|
|
45
|
+
// Number coercion via +date — equivalent to .getTime() without a Date-API
|
|
46
|
+
// method (guard-no-date-api). Date → epoch milliseconds, then to Temporal.Instant.
|
|
47
|
+
if (value instanceof Date)
|
|
48
|
+
return Temporal.Instant.fromEpochMilliseconds(+value);
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
try {
|
|
51
|
+
return Temporal.Instant.from(value);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === "number")
|
|
58
|
+
return Temporal.Instant.fromEpochMilliseconds(value);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// Verified empirically: a `date`
|
|
62
|
+
// column comes back as a native JS Date, but ALWAYS anchored at UTC
|
|
63
|
+
// midnight regardless of process TZ — epochMilliseconds is stable across
|
|
64
|
+
// zones, only the local wall-clock read of that Date would drift the day
|
|
65
|
+
// (e.g. America/Los_Angeles reads back the 14th for a stored 15th). Routing
|
|
66
|
+
// through Instant→ZonedDateTimeISO("UTC")→PlainDate reads the calendar day
|
|
67
|
+
// back deterministically without ever touching a local Date getter — the
|
|
68
|
+
// same +value idiom instantFromDriver uses (guard-no-date-api: no
|
|
69
|
+
// `.getTime()`), just anchored to a fixed zone instead of process-local.
|
|
70
|
+
// A real `date` column always arrives as a UTC-midnight Date. Anything else means the column is
|
|
71
|
+
// still timestamptz (declared `date` before its ALTER ran); the UTC anchor then silently shifts
|
|
72
|
+
// the day, so say so once per column instead of returning the wrong day quietly.
|
|
73
|
+
const warnedNonMidnightDateColumns = new Set();
|
|
74
|
+
const MS_PER_DAY = 86_400_000;
|
|
75
|
+
const dateColumnDriftLog = createFallbackLogger("postgres-js");
|
|
76
|
+
function warnIfNotDateColumn(epochMs, column) {
|
|
77
|
+
// skip: midnight value is a correct date column, and an already-warned column is deduped
|
|
78
|
+
if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
|
|
79
|
+
return;
|
|
80
|
+
warnedNonMidnightDateColumns.add(column);
|
|
81
|
+
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.`);
|
|
82
|
+
}
|
|
83
|
+
function plainDateFromDriver(value, column) {
|
|
84
|
+
if (value === null || value === undefined)
|
|
85
|
+
return null;
|
|
86
|
+
if (isTemporalPlainDate(value))
|
|
87
|
+
return value;
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
const isoDay = /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0];
|
|
90
|
+
if (isoDay === undefined)
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
return Temporal.PlainDate.from(isoDay);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (value instanceof Date) {
|
|
100
|
+
warnIfNotDateColumn(+value, column);
|
|
101
|
+
return Temporal.Instant.fromEpochMilliseconds(+value).toZonedDateTimeISO("UTC").toPlainDate();
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function coerceTimestamptz(value) {
|
|
106
|
+
const instant = instantFromDriver(value);
|
|
107
|
+
return instant !== null ? instant : value;
|
|
108
|
+
}
|
|
109
|
+
function coerceDate(value, column) {
|
|
110
|
+
const day = plainDateFromDriver(value, column);
|
|
111
|
+
return day !== null ? day : value;
|
|
112
|
+
}
|
|
113
|
+
function coerceJsonb(value) {
|
|
114
|
+
return typeof value === "string" ? parseJsonSafe(value, value) : value;
|
|
115
|
+
}
|
|
116
|
+
function coerceBigIntColumn(value, column, info) {
|
|
117
|
+
if (typeof value !== "string" && typeof value !== "bigint")
|
|
118
|
+
return value;
|
|
119
|
+
if (info.bigintJsModeOf(column) === "number")
|
|
120
|
+
return coerceBigIntFromDriver(value);
|
|
121
|
+
if (typeof value === "string") {
|
|
122
|
+
try {
|
|
123
|
+
return BigInt(value);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// leave as string on parse error
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
function numberUnlessNaN(value) {
|
|
133
|
+
if (typeof value !== "string" && typeof value !== "bigint")
|
|
134
|
+
return value;
|
|
135
|
+
const n = Number(value);
|
|
136
|
+
return Number.isNaN(n) ? value : n;
|
|
137
|
+
}
|
|
138
|
+
// Keyed by the driver's pgType string, not PgType: legacy tables report int4/smallint/int2.
|
|
139
|
+
const DRIVER_VALUE_COERCERS = new Map([
|
|
140
|
+
["timestamptz", coerceTimestamptz],
|
|
141
|
+
["timestamptz(3)", coerceTimestamptz],
|
|
142
|
+
["date", coerceDate],
|
|
143
|
+
["jsonb", coerceJsonb],
|
|
144
|
+
["bigint", coerceBigIntColumn],
|
|
145
|
+
["bigserial", coerceBigIntColumn],
|
|
146
|
+
// Drivers may return int4 as bigint or numeric string.
|
|
147
|
+
// Drizzle coerced to number for numberField columns — match that.
|
|
148
|
+
["integer", numberUnlessNaN],
|
|
149
|
+
["int4", numberUnlessNaN],
|
|
150
|
+
["smallint", numberUnlessNaN],
|
|
151
|
+
["int2", numberUnlessNaN],
|
|
152
|
+
]);
|
|
153
|
+
// pg returns numeric(p,s) as a string to preserve precision; the
|
|
154
|
+
// decimal-field contract surfaces it as JS number (safe ≤ 2^53). A
|
|
155
|
+
// non-numeric string would be DB corruption — leave it untouched so it
|
|
156
|
+
// surfaces loud downstream rather than becoming a silent NaN.
|
|
157
|
+
function driverValueCoercerFor(pgType) {
|
|
158
|
+
if (pgType === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
const coercer = DRIVER_VALUE_COERCERS.get(pgType);
|
|
161
|
+
if (coercer)
|
|
162
|
+
return coercer;
|
|
163
|
+
return pgType.startsWith("numeric") ? numberUnlessNaN : undefined;
|
|
164
|
+
}
|
|
165
|
+
export function coerceDriverRow(row, info) {
|
|
166
|
+
const out = {};
|
|
167
|
+
let changed = false;
|
|
168
|
+
for (const key of Object.keys(row)) {
|
|
169
|
+
// Blind-index columns are pure lookup artifacts — the
|
|
170
|
+
// deterministic HMAC must never leak into caller/API responses.
|
|
171
|
+
if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
|
|
172
|
+
changed = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const value = row[key];
|
|
176
|
+
const coercer = driverValueCoercerFor(info.pgTypeOf(key));
|
|
177
|
+
const coerced = coercer ? coercer(value, key, info) : value;
|
|
178
|
+
const fieldName = info.fieldOf(key);
|
|
179
|
+
if (fieldName !== key)
|
|
180
|
+
changed = true;
|
|
181
|
+
if (coerced !== value)
|
|
182
|
+
changed = true;
|
|
183
|
+
out[fieldName] = coerced;
|
|
184
|
+
}
|
|
185
|
+
return changed ? out : row;
|
|
186
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
+
export type PreparedValue = {
|
|
3
|
+
readonly kind: "param";
|
|
4
|
+
readonly sql: string;
|
|
5
|
+
readonly bound: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: "literal";
|
|
8
|
+
readonly literal: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function isSqlExpression(v: unknown): v is {
|
|
11
|
+
kind: "sql-expr";
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function prepareJsonbValue(value: unknown): PreparedValue | undefined;
|
|
15
|
+
export declare function instantToTimestamptzParam(instant: Temporal.Instant): string;
|
|
16
|
+
export declare function prepareValue(value: unknown, pgType: string | undefined): PreparedValue;
|