@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.
Files changed (108) hide show
  1. package/dist/api/auth-middleware.js +7 -5
  2. package/dist/api/redis-sse-broker.js +10 -8
  3. package/dist/api/routes.js +2 -2
  4. package/dist/bun-db/query.d.ts +6 -4
  5. package/dist/bun-db/query.js +66 -92
  6. package/dist/crypto/kek-source.js +5 -3
  7. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  8. package/dist/crypto/pii-field-encryption.js +2 -2
  9. package/dist/db/apply-entity-event.js +2 -2
  10. package/dist/db/cursor.js +2 -1
  11. package/dist/db/dialect.d.ts +1 -0
  12. package/dist/db/dialect.js +28 -13
  13. package/dist/db/entity-table-from-registry.js +1 -1
  14. package/dist/db/entity-table-meta.js +5 -13
  15. package/dist/db/event-store-executor-context.js +6 -6
  16. package/dist/db/event-store-executor-read.js +9 -9
  17. package/dist/db/migrate-generator.js +5 -3
  18. package/dist/db/migrate-runner.js +31 -35
  19. package/dist/db/parent-ref-clause.js +2 -2
  20. package/dist/db/queries/backfill-pii.js +11 -9
  21. package/dist/db/queries/es-ops.js +1 -1
  22. package/dist/db/queries/event-consumer.d.ts +6 -6
  23. package/dist/db/queries/event-consumer.js +17 -17
  24. package/dist/db/queries/event-store-admin.js +6 -6
  25. package/dist/db/queries/event-store-transfer.js +2 -2
  26. package/dist/db/queries/event-store.d.ts +1 -1
  27. package/dist/db/queries/event-store.js +2 -2
  28. package/dist/db/queries/pii-backfill-state.js +2 -2
  29. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  30. package/dist/db/queries/raw-sql.js +1 -1
  31. package/dist/db/queries/test-stack.js +1 -1
  32. package/dist/db/reference-data.js +3 -5
  33. package/dist/db/table-builder.js +6 -4
  34. package/dist/db/table-introspection.d.ts +16 -0
  35. package/dist/db/table-introspection.js +76 -0
  36. package/dist/db/tenant-db.d.ts +3 -2
  37. package/dist/db/tenant-db.js +4 -10
  38. package/dist/derivatives/variant-key.js +2 -1
  39. package/dist/engine/boot-validator/entity-list-screens.js +1 -1
  40. package/dist/engine/boot-validator/pii-retention.js +1 -1
  41. package/dist/engine/boot-validator/screens.js +2 -1
  42. package/dist/engine/changeset-changes.js +3 -3
  43. package/dist/engine/define-handler.js +3 -6
  44. package/dist/engine/embedded-derived.js +10 -6
  45. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  46. package/dist/engine/factories.js +5 -6
  47. package/dist/engine/feature-ast/extractors/events.js +43 -33
  48. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  49. package/dist/engine/feature-ast/patcher.js +3 -3
  50. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
  51. package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
  52. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  53. package/dist/engine/feature-ast/render.js +6 -3
  54. package/dist/engine/feature-changelog.d.ts +2 -1
  55. package/dist/engine/feature-changelog.js +8 -6
  56. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  57. package/dist/engine/feature-config-events-jobs.js +20 -5
  58. package/dist/engine/feature-ui-extensions.js +2 -3
  59. package/dist/engine/index.d.ts +2 -1
  60. package/dist/engine/index.js +1 -0
  61. package/dist/engine/ownership.js +7 -20
  62. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  63. package/dist/engine/registry-ingest.d.ts +2 -1
  64. package/dist/engine/registry-ingest.js +85 -2
  65. package/dist/engine/registry-validate.d.ts +1 -0
  66. package/dist/engine/registry-validate.js +38 -8
  67. package/dist/engine/registry.js +2 -1
  68. package/dist/engine/schema-builder.js +1 -3
  69. package/dist/engine/soft-delete-cleanup.js +2 -1
  70. package/dist/engine/steps/call-feature.js +1 -3
  71. package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
  72. package/dist/engine/template-variables.d.ts +29 -0
  73. package/dist/engine/template-variables.js +77 -0
  74. package/dist/engine/tier-resolver-extension.js +2 -3
  75. package/dist/engine/types/index.d.ts +2 -2
  76. package/dist/engine/validate-projection-allowlist.js +12 -13
  77. package/dist/engine/where-rule-lint.js +8 -7
  78. package/dist/errors/reasons.d.ts +1 -0
  79. package/dist/errors/reasons.js +2 -0
  80. package/dist/files/file-routes.js +1 -1
  81. package/dist/files/local-provider.js +2 -1
  82. package/dist/i18n/index.d.ts +1 -1
  83. package/dist/i18n/index.js +1 -1
  84. package/dist/i18n/mail-registry.d.ts +2 -0
  85. package/dist/i18n/mail-registry.js +7 -0
  86. package/dist/jobs/job-runner.js +5 -5
  87. package/dist/pipeline/dispatch-query.js +2 -3
  88. package/dist/pipeline/dispatch-shared.js +45 -14
  89. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  90. package/dist/pipeline/index.d.ts +1 -0
  91. package/dist/pipeline/index.js +1 -0
  92. package/dist/pipeline/member-read-only-transaction.js +1 -1
  93. package/dist/pipeline/notify-scope.d.ts +13 -0
  94. package/dist/pipeline/notify-scope.js +32 -0
  95. package/dist/pipeline/system-hooks.js +2 -1
  96. package/dist/pipeline/system-identity-switch.js +2 -2
  97. package/dist/search/meilisearch-adapter.js +3 -4
  98. package/dist/secrets/stored-envelope.js +7 -7
  99. package/dist/stack/push-entity-projection-tables.js +2 -3
  100. package/dist/stack/table-helpers.js +10 -26
  101. package/dist/stack/test-stack.js +2 -5
  102. package/dist/testing/multipart-helper.d.ts +0 -6
  103. package/dist/testing/multipart-helper.js +5 -5
  104. package/dist/testing/without-ambient-temporal.js +4 -4
  105. package/dist/utils/is-plain-object.d.ts +1 -2
  106. package/dist/utils/is-plain-object.js +1 -4
  107. package/package.json +4 -4
  108. package/src/changes.json +44 -0
@@ -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 && typeof value === "object" && SQL_EXPR_BRAND in 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 && typeof v === "object" && SQL_EXPR_BRAND in 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 = Array.isArray(opts)
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 ("__pk" in value && value.__pk === true) {
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 ("__index" in value && value.__index === true) {
332
- const idx = value;
347
+ else if (isIndexBuilderWithCols(value)) {
333
348
  indexes.push({
334
- name: idx.name,
335
- columns: idx.columns,
336
- ...(idx.unique && { unique: true }),
337
- ...(idx.whereSql !== undefined && { whereSql: idx.whereSql }),
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[KUMIKO_NAME_SYMBOL];
371
- if (typeof name !== "string") {
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 registry-projection — ProjectionDefinition.table is the
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 { SQL_EXPR_BRAND } from "./dialect.js";
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 (entity.indexes ?? [])) {
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
- if (typeof where === "object" &&
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 type-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
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[KUMIKO_NAME_SYMBOL]);
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[field]?.name ?? toSnakeCase(field)}"`;
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[KUMIKO_NAME_SYMBOL]);
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[field]?.name ?? toSnakeCase(field));
397
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
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 { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
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[Symbol.toStringTag];
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 (typeof value === "object") {
158
- const valueObj = value;
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[KUMIKO_NAME_SYMBOL]);
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[KUMIKO_NAME_SYMBOL]);
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[KUMIKO_NAME_SYMBOL]);
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[KUMIKO_NAME_SYMBOL]);
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
- if (parsed.version !== SNAPSHOT_VERSION) {
40
- throw new Error(`Snapshot at ${path} has version ${String(parsed.version)}, expected ${SNAPSHOT_VERSION}`);
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.code === "ENOENT")
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
- // Adapter: extract raw postgres-js client from drizzle DbConnection,
20
- // or use Bun.sql instance directly. Either way `.unsafe()` is the
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 rawClient(db).unsafe(sqlText);
186
+ await migrationClient(db).unsafe(sqlText);
187
187
  }
188
188
  export async function fetchAppliedMigrations(db) {
189
- const result = await rawClient(db).unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
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
- if (typeof row === "object" &&
194
- row !== null &&
195
- typeof row.id === "string" &&
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 = rawClient(db);
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 txClient.unsafe(stmt);
239
+ await tx.unsafe(stmt);
247
240
  }
248
- await txClient.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [m.id, m.checksum]);
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 = rawClient(db);
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[KUMIKO_NAME_SYMBOL]);
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 = (await (options.eventIds
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 = (await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [[...touchedAggregates]]));
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 = (await raw.unsafe(`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`));
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 = (await raw.unsafe(`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`, [[...ids]]));
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; // @cast-boundary db-read — tenant_id column is the branded TenantId
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 = (await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`));
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.includes(verb);
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 = (await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_es_operations" WHERE id = $1 LIMIT 1`, [operationId]));
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<Record<string, unknown> | undefined>;
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<Record<string, unknown> | undefined>;
30
- export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
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<Record<string, unknown> | undefined>;
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<Record<string, unknown> | undefined>;
36
- export declare function removePendingGapReturning(db: AnyDb, name: string, instanceId: string, newPendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
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>;