@cosmicdrift/kumiko-framework 0.362.0 → 0.363.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-middleware.js +7 -5
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +2 -2
- package/dist/bun-db/query.d.ts +6 -4
- package/dist/bun-db/query.js +66 -92
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.js +6 -6
- package/dist/db/event-store-executor-read.js +9 -9
- package/dist/db/migrate-generator.js +5 -3
- package/dist/db/migrate-runner.js +31 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db.d.ts +3 -2
- package/dist/db/tenant-db.js +4 -10
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/entity-list-screens.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +1 -1
- package/dist/engine/boot-validator/screens.js +2 -1
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.js +3 -6
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +6 -3
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +1 -3
- package/dist/engine/soft-delete-cleanup.js +2 -1
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
- package/dist/engine/template-variables.d.ts +29 -0
- package/dist/engine/template-variables.js +77 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/files/file-routes.js +1 -1
- package/dist/files/local-provider.js +2 -1
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/job-runner.js +5 -5
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.js +45 -14
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/search/meilisearch-adapter.js +3 -4
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +4 -4
- package/src/changes.json +44 -0
package/dist/db/dialect.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// crashed on timestamptz writes without it (#1480).
|
|
19
19
|
import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
20
20
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
21
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
21
22
|
function isNumericPgType(t) {
|
|
22
23
|
return t.startsWith("numeric(");
|
|
23
24
|
}
|
|
@@ -76,9 +77,8 @@ function buildColumn(sqlName, pgType, opts) {
|
|
|
76
77
|
return value ? "true" : "false";
|
|
77
78
|
if (typeof value === "bigint")
|
|
78
79
|
return value.toString();
|
|
79
|
-
if (value
|
|
80
|
+
if (isSqlExpression(value))
|
|
80
81
|
return value.text;
|
|
81
|
-
}
|
|
82
82
|
if (typeof value === "function")
|
|
83
83
|
return null; // function-defaults stay JS-side
|
|
84
84
|
// Object/array → jsonb literal
|
|
@@ -256,6 +256,13 @@ export function primaryKey(opts) {
|
|
|
256
256
|
// `{ kind: "sql-expr" }` from a client-controlled schema definition is never
|
|
257
257
|
// spliced into SQL text anywhere.
|
|
258
258
|
export const SQL_EXPR_BRAND = Symbol("sql-expr");
|
|
259
|
+
export function isSqlExpression(value) {
|
|
260
|
+
return (typeof value === "object" &&
|
|
261
|
+
value !== null &&
|
|
262
|
+
SQL_EXPR_BRAND in value &&
|
|
263
|
+
"text" in value &&
|
|
264
|
+
typeof value.text === "string");
|
|
265
|
+
}
|
|
259
266
|
export function sql(strings, ...values) {
|
|
260
267
|
const parts = [];
|
|
261
268
|
const params = [];
|
|
@@ -263,7 +270,7 @@ export function sql(strings, ...values) {
|
|
|
263
270
|
parts.push(strings[i] ?? "");
|
|
264
271
|
if (i < values.length) {
|
|
265
272
|
const v = values[i];
|
|
266
|
-
if (v
|
|
273
|
+
if (isSqlExpression(v)) {
|
|
267
274
|
parts.push(v.text);
|
|
268
275
|
}
|
|
269
276
|
else {
|
|
@@ -279,6 +286,15 @@ sql.raw = (text) => ({
|
|
|
279
286
|
params: [],
|
|
280
287
|
[SQL_EXPR_BRAND]: true,
|
|
281
288
|
});
|
|
289
|
+
function isIndexOrPkList(opts) {
|
|
290
|
+
return Array.isArray(opts);
|
|
291
|
+
}
|
|
292
|
+
function isPrimaryKeyDescriptor(value) {
|
|
293
|
+
return "__pk" in value && value.__pk === true;
|
|
294
|
+
}
|
|
295
|
+
function isIndexBuilderWithCols(value) {
|
|
296
|
+
return "__index" in value && value.__index === true;
|
|
297
|
+
}
|
|
282
298
|
export function table(tableName, cols, optsFn) {
|
|
283
299
|
// Finalise columns + build the column-handle map.
|
|
284
300
|
const handles = {};
|
|
@@ -316,25 +332,24 @@ export function table(tableName, cols, optsFn) {
|
|
|
316
332
|
if (optsFn) {
|
|
317
333
|
const tHandle = handles;
|
|
318
334
|
const opts = optsFn(tHandle);
|
|
319
|
-
const entries =
|
|
335
|
+
const entries = isIndexOrPkList(opts)
|
|
320
336
|
? opts.map((v, i) => [String(i), v])
|
|
321
337
|
: Object.entries(opts);
|
|
322
338
|
for (const [key, value] of entries) {
|
|
323
339
|
if (!value)
|
|
324
340
|
continue;
|
|
325
|
-
if (
|
|
341
|
+
if (isPrimaryKeyDescriptor(value)) {
|
|
326
342
|
compositePrimaryKey = {
|
|
327
343
|
name: value.name ?? `${tableName}_pk`,
|
|
328
344
|
columns: value.columns,
|
|
329
345
|
};
|
|
330
346
|
}
|
|
331
|
-
else if (
|
|
332
|
-
const idx = value;
|
|
347
|
+
else if (isIndexBuilderWithCols(value)) {
|
|
333
348
|
indexes.push({
|
|
334
|
-
name:
|
|
335
|
-
columns:
|
|
336
|
-
...(
|
|
337
|
-
...(
|
|
349
|
+
name: value.name,
|
|
350
|
+
columns: value.columns,
|
|
351
|
+
...(value.unique && { unique: true }),
|
|
352
|
+
...(value.whereSql !== undefined && { whereSql: value.whereSql }),
|
|
338
353
|
});
|
|
339
354
|
}
|
|
340
355
|
else {
|
|
@@ -367,8 +382,8 @@ export function extractTableName(table, context = "extractTableName") {
|
|
|
367
382
|
if (typeof table !== "object" || table === null) {
|
|
368
383
|
throw new Error(`${context}: table is not an object`);
|
|
369
384
|
}
|
|
370
|
-
const name = table
|
|
371
|
-
if (
|
|
385
|
+
const name = schemaTableName(table);
|
|
386
|
+
if (name === undefined) {
|
|
372
387
|
throw new Error(`${context}: table missing kumiko:schema:Name symbol`);
|
|
373
388
|
}
|
|
374
389
|
return name;
|
|
@@ -17,7 +17,7 @@ export function entityTableFromRegistry(registry, entityName, entity) {
|
|
|
17
17
|
continue;
|
|
18
18
|
if (projection.source !== entityName)
|
|
19
19
|
continue;
|
|
20
|
-
// @cast-boundary
|
|
20
|
+
// @cast-boundary projection-table — ProjectionDefinition.table is the
|
|
21
21
|
// untyped table object every projection registrar accepts.
|
|
22
22
|
return projection.table;
|
|
23
23
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
18
|
import { collectPiiEncryptedFields } from "../crypto/index.js";
|
|
19
|
-
import {
|
|
19
|
+
import { isSqlExpression } from "./dialect.js";
|
|
20
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).
|
|
@@ -226,9 +226,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
226
226
|
// Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
|
|
227
227
|
// feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
|
|
228
228
|
// `record:<entity>:<id>`; forgetSubject requires a UUID, so the value could never be shredded.
|
|
229
|
-
const recordOwnedField = Object.entries(entity.fields).find(
|
|
230
|
-
// @cast-boundary schema-walk — PII flags live on the resolved field annotation
|
|
231
|
-
([, field]) => field.recordOwned === true);
|
|
229
|
+
const recordOwnedField = Object.entries(entity.fields).find(([, field]) => "recordOwned" in field && field.recordOwned === true);
|
|
232
230
|
if (recordOwnedField) {
|
|
233
231
|
throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
|
|
234
232
|
}
|
|
@@ -302,7 +300,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
302
300
|
// Explicitly declared indexes (EntityIndexDef). `def.where` is a
|
|
303
301
|
// SqlExpression (`sql\`…\`` from @cosmicdrift/kumiko-framework/db) —
|
|
304
302
|
// renderable via `.text`. Unknown where-shapes stay needsManualWhere.
|
|
305
|
-
for (const def of
|
|
303
|
+
for (const def of entity.indexes ?? []) {
|
|
306
304
|
const cols = def.columns.map((fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName));
|
|
307
305
|
const suffix = def.unique === true ? "unique" : "idx";
|
|
308
306
|
const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
|
|
@@ -358,13 +356,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
358
356
|
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
359
357
|
export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
360
358
|
function sqlExpressionText(where) {
|
|
361
|
-
|
|
362
|
-
where !== null &&
|
|
363
|
-
SQL_EXPR_BRAND in where &&
|
|
364
|
-
typeof where.text === "string") {
|
|
365
|
-
return where.text;
|
|
366
|
-
}
|
|
367
|
-
return undefined;
|
|
359
|
+
return isSqlExpression(where) ? where.text : undefined;
|
|
368
360
|
}
|
|
369
361
|
// Validates that a backing Drizzle table (declared via `r.entity(name, def,
|
|
370
362
|
// { table })`) is a SUPERSET of the field-derived meta: every column the
|
|
@@ -423,7 +415,7 @@ export function defineUnmanagedTable(input) {
|
|
|
423
415
|
}),
|
|
424
416
|
source: "unmanaged",
|
|
425
417
|
...(input.tenancy === "global" && { tenancy: "global" }),
|
|
426
|
-
// @cast-boundary
|
|
418
|
+
// @cast-boundary phantom-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
|
|
427
419
|
};
|
|
428
420
|
}
|
|
429
421
|
function assertUnmanagedTableName(tableName, via) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { requestContext } from "../api/request-context.js";
|
|
3
2
|
import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
4
3
|
import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
|
|
@@ -12,6 +11,7 @@ import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
|
12
11
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
13
12
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
14
13
|
import { toSnakeCase } from "./table-builder.js";
|
|
14
|
+
import { columnHandleName, schemaTableName } from "./table-introspection.js";
|
|
15
15
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
16
16
|
// Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
|
|
17
17
|
// Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
|
|
@@ -312,7 +312,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
312
312
|
}
|
|
313
313
|
// ownership has raw SQL — splice it into a raw query alongside the
|
|
314
314
|
// idFilter + tenant-filter that TenantDb would have added.
|
|
315
|
-
const tableName = String(table
|
|
315
|
+
const tableName = String(schemaTableName(table));
|
|
316
316
|
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
317
317
|
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
318
318
|
return [
|
|
@@ -323,7 +323,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
323
323
|
// TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
|
|
324
324
|
// `isDeleted`, so no null/IS NULL branch is needed.
|
|
325
325
|
function buildRawIdWhere(db, idWhere, ownership) {
|
|
326
|
-
const colSql = (field) => `"${table
|
|
326
|
+
const colSql = (field) => `"${columnHandleName(table, field) ?? toSnakeCase(field)}"`;
|
|
327
327
|
const whereParts = [];
|
|
328
328
|
const params = [];
|
|
329
329
|
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
@@ -357,7 +357,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
357
357
|
async function loadDetailSnapshot(db, idWhere, ownership, streamTenantId, aggregateId) {
|
|
358
358
|
if (ownership.kind === "empty")
|
|
359
359
|
return { row: null, streamVersion: 0 };
|
|
360
|
-
const tableName = String(table
|
|
360
|
+
const tableName = String(schemaTableName(table));
|
|
361
361
|
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
362
362
|
params.push(aggregateId, streamTenantId);
|
|
363
363
|
const sqlText = `SELECT *, ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
|
|
@@ -393,8 +393,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
396
|
-
const columnOf = (field) => quote(table
|
|
397
|
-
const tableName = String(table
|
|
396
|
+
const columnOf = (field) => quote(columnHandleName(table, field) ?? toSnakeCase(field));
|
|
397
|
+
const tableName = String(schemaTableName(table));
|
|
398
398
|
const selectCols = expectKeys.map((key) => `${columnOf(key)} AS ${quote(key)}`);
|
|
399
399
|
const whereParts = [`${columnOf("id")} = $1`];
|
|
400
400
|
const params = [id];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
2
|
import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
|
|
3
3
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
@@ -14,6 +14,7 @@ import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-en
|
|
|
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";
|
|
17
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
17
18
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
18
19
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
19
20
|
// of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
|
|
@@ -57,7 +58,7 @@ function toCursorSortText(value) {
|
|
|
57
58
|
if (value instanceof Date)
|
|
58
59
|
return value.toISOString();
|
|
59
60
|
if (typeof value === "object") {
|
|
60
|
-
const tag = value
|
|
61
|
+
const tag = Reflect.get(value, Symbol.toStringTag);
|
|
61
62
|
if (typeof tag === "string" && tag.startsWith("Temporal."))
|
|
62
63
|
return String(value);
|
|
63
64
|
return JSON.stringify(value);
|
|
@@ -154,9 +155,8 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
|
154
155
|
});
|
|
155
156
|
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
156
157
|
}
|
|
157
|
-
else if (
|
|
158
|
-
|
|
159
|
-
if (valueObj["ne"] === null && Object.keys(valueObj).length === 1) {
|
|
158
|
+
else if (isPlainObject(value)) {
|
|
159
|
+
if (value["ne"] === null && Object.keys(value).length === 1) {
|
|
160
160
|
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
161
161
|
continue;
|
|
162
162
|
}
|
|
@@ -362,7 +362,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
362
362
|
return undefined;
|
|
363
363
|
}
|
|
364
364
|
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
365
|
-
const targetTableName = String(targetTable
|
|
365
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
366
366
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
367
367
|
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
368
368
|
}
|
|
@@ -419,7 +419,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
419
419
|
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
420
420
|
if (ownership.kind === "empty")
|
|
421
421
|
return undefined;
|
|
422
|
-
const targetTableName = String(targetTable
|
|
422
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
423
423
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
424
424
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
425
425
|
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
@@ -456,7 +456,7 @@ export function createReadVerbs(ctx) {
|
|
|
456
456
|
const runner = tenantDbRunner(db);
|
|
457
457
|
const { limit, offset } = resolveListPagination(payload);
|
|
458
458
|
const totalCount = payload.totalCount === true;
|
|
459
|
-
const tableName = String(table
|
|
459
|
+
const tableName = String(schemaTableName(table));
|
|
460
460
|
// H.2 — entity-level read ownership, AND-ed with the parent-ref
|
|
461
461
|
// read-gate (join-row entities derive their visibility from a host
|
|
462
462
|
// row's own read path — see db/parent-ref-clause.ts). Decide before
|
|
@@ -709,7 +709,7 @@ export function createReadVerbs(ctx) {
|
|
|
709
709
|
};
|
|
710
710
|
},
|
|
711
711
|
async detail(payload, user, db, options) {
|
|
712
|
-
const tableName = String(table
|
|
712
|
+
const tableName = String(schemaTableName(table));
|
|
713
713
|
// H.2 — ownership check, AND-ed with the parent-ref read-gate (see
|
|
714
714
|
// db/parent-ref-clause.ts). `empty` → the user can never see this row
|
|
715
715
|
// regardless of its id. Return null (same shape as "not found", so a
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// uncommenten + ggf. Data-Backup-Schritt vorlauf einfügen. Default:
|
|
16
16
|
// niemals automatisch droppen.
|
|
17
17
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { errorCodeOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
18
19
|
import { compareByCodepoint } from "../utils/index.js";
|
|
19
20
|
import { renderIndex, renderTableDdl } from "./render-ddl.js";
|
|
20
21
|
import { findRetiredTableDrops } from "./retired-framework-tables.js";
|
|
@@ -36,13 +37,14 @@ export function loadSnapshotJson(path) {
|
|
|
36
37
|
try {
|
|
37
38
|
const text = readFileSync(path, "utf8");
|
|
38
39
|
const parsed = JSON.parse(text);
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const version = isPlainObject(parsed) ? parsed["version"] : undefined;
|
|
41
|
+
if (version !== SNAPSHOT_VERSION) {
|
|
42
|
+
throw new Error(`Snapshot at ${path} has version ${String(version)}, expected ${SNAPSHOT_VERSION}`);
|
|
41
43
|
}
|
|
42
44
|
return parsed;
|
|
43
45
|
}
|
|
44
46
|
catch (err) {
|
|
45
|
-
if (err
|
|
47
|
+
if (errorCodeOf(err) === "ENOENT")
|
|
46
48
|
return null;
|
|
47
49
|
throw err;
|
|
48
50
|
}
|
|
@@ -16,25 +16,8 @@
|
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// runtime call.
|
|
22
|
-
function rawClient(db) {
|
|
23
|
-
const dbAny = db;
|
|
24
|
-
if (typeof dbAny["unsafe"] === "function" && typeof dbAny["begin"] === "function") {
|
|
25
|
-
return dbAny;
|
|
26
|
-
}
|
|
27
|
-
const $client = dbAny["$client"];
|
|
28
|
-
if ($client && typeof $client["unsafe"] === "function") {
|
|
29
|
-
return $client;
|
|
30
|
-
}
|
|
31
|
-
const session = dbAny["session"];
|
|
32
|
-
const sessionClient = session?.["client"];
|
|
33
|
-
if (sessionClient && typeof sessionClient["unsafe"] === "function") {
|
|
34
|
-
return sessionClient;
|
|
35
|
-
}
|
|
36
|
-
throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
|
|
37
|
-
}
|
|
19
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
20
|
+
import { asRawClient } from "../bun-db/query.js";
|
|
38
21
|
// Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
|
|
39
22
|
// gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
|
|
40
23
|
// blockiert bis erster fertig).
|
|
@@ -181,24 +164,35 @@ export function readMigrationSqlTexts(dir) {
|
|
|
181
164
|
.sort()
|
|
182
165
|
.map((file) => readFileSync(join(dir, file), "utf8"));
|
|
183
166
|
}
|
|
167
|
+
// Stricter than asRawClient on purpose: a direct handle must also offer
|
|
168
|
+
// begin(), because runMigrations wraps every file in a transaction. Handles
|
|
169
|
+
// without it fall through to the drizzle wrappers and otherwise fail here,
|
|
170
|
+
// before any SQL runs, with the migrate-runner message.
|
|
171
|
+
function migrationClient(db) {
|
|
172
|
+
const hasDirectClient = typeof propertyOf(db, "unsafe") === "function" && typeof propertyOf(db, "begin") === "function";
|
|
173
|
+
if (hasDirectClient)
|
|
174
|
+
return asRawClient(db);
|
|
175
|
+
for (const wrapped of [
|
|
176
|
+
propertyOf(db, "$client"),
|
|
177
|
+
propertyOf(propertyOf(db, "session"), "client"),
|
|
178
|
+
]) {
|
|
179
|
+
if (typeof propertyOf(wrapped, "unsafe") === "function")
|
|
180
|
+
return asRawClient(wrapped);
|
|
181
|
+
}
|
|
182
|
+
throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
|
|
183
|
+
}
|
|
184
184
|
// Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
|
|
185
185
|
async function executeRaw(db, sqlText) {
|
|
186
|
-
await
|
|
186
|
+
await migrationClient(db).unsafe(sqlText);
|
|
187
187
|
}
|
|
188
188
|
export async function fetchAppliedMigrations(db) {
|
|
189
|
-
const result = await
|
|
189
|
+
const result = await migrationClient(db).unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
|
|
190
190
|
const rows = Array.isArray(result) ? result : [];
|
|
191
191
|
const applied = [];
|
|
192
192
|
for (const row of rows) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
typeof row.checksum === "string") {
|
|
197
|
-
applied.push({
|
|
198
|
-
id: row.id,
|
|
199
|
-
checksum: row.checksum,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
193
|
+
const { id, checksum } = row;
|
|
194
|
+
if (typeof id === "string" && typeof checksum === "string")
|
|
195
|
+
applied.push({ id, checksum });
|
|
202
196
|
}
|
|
203
197
|
return applied;
|
|
204
198
|
}
|
|
@@ -239,13 +233,15 @@ export async function runMigrations(db, migrations) {
|
|
|
239
233
|
// Apply file content + INSERT tracking-row in einer TX. Wenn ein
|
|
240
234
|
// Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
|
|
241
235
|
// stuck in den books.
|
|
242
|
-
const client =
|
|
236
|
+
const client = migrationClient(db);
|
|
243
237
|
await client.begin(async (tx) => {
|
|
244
|
-
const txClient = tx;
|
|
245
238
|
for (const stmt of m.statements) {
|
|
246
|
-
await
|
|
239
|
+
await tx.unsafe(stmt);
|
|
247
240
|
}
|
|
248
|
-
await
|
|
241
|
+
await tx.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
|
|
242
|
+
m.id,
|
|
243
|
+
m.checksum,
|
|
244
|
+
]);
|
|
249
245
|
});
|
|
250
246
|
appliedIds.push(m.id);
|
|
251
247
|
}
|
|
@@ -270,7 +266,7 @@ export async function baselineMigrations(db, migrations) {
|
|
|
270
266
|
const applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
|
|
271
267
|
const marked = [];
|
|
272
268
|
const alreadyTracked = [];
|
|
273
|
-
const client =
|
|
269
|
+
const client = migrationClient(db);
|
|
274
270
|
for (const m of migrations) {
|
|
275
271
|
if (applied.has(m.id)) {
|
|
276
272
|
alreadyTracked.push(m.id);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { buildOwnershipClause } from "../engine/ownership.js";
|
|
3
2
|
import { SYSTEM_TENANT_ID, UUID_SHAPE_PATTERN } from "../engine/types/identifiers.js";
|
|
4
3
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
4
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
5
5
|
const candidateStaticsCache = new WeakMap();
|
|
6
6
|
function candidateStaticsFor(candidateName, candidate) {
|
|
7
7
|
const cached = candidateStaticsCache.get(candidate);
|
|
8
8
|
if (cached)
|
|
9
9
|
return cached;
|
|
10
10
|
const table = buildEntityTable(candidateName, candidate);
|
|
11
|
-
const tableName = String(table
|
|
11
|
+
const tableName = String(schemaTableName(table));
|
|
12
12
|
const statics = {
|
|
13
13
|
table,
|
|
14
14
|
tableName,
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
// plaintext); the next snapshotting load recreates them. AFTER a run, rebuild the
|
|
50
50
|
// affected projections — applyEntityEvent materializes ciphertext AND the
|
|
51
51
|
// blind-index columns, which keeps equality lookups (login by email) alive.
|
|
52
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
52
53
|
import { asRawClient } from "../../bun-db/index.js";
|
|
53
54
|
import { quoteIdent } from "../../crypto/ciphertext-pattern.js";
|
|
54
55
|
import { configuredEventPiiCatalog } from "../../crypto/event-pii.js";
|
|
@@ -127,6 +128,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
127
128
|
const { entityTargets, eventCatalog } = collectPiiTargets(registry);
|
|
128
129
|
const aggregateTypes = [...entityTargets.keys()];
|
|
129
130
|
const catalogTypes = [...eventCatalog.keys()];
|
|
131
|
+
const failures = [];
|
|
130
132
|
const result = {
|
|
131
133
|
scannedEvents: 0,
|
|
132
134
|
updatedEvents: 0,
|
|
@@ -136,7 +138,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
136
138
|
ownerFromProjection: 0,
|
|
137
139
|
erasedUnresolvable: 0,
|
|
138
140
|
deletedSnapshots: 0,
|
|
139
|
-
failures
|
|
141
|
+
failures,
|
|
140
142
|
};
|
|
141
143
|
const emptyBatch = () => ({
|
|
142
144
|
...result,
|
|
@@ -148,7 +150,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
148
150
|
});
|
|
149
151
|
if (aggregateTypes.length === 0 && catalogTypes.length === 0)
|
|
150
152
|
return emptyBatch();
|
|
151
|
-
const rows =
|
|
153
|
+
const rows = await (options.eventIds
|
|
152
154
|
? raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
|
|
153
155
|
FROM "kumiko_events"
|
|
154
156
|
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" = ANY($3::bigint[])
|
|
@@ -157,7 +159,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
157
159
|
FROM "kumiko_events"
|
|
158
160
|
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
|
|
159
161
|
ORDER BY "id" ASC
|
|
160
|
-
LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize]))
|
|
162
|
+
LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize]));
|
|
161
163
|
const last = rows[rows.length - 1];
|
|
162
164
|
if (last === undefined)
|
|
163
165
|
return emptyBatch();
|
|
@@ -203,7 +205,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
203
205
|
}
|
|
204
206
|
// Snapshots may cache the plaintext state of touched aggregates.
|
|
205
207
|
if (!options.dryRun && touchedAggregates.size > 0) {
|
|
206
|
-
const deleted =
|
|
208
|
+
const deleted = await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [[...touchedAggregates]]);
|
|
207
209
|
result.deletedSnapshots = deleted.length;
|
|
208
210
|
}
|
|
209
211
|
return {
|
|
@@ -222,7 +224,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
222
224
|
// Pre-KMS forgets left no key tombstone — the *.forgotten event on the
|
|
223
225
|
// stream is the only durable marker. aggregate_id doubles as the user id
|
|
224
226
|
// for user-subject lookups.
|
|
225
|
-
const forgottenRows =
|
|
227
|
+
const forgottenRows = await raw.unsafe(`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`);
|
|
226
228
|
scanCache.forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
|
|
227
229
|
return scanCache.forgottenAggregates;
|
|
228
230
|
}
|
|
@@ -248,7 +250,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
248
250
|
// aborts the surrounding transaction (25P02). Entity never mounted/rebuilt
|
|
249
251
|
// → stage 3 still gets a chance; this batch just contributes no owners.
|
|
250
252
|
if (await tableExists(db, `public.${tableName}`)) {
|
|
251
|
-
const projectionRows =
|
|
253
|
+
const projectionRows = await raw.unsafe(`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`, [[...ids]]);
|
|
252
254
|
for (const projectionRow of projectionRows) {
|
|
253
255
|
const id = projectionRow["id"];
|
|
254
256
|
if (typeof id === "string") {
|
|
@@ -273,7 +275,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
273
275
|
erasedUnresolvable: 0,
|
|
274
276
|
};
|
|
275
277
|
const payload = structuredClone(row.payload);
|
|
276
|
-
const tenantId = row.tenant_id;
|
|
278
|
+
const tenantId = row.tenant_id;
|
|
277
279
|
const catalogFields = eventCatalog.get(row.type);
|
|
278
280
|
if (catalogFields) {
|
|
279
281
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
@@ -443,7 +445,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
443
445
|
async function loadDestroyedTenants() {
|
|
444
446
|
if (scanCache.destroyedTenants)
|
|
445
447
|
return scanCache.destroyedTenants;
|
|
446
|
-
const rows =
|
|
448
|
+
const rows = await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`);
|
|
447
449
|
scanCache.destroyedTenants = new Set(rows.map((r) => r.id));
|
|
448
450
|
return scanCache.destroyedTenants;
|
|
449
451
|
}
|
|
@@ -557,7 +559,7 @@ function isLifecycleEventOf(eventType, aggregateType) {
|
|
|
557
559
|
if (!eventType.startsWith(`${aggregateType}.`))
|
|
558
560
|
return false;
|
|
559
561
|
const verb = eventType.slice(aggregateType.length + 1);
|
|
560
|
-
return LIFECYCLE_VERBS
|
|
562
|
+
return isOneOf(LIFECYCLE_VERBS, verb);
|
|
561
563
|
}
|
|
562
564
|
// created events carry the fields flat; updated carries { changes, previous };
|
|
563
565
|
// deleted/forgotten/restored carry { previous }. Returned sections are the
|
|
@@ -5,6 +5,6 @@ export async function acquireEsOpsAdvisoryLock(db) {
|
|
|
5
5
|
await asRawClient(db).unsafe(`SELECT pg_advisory_xact_lock($1)`, [ES_OPS_ADVISORY_LOCK_KEY]);
|
|
6
6
|
}
|
|
7
7
|
export async function esOperationExists(db, operationId) {
|
|
8
|
-
const rows =
|
|
8
|
+
const rows = await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_es_operations" WHERE id = $1 LIMIT 1`, [operationId]);
|
|
9
9
|
return rows.length > 0;
|
|
10
10
|
}
|
|
@@ -13,7 +13,7 @@ export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readon
|
|
|
13
13
|
readonly name: string;
|
|
14
14
|
readonly instanceId: string;
|
|
15
15
|
}>>;
|
|
16
|
-
export declare function selectConsumerForUpdateSkipLocked(db: AnyDb, name: string, instanceId: string): Promise<
|
|
16
|
+
export declare function selectConsumerForUpdateSkipLocked<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string): Promise<TRow | undefined>;
|
|
17
17
|
export declare function markConsumerProcessing(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
18
18
|
export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string, cursorBeforePass?: bigint): Promise<void>;
|
|
19
19
|
export type ConsumerDeliveryOutcome = {
|
|
@@ -26,11 +26,11 @@ export type ConsumerDeliveryOutcome = {
|
|
|
26
26
|
readonly failedEventId: bigint | null;
|
|
27
27
|
};
|
|
28
28
|
export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
|
|
29
|
-
export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<
|
|
30
|
-
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<
|
|
29
|
+
export declare function updateConsumerStatusReturning<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<TRow | undefined>;
|
|
30
|
+
export declare function advanceConsumerPastEventReturning<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<TRow | undefined>;
|
|
31
31
|
export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
32
|
-
export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<
|
|
32
|
+
export declare function selectConsumerForUpdate<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string): Promise<TRow | undefined>;
|
|
33
33
|
export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
|
|
34
34
|
export declare function markConsumerRebuildFailed(db: AnyDb, name: string, instanceId: string, errorMessage: string): Promise<void>;
|
|
35
|
-
export declare function rearmDeadConsumer(db: AnyDb, name: string, instanceId: string): Promise<
|
|
36
|
-
export declare function removePendingGapReturning(db: AnyDb, name: string, instanceId: string, newPendingGaps: readonly PendingGapEntry[]): Promise<
|
|
35
|
+
export declare function rearmDeadConsumer<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string): Promise<TRow | undefined>;
|
|
36
|
+
export declare function removePendingGapReturning<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string, newPendingGaps: readonly PendingGapEntry[]): Promise<TRow | undefined>;
|