@cosmicdrift/kumiko-framework 0.386.0 → 0.387.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/LICENSE +21 -57
- package/README.md +2 -2
- package/agents/widgets.md +39 -39
- package/dist/api/api-constants.js +2 -2
- package/dist/api/auth-middleware.js +7 -7
- package/dist/api/auth-rate-limiters.js +7 -3
- package/dist/api/auth-routes-mfa.js +1 -1
- package/dist/api/auth-routes-session.js +1 -1
- package/dist/api/auth-routes-shared.js +7 -3
- package/dist/api/auth-routes-signup.js +1 -1
- package/dist/api/client-ip.js +3 -1
- package/dist/api/extra-route.js +1 -1
- package/dist/api/pii-leak-guard.js +4 -2
- package/dist/api/readiness.js +5 -5
- package/dist/api/redis-sse-broker.js +8 -6
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +34 -33
- package/dist/api/sse-broker.js +1 -1
- package/dist/api/sse-route.d.ts +13 -13
- package/dist/api/sse-route.js +15 -15
- package/dist/auth/index.js +4 -4
- package/dist/auth/roles.d.ts +4 -4
- package/dist/auth/roles.js +24 -24
- package/dist/compliance/duration-spec.js +18 -20
- package/dist/compliance/index.js +2 -3
- package/dist/compliance/override-schema.d.ts +4 -4
- package/dist/compliance/override-schema.js +19 -22
- package/dist/compliance/profiles.d.ts +26 -29
- package/dist/compliance/profiles.js +62 -69
- package/dist/compliance/sub-processors.d.ts +28 -30
- package/dist/compliance/sub-processors.js +17 -21
- package/dist/consumer-cli.js +4 -4
- package/dist/crypto/blind-index.js +18 -17
- package/dist/crypto/ciphertext-pattern.js +1 -1
- package/dist/crypto/event-pii.js +3 -3
- package/dist/crypto/kek-source.d.ts +1 -1
- package/dist/crypto/kek-source.js +3 -2
- package/dist/crypto/kms-adapter.d.ts +1 -1
- package/dist/crypto/kms-adapter.js +4 -6
- package/dist/crypto/kms-wiring.d.ts +1 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/crypto/pg-kms-adapter.js +2 -6
- package/dist/crypto/pii-field-encryption.js +4 -4
- package/dist/crypto/subject-resolver.d.ts +1 -1
- package/dist/crypto/subject-resolver.js +2 -2
- package/dist/db/apply-entity-event.d.ts +6 -6
- package/dist/db/apply-entity-event.js +55 -55
- package/dist/db/blind-index-cleanup.js +7 -7
- package/dist/db/collect-table-metas.js +10 -11
- package/dist/db/compound-types.d.ts +1 -1
- package/dist/db/compound-types.js +12 -12
- package/dist/db/cursor.js +3 -4
- package/dist/db/dialect.d.ts +2 -2
- package/dist/db/dialect.js +2 -2
- package/dist/db/eagerload.d.ts +3 -3
- package/dist/db/eagerload.js +21 -12
- package/dist/db/entity-table-meta.d.ts +2 -2
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-context.d.ts +1 -1
- package/dist/db/event-store-executor-context.js +13 -13
- package/dist/db/event-store-executor-read.js +27 -28
- package/dist/db/event-store-executor-write.js +65 -70
- package/dist/db/located-timestamp.d.ts +11 -11
- package/dist/db/located-timestamp.js +29 -30
- package/dist/db/migrate-generator.d.ts +1 -1
- package/dist/db/migrate-generator.js +26 -19
- package/dist/db/migrate-runner.js +25 -25
- package/dist/db/money.d.ts +6 -6
- package/dist/db/money.js +29 -27
- package/dist/db/queries/backfill-pii.js +5 -5
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/es-ops.d.ts +1 -1
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/event-store.js +4 -2
- package/dist/db/queries/projection-rebuild.js +3 -3
- package/dist/db/queries/raw-sql.d.ts +2 -2
- package/dist/db/queries/raw-sql.js +2 -2
- package/dist/db/queries/shadow-swap.js +19 -19
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +6 -6
- package/dist/db/rebuild-marker.js +20 -21
- package/dist/db/replay-migration-sql.js +2 -2
- package/dist/db/schema-inspection.js +4 -5
- package/dist/db/table-builder.d.ts +1 -2
- package/dist/db/table-builder.js +82 -82
- package/dist/db/tenant-db.d.ts +2 -2
- package/dist/db/tenant-db.js +9 -2
- package/dist/derivatives/delete-stored-file.js +5 -2
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-roles.js +4 -3
- package/dist/engine/boot-validator/api-ext.js +14 -14
- package/dist/engine/boot-validator/config-deps.js +7 -6
- package/dist/engine/boot-validator/detail-screens.js +3 -3
- package/dist/engine/boot-validator/entity-handler.js +60 -61
- package/dist/engine/boot-validator/gdpr-storage.js +4 -4
- package/dist/engine/boot-validator/i18n-keys.js +1 -1
- package/dist/engine/boot-validator/index.d.ts +2 -2
- package/dist/engine/boot-validator/index.js +20 -20
- package/dist/engine/boot-validator/nav.js +5 -5
- package/dist/engine/boot-validator/ownership.js +12 -9
- package/dist/engine/boot-validator/parent-ref.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +50 -57
- package/dist/engine/boot-validator/projection-list-screens.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens-actions.js +15 -16
- package/dist/engine/boot-validator/screens-forms.js +10 -11
- package/dist/engine/boot-validator/screens-lists.js +14 -15
- package/dist/engine/boot-validator/screens.js +34 -40
- package/dist/engine/boot-validator/security-baseline.js +3 -2
- package/dist/engine/boot-validator/transfer-graph.js +2 -2
- package/dist/engine/boot-validator/workspaces.js +3 -3
- package/dist/engine/bridge-stub.js +3 -4
- package/dist/engine/build-app-schema.js +75 -75
- package/dist/engine/build-config-feature-schema.js +50 -50
- package/dist/engine/build-target.js +20 -23
- package/dist/engine/codemod/pipeline-codemod.js +20 -13
- package/dist/engine/config-helpers.js +4 -4
- package/dist/engine/constants.js +2 -2
- package/dist/engine/create-app.js +2 -2
- package/dist/engine/define-feature.js +12 -11
- package/dist/engine/define-handler.js +12 -11
- package/dist/engine/define-step.js +1 -1
- package/dist/engine/define-workflow.d.ts +5 -5
- package/dist/engine/define-workflow.js +6 -6
- package/dist/engine/entity-handlers.js +13 -14
- package/dist/engine/event-def.js +2 -2
- package/dist/engine/extension-names.d.ts +16 -17
- package/dist/engine/extension-names.js +37 -38
- package/dist/engine/extensions/escape-hatch-usage.js +1 -1
- package/dist/engine/extensions/tenant-data.js +2 -2
- package/dist/engine/extensions/user-data.d.ts +23 -24
- package/dist/engine/factories.d.ts +14 -13
- package/dist/engine/factories.js +38 -37
- package/dist/engine/feature-ast/entity-field-types.js +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +1 -1
- package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
- package/dist/engine/feature-ast/extractors/shared.js +2 -3
- package/dist/engine/feature-ast/parse.js +13 -13
- package/dist/engine/feature-ast/patch.js +1 -1
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
- package/dist/engine/feature-ast/patterns.d.ts +2 -2
- package/dist/engine/feature-ast/render.js +2 -2
- package/dist/engine/feature-config-events-jobs.js +15 -12
- package/dist/engine/feature-entity-handlers.js +9 -5
- package/dist/engine/feature-manifest.d.ts +9 -9
- package/dist/engine/feature-manifest.js +6 -7
- package/dist/engine/feature-ui-extensions.d.ts +3 -3
- package/dist/engine/feature-ui-extensions.js +30 -22
- package/dist/engine/field-access.js +5 -6
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +4 -4
- package/dist/engine/object-form.d.ts +1 -0
- package/dist/engine/object-form.js +14 -4
- package/dist/engine/ownership.js +3 -3
- package/dist/engine/parse-ref-target.js +5 -7
- package/dist/engine/pattern-library/index.js +2 -2
- package/dist/engine/pattern-library/library.js +2 -2
- package/dist/engine/pattern-library/types.js +3 -3
- package/dist/engine/pipeline.js +2 -2
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/projection-helpers.js +9 -9
- package/dist/engine/qualified-name.js +2 -2
- package/dist/engine/registry-facade.js +1 -1
- package/dist/engine/registry-ingest.js +12 -12
- package/dist/engine/registry-state.js +17 -19
- package/dist/engine/registry-validate.js +12 -12
- package/dist/engine/run-in.js +2 -2
- package/dist/engine/run-pipeline.js +7 -0
- package/dist/engine/schema-builder.js +40 -38
- package/dist/engine/screen-filter-ops.js +17 -20
- package/dist/engine/screen-helpers.js +4 -3
- package/dist/engine/state-machine.d.ts +12 -12
- package/dist/engine/state-machine.js +7 -7
- package/dist/engine/steps/_resolver-utils.js +4 -3
- package/dist/engine/steps/for-each.js +4 -4
- package/dist/engine/steps/unsafe-projection-delete.js +1 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
- package/dist/engine/steps/wait-for-event.js +4 -4
- package/dist/engine/steps/workflow-system-events.js +3 -3
- package/dist/engine/tier-resolver-extension.d.ts +29 -27
- package/dist/engine/tier-resolver-extension.js +26 -26
- package/dist/engine/transfer-adjacency.js +2 -2
- package/dist/engine/validate-projection-allowlist.js +7 -27
- package/dist/engine/walk-step-instances.d.ts +2 -0
- package/dist/engine/walk-step-instances.js +38 -0
- package/dist/engine/where-rule-lint.js +1 -1
- package/dist/engine/write-handler-qn-extract.js +1 -1
- package/dist/entrypoint/index.js +3 -3
- package/dist/env/index.d.ts +7 -6
- package/dist/env/index.js +10 -9
- package/dist/errors/transition-details.js +7 -7
- package/dist/errors/write-error-info.d.ts +6 -6
- package/dist/errors/write-error-info.js +6 -6
- package/dist/es-ops/context.d.ts +9 -9
- package/dist/es-ops/context.js +28 -28
- package/dist/es-ops/index.js +7 -9
- package/dist/es-ops/operations-schema.js +4 -10
- package/dist/es-ops/runner.d.ts +11 -11
- package/dist/es-ops/runner.js +49 -53
- package/dist/es-ops/types.d.ts +55 -55
- package/dist/es-ops/types.js +11 -11
- package/dist/event-store/admin-api.js +6 -7
- package/dist/event-store/errors.js +1 -1
- package/dist/event-store/event-store.js +2 -2
- package/dist/event-store/rebuild-dead-letter.js +1 -1
- package/dist/files/feature.js +9 -10
- package/dist/files/file-handle.js +5 -6
- package/dist/files/file-ref-entity.js +15 -15
- package/dist/files/file-ref-table.js +7 -8
- package/dist/files/file-routes.d.ts +7 -0
- package/dist/files/file-routes.js +11 -5
- package/dist/files/in-memory-provider.js +8 -9
- package/dist/files/local-provider.js +18 -25
- package/dist/files/provider-resolver.d.ts +7 -7
- package/dist/files/storage-tracking.js +2 -2
- package/dist/files/zip-stream.d.ts +12 -19
- package/dist/files/zip-stream.js +44 -63
- package/dist/i18n/required-surface-keys.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +8 -8
- package/dist/jobs/job-runner-helpers.js +8 -2
- package/dist/jobs/job-runner-schedulers.d.ts +1 -1
- package/dist/jobs/job-runner-schedulers.js +3 -4
- package/dist/jobs/job-runner.js +13 -15
- package/dist/lifecycle/lifecycle.js +3 -5
- package/dist/lifecycle/signal-handlers.js +5 -4
- package/dist/logging/utils.d.ts +2 -1
- package/dist/logging/utils.js +38 -10
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/kumiko-drift.js +14 -13
- package/dist/migrations/pending-rebuilds.d.ts +21 -20
- package/dist/migrations/pending-rebuilds.js +27 -27
- package/dist/migrations/projection-table-index.d.ts +3 -3
- package/dist/migrations/projection-table-index.js +8 -8
- package/dist/observability/metric-validator.js +1 -1
- package/dist/observability/metrics-handle.js +3 -3
- package/dist/pipeline/append-event-core.js +11 -2
- package/dist/pipeline/cascade-handler.d.ts +1 -1
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatch-query.js +10 -10
- package/dist/pipeline/dispatch-shared.d.ts +1 -0
- package/dist/pipeline/dispatch-shared.js +15 -17
- package/dist/pipeline/dispatch-stream.js +1 -1
- package/dist/pipeline/dispatch-write.js +3 -3
- package/dist/pipeline/dispatcher-utils.d.ts +1 -1
- package/dist/pipeline/dispatcher-utils.js +1 -1
- package/dist/pipeline/event-consumer-state.js +2 -2
- package/dist/pipeline/event-dispatcher-delivery.js +1 -1
- package/dist/pipeline/event-dispatcher.js +17 -28
- package/dist/pipeline/irreversible-operation-gate.js +1 -1
- package/dist/pipeline/lifecycle-pipeline.js +9 -9
- package/dist/pipeline/member-read-only-transaction.js +1 -3
- package/dist/pipeline/msp-rebuild.js +1 -1
- package/dist/pipeline/projection-rebuild.js +13 -12
- package/dist/pipeline/system-hooks.js +39 -41
- package/dist/pipeline/system-identity-switch.js +1 -3
- package/dist/pipeline/tenant-timezone-cache.js +1 -1
- package/dist/random/generate.d.ts +29 -29
- package/dist/random/generate.js +30 -30
- package/dist/random/words.js +20 -20
- package/dist/rate-limit/middleware.js +9 -5
- package/dist/redis/client.js +2 -2
- package/dist/redis/pubsub-signal.js +14 -17
- package/dist/schema-cli.js +116 -22
- package/dist/search/index.js +4 -4
- package/dist/search/purge-subject.js +4 -5
- package/dist/search/reindex-entity.js +6 -9
- package/dist/time/boot-tz-warning.d.ts +2 -2
- package/dist/time/boot-tz-warning.js +8 -8
- package/dist/time/iana.js +10 -10
- package/dist/time/index.js +9 -9
- package/dist/time/legacy-date.d.ts +2 -2
- package/dist/time/legacy-date.js +6 -8
- package/dist/time/polyfill.js +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +1 -1
- package/dist/ui-types/app-schema.js +7 -7
- package/dist/ui-types/list-row-meta.js +4 -4
- package/dist/upgrade-cli.js +10 -9
- package/dist/utils/compare.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +48 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
- package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
- package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
- package/src/scripts/codemod/pii-personal-migration.ts +17 -8
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { asRawClient, unsafeReadRetrying } from "../query.js";
|
|
2
2
|
/** Escape hatch for caller-built SQL that may write (or lock). No closed-connection retry —
|
|
3
|
-
* retrying an ambiguous write risks double-apply
|
|
3
|
+
* retrying an ambiguous write risks double-apply. Prefer {@link executeRawQueryRead}
|
|
4
4
|
* for SELECT-only paths. */
|
|
5
5
|
export async function executeRawQuery(db, sqlText, params = []) {
|
|
6
6
|
return await asRawClient(db).unsafe(sqlText, params);
|
|
7
7
|
}
|
|
8
|
-
/** SELECT-only escape hatch with the
|
|
8
|
+
/** SELECT-only escape hatch with the closed-connection retry. Do not pass
|
|
9
9
|
* INSERT/UPDATE/DELETE — retry re-executes the statement. */
|
|
10
10
|
// @wrapper-known semantic-alias
|
|
11
11
|
export async function executeRawQueryRead(db, sqlText, params = []) {
|
|
@@ -64,10 +64,10 @@ export function rebuildMetaOrThrow(table, projectionName) {
|
|
|
64
64
|
return meta;
|
|
65
65
|
}
|
|
66
66
|
// Fence against a rebuild running with a registry that does not match the
|
|
67
|
-
// migrated live schema
|
|
67
|
+
// migrated live schema: during a rolling deploy, a pod still running
|
|
68
68
|
// the previous build can pick up an async rebuild job; its shadow — built from
|
|
69
69
|
// the stale EntityTableMeta — would swap away a freshly-migrated column
|
|
70
|
-
// (
|
|
70
|
+
// (a recurring failure). Compares COLUMN NAMES only; a type-/nullability-
|
|
71
71
|
// only drift passes (schema regression, not data loss — the boot gate of the
|
|
72
72
|
// next deploy catches it). A missing live table is fine: nothing to wipe.
|
|
73
73
|
// Must run BEFORE buildShadowTable; columnNamesOf pins table_schema='public',
|
|
@@ -93,7 +93,7 @@ export async function assertLiveColumnsMatchMeta(db, meta, projectionName) {
|
|
|
93
93
|
"Rebuilding would swap away the difference. Likely cause: this pod runs a build whose registry is behind (or ahead of) the applied migrations — rolling deploy in progress? — or DDL was applied by hand. " +
|
|
94
94
|
"Rebuild aborted; retry from a pod whose code matches the migrated schema.");
|
|
95
95
|
}
|
|
96
|
-
// EntityTableMeta carries no RLS/policies, so the swap would silently drop them
|
|
96
|
+
// EntityTableMeta carries no RLS/policies, so the swap would silently drop them.
|
|
97
97
|
export async function assertLiveTableHasNoRowLevelSecurity(tx, tableName, projectionName) {
|
|
98
98
|
const rows = await asRawClient(tx).unsafe(`SELECT c.relrowsecurity AS rls_enabled, c.relforcerowsecurity AS rls_forced,
|
|
99
99
|
(SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policy_count
|
|
@@ -158,7 +158,7 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
|
158
158
|
// Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
|
|
159
159
|
// row whose aggregate id has NO event in the projection's source streams is
|
|
160
160
|
// UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
|
|
161
|
-
// drop it. That is
|
|
161
|
+
// drop it. That is a ghost row direct-inserted without ever emitting
|
|
162
162
|
// a .created event. The static CI guard cannot see it in data that already
|
|
163
163
|
// exists in production, or on table identifiers it couldn't resolve; this
|
|
164
164
|
// catches it at cutover and aborts (tx rolls back, live untouched).
|
|
@@ -169,14 +169,14 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
|
169
169
|
// - a blind-index column recomputed to NULL after the subject's key is
|
|
170
170
|
// shredded (GDPR erase) — the NULL is the intended end state;
|
|
171
171
|
// - a `sensitive` column stripped from the event log by design;
|
|
172
|
-
// - an archived stream that stops replaying
|
|
172
|
+
// - an archived stream that stops replaying — the row's wipe is the
|
|
173
173
|
// intended tombstone behavior, reported via backfill's `failed` list;
|
|
174
174
|
// - a legacy column direct-written before its handler emitted events, healed
|
|
175
|
-
// by the
|
|
175
|
+
// by the backfill-then-rebuild flow.
|
|
176
176
|
// Checking event existence INCLUDING archived streams leaves every one of them
|
|
177
177
|
// alone: those rows all have a real event, so they are not ghosts. Column-level
|
|
178
|
-
// drift is a SEPARATE, non-blocking check — see countColumnDrift below
|
|
179
|
-
// resolves the
|
|
178
|
+
// drift is a SEPARATE, non-blocking check — see countColumnDrift below,
|
|
179
|
+
// which resolves the open question: observe, don't block.
|
|
180
180
|
//
|
|
181
181
|
// Implicit projections only (caller-gated). aggregate_id and the entity id are
|
|
182
182
|
// both uuid, so the anti-join probes the events index without a cast.
|
|
@@ -298,8 +298,8 @@ async function assertBlindIndexKeyMatchesLive(raw, tableName, quotedTable, bidxC
|
|
|
298
298
|
`untouched.`);
|
|
299
299
|
}
|
|
300
300
|
// The bidx column is schema-driven, not key-driven: it exists NULL in a
|
|
301
|
-
// plaintext install and in the
|
|
302
|
-
// both of which are correct as-is. Only a POPULATED column with no key
|
|
301
|
+
// plaintext install and in the case where KMS is configured but no index key
|
|
302
|
+
// is present, both of which are correct as-is. Only a POPULATED column with no key
|
|
303
303
|
// configured in THIS process means the rebuild is about to overwrite proof
|
|
304
304
|
// of a real index with NULL — that's the one provable data-loss case.
|
|
305
305
|
export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName) {
|
|
@@ -331,29 +331,29 @@ export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName
|
|
|
331
331
|
// live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
|
|
332
332
|
// recomputed to NULL on GDPR key-shredding; the NULL is the intended end
|
|
333
333
|
// state, not drift. Everything else that legitimately diverges (archived
|
|
334
|
-
// streams,
|
|
334
|
+
// streams, backfill) either never reaches this comparison (archived rows
|
|
335
335
|
// are absent from the shadow entirely, see swapShadowIntoLive) or IS real
|
|
336
|
-
// column drift that the
|
|
336
|
+
// column drift that the backfill-then-rebuild flow relies on replay to
|
|
337
337
|
// heal — reporting it (without blocking) is correct, not a false positive.
|
|
338
338
|
const COLUMN_DRIFT_SAMPLE_LIMIT = 20;
|
|
339
339
|
// Runs INSIDE the rebuild tx, in the same slot as assertNoUnreachableLiveRows
|
|
340
340
|
// (after replay settles, before swapShadowIntoLive). Non-blocking counterpart
|
|
341
341
|
// to the ghost-row guard: reports live rows whose column values differ from
|
|
342
|
-
// the freshly-replayed shadow, WITHOUT aborting the swap (
|
|
343
|
-
//
|
|
342
|
+
// the freshly-replayed shadow, WITHOUT aborting the swap (it resolves the
|
|
343
|
+
// open question in favor of observe-not-block).
|
|
344
344
|
//
|
|
345
345
|
// Why non-blocking: a legacy column direct-written before its handler emitted
|
|
346
|
-
// events
|
|
346
|
+
// events diverges from replay by design — that divergence is exactly
|
|
347
347
|
// what the backfill-then-rebuild flow relies on replay to heal. Failing hard
|
|
348
348
|
// here would make rebuild mutually exclusive with that shipped healing path.
|
|
349
|
-
// There is no reliable metadata to distinguish "
|
|
349
|
+
// There is no reliable metadata to distinguish "healing in progress" from
|
|
350
350
|
// "someone else corrupted this row" short of an open-ended per-column policy
|
|
351
351
|
// blocklist — wrong-by-default whenever a class is missed. So: surface it,
|
|
352
352
|
// don't police it. The caller logs the result; ops decides.
|
|
353
353
|
//
|
|
354
|
-
// Caveat: sensitive CUSTOM fields still diverge until
|
|
355
|
-
// design
|
|
356
|
-
// post
|
|
354
|
+
// Caveat: sensitive CUSTOM fields still diverge until the Subject-DEK
|
|
355
|
+
// design — regular sensitive fields carry event-payload ciphertext parity
|
|
356
|
+
// post-design and don't drift. Both are reported the same as any other column
|
|
357
357
|
// drift; this is deliberate (see module comment above), not an oversight.
|
|
358
358
|
//
|
|
359
359
|
// Relies on assertLiveColumnsMatchMeta having already run: live/shadow/meta
|
package/dist/db/query.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export declare function hasBegin(client: RawClient): client is RawClientWithBegi
|
|
|
24
24
|
export declare function asRawClient(db: unknown): RawClient;
|
|
25
25
|
export declare function runnerCanBeginTransaction(db: unknown): boolean;
|
|
26
26
|
export declare function runInSavepoint<T>(tx: ExecutorManagedRunner, fn: (sp: ExecutorManagedRunner) => Promise<T>): Promise<T>;
|
|
27
|
-
export declare function runInSavepoint<T>(tx: unknown, fn: (sp:
|
|
27
|
+
export declare function runInSavepoint<T>(tx: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
|
|
28
28
|
export declare function runInSavepointIfSupported<T>(db: ExecutorManagedRunner, fn: (sp: ExecutorManagedRunner) => Promise<T>): Promise<T>;
|
|
29
29
|
export declare function runInSavepointIfSupported<T>(db: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
|
|
30
30
|
export type AnyDb = DbRunner | unknown;
|
package/dist/db/query.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// `table` is an EntityTableMeta; the legacy table objects carrying it under a Symbol are accepted too.
|
|
15
15
|
// Static import (not getTemporal()): coercion works without an installed
|
|
16
|
-
// global and returns the same Temporal classes as the rest of the repo
|
|
16
|
+
// global and returns the same Temporal classes as the rest of the repo.
|
|
17
17
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
18
18
|
import { isOneOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
19
19
|
import { requestContext } from "../api/request-context.js";
|
|
@@ -279,7 +279,7 @@ function isTemporalPlainDateTimeLike(v) {
|
|
|
279
279
|
"toPlainDate" in v &&
|
|
280
280
|
typeof v.toPlainDate === "function");
|
|
281
281
|
}
|
|
282
|
-
// Verified empirically
|
|
282
|
+
// Verified empirically: a `date`
|
|
283
283
|
// column comes back as a native JS Date, but ALWAYS anchored at UTC
|
|
284
284
|
// midnight regardless of process TZ — epochMilliseconds is stable across
|
|
285
285
|
// zones, only the local wall-clock read of that Date would drift the day
|
|
@@ -339,7 +339,7 @@ function coerceDriverRow(row, info) {
|
|
|
339
339
|
const out = {};
|
|
340
340
|
let changed = false;
|
|
341
341
|
for (const key of Object.keys(row)) {
|
|
342
|
-
// Blind-index columns
|
|
342
|
+
// Blind-index columns are pure lookup artifacts — the
|
|
343
343
|
// deterministic HMAC must never leak into caller/API responses.
|
|
344
344
|
if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
|
|
345
345
|
changed = true;
|
|
@@ -420,7 +420,7 @@ function isSqlExpression(v) {
|
|
|
420
420
|
// as-is — no Instant detour. Postgres parses a DATE literal without ever
|
|
421
421
|
// consulting the session timezone, so this is TZ-independent by construction
|
|
422
422
|
// (verified empirically — see plainDateFromDriver above for the read side).
|
|
423
|
-
// Compat cushion:
|
|
423
|
+
// Compat cushion: code (this field used to be an Instant) may still
|
|
424
424
|
// hand a Temporal.Instant into a where-filter or write on a `date` column —
|
|
425
425
|
// same UTC anchor as plainDateFromDriver's read side, so a filter built with
|
|
426
426
|
// `Temporal.Now.instant()` keeps working instead of binding a raw Instant the
|
|
@@ -515,7 +515,7 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
515
515
|
// multiSelect (and any other jsonb-array-of-scalars column) stores an
|
|
516
516
|
// array — a filter value is one option, not the whole array, so eq/ne/in
|
|
517
517
|
// must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
|
|
518
|
-
// against the jsonb column, which Postgres rejects outright
|
|
518
|
+
// against the jsonb column, which Postgres rejects outright.
|
|
519
519
|
// Mirrors event-store-executor-read.ts's applyFilter — keep in lock-step.
|
|
520
520
|
function jsonbContainsAny(col, candidates) {
|
|
521
521
|
if (candidates.length === 0)
|
|
@@ -703,7 +703,7 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
703
703
|
conditions.push(`${ref(col)} = ${p.literal}`);
|
|
704
704
|
}
|
|
705
705
|
else {
|
|
706
|
-
// Blind-index OR rewrite
|
|
706
|
+
// Blind-index OR rewrite: if the column has a bidx
|
|
707
707
|
// counterpart (suffix convention `<col>_bidx`, framework-reserved)
|
|
708
708
|
// and a key is configured, equality matches both arms —
|
|
709
709
|
// plaintext legacy rows via the plaintext arm, encrypted rows
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
// Rebuild
|
|
1
|
+
// Rebuild marker for the drizzle-free migration path.
|
|
2
2
|
//
|
|
3
|
-
// `kumiko schema generate`
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// Projektionen.
|
|
3
|
+
// `kumiko schema generate` writes a sibling marker
|
|
4
|
+
// `NNNN_<name>.rebuild.json` next to each `NNNN_<name>.sql`, listing the tables
|
|
5
|
+
// changed or created by that migration. `kumiko schema apply` reads the marker
|
|
6
|
+
// for each newly applied migration and rebuilds the affected projections.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
// registry-
|
|
11
|
-
//
|
|
12
|
-
// `buildProjectionTableIndex(registry)`.
|
|
13
|
-
//
|
|
8
|
+
// Deliberately stores only **table names** (not projection names): the generator
|
|
9
|
+
// is registry-free and does not know the app's projections. The table-to-
|
|
10
|
+
// projection resolution happens in the app during apply via
|
|
11
|
+
// `buildProjectionTableIndex(registry)`. Tables without a corresponding
|
|
12
|
+
// projection are simply skipped there.
|
|
14
13
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// re-derived.
|
|
14
|
+
// Markers are written at generate time from the structured `SchemaDiff`, rather
|
|
15
|
+
// than being re-derived during apply from the possibly hand-edited SQL.
|
|
18
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
17
|
import { join } from "node:path";
|
|
20
18
|
import { managedChangeRequiresRecreate } from "./migrate-generator.js";
|
|
@@ -29,12 +27,12 @@ function markerPathFor(migrationsDir, migrationId) {
|
|
|
29
27
|
// (managedChangeRequiresRecreate: drop/NOT-NULL-w/o-default/unique-index/type-
|
|
30
28
|
// or nullability-change) — then the table is emptied and must be re-derived from
|
|
31
29
|
// events. A pure additive nullable column is an in-place `ADD COLUMN` ALTER that
|
|
32
|
-
// already brings the table to the target state (same logic
|
|
30
|
+
// already brings the table to the target state (same logic applied to
|
|
33
31
|
// index-/default-only changes). Rebuilding it anyway is wasted replay AND — the
|
|
34
32
|
// bug this closes — a co-triggered rebuild can drop the fresh column (rebuild
|
|
35
33
|
// runs from the rebuilding process's registry meta; on a rolling deploy an older
|
|
36
34
|
// pod's meta lacks the column → shadow-swap wipes it → phantom migration + boot-
|
|
37
|
-
// drift crash
|
|
35
|
+
// drift crash).
|
|
38
36
|
//
|
|
39
37
|
// If a NEW additive column genuinely needs value-backfill from historical events
|
|
40
38
|
// (a column DERIVED from existing event fields), opt in explicitly by hand-adding
|
|
@@ -60,18 +58,19 @@ export function rebuildTablesFromDiff(diff) {
|
|
|
60
58
|
}
|
|
61
59
|
return [...names].sort();
|
|
62
60
|
}
|
|
63
|
-
//
|
|
64
|
-
//
|
|
61
|
+
// Writes `<sqlFilename without .sql>.rebuild.json`. An empty table list writes no
|
|
62
|
+
// marker (for example, a drop-only migration).
|
|
65
63
|
export function writeRebuildMarker(migrationsDir, sqlFilename, tables) {
|
|
66
|
-
// skip:
|
|
64
|
+
// skip: empty table list means no marker is written (for example, for a
|
|
65
|
+
// migration that only drops tables).
|
|
67
66
|
if (tables.length === 0)
|
|
68
67
|
return;
|
|
69
68
|
const migrationId = sqlFilename.replace(/\.sql$/, "");
|
|
70
69
|
const marker = { version: MARKER_VERSION, tables };
|
|
71
70
|
writeFileSync(markerPathFor(migrationsDir, migrationId), `${JSON.stringify(marker, null, 2)}\n`);
|
|
72
71
|
}
|
|
73
|
-
//
|
|
74
|
-
//
|
|
72
|
+
// Reads the table list for an applied migration. A missing or malformed file
|
|
73
|
+
// yields an empty list (the migration has no projection impact).
|
|
75
74
|
export function readRebuildMarker(migrationsDir, migrationId) {
|
|
76
75
|
const path = markerPathFor(migrationsDir, migrationId);
|
|
77
76
|
if (!existsSync(path))
|
|
@@ -70,7 +70,7 @@ function parseColumnNames(body) {
|
|
|
70
70
|
// through to the fail-loud check as "unparsed". RENAME TO / RENAME COLUMN
|
|
71
71
|
// are not listed here: they change identity and are applied explicitly.
|
|
72
72
|
const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp([
|
|
73
|
-
`ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, //
|
|
73
|
+
`ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // int/bigint-catchup fixes
|
|
74
74
|
`ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
|
|
75
75
|
`ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
|
|
76
76
|
`(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
|
|
@@ -269,7 +269,7 @@ function applyStatement(schema, statement, context) {
|
|
|
269
269
|
// Reuses the real runner's file-discovery + statement-splitting
|
|
270
270
|
// (loadMigrationsFromDir) instead of a second copy, so any future change to
|
|
271
271
|
// sub-directory handling, numeric sort order, or the .sql filter can't
|
|
272
|
-
// silently drift between the runner and this replay
|
|
272
|
+
// silently drift between the runner and this replay.
|
|
273
273
|
export function replayMigrationsDir(migrationsDir) {
|
|
274
274
|
const schema = new Map();
|
|
275
275
|
const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
|
|
@@ -26,11 +26,10 @@ function resolveUnsafeClient(db) {
|
|
|
26
26
|
// await unsafePushTables(db, { events: eventsTable });
|
|
27
27
|
export async function tableExists(db, fullyQualifiedName) {
|
|
28
28
|
const unsafe = resolveUnsafeClient(db);
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// Schema-qualifizierte Namen (`public.events`) werden per Split einzeln quotet.
|
|
29
|
+
// SQL-side quote_ident round-trip: unquoted PostgreSQL identifiers are folded case-insensitively
|
|
30
|
+
// (`myWidget` → `mywidget`), while generated DDL preserves the case via
|
|
31
|
+
// `quoteIdent("myWidget")` → `"myWidget"`. quote_ident keeps both sides quoted identically.
|
|
32
|
+
// Schema-qualified names (`public.events`) are quoted one component at a time after splitting.
|
|
34
33
|
const dotIdx = fullyQualifiedName.indexOf(".");
|
|
35
34
|
const [sql, params] = dotIdx >= 0
|
|
36
35
|
? [
|
|
@@ -196,8 +196,7 @@ export type EntityRow<E extends EntityDefinition = EntityDefinition> = string ex
|
|
|
196
196
|
export type { ExecutorManagedRunner, ExecutorManagedWriteCapability, Writable, WritableSchemaTable, WritableTable, } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
197
197
|
export type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
198
198
|
type EntityTableTenancy<E extends EntityDefinition> = [NonNullable<E["tenancy"]>] extends [never] ? "tenant" : [NonNullable<E["tenancy"]>] extends ["global"] ? "global" : "tenant";
|
|
199
|
-
export type EntityTable<E extends EntityDefinition = EntityDefinition> = TableColumns
|
|
200
|
-
any> & BaseColumnsType<E> & SoftDeleteColumnsType & ColumnsForEntity<E["fields"]> & TenancyBrand<EntityTableTenancy<E>>;
|
|
199
|
+
export type EntityTable<E extends EntityDefinition = EntityDefinition> = TableColumns & BaseColumnsType<E> & SoftDeleteColumnsType & ColumnsForEntity<E["fields"]> & TenancyBrand<EntityTableTenancy<E>>;
|
|
201
200
|
export declare function buildBaseColumns(softDelete: boolean, idType?: "serial" | "uuid"): {
|
|
202
201
|
id: ColumnBuilder<string> | ColumnBuilder<number>;
|
|
203
202
|
tenantId: ColumnBuilder<string>;
|
package/dist/db/table-builder.js
CHANGED
|
@@ -16,29 +16,28 @@ export function physicalColumnName(table, field) {
|
|
|
16
16
|
// Returns column(s) for a field. Most fields return a single entry,
|
|
17
17
|
// money returns two (amount + currency).
|
|
18
18
|
//
|
|
19
|
-
// `required: true`
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// die einzige Wahrheit.
|
|
19
|
+
// `required: true` on a FieldDefinition always maps to .notNull() in the
|
|
20
|
+
// database column. Previously this applied only to reference fields, while
|
|
21
|
+
// text/select/number/etc. remained silently nullable even when the API
|
|
22
|
+
// validator enforced required. That forced hand-written PgTable definitions
|
|
23
|
+
// to add .notNull(), causing duplicate definitions and schema drift. Now
|
|
24
|
+
// r.entity is the single source of truth.
|
|
26
25
|
function fieldToColumns(name, field, entity) {
|
|
27
26
|
const snakeName = toSnakeCase(name);
|
|
28
27
|
switch (field.type) {
|
|
29
28
|
case "text":
|
|
30
29
|
case "longText": {
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
30
|
+
// Both map to PG `text` (unbounded). The difference exists only in the
|
|
31
|
+
// type layer: longText has no sortable/searchable/filterable support.
|
|
32
|
+
// The order default() before notNull() matters: the column builder
|
|
33
|
+
// chains both; without default(), the generated SQL has no DEFAULT
|
|
34
|
+
// clause, which breaks ALTER TABLE ADD COLUMN on existing rows.
|
|
35
|
+
// longText currently has no default field in its type, but checking
|
|
36
|
+
// `field.default !== undefined` is defensive.
|
|
38
37
|
const base = text(snakeName);
|
|
39
38
|
const withDefault = "default" in field && field.default !== undefined ? base.default(field.default) : base;
|
|
40
39
|
const mainCol = { [name]: field.required ? withDefault.notNull() : withDefault };
|
|
41
|
-
// lookupable → nullable HMAC-
|
|
40
|
+
// lookupable → nullable HMAC blind-index column (kept in lockstep with
|
|
42
41
|
// fieldToColumnMeta in entity-table-meta.ts).
|
|
43
42
|
if (field.type === "text" && field.lookupable === true) {
|
|
44
43
|
return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
|
|
@@ -54,23 +53,23 @@ function fieldToColumns(name, field, entity) {
|
|
|
54
53
|
: boolean(snakeName),
|
|
55
54
|
};
|
|
56
55
|
case "select": {
|
|
57
|
-
// default()
|
|
58
|
-
// defaults
|
|
59
|
-
//
|
|
60
|
-
//
|
|
56
|
+
// Pass default() through because entity-table-meta renders declared
|
|
57
|
+
// defaults. This builder must stay in lockstep; otherwise the metadata
|
|
58
|
+
// on the buildEntityTable object describes a different column than the
|
|
59
|
+
// migration.
|
|
61
60
|
const base = text(snakeName);
|
|
62
61
|
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
63
62
|
return { [name]: field.required ? col.notNull() : col };
|
|
64
63
|
}
|
|
65
64
|
case "multiSelect":
|
|
66
|
-
// jsonb
|
|
65
|
+
// jsonb string array with default `[]`, always NOT NULL.
|
|
67
66
|
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// null
|
|
73
|
-
//
|
|
67
|
+
// The `required` flag on MultiSelectFieldDef is intentionally ignored:
|
|
68
|
+
// with default `[]`, the field is structurally never null (an insert
|
|
69
|
+
// without a value produces an empty array, not NULL). Read-side code
|
|
70
|
+
// therefore needs no null check; this is an API guarantee. Code that
|
|
71
|
+
// needs actual null (meaning the field has never been set) should use
|
|
72
|
+
// a separate status field or an optional-typed reference instead of a
|
|
74
73
|
// multi-select.
|
|
75
74
|
return { [name]: jsonb(snakeName).default([]).notNull() };
|
|
76
75
|
case "number": {
|
|
@@ -84,19 +83,18 @@ function fieldToColumns(name, field, entity) {
|
|
|
84
83
|
return { [name]: field.required ? col.notNull() : col };
|
|
85
84
|
}
|
|
86
85
|
case "bigInt": {
|
|
87
|
-
// 64-bit
|
|
88
|
-
// mode:"number"
|
|
89
|
-
// JS
|
|
90
|
-
//
|
|
91
|
-
// eigenem Codec.
|
|
86
|
+
// 64-bit integer for audit counters, byte sizes, and cumulative sums.
|
|
87
|
+
// mode:"number" produces a JS `number` (safe up to 2^53 ≈ 9 PB) rather
|
|
88
|
+
// than JS `bigint`, making it JSON-serializable and frontend-friendly.
|
|
89
|
+
// Values above 2^53 should use a text field with a custom codec.
|
|
92
90
|
const base = bigint(snakeName, { mode: "number" });
|
|
93
91
|
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
94
92
|
return { [name]: field.required ? col.notNull() : col };
|
|
95
93
|
}
|
|
96
94
|
case "reference":
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
95
|
+
// FK-style UUID column. Multi-mode stores UUIDs as a jsonb string array;
|
|
96
|
+
// single-mode remains a conventional UUID column (NOT NULL only when
|
|
97
|
+
// required).
|
|
100
98
|
if (field.multiple === true) {
|
|
101
99
|
return { [name]: jsonb(snakeName).default([]).notNull() };
|
|
102
100
|
}
|
|
@@ -104,11 +102,12 @@ function fieldToColumns(name, field, entity) {
|
|
|
104
102
|
[name]: field.required ? uuid(snakeName).notNull() : uuid(snakeName),
|
|
105
103
|
};
|
|
106
104
|
case "money":
|
|
107
|
-
// BIGINT
|
|
108
|
-
// the currency column
|
|
109
|
-
// which is too
|
|
110
|
-
// aggregates. BIGINT handles up to
|
|
111
|
-
//
|
|
105
|
+
// BIGINT stores the integer minor unit (cents for EUR, yen for JPY —
|
|
106
|
+
// the currency column identifies which). INTEGER would cap at about
|
|
107
|
+
// 21 M EUR, which is too low for B2B invoices, property values, or
|
|
108
|
+
// balance aggregates. BIGINT safely handles up to about 90 trillion
|
|
109
|
+
// EUR in JS.
|
|
110
|
+
// Currency always has a default, so it is structurally .notNull().
|
|
112
111
|
return {
|
|
113
112
|
[name]: field.required ? moneyAmount(snakeName).notNull() : moneyAmount(snakeName),
|
|
114
113
|
[`${name}Currency`]: text(`${snakeName}_currency`)
|
|
@@ -116,47 +115,47 @@ function fieldToColumns(name, field, entity) {
|
|
|
116
115
|
.notNull(),
|
|
117
116
|
};
|
|
118
117
|
case "embedded":
|
|
119
|
-
// jsonb
|
|
120
|
-
// `required`
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
118
|
+
// jsonb with default `{}`, always NOT NULL, analogous to multiSelect.
|
|
119
|
+
// `required` is intentionally ignored because the default makes the
|
|
120
|
+
// field structurally never null. To make embedded data optional (so
|
|
121
|
+
// the field can be omitted entirely), model it with a wrapper field
|
|
122
|
+
// and boolean flag or a discriminated union.
|
|
124
123
|
// multiple → the value is a list of rows, so the empty default is `[]`.
|
|
125
124
|
return field.multiple === true
|
|
126
125
|
? { [name]: jsonb(snakeName).default([]).notNull() }
|
|
127
126
|
: { [name]: jsonb(snakeName).default({}).notNull() };
|
|
128
127
|
case "jsonb":
|
|
129
|
-
// Free-form jsonb — keys
|
|
130
|
-
// (
|
|
131
|
-
// `customFields
|
|
128
|
+
// Free-form jsonb — keys are not schema-validated. Default `{}`, NOT NULL
|
|
129
|
+
// (analogous to embedded). Use case: the host entity's custom-fields
|
|
130
|
+
// bundle in the `customFields` column, whose keys are tenant-defined.
|
|
132
131
|
return { [name]: jsonb(snakeName).default({}).notNull() };
|
|
133
132
|
case "date": {
|
|
134
133
|
// Real PG `date` — no time-of-day, no timezone. Temporal.PlainDate
|
|
135
134
|
// end-to-end (see plainDate() in dialect.ts). Was aliased on instant()
|
|
136
|
-
// = TIMESTAMPTZ
|
|
135
|
+
// = TIMESTAMPTZ (TZ-dependent round-trip).
|
|
137
136
|
const col = plainDate(snakeName);
|
|
138
137
|
return { [name]: field.required ? col.notNull() : col };
|
|
139
138
|
}
|
|
140
139
|
case "timestamp": {
|
|
141
|
-
// UTC
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
140
|
+
// UTC instant — stored as TIMESTAMPTZ in PG and read/written as
|
|
141
|
+
// Temporal.Instant through the instant() customType (see dialect.ts).
|
|
142
|
+
// Callers use only Temporal.Instant, never JS Date. Comparisons
|
|
143
|
+
// (lte/gt/orderBy) also accept Instants directly, so no .toString()
|
|
144
|
+
// cast is needed.
|
|
146
145
|
const col = instant(snakeName);
|
|
147
146
|
return { [name]: field.required ? col.notNull() : col };
|
|
148
147
|
}
|
|
149
148
|
case "tz": {
|
|
150
|
-
// IANA-
|
|
151
|
-
//
|
|
149
|
+
// IANA time-zone name as TEXT — validated through the Zod schema in the
|
|
150
|
+
// validator step. Snake-case convention: `pickup_tz`.
|
|
152
151
|
const col = text(snakeName);
|
|
153
152
|
return { [name]: field.required ? col.notNull() : col };
|
|
154
153
|
}
|
|
155
154
|
case "locatedTimestamp": {
|
|
156
|
-
//
|
|
157
|
-
// _utc
|
|
158
|
-
//
|
|
159
|
-
//
|
|
155
|
+
// Two columns form an atomic pair: <name>_utc TIMESTAMPTZ + <name>_tz TEXT.
|
|
156
|
+
// _utc is instant() (Temporal.Instant), and _tz is text (IANA name).
|
|
157
|
+
// The executor wires automatic conversion (at+tz → utc on insert;
|
|
158
|
+
// utc+tz → at on read). required propagates to both columns.
|
|
160
159
|
const utc = instant(`${snakeName}_utc`);
|
|
161
160
|
const tz = text(`${snakeName}_tz`);
|
|
162
161
|
return {
|
|
@@ -188,12 +187,12 @@ function fieldToColumns(name, field, entity) {
|
|
|
188
187
|
* Derives a table name from an entity name:
|
|
189
188
|
* 1. camelCase → snake_case (e.g. "memberTask" → "member_task")
|
|
190
189
|
* 2. Simple English pluralization (category→categories, status→statuses, task→tasks)
|
|
191
|
-
* 3. `read_` prefix —
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* (
|
|
195
|
-
*
|
|
196
|
-
*
|
|
190
|
+
* 3. `read_` prefix — marks the table as an event-sourced read model so
|
|
191
|
+
* it is immediately distinguishable in database tools and application
|
|
192
|
+
* code is not expected to write to it directly. The event store
|
|
193
|
+
* (`kumiko_events`) and framework state (`kumiko_*`) use their own
|
|
194
|
+
* prefixes; ordinary app-side tables without event-sourcing have none.
|
|
195
|
+
* The three categories are therefore distinguishable in the table browser.
|
|
197
196
|
*/
|
|
198
197
|
const ES_PLURAL_SUFFIXES = ["s", "sh", "ch", "x"];
|
|
199
198
|
export const READ_MODEL_PREFIX = "read_";
|
|
@@ -219,12 +218,13 @@ export function buildBaseColumns(softDelete, idType = "uuid") {
|
|
|
219
218
|
id: idColumn,
|
|
220
219
|
tenantId: uuid("tenant_id").notNull(),
|
|
221
220
|
version: integer("version").default(1).notNull(),
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
221
|
+
// Temporal.Instant is used throughout (see instant() in dialect.ts).
|
|
222
|
+
// Previously the default mode was "date", which was inconsistent with
|
|
223
|
+
// user-defined timestamp fields (mode "string"). A single mode is now
|
|
224
|
+
// used for all timestamps.
|
|
225
225
|
// customType doesn't expose Drizzle's `defaultNow()` shortcut — use raw
|
|
226
|
-
// SQL so PG sets the value on insert and
|
|
227
|
-
// Instant
|
|
226
|
+
// SQL so PG sets the value on insert and callers need not pass an
|
|
227
|
+
// Instant for every row creation.
|
|
228
228
|
insertedAt: instant("inserted_at").default(sql `now()`).notNull(),
|
|
229
229
|
modifiedAt: instant("modified_at"),
|
|
230
230
|
// User-IDs are stringified UUIDs post-ES migration. Text (not uuid) so the
|
|
@@ -295,9 +295,9 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
295
295
|
}
|
|
296
296
|
// Default table name derived from entityName (e.g. "memberTask" → "read_member_tasks")
|
|
297
297
|
const baseTableName = entity.table ?? toTableName(entityName);
|
|
298
|
-
// featureName
|
|
299
|
-
//
|
|
300
|
-
// featureName
|
|
298
|
+
// The featureName prefix is inserted between read_ and the base name so
|
|
299
|
+
// all read models consistently start with `read_`, whether or not
|
|
300
|
+
// featureName is set. Examples:
|
|
301
301
|
// featureName="shop", base="read_orders" → "read_shop_orders"
|
|
302
302
|
// featureName=undef, base="read_orders" → "read_orders"
|
|
303
303
|
// featureName="shop", base="orders" (no read_) → "shop_orders"
|
|
@@ -349,7 +349,7 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
349
349
|
indexes[`${tableName}_${toSnakeCase(fieldName)}_idx`] = index(`${tableName}_${toSnakeCase(fieldName)}_idx`).on(column);
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
|
-
// lookupable
|
|
352
|
+
// lookupable fields: index the bidx column (kept in lockstep with
|
|
353
353
|
// deriveEntityTableMeta).
|
|
354
354
|
const bidxFieldByField = new Map();
|
|
355
355
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
@@ -362,10 +362,10 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
362
362
|
indexes[idxName] = index(idxName).on(column);
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
|
-
// entity.indexes
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
365
|
+
// entity.indexes contains composite/unique indexes explicitly declared by
|
|
366
|
+
// the author. Columns are addressed by field name (camelCase), and the
|
|
367
|
+
// index name follows <table>_<col1>_<col2>_<unique|idx>; index.name can
|
|
368
|
+
// override the convention.
|
|
369
369
|
for (const def of entity.indexes ?? []) {
|
|
370
370
|
const cols = def.columns
|
|
371
371
|
.map((fieldName) => tHandle[fieldName])
|
|
@@ -388,12 +388,12 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
388
388
|
}
|
|
389
389
|
else if (def.unique === true && entity.softDelete === true) {
|
|
390
390
|
// Soft-deleted rows keep their value, so a full unique index would
|
|
391
|
-
// permanently block reuse; kept in lock-step with entity-table-meta.ts
|
|
391
|
+
// permanently block reuse; kept in lock-step with entity-table-meta.ts.
|
|
392
392
|
chain = chain.where(sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE));
|
|
393
393
|
}
|
|
394
394
|
indexes[indexName] = chain;
|
|
395
|
-
//
|
|
396
|
-
// (
|
|
395
|
+
// Partial bidx counterpart for unique indexes over lookupable fields
|
|
396
|
+
// (kept in lockstep with deriveEntityTableMeta).
|
|
397
397
|
if (def.unique === true && def.where === undefined) {
|
|
398
398
|
const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
|
|
399
399
|
if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
|
|
@@ -406,7 +406,7 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
406
406
|
.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`);
|
|
407
407
|
// Soft-deleted rows keep their bidx hash — without this, a
|
|
408
408
|
// restored row can't reuse a value a soft-deleted sibling still
|
|
409
|
-
// holds
|
|
409
|
+
// holds.
|
|
410
410
|
const whereText = (entity.softDelete === true
|
|
411
411
|
? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
|
|
412
412
|
: notNullParts).join(" AND ");
|