@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- 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 +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- 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 +11 -1
- 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 +39 -36
- 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 +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +103 -33
- 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 +62 -19
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -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/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/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 +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/where-rule-lint.js +22 -4
- 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 +6 -1
- 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 +150 -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 +1 -0
- package/dist/observability/index.js +1 -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.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- 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 +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -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-jobs.js +24 -12
- 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 +206 -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 +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- 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
|
@@ -16,7 +16,7 @@ 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
22
|
for (const feature of features.values()) {
|
|
@@ -34,8 +34,14 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
|
34
34
|
// linkable (fw#2550).
|
|
35
35
|
if (!(await tableExists(db, tableName)))
|
|
36
36
|
continue;
|
|
37
|
+
// Same hazard for a migrated table that predates a later-added
|
|
38
|
+
// lookupable field: skip fields whose columns are missing instead of
|
|
39
|
+
// aborting the sweep for the remaining ones.
|
|
40
|
+
const columns = await columnNamesOf(db, tableName);
|
|
37
41
|
for (const fieldName of lookupable) {
|
|
38
42
|
const snake = toSnakeCase(fieldName);
|
|
43
|
+
if (!columns.has(snake) || !columns.has(`${snake}_bidx`))
|
|
44
|
+
continue;
|
|
39
45
|
await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
|
|
40
46
|
}
|
|
41
47
|
}
|
|
@@ -67,6 +73,10 @@ export async function subjectRowExistsInTenant(db, features, subjectId, tenantId
|
|
|
67
73
|
const hasSelfPiiField = Object.values(entity.fields).some(isSelfPiiField);
|
|
68
74
|
if (!hasSelfPiiField)
|
|
69
75
|
continue;
|
|
76
|
+
// Subject ids are uuids; comparing one to a serial id column makes
|
|
77
|
+
// postgres throw (invalid input syntax for integer) mid forget-TX.
|
|
78
|
+
if (entity.idType === "serial")
|
|
79
|
+
continue;
|
|
70
80
|
candidateTables.push(resolveTableName(entityName, entity, undefined));
|
|
71
81
|
}
|
|
72
82
|
}
|
|
@@ -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;
|
|
@@ -608,21 +618,14 @@ export function createReadVerbs(ctx) {
|
|
|
608
618
|
// total: extra COUNT(*) — only when explicitly requested (pager UI).
|
|
609
619
|
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
610
620
|
// 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.
|
|
621
|
+
// Always counted over the full WHERE: search ids, reference matches, tenant,
|
|
622
|
+
// soft-delete, cursor, ownership and filters all narrow the result, so the
|
|
623
|
+
// search hit count alone is never the total.
|
|
616
624
|
let total;
|
|
617
625
|
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
|
-
}
|
|
626
|
+
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
627
|
+
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
628
|
+
total = countRows[0]?.count ?? 0;
|
|
626
629
|
}
|
|
627
630
|
return { rows, nextCursor, ...(total !== undefined && { total }) };
|
|
628
631
|
},
|
|
@@ -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;
|
|
@@ -249,11 +249,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
249
249
|
const catalogFields = eventCatalog.get(row.type);
|
|
250
250
|
if (catalogFields) {
|
|
251
251
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
252
|
+
let subject;
|
|
253
|
+
try {
|
|
254
|
+
subject = resolveEventSubject(field, spec, payload, {
|
|
255
|
+
tenantId: row.tenant_id, // @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
256
|
+
aggregateType: row.aggregate_type,
|
|
257
|
+
aggregateId: row.aggregate_id,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
// Re-thrown as a plain Error: the owner-resolution retry hint added for
|
|
262
|
+
// SubjectResolutionError only applies to entity events.
|
|
263
|
+
if (!(e instanceof SubjectResolutionError))
|
|
264
|
+
throw e;
|
|
265
|
+
throw new Error(`catalog event subject unresolvable: ${e.message} (fix aggregate_type/tenant_id or the piiFields declaration)`);
|
|
266
|
+
}
|
|
257
267
|
if (subject === null)
|
|
258
268
|
continue;
|
|
259
269
|
const outcome = await encryptField(payload, field, subject);
|
|
@@ -109,8 +109,8 @@ export async function assertLiveTableHasNoRowLevelSecurity(tx, tableName, projec
|
|
|
109
109
|
throw new Error(`${context}: live table "${tableName}" has row level security (enabled: ${row.rls_enabled}, ` +
|
|
110
110
|
`forced: ${row.rls_forced}, policies: ${row.policy_count}). The shadow swap rebuilds the table ` +
|
|
111
111
|
"from EntityTableMeta and would silently drop RLS and every policy. Kumiko does not support RLS " +
|
|
112
|
-
"on rebuildable tables
|
|
113
|
-
"table untouched.");
|
|
112
|
+
"on rebuildable tables: drop the RLS flags/policies on this table or exclude it from online " +
|
|
113
|
+
"rebuild. Rebuild aborted; live table untouched.");
|
|
114
114
|
}
|
|
115
115
|
// Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
|
|
116
116
|
// Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
|
|
@@ -227,8 +227,9 @@ export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName
|
|
|
227
227
|
throw new Error(`projection-rebuild "${projectionName}": "${tableName}" has ${count} row(s) with a populated ` +
|
|
228
228
|
`blind-index column, but KUMIKO_BLIND_INDEX_KEY is not configured in this process. The rebuild ` +
|
|
229
229
|
`would recompute those columns to NULL, and equality lookups on that field (login, password ` +
|
|
230
|
-
`reset) would stop matching afterward. Configure the
|
|
231
|
-
`
|
|
230
|
+
`reset) would stop matching afterward. Configure the complete KMS wiring (PLATFORM_KEK, ` +
|
|
231
|
+
`SUBJECT_KEYS_DATABASE_URL, KUMIKO_BLIND_INDEX_KEY, all-or-none) in the process running this ` +
|
|
232
|
+
`apply/rebuild. See fw#3091. Rebuild aborted; live table untouched.`);
|
|
232
233
|
}
|
|
233
234
|
// Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
|
|
234
235
|
// live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
|
|
@@ -5,6 +5,7 @@ import type { EntityDefinition, EntityRelations, FieldDefinition, FieldsMap } fr
|
|
|
5
5
|
import type { Table } from "./event-store-executor-context.js";
|
|
6
6
|
export { toSnakeCase } from "../utils/case.js";
|
|
7
7
|
import { type ColumnBuilder, type ColumnHandle, type SchemaTable, type TableColumns } from "./dialect.js";
|
|
8
|
+
export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
|
|
8
9
|
export declare function physicalColumnName(table: Table, field: string): string;
|
|
9
10
|
export type AnyPgColumn = ColumnHandle;
|
|
10
11
|
export declare const READ_MODEL_PREFIX = "read_";
|
package/dist/db/table-builder.js
CHANGED
|
@@ -2,7 +2,10 @@ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
|
|
|
2
2
|
import { toSnakeCase } from "../utils/case.js";
|
|
3
3
|
import { assertUnreachable } from "../utils/index.js";
|
|
4
4
|
export { toSnakeCase } from "../utils/case.js";
|
|
5
|
-
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate,
|
|
5
|
+
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
|
|
6
|
+
// Partial-index predicate for soft-delete entities; table-builder and
|
|
7
|
+
// entity-table-meta must render identical index definitions.
|
|
8
|
+
export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
|
|
6
9
|
// Shared by the CRUD executor and the parent-ref read-gate — a field's
|
|
7
10
|
// declared column name if it has one, else its snake_case default.
|
|
8
11
|
export function physicalColumnName(table, field) {
|
|
@@ -382,13 +385,7 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
382
385
|
else if (def.unique === true && entity.softDelete === true) {
|
|
383
386
|
// Soft-deleted rows keep their value, so a full unique index would
|
|
384
387
|
// permanently block reuse; kept in lock-step with entity-table-meta.ts (framework#2593).
|
|
385
|
-
|
|
386
|
-
kind: "sql-expr",
|
|
387
|
-
text: `"is_deleted" = false`,
|
|
388
|
-
params: [],
|
|
389
|
-
[SQL_EXPR_BRAND]: true,
|
|
390
|
-
};
|
|
391
|
-
chain = chain.where(softDeleteWhere);
|
|
388
|
+
chain = chain.where(sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE));
|
|
392
389
|
}
|
|
393
390
|
indexes[indexName] = chain;
|
|
394
391
|
// Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
|
|
@@ -407,14 +404,9 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
407
404
|
// restored row can't reuse a value a soft-deleted sibling still
|
|
408
405
|
// holds (framework#2464).
|
|
409
406
|
const whereText = (entity.softDelete === true
|
|
410
|
-
? [...notNullParts,
|
|
407
|
+
? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
|
|
411
408
|
: notNullParts).join(" AND ");
|
|
412
|
-
const partialWhere =
|
|
413
|
-
kind: "sql-expr",
|
|
414
|
-
text: whereText,
|
|
415
|
-
params: [],
|
|
416
|
-
[SQL_EXPR_BRAND]: true,
|
|
417
|
-
};
|
|
409
|
+
const partialWhere = sql.raw(whereText);
|
|
418
410
|
indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
|
|
419
411
|
.on(...bidxCols)
|
|
420
412
|
.where(partialWhere);
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -3,8 +3,7 @@ import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-t
|
|
|
3
3
|
import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
|
-
import { fallbackEscapeHatchReporter } from "../observability/
|
|
7
|
-
import { emitDbQuery, registerStandardMetrics, } from "../observability/index.js";
|
|
6
|
+
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
8
7
|
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
|
|
9
8
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
10
9
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
@@ -361,6 +360,17 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
361
360
|
return undefined;
|
|
362
361
|
return new AccessDeniedError({ message });
|
|
363
362
|
}
|
|
363
|
+
// A tenant-mode write against a "global" table can never match (rows carry SYSTEM_TENANT_ID, writeWhere pins the caller's tenant), so reject loudly instead of silently affecting zero rows.
|
|
364
|
+
function tenantWriteOnGlobalTable(table, method) {
|
|
365
|
+
if (mode !== "tenant" || !hasTenantColumn(table))
|
|
366
|
+
return undefined;
|
|
367
|
+
if (asEntityTableMeta(table)?.tenancy !== "global")
|
|
368
|
+
return undefined;
|
|
369
|
+
return new AccessDeniedError({
|
|
370
|
+
message: `${method}(${tableNameOf(table)}): "global" table rows carry SYSTEM_TENANT_ID and never match the caller's tenant; ` +
|
|
371
|
+
"use db.global(table) with escapeHatch instead.",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
364
374
|
function globalTable(table) {
|
|
365
375
|
const meta = asEntityTableMeta(table);
|
|
366
376
|
if (meta?.tenancy !== "global") {
|
|
@@ -466,6 +476,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
466
476
|
if (!where || Object.keys(where).length === 0) {
|
|
467
477
|
return Promise.reject(new Error("TenantDb.updateMany without where would mass-update all tenant rows. Pass at least one where condition."));
|
|
468
478
|
}
|
|
479
|
+
const globalDenied = tenantWriteOnGlobalTable(table, "updateMany");
|
|
480
|
+
if (globalDenied)
|
|
481
|
+
return Promise.reject(globalDenied);
|
|
469
482
|
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
470
483
|
if (personalDenied)
|
|
471
484
|
return Promise.reject(personalDenied);
|
|
@@ -476,6 +489,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
476
489
|
if (!where || Object.keys(where).length === 0) {
|
|
477
490
|
return Promise.reject(new Error("TenantDb.deleteMany without where would mass-delete all tenant rows. Pass at least one where condition."));
|
|
478
491
|
}
|
|
492
|
+
const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
|
|
493
|
+
if (globalDenied)
|
|
494
|
+
return Promise.reject(globalDenied);
|
|
479
495
|
const filter = writeWhere(table, where);
|
|
480
496
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
|
|
481
497
|
},
|
|
@@ -34,7 +34,9 @@ export function variantSuffix(name, spec) {
|
|
|
34
34
|
// A full derivative-suffix segment is `<name>-<16 hex chars>` — mirrors
|
|
35
35
|
// VARIANT_NAME_PATTERN (name grammar) + specHash's fixed 16-char slice.
|
|
36
36
|
// Keep in sync with both if either changes.
|
|
37
|
-
|
|
37
|
+
// The hash part stays case-sensitive (specHash emits lowercase hex only), so
|
|
38
|
+
// the erasure sweep never matches a differently-cased sibling file.
|
|
39
|
+
const DERIVATIVE_SUFFIX_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}-[0-9a-f]{16}$/;
|
|
38
40
|
// Mirrors deriveKey's own split so callers get the exact same base/ext this
|
|
39
41
|
// key's derivatives were built from.
|
|
40
42
|
function splitKey(key) {
|