@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +32 -11
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +45 -38
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +10 -2
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +172 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +264 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
|
@@ -34,12 +34,12 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
|
|
|
34
34
|
signal: AbortSignal.timeout(DECRYPT_TIMEOUT_MS),
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
catch {
|
|
37
|
+
catch (error) {
|
|
38
38
|
if (attempt < maxAttempts) {
|
|
39
39
|
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
40
40
|
continue;
|
|
41
41
|
}
|
|
42
|
-
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout
|
|
42
|
+
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`, { cause: error });
|
|
43
43
|
}
|
|
44
44
|
if (!response.ok) {
|
|
45
45
|
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
@@ -62,60 +62,64 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
|
|
|
62
62
|
}
|
|
63
63
|
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: retries exhausted`);
|
|
64
64
|
}
|
|
65
|
+
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
66
|
+
// migrated, which is indistinguishable from a finished cutover unless the
|
|
67
|
+
// boot says which source won. The line is built from the path actually taken.
|
|
65
68
|
async function resolveSlot(name, env, options, fetchImpl) {
|
|
66
69
|
const plaintext = env[name];
|
|
67
|
-
if (plaintext)
|
|
68
|
-
return plaintext;
|
|
69
70
|
const ciphertext = env[`${name}_CIPHERTEXT`];
|
|
71
|
+
if (plaintext) {
|
|
72
|
+
return {
|
|
73
|
+
value: plaintext,
|
|
74
|
+
sourceLine: ciphertext
|
|
75
|
+
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
76
|
+
: `${name} source=plaintext-env`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
70
79
|
if (!ciphertext)
|
|
71
|
-
return undefined;
|
|
80
|
+
return { value: undefined, sourceLine: undefined };
|
|
72
81
|
const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
|
|
73
82
|
const token = env.PLATFORM_KEK_KMS_TOKEN;
|
|
74
83
|
if (!keyId || !token) {
|
|
75
84
|
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
76
|
-
throw new Error(`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are
|
|
77
|
-
}
|
|
78
|
-
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
79
|
-
return decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
|
|
80
|
-
}
|
|
81
|
-
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
82
|
-
// migrated, which is indistinguishable from a finished cutover unless the
|
|
83
|
-
// boot says which source won. Never carries a key value, only its origin.
|
|
84
|
-
function describeKekSource(name, env) {
|
|
85
|
-
const plaintext = env[name];
|
|
86
|
-
const ciphertext = env[`${name}_CIPHERTEXT`];
|
|
87
|
-
if (plaintext) {
|
|
88
|
-
return ciphertext
|
|
89
|
-
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
90
|
-
: `${name} source=plaintext-env`;
|
|
85
|
+
throw new Error(`${prefix}${name}_CIPHERTEXT is set but PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are missing — a partial set means the KMS wiring is broken.`);
|
|
91
86
|
}
|
|
92
|
-
if (!ciphertext)
|
|
93
|
-
return undefined;
|
|
94
87
|
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
95
|
-
|
|
88
|
+
const value = await decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
|
|
89
|
+
return {
|
|
90
|
+
value,
|
|
91
|
+
sourceLine: `${name} source=key-manager keyId=${keyId} region=${region}`,
|
|
92
|
+
};
|
|
96
93
|
}
|
|
97
94
|
// Each slot resolves independently so a rollback that clears one slot's
|
|
98
95
|
// plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
|
|
99
96
|
// another slot's fallback path — the trio check downstream still applies.
|
|
97
|
+
// Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
|
|
98
|
+
// ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
|
|
100
99
|
export async function resolvePlatformKeks(env, options = {}) {
|
|
101
100
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
101
|
+
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
102
102
|
const slots = options.slots ?? LEGACY_SLOTS;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
// Misconfiguration must not cost a Key Manager round trip.
|
|
104
|
+
if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
|
|
105
|
+
(env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
|
|
106
|
+
!env.PLATFORM_KEK_PREVIOUS_VERSION) {
|
|
107
|
+
throw new Error(`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`);
|
|
106
108
|
}
|
|
107
|
-
const
|
|
109
|
+
const results = await Promise.all(slots.map((name) => resolveSlot(name, env, options, fetchImpl)));
|
|
108
110
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
109
111
|
const log = options.log ?? console.info;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
112
|
+
const resolved = {};
|
|
113
|
+
slots.forEach((name, index) => {
|
|
114
|
+
const result = results[index];
|
|
115
|
+
// skip: slot without a resolved result stays unset
|
|
116
|
+
if (!result)
|
|
117
|
+
return;
|
|
118
|
+
resolved[name] = result.value;
|
|
119
|
+
if (result.sourceLine)
|
|
120
|
+
log(`${prefix}${result.sourceLine}`);
|
|
121
|
+
});
|
|
122
|
+
const changed = Object.keys(resolved).some((name) => resolved[name] !== env[name]);
|
|
119
123
|
if (!changed)
|
|
120
124
|
return env;
|
|
121
125
|
return { ...env, ...resolved };
|
|
@@ -3,8 +3,8 @@ import * as z from "zod";
|
|
|
3
3
|
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
4
4
|
// The KMS error classes live here and not in kumiko-types (#1629): callers
|
|
5
5
|
// branch on them with `instanceof`, which needs a single copy of the class.
|
|
6
|
-
// `satisfies z.ZodType<SubjectId>`
|
|
7
|
-
// (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
|
|
6
|
+
// `satisfies z.ZodType<SubjectId>` plus the reverse check in subject-id-schema.test.ts bind this to the TS type
|
|
7
|
+
// at compile time (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
|
|
8
8
|
// longer drift apart on which subjects are valid, the way #2809 happened.
|
|
9
9
|
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
10
10
|
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
@@ -114,8 +114,14 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
114
114
|
// UPDATE so Postgres doesn't error with "column X does not exist".
|
|
115
115
|
const changes = {};
|
|
116
116
|
for (const [key, value] of Object.entries(rawChanges)) {
|
|
117
|
-
const
|
|
118
|
-
|
|
117
|
+
const field = entity.fields[key];
|
|
118
|
+
// Historical events can carry fields since removed from the entity; the
|
|
119
|
+
// tolerance lives here (not in updateMany) so a live typo still throws.
|
|
120
|
+
// Base/soft-delete columns (insertedById, deletedAt, ...) are table
|
|
121
|
+
// columns without an entity field and must still be applied.
|
|
122
|
+
if (field === undefined && !(key in table))
|
|
123
|
+
continue;
|
|
124
|
+
if (field?.type === "files" || field?.type === "images")
|
|
119
125
|
continue;
|
|
120
126
|
changes[key] = value;
|
|
121
127
|
}
|
|
@@ -16,9 +16,10 @@ import { isSelfPiiField } from "../crypto/is-self-pii-field.js";
|
|
|
16
16
|
import { toSnakeCase } from "../utils/case.js";
|
|
17
17
|
import { resolveTableName } from "./entity-table-meta.js";
|
|
18
18
|
import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
19
|
-
import { tableExists } from "./schema-inspection.js";
|
|
19
|
+
import { columnNamesOf, tableExists } from "./schema-inspection.js";
|
|
20
20
|
export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
21
21
|
const likePattern = subjectCiphertextLikePattern(subjectKey);
|
|
22
|
+
const candidates = [];
|
|
22
23
|
for (const feature of features.values()) {
|
|
23
24
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
24
25
|
const lookupable = collectLookupableFields(entity);
|
|
@@ -27,20 +28,36 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
|
27
28
|
// No featureName prefix — the dispatcher builds entity tables without
|
|
28
29
|
// one (buildEntityTable with no featureName option), the sweep has to
|
|
29
30
|
// hit the same names.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
candidates.push({ tableName: resolveTableName(entityName, entity, undefined), lookupable });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Skip entities whose projection was never migrated — same class as
|
|
35
|
+
// subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
|
|
36
|
+
// in forget-subject and would leave deterministic bidx columns still
|
|
37
|
+
// linkable (fw#2550). One batched existence query instead of one
|
|
38
|
+
// roundtrip per entity.
|
|
39
|
+
const existing = await existingTableNames(db, candidates.map((c) => c.tableName));
|
|
40
|
+
for (const { tableName, lookupable } of candidates) {
|
|
41
|
+
if (!existing.has(tableName))
|
|
42
|
+
continue;
|
|
43
|
+
// Same hazard for a migrated table that predates a later-added
|
|
44
|
+
// lookupable field: skip fields whose columns are missing instead of
|
|
45
|
+
// aborting the sweep for the remaining ones.
|
|
46
|
+
const columns = await columnNamesOf(db, tableName);
|
|
47
|
+
for (const fieldName of lookupable) {
|
|
48
|
+
const snake = toSnakeCase(fieldName);
|
|
49
|
+
if (!columns.has(snake) || !columns.has(`${snake}_bidx`))
|
|
36
50
|
continue;
|
|
37
|
-
|
|
38
|
-
const snake = toSnakeCase(fieldName);
|
|
39
|
-
await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
|
|
40
|
-
}
|
|
51
|
+
await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
}
|
|
55
|
+
async function existingTableNames(db, tableNames) {
|
|
56
|
+
if (tableNames.length === 0)
|
|
57
|
+
return new Set();
|
|
58
|
+
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass(quote_ident(t.name)) IS NOT NULL`, [tableNames]);
|
|
59
|
+
return new Set(rows.map((r) => r.name));
|
|
60
|
+
}
|
|
44
61
|
// Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
|
|
45
62
|
// "user"-kind subject id is often not a real user (share-token recipient,
|
|
46
63
|
// email subscriber, ...) — those entities self-own their PII (`pii: true`,
|
|
@@ -67,6 +84,10 @@ export async function subjectRowExistsInTenant(db, features, subjectId, tenantId
|
|
|
67
84
|
const hasSelfPiiField = Object.values(entity.fields).some(isSelfPiiField);
|
|
68
85
|
if (!hasSelfPiiField)
|
|
69
86
|
continue;
|
|
87
|
+
// Subject ids are uuids; comparing one to a serial id column makes
|
|
88
|
+
// postgres throw (invalid input syntax for integer) mid forget-TX.
|
|
89
|
+
if (entity.idType === "serial")
|
|
90
|
+
continue;
|
|
70
91
|
candidateTables.push(resolveTableName(entityName, entity, undefined));
|
|
71
92
|
}
|
|
72
93
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
18
|
import { collectPiiSubjectFields } from "../crypto/index.js";
|
|
19
19
|
import { SQL_EXPR_BRAND } from "./dialect.js";
|
|
20
|
-
import { READ_MODEL_PREFIX, toSnakeCase, toTableName } from "./table-builder.js";
|
|
20
|
+
import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
21
21
|
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
22
22
|
// `buildBaseColumns()` from table-builder.ts (drizzle variant).
|
|
23
23
|
function fullBaseColumns(idType, softDelete) {
|
|
@@ -300,7 +300,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
300
300
|
// permanently block reuse; kept in lock-step with table-builder.ts (framework#2593).
|
|
301
301
|
const whereSql = explicitWhereSql ??
|
|
302
302
|
(def.where === undefined && def.unique === true && entity.softDelete === true
|
|
303
|
-
?
|
|
303
|
+
? SOFT_DELETE_LIVE_ROW_PREDICATE
|
|
304
304
|
: undefined);
|
|
305
305
|
indexes.push({
|
|
306
306
|
name: indexName,
|
|
@@ -322,7 +322,9 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
322
322
|
// Soft-deleted rows keep their bidx hash — without this, a restored
|
|
323
323
|
// row can't reuse a value a soft-deleted sibling still holds
|
|
324
324
|
// (framework#2464). Kept in lock-step with table-builder.ts.
|
|
325
|
-
const whereParts = entity.softDelete === true
|
|
325
|
+
const whereParts = entity.softDelete === true
|
|
326
|
+
? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
|
|
327
|
+
: notNullParts;
|
|
326
328
|
indexes.push({
|
|
327
329
|
name: `${indexName}_bidx`,
|
|
328
330
|
columns: bidxCols,
|
|
@@ -289,27 +289,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
289
289
|
...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
|
|
290
290
|
];
|
|
291
291
|
}
|
|
292
|
-
// Combined, atomic read for the `expect:` precondition
|
|
293
|
-
//
|
|
294
|
-
//
|
|
292
|
+
// Combined, atomic read for the `expect:` precondition: the projection
|
|
293
|
+
// row's expect-checked fields AND the events table's current MAX(version)
|
|
294
|
+
// for this aggregate, in ONE SQL statement.
|
|
295
295
|
//
|
|
296
|
-
// Two separate reads
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
// empirically: ~40% of genuinely concurrent update() calls slipped a stale
|
|
302
|
-
// precondition through with two round-trips, regardless of read order. One
|
|
303
|
-
// query removes the gap: Postgres executes it against a single consistent
|
|
304
|
-
// snapshot.
|
|
296
|
+
// Two separate reads leave a gap: applyEntityEvent writes the projection in
|
|
297
|
+
// a separate statement after its event commits, so a second reader can see
|
|
298
|
+
// a version that already reflects a concurrent writer's event while its read
|
|
299
|
+
// of the expect fields still shows the pre-write row. One query runs against
|
|
300
|
+
// a single consistent snapshot.
|
|
305
301
|
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// matching events. Deriving expectedVersion from such a row.version makes
|
|
312
|
-
// the append below target a non-existent predecessor and fail outright.
|
|
302
|
+
// The row's own `version` column can't be trusted instead: it is only in
|
|
303
|
+
// lock-step with the events for rows this executor wrote. A raw-seeded row
|
|
304
|
+
// (test fixtures, legacy data) can carry a default version with zero
|
|
305
|
+
// matching events, so deriving expectedVersion from it makes the append
|
|
306
|
+
// target a non-existent predecessor and fail.
|
|
313
307
|
// The events table's MAX(version) (0 for such a row) is the only value
|
|
314
308
|
// append() can safely use as expectedVersion — exactly what
|
|
315
309
|
// getStreamVersion() already returns for every other (non-`expect`)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
|
|
3
|
-
import {
|
|
3
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
6
6
|
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
@@ -10,7 +10,7 @@ import { getStreamVersion } from "../event-store/index.js";
|
|
|
10
10
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
13
|
-
import {
|
|
13
|
+
import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
14
14
|
import { buildFilterWhere, } from "./event-store-executor-context.js";
|
|
15
15
|
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
16
16
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
@@ -105,20 +105,29 @@ function applyMultiSelectFilter(colSql, whereSql, params, f) {
|
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
const parts = f.value.map((v) => {
|
|
108
|
-
params.push([v]);
|
|
109
|
-
return `${colSql(f.field)} @> $${params.length}::jsonb`;
|
|
108
|
+
params.push(JSON.stringify([v]));
|
|
109
|
+
return `${colSql(f.field)} @> $${params.length}::text::jsonb`;
|
|
110
110
|
});
|
|
111
111
|
whereSql.push(`(${parts.join(" OR ")})`);
|
|
112
112
|
// skip: containment condition already pushed — don't fall through to the eq/ne branch below
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
const candidates = Array.isArray(f.value) ? f.value : [f.value];
|
|
116
|
+
// `@> '[]'` is true for every non-null row, so an empty array would turn
|
|
117
|
+
// the filter into a silent match-all.
|
|
118
|
+
if (candidates.length === 0) {
|
|
119
|
+
whereSql.push("FALSE");
|
|
120
|
+
// skip: empty candidate list is unsatisfiable
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Bound as text then cast: a JS array makes postgres.js infer boolean[]/int4[]
|
|
124
|
+
// for client-supplied non-string values, and `boolean[]::jsonb` is rejected
|
|
125
|
+
// (500 on client input). An array value means "contains all of these".
|
|
126
|
+
params.push(JSON.stringify(candidates));
|
|
127
|
+
const containment = `${colSql(f.field)} @> $${params.length}::text::jsonb`;
|
|
128
|
+
// A NULL column must count as "does not contain" — plain NOT(...) yields
|
|
129
|
+
// NULL and would drop those rows.
|
|
130
|
+
whereSql.push(f.op === "ne" ? `(${colSql(f.field)} IS NULL OR NOT (${containment}))` : containment);
|
|
122
131
|
}
|
|
123
132
|
// Falls through to the screen-filter WHERE builder shared with
|
|
124
133
|
// screen-declared filters — buildFilterWhere flattens the op into a
|
|
@@ -308,7 +317,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
308
317
|
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
309
318
|
return undefined;
|
|
310
319
|
}
|
|
311
|
-
const targetTable =
|
|
320
|
+
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
312
321
|
const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
|
|
313
322
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
314
323
|
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db);
|
|
@@ -320,6 +329,17 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
320
329
|
}
|
|
321
330
|
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
|
|
322
331
|
}
|
|
332
|
+
// Building the table is pure but not free, and runs on every search request;
|
|
333
|
+
// keyed by the definition object so a re-registered entity never sees a stale table.
|
|
334
|
+
const referenceTargetTables = new WeakMap();
|
|
335
|
+
function referenceTargetTable(name, entity) {
|
|
336
|
+
const cached = referenceTargetTables.get(entity);
|
|
337
|
+
if (cached?.name === name)
|
|
338
|
+
return cached.table;
|
|
339
|
+
const table = buildEntityTable(name, entity);
|
|
340
|
+
referenceTargetTables.set(entity, { name, table });
|
|
341
|
+
return table;
|
|
342
|
+
}
|
|
323
343
|
// Projected alongside the row when a reference sort is active, so the keyset
|
|
324
344
|
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
325
345
|
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
@@ -342,8 +362,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
342
362
|
// skip: labelField isn't a real (declared) column on the target entity
|
|
343
363
|
return undefined;
|
|
344
364
|
}
|
|
345
|
-
if (
|
|
346
|
-
collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)) {
|
|
365
|
+
if (isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
347
366
|
// skip: encrypted/PII labelField — ciphertext orders by its bytes, which is
|
|
348
367
|
// arbitrary to the reader and would also rank rows by their stored secret
|
|
349
368
|
return undefined;
|
|
@@ -389,10 +408,6 @@ export function createReadVerbs(ctx) {
|
|
|
389
408
|
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
390
409
|
}
|
|
391
410
|
let filterIds;
|
|
392
|
-
// fw#2660 — true once a searchable-reference or row-meta-reference
|
|
393
|
-
// clause contributed rows, so the total-count fast-path below knows
|
|
394
|
-
// `filterIds.length` alone would undercount.
|
|
395
|
-
let referenceClauseActive = false;
|
|
396
411
|
// Build the WHERE clause as raw SQL — ownership produces a
|
|
397
412
|
// parameterised fragment that we splice in alongside simple WhereObject
|
|
398
413
|
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
@@ -461,17 +476,12 @@ export function createReadVerbs(ctx) {
|
|
|
461
476
|
return `$${params.length}`;
|
|
462
477
|
});
|
|
463
478
|
orParts.push(`${colSql(match.ownColumn)} IN (${placeholders.join(", ")})`);
|
|
464
|
-
referenceClauseActive = true;
|
|
465
479
|
}
|
|
466
480
|
// Parenthesized as one whereSql entry — whereSql is joined with
|
|
467
481
|
// " AND " below, an unparenthesized OR here would leak past the
|
|
468
482
|
// tenant filter that follows.
|
|
469
483
|
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
470
484
|
}
|
|
471
|
-
// The total-count fast-path below is only valid while this is still
|
|
472
|
-
// true after every later clause (tenant, soft-delete, cursor,
|
|
473
|
-
// ownership, parent-ref, filters) — see where it's checked.
|
|
474
|
-
const whereSqlLenAfterSearch = whereSql.length;
|
|
475
485
|
const sortCandidate = payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
|
|
476
486
|
? payload.sort
|
|
477
487
|
: undefined;
|
|
@@ -558,8 +568,12 @@ export function createReadVerbs(ctx) {
|
|
|
558
568
|
: referenceSortExpr !== undefined
|
|
559
569
|
? referenceSortExpr(params)
|
|
560
570
|
: colSql(sortField);
|
|
561
|
-
|
|
562
|
-
|
|
571
|
+
// The projected label alias is ordered by directly, so Postgres evaluates
|
|
572
|
+
// the correlated subquery once per row instead of once for ORDER BY and
|
|
573
|
+
// once for the SELECT list.
|
|
574
|
+
const orderBySortSql = referenceSortExpr !== undefined ? `"${SORT_LABEL_ALIAS}"` : sortSql;
|
|
575
|
+
const orderByClause = orderBySortSql !== undefined
|
|
576
|
+
? ` ORDER BY ${orderBySortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
|
|
563
577
|
: ` ORDER BY ${colSql("id")} ASC`;
|
|
564
578
|
const useOffset = !payload.cursor && offset > 0;
|
|
565
579
|
const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
|
|
@@ -608,21 +622,14 @@ export function createReadVerbs(ctx) {
|
|
|
608
622
|
// total: extra COUNT(*) — only when explicitly requested (pager UI).
|
|
609
623
|
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
610
624
|
// expensive as the corresponding WHERE — cheap enough on indexed columns.
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// soft-delete, cursor all splice in afterwards), in which case the raw
|
|
615
|
-
// filterIds length alone would undercount.
|
|
625
|
+
// Always counted over the full WHERE: search ids, reference matches, tenant,
|
|
626
|
+
// soft-delete, cursor, ownership and filters all narrow the result, so the
|
|
627
|
+
// search hit count alone is never the total.
|
|
616
628
|
let total;
|
|
617
629
|
if (totalCount) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
else {
|
|
622
|
-
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
623
|
-
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
624
|
-
total = countRows[0]?.count ?? 0;
|
|
625
|
-
}
|
|
630
|
+
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
631
|
+
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
632
|
+
total = countRows[0]?.count ?? 0;
|
|
626
633
|
}
|
|
627
634
|
return { rows, nextCursor, ...(total !== undefined && { total }) };
|
|
628
635
|
},
|
|
@@ -66,13 +66,13 @@ export function createWriteVerbs(ctx) {
|
|
|
66
66
|
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
67
67
|
return {
|
|
68
68
|
async create(payload, user, db, options) {
|
|
69
|
-
const runner = tenantDbRunner(db);
|
|
70
69
|
if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
|
|
71
70
|
throw new AccessDeniedError({
|
|
72
71
|
message: `${entityName}.create: entity is tenancy: "global" — payload.tenantId must be ` +
|
|
73
72
|
"SYSTEM_TENANT_ID or omitted.",
|
|
74
73
|
});
|
|
75
74
|
}
|
|
75
|
+
const runner = tenantDbRunner(db);
|
|
76
76
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
77
77
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
78
78
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
@@ -304,20 +304,8 @@ export function createWriteVerbs(ctx) {
|
|
|
304
304
|
// trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
|
|
305
305
|
// with version_conflict.
|
|
306
306
|
//
|
|
307
|
-
// `expect:`
|
|
308
|
-
//
|
|
309
|
-
// separate getStreamVersion() round-trip: two reads (in either order)
|
|
310
|
-
// leave a real gap — applyEntityEvent writes the projection in a
|
|
311
|
-
// SEPARATE statement after its event commits, so a second reader's
|
|
312
|
-
// version-read can land in that gap and see a fresh, non-conflicting
|
|
313
|
-
// version paired with a still-stale projection row (verified
|
|
314
|
-
// empirically: ~40% of genuinely concurrent runs slipped through with
|
|
315
|
-
// two round-trips). One query removes the gap. It also avoids trusting
|
|
316
|
-
// the row's own `version` column for the expectedVersion: that column
|
|
317
|
-
// is only in lock-step with the rest of the row for rows THIS executor
|
|
318
|
-
// wrote — a raw-seeded row (test fixture, or legacy pre-#762 data) can
|
|
319
|
-
// carry a default version with zero matching events, which would make
|
|
320
|
-
// the append below target a non-existent predecessor and fail outright.
|
|
307
|
+
// With `expect:` the version comes from loadExpectSnapshot's combined
|
|
308
|
+
// query instead; see its comment for why.
|
|
321
309
|
let currentVersion;
|
|
322
310
|
if (updateOptions?.expect) {
|
|
323
311
|
const expectKeys = Object.keys(updateOptions.expect);
|
|
@@ -603,12 +591,12 @@ export function createWriteVerbs(ctx) {
|
|
|
603
591
|
};
|
|
604
592
|
},
|
|
605
593
|
async restore(payload, user, db) {
|
|
606
|
-
const runner = tenantDbRunner(db);
|
|
607
594
|
if (!softDelete) {
|
|
608
595
|
return writeFailure(new UnprocessableError("soft_delete_not_enabled", {
|
|
609
596
|
i18nKey: "errors.softDeleteNotEnabled",
|
|
610
597
|
}));
|
|
611
598
|
}
|
|
599
|
+
const runner = tenantDbRunner(db);
|
|
612
600
|
// Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
|
|
613
601
|
// selectMany(runner, ...) did not — any caller could un-delete a foreign
|
|
614
602
|
// tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
|
|
@@ -672,7 +660,17 @@ export function createWriteVerbs(ctx) {
|
|
|
672
660
|
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
673
661
|
// plus the version bump, so there is no sensitive-field drift and no
|
|
674
662
|
// payload override is needed.
|
|
675
|
-
|
|
663
|
+
// Soft-delete unique indexes are partial (WHERE is_deleted = false), so a live row may have re-taken the value this restore brings back.
|
|
664
|
+
let restoreResult;
|
|
665
|
+
try {
|
|
666
|
+
restoreResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
667
|
+
}
|
|
668
|
+
catch (e) {
|
|
669
|
+
const mapped = tryMapUniqueViolation(e, entityName);
|
|
670
|
+
if (mapped)
|
|
671
|
+
return mapped;
|
|
672
|
+
throw e;
|
|
673
|
+
}
|
|
676
674
|
if (restoreResult.kind !== "applied" || restoreResult.row === null) {
|
|
677
675
|
return writeFailure(new InternalError({ message: "projection restore returned no row" }));
|
|
678
676
|
}
|
|
@@ -115,34 +115,25 @@ function diffOneTable(prev, next) {
|
|
|
115
115
|
newIndexes.push(idx);
|
|
116
116
|
continue;
|
|
117
117
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
from: prevI.needsManualWhere ?? false,
|
|
138
|
-
to: idx.needsManualWhere ?? false,
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
if (change.columnsChanged ||
|
|
143
|
-
change.uniqueChanged ||
|
|
144
|
-
change.whereSqlChanged ||
|
|
145
|
-
change.needsManualWhereChanged) {
|
|
118
|
+
const columnsChanged = indexColumnsEqual(prevI.columns, idx.columns)
|
|
119
|
+
? undefined
|
|
120
|
+
: { from: prevI.columns, to: idx.columns };
|
|
121
|
+
const uniqueChanged = (prevI.unique ?? false) === (idx.unique ?? false)
|
|
122
|
+
? undefined
|
|
123
|
+
: { from: prevI.unique ?? false, to: idx.unique ?? false };
|
|
124
|
+
const whereSqlChanged = prevI.whereSql === idx.whereSql ? undefined : { from: prevI.whereSql, to: idx.whereSql };
|
|
125
|
+
const needsManualWhereChanged = (prevI.needsManualWhere ?? false) === (idx.needsManualWhere ?? false)
|
|
126
|
+
? undefined
|
|
127
|
+
: { from: prevI.needsManualWhere ?? false, to: idx.needsManualWhere ?? false };
|
|
128
|
+
if (columnsChanged || uniqueChanged || whereSqlChanged || needsManualWhereChanged) {
|
|
129
|
+
// Single literal typed as IndexChange so a misspelled key fails to compile instead of silently dropping the change.
|
|
130
|
+
const change = {
|
|
131
|
+
name,
|
|
132
|
+
...(columnsChanged && { columnsChanged }),
|
|
133
|
+
...(uniqueChanged && { uniqueChanged }),
|
|
134
|
+
...(whereSqlChanged && { whereSqlChanged }),
|
|
135
|
+
...(needsManualWhereChanged && { needsManualWhereChanged }),
|
|
136
|
+
};
|
|
146
137
|
changedIndexes.push(change);
|
|
147
138
|
}
|
|
148
139
|
}
|
|
@@ -215,6 +206,14 @@ function describeRecreateReasons(td) {
|
|
|
215
206
|
if (newUniqueIndexes.length > 0) {
|
|
216
207
|
reasons.push(`new UNIQUE index(es): ${newUniqueIndexes.join(", ")}`);
|
|
217
208
|
}
|
|
209
|
+
// A changed index that ends up UNIQUE (newly unique, new columns, widened WHERE) can fail on existing duplicates in place.
|
|
210
|
+
const uniqueNextNames = new Set(td.nextMeta.indexes.filter((idx) => idx.unique === true).map((idx) => idx.name));
|
|
211
|
+
const changedToUnique = td.changedIndexes
|
|
212
|
+
.filter((c) => uniqueNextNames.has(c.name))
|
|
213
|
+
.map((c) => c.name);
|
|
214
|
+
if (changedToUnique.length > 0) {
|
|
215
|
+
reasons.push(`changed UNIQUE index(es): ${changedToUnique.join(", ")}`);
|
|
216
|
+
}
|
|
218
217
|
const madeNotNull = td.changedColumns
|
|
219
218
|
.filter((c) => c.nullabilityChanged?.to === true)
|
|
220
219
|
.map((c) => c.name);
|
package/dist/db/pg-error.js
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
// error. Used by the event-store to distinguish a unique-violation on the
|
|
5
5
|
// aggregate-version index (optimistic-concurrency conflict) from the one on
|
|
6
6
|
// the idempotency-key index (caller-side replay signal).
|
|
7
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
7
8
|
export function extractPgError(e) {
|
|
8
9
|
if (typeof e !== "object" || e === null)
|
|
9
10
|
return null;
|
|
10
|
-
const layers = [
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
layers.push(
|
|
11
|
+
const layers = [];
|
|
12
|
+
let current = e;
|
|
13
|
+
// Depth cap doubles as cycle protection for self-referential causes.
|
|
14
|
+
while (typeof current === "object" && current !== null && layers.length < MAX_CAUSE_DEPTH) {
|
|
15
|
+
layers.push(current);
|
|
16
|
+
// @cast-boundary error-details — DrizzleQueryError wraps PG-error in .cause
|
|
17
|
+
current = current.cause;
|
|
18
|
+
}
|
|
15
19
|
for (const layer of layers) {
|
|
16
20
|
// @cast-boundary error-details — postgres-js error shape (code, constraint_name)
|
|
17
21
|
const code = layer.code;
|