@cosmicdrift/kumiko-framework 0.356.0 → 0.358.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 (51) hide show
  1. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  2. package/dist/crypto/pii-field-encryption.js +1 -0
  3. package/dist/crypto/subject-resolver.d.ts +2 -0
  4. package/dist/crypto/subject-resolver.js +9 -6
  5. package/dist/db/connection.d.ts +2 -0
  6. package/dist/db/connection.js +10 -0
  7. package/dist/db/event-store-executor-context.d.ts +4 -1
  8. package/dist/db/event-store-executor-context.js +4 -2
  9. package/dist/db/event-store-executor-write.js +6 -1
  10. package/dist/db/index.d.ts +1 -1
  11. package/dist/db/index.js +1 -1
  12. package/dist/db/queries/backfill-pii.js +58 -34
  13. package/dist/engine/boot-validator/card-meta-overflow.d.ts +10 -0
  14. package/dist/engine/boot-validator/card-meta-overflow.js +95 -0
  15. package/dist/engine/boot-validator/entity-handler.js +3 -2
  16. package/dist/engine/boot-validator/index.d.ts +1 -0
  17. package/dist/engine/boot-validator/index.js +4 -1
  18. package/dist/engine/boot-validator/pii-retention.js +7 -0
  19. package/dist/engine/boot-validator/query-refs.js +3 -0
  20. package/dist/engine/boot-validator/screens.js +7 -1
  21. package/dist/engine/boot-validator.d.ts +1 -1
  22. package/dist/engine/boot-validator.js +1 -1
  23. package/dist/engine/factories.js +23 -19
  24. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +3 -1
  25. package/dist/engine/feature-ast/extractors/resolve-import.js +9 -8
  26. package/dist/engine/feature-ast/extractors/round3.js +3 -2
  27. package/dist/engine/feature-ast/extractors/shared.d.ts +12 -2
  28. package/dist/engine/feature-ast/extractors/shared.js +31 -11
  29. package/dist/engine/index.d.ts +1 -1
  30. package/dist/engine/index.js +1 -1
  31. package/dist/files/file-ref-entity.d.ts +6 -0
  32. package/dist/files/file-ref-table.d.ts +6 -0
  33. package/dist/i18n/required-surface-keys.js +1 -0
  34. package/dist/jobs/job-runner.d.ts +1 -0
  35. package/dist/jobs/job-runner.js +51 -13
  36. package/dist/pipeline/dispatch-shared.d.ts +1 -0
  37. package/dist/pipeline/dispatch-shared.js +1 -1
  38. package/dist/pipeline/dispatcher.d.ts +2 -0
  39. package/dist/pipeline/dispatcher.js +1 -0
  40. package/dist/pipeline/system-hooks.js +7 -3
  41. package/dist/stack/db.d.ts +3 -0
  42. package/dist/stack/db.js +7 -1
  43. package/dist/stack/test-stack.d.ts +4 -0
  44. package/dist/stack/test-stack.js +5 -0
  45. package/dist/testing/shared-entities.d.ts +10 -0
  46. package/dist/ui-types/card-column-roles.d.ts +14 -0
  47. package/dist/ui-types/card-column-roles.js +9 -0
  48. package/dist/ui-types/index.d.ts +2 -0
  49. package/dist/ui-types/index.js +1 -0
  50. package/package.json +4 -4
  51. package/src/changes.json +38 -0
@@ -11,6 +11,7 @@ export interface EncryptPiiOptions {
11
11
  readonly tenantId?: TenantId;
12
12
  readonly subjectSource?: Record<string, unknown>;
13
13
  readonly entityName: string;
14
+ readonly ownerFallback?: Record<string, unknown>;
14
15
  }
15
16
  export declare function encryptPiiFieldValues(row: Record<string, unknown>, entity: EntityDefinition, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext, opts: EncryptPiiOptions): Promise<Record<string, unknown>>;
16
17
  export declare function serializeJsonbValue(field: string, value: unknown): string;
@@ -151,6 +151,7 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
151
151
  const subject = resolveSubjectForField(entity, name, subjectSource, {
152
152
  entityName: opts.entityName,
153
153
  ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
154
+ ...(opts.ownerFallback !== undefined && { ownerFallback: opts.ownerFallback }),
154
155
  });
155
156
  // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
156
157
  if (subject === null)
@@ -9,6 +9,8 @@ export declare class SubjectResolutionError extends Error {
9
9
  export interface ResolveSubjectOptions {
10
10
  readonly tenantId?: TenantId;
11
11
  readonly entityName: string;
12
+ readonly ownerFallback?: Record<string, unknown>;
13
+ readonly ignoreWhenAbsent?: boolean;
12
14
  }
13
15
  /**
14
16
  * Maps a pii-annotated field to the subject whose key encrypts it.
@@ -29,12 +29,15 @@ function resolveRecordSubject(fieldName, row, opts) {
29
29
  }
30
30
  return { kind: "record", entity: entityName, id: recordId };
31
31
  }
32
- function resolveUserOwnedSubject(ownerField, fieldName, row) {
33
- const userId = nonEmptyString(row[ownerField]);
34
- if (userId === null) {
35
- throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row`);
32
+ function resolveUserOwnedSubject(userOwned, fieldName, row, opts) {
33
+ const { ownerField } = userOwned;
34
+ const userId = nonEmptyString(row[ownerField]) ?? nonEmptyString(opts.ownerFallback?.[ownerField]);
35
+ if (userId !== null)
36
+ return { kind: "user", userId };
37
+ if (userOwned.whenAbsent === "tenant" && opts.ignoreWhenAbsent !== true) {
38
+ return resolveTenantSubject(fieldName, row, opts);
36
39
  }
37
- return { kind: "user", userId };
40
+ throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row and the field declares no whenAbsent — declare { personal: { of: "${ownerField}", whenAbsent: "tenant" } } (fw#3654)`);
38
41
  }
39
42
  function resolveTenantSubject(fieldName, row, opts) {
40
43
  const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
@@ -68,7 +71,7 @@ export function resolveSubjectForField(entity, fieldName, row, opts) {
68
71
  return resolveRecordSubject(fieldName, row, opts);
69
72
  }
70
73
  if ("userOwned" in field && field.userOwned !== undefined) {
71
- return resolveUserOwnedSubject(field.userOwned.ownerField, fieldName, row);
74
+ return resolveUserOwnedSubject(field.userOwned, fieldName, row, opts);
72
75
  }
73
76
  if ("tenantOwned" in field && field.tenantOwned === true) {
74
77
  return resolveTenantSubject(fieldName, row, opts);
@@ -8,3 +8,5 @@ export declare function createDbConnection(url: string, options?: import("./api.
8
8
  close: () => Promise<void>;
9
9
  };
10
10
  export declare function dbConnectionOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): import("./api.js").DbConnectionOptions;
11
+ export declare const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
12
+ export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): import("./api.js").DbConnectionOptions;
@@ -33,3 +33,13 @@ export function dbConnectionOptionsFromEnv(env = process.env) {
33
33
  opts.connectTimeoutSeconds = connect;
34
34
  return opts;
35
35
  }
36
+ // Outside-tx access runs on its own pool: sharing the write pool deadlocks once
37
+ // pool-max concurrent writes each hold a tx and then wait for a second connection.
38
+ export const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
39
+ export function outsideTransactionPoolOptionsFromEnv(env = process.env) {
40
+ return {
41
+ ...dbConnectionOptionsFromEnv(env),
42
+ maxConnections: readPositiveIntEnv(env, "DATABASE_OUTSIDE_TX_POOL_MAX") ??
43
+ DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
44
+ };
45
+ }
@@ -29,7 +29,9 @@ export type ExecutorContext = {
29
29
  readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
30
30
  readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
31
31
  readonly idFilter: (id: EntityId) => WhereObject;
32
- readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
32
+ readonly loadById: (id: EntityId, db: TenantDb, options?: {
33
+ readonly includeDeleted?: boolean;
34
+ }) => Promise<Record<string, unknown> | null>;
33
35
  readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
34
36
  readonly loadWithOwnership: (db: TenantDb, idWhere: WhereObject, ownership: {
35
37
  kind: "pass";
@@ -60,6 +62,7 @@ export type ExecutorContext = {
60
62
  onlyKeys?: Iterable<string>;
61
63
  subjectSource?: Record<string, unknown>;
62
64
  keyTenantId?: TenantId | undefined;
65
+ ownerFallback?: Record<string, unknown>;
63
66
  }) => Promise<Record<string, unknown>>;
64
67
  readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
65
68
  readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
@@ -208,6 +208,7 @@ export function buildExecutorContext(table, entity, options) {
208
208
  entityName,
209
209
  ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
210
210
  ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
211
+ ...(opts?.ownerFallback !== undefined && { ownerFallback: opts.ownerFallback }),
211
212
  });
212
213
  }
213
214
  catch (e) {
@@ -272,8 +273,9 @@ export function buildExecutorContext(table, entity, options) {
272
273
  filter["isDeleted"] = false;
273
274
  return filter;
274
275
  }
275
- async function loadById(id, db) {
276
- const row = await db.fetchOne(table, idFilter(id));
276
+ async function loadById(id, db, options) {
277
+ // Only the soft-delete predicate is relaxed; the tenant filter of db.fetchOne stays.
278
+ const row = await db.fetchOne(table, options?.includeDeleted ? { id } : idFilter(id));
277
279
  if (!row)
278
280
  return null;
279
281
  return await decryptForRead(rehydrateCompoundTypes(row, entity));
@@ -343,7 +343,9 @@ export function createWriteVerbs(ctx) {
343
343
  "be SYSTEM_TENANT_ID or omitted.",
344
344
  });
345
345
  }
346
- const previous = await loadById(payload.id, db);
346
+ const previous = await loadById(payload.id, db, {
347
+ includeDeleted: updateOptions?.includeDeleted === true,
348
+ });
347
349
  if (!previous)
348
350
  return writeFailure(new NotFoundError(entityName, payload.id));
349
351
  const streamTenantId = updateOptions?.streamTenantId;
@@ -462,8 +464,11 @@ export function createWriteVerbs(ctx) {
462
464
  // re-encrypt it before it's persisted so plaintext of pii/encrypted
463
465
  // fields doesn't land in the immutable log (flatChanges is already
464
466
  // ciphertext from encryptForStorage above).
467
+ // Owner chain for the ownerless-then-assigned row: previous owner, then the
468
+ // owner this event sets, then whenAbsent (fw#3654).
465
469
  const encryptedPrevious = await encryptForStorage(previous, user, {
466
470
  keyTenantId: streamTenantId,
471
+ ownerFallback: submittedChanges,
467
472
  });
468
473
  // Savepoint-scoped — see the create() append() above for why:
469
474
  // confines a losing writer's unique-violation to a nested scope
@@ -4,7 +4,7 @@ export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
6
6
  export type { DbConnection, DbConnectionOptions, DbPoolHandle, DbRow, DbRunner, DbTx, } from "./connection.js";
7
- export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection.js";
7
+ export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.js";
8
8
  export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor.js";
9
9
  export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
10
10
  export type { SchemaTable, SelectQuery, TableColumns } from "./dialect.js";
package/dist/db/index.js CHANGED
@@ -3,7 +3,7 @@ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recor
3
3
  export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
6
- export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection.js";
6
+ export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.js";
7
7
  export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
8
8
  export { bigint, bigserial, boolean, extractTableName, index, instant, instantToDriver, integer, jsonb, moneyAmount, numeric, primaryKey, serial, sql, table, text, timestamp, uniqueIndex, uuid, } from "./dialect.js";
9
9
  export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
@@ -25,17 +25,22 @@
25
25
  // second run reports 0 updates. One failing event does not abort the run;
26
26
  // failures are collected and reported (fail-loud at the caller).
27
27
  //
28
- // Owner resolution for entity-lifecycle events (backfill-only — the live
29
- // write path in event-store-executor-write.ts / subject-resolver.ts stays
30
- // fail-closed and is untouched by this):
31
- // 1. payload — the event section itself names the owner field.
28
+ // Owner resolution for entity-lifecycle events. The live write path
29
+ // (event-store-executor-write.ts) resolves the same chain via
30
+ // subject-resolver.ts: row owner, then the owner the same event sets, then
31
+ // the field's whenAbsent (fw#3654). Backfill adds two opt-in stages and
32
+ // delays whenAbsent behind the projection so a known owner is never shadowed:
33
+ // 1. payload — the event section (plus the owner the same event sets) names
34
+ // the owner field; whenAbsent is not applied yet.
32
35
  // 2. projection ({@link PiiBackfillOptions.resolveOwnerFromProjection}) —
33
36
  // pre-owner-field-addition events don't carry it; read it from the
34
37
  // entity's current projection row instead.
38
+ // 2b. whenAbsent — the payload again, now falling back to the tenant key for
39
+ // fields that declare it.
35
40
  // 3. erase ({@link PiiBackfillOptions.eraseUnresolvableSubjects}) — still
36
41
  // unresolvable (hard-deleted aggregate, no projection) → PII_ERASED_SENTINEL.
37
- // Both stages are opt-in and off by default; without them an unresolvable
38
- // subject still fails loud into `failures`, as before.
42
+ // Stages 2 and 3 are opt-in and off by default; without them an
43
+ // unresolvable subject still fails loud into `failures`, as before.
39
44
  //
40
45
  // Snapshots of touched aggregates are dropped per batch (they may cache
41
46
  // plaintext); the next snapshotting load recreates them. AFTER a run, rebuild the
@@ -293,14 +298,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
293
298
  async function applyEntityLifecycleFields(target, projectionOwnerRow) {
294
299
  const sections = lifecycleSections(payload);
295
300
  for (const section of sections) {
296
- // Update-changes may carry a pii field without its owner field —
297
- // resolve subjects from the merged view; aggregate_id backs the
298
- // self-subject when a section lacks the id column.
299
- const subjectSource = {
300
- id: row.aggregate_id,
301
- ...Object.assign({}, ...sections),
302
- ...section,
303
- };
301
+ const { subjectSource, ownerFallback } = lifecycleSubjectView(section, payload, row.aggregate_id);
304
302
  for (const field of target.piiFields) {
305
303
  const value = section[field];
306
304
  if (value === null || value === undefined)
@@ -316,7 +314,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
316
314
  // must stay untouched, not get erased by stage 3.
317
315
  if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
318
316
  continue;
319
- const resolution = resolveOwnerSubject(target.entity, field, subjectSource, projectionOwnerRow,
317
+ const resolution = resolveOwnerSubject(target.entity, field, subjectSource, ownerFallback, projectionOwnerRow,
320
318
  // @cast-boundary db-read — tenant_id column is the branded TenantId
321
319
  row.tenant_id, row.aggregate_type);
322
320
  if (resolution.kind === "unannotated")
@@ -401,32 +399,41 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
401
399
  return scanCache.destroyedTenants;
402
400
  }
403
401
  }
404
- // Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
405
- // (erase) is the caller's call, made from the "unresolved" outcome.
406
- function resolveOwnerSubject(entity, field, subjectSource, projectionOwnerRow, tenantId, entityName) {
402
+ function attemptResolve(entity, field, source, opts) {
407
403
  try {
408
- const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId, entityName });
409
- return subject === null
410
- ? { kind: "unannotated" }
411
- : { kind: "resolved", subject, viaProjection: false };
404
+ return { ok: true, subject: resolveSubjectForField(entity, field, source, opts) };
412
405
  }
413
406
  catch (e) {
414
407
  if (!(e instanceof SubjectResolutionError))
415
408
  throw e;
416
- if (projectionOwnerRow) {
417
- try {
418
- const augmented = { ...subjectSource, ...projectionOwnerRow };
419
- const subject = resolveSubjectForField(entity, field, augmented, { tenantId, entityName });
420
- if (subject !== null)
421
- return { kind: "resolved", subject, viaProjection: true };
422
- }
423
- catch (e2) {
424
- if (!(e2 instanceof SubjectResolutionError))
425
- throw e2;
426
- }
409
+ return { ok: false, error: e };
410
+ }
411
+ }
412
+ // Stage 1 (payload) and stage 2 (projection row, when supplied) resolve the
413
+ // owner strictly, ignoring whenAbsent — an ownerless payload must not shadow
414
+ // an owner the projection knows. Stage 2b then applies whenAbsent to the
415
+ // payload. Stage 3 (erase) is the caller's call, made from "unresolved".
416
+ function resolveOwnerSubject(entity, field, subjectSource, ownerFallback, projectionOwnerRow, tenantId, entityName) {
417
+ const baseOpts = { tenantId, entityName, ...(ownerFallback && { ownerFallback }) };
418
+ const strictOpts = { ...baseOpts, ignoreWhenAbsent: true };
419
+ const payloadAttempt = attemptResolve(entity, field, subjectSource, strictOpts);
420
+ if (payloadAttempt.ok) {
421
+ return payloadAttempt.subject === null
422
+ ? { kind: "unannotated" }
423
+ : { kind: "resolved", subject: payloadAttempt.subject, viaProjection: false };
424
+ }
425
+ if (projectionOwnerRow) {
426
+ const augmented = { ...subjectSource, ...projectionOwnerRow };
427
+ const projectionAttempt = attemptResolve(entity, field, augmented, strictOpts);
428
+ if (projectionAttempt.ok && projectionAttempt.subject !== null) {
429
+ return { kind: "resolved", subject: projectionAttempt.subject, viaProjection: true };
427
430
  }
428
- return { kind: "unresolved", error: e };
429
431
  }
432
+ const whenAbsentAttempt = attemptResolve(entity, field, subjectSource, baseOpts);
433
+ if (whenAbsentAttempt.ok && whenAbsentAttempt.subject !== null) {
434
+ return { kind: "resolved", subject: whenAbsentAttempt.subject, viaProjection: false };
435
+ }
436
+ return { kind: "unresolved", error: payloadAttempt.error };
430
437
  }
431
438
  // Projection columns are snake_case (author_id); event-payload/subject
432
439
  // fields are camelCase (authorId) — forward-map from entity.fields like
@@ -460,6 +467,23 @@ function lifecycleSections(payload) {
460
467
  sections.push(payload);
461
468
  return sections;
462
469
  }
470
+ // Same view the live write path resolves from: changes see the merged row,
471
+ // previous sees the owner the same event sets as fallback. aggregate_id backs
472
+ // the self-subject when a section lacks the id column.
473
+ function lifecycleSubjectView(section, payload, aggregateId) {
474
+ const changes = isRecord(payload["changes"]) ? payload["changes"] : undefined;
475
+ const previous = isRecord(payload["previous"]) ? payload["previous"] : undefined;
476
+ if (section === changes) {
477
+ return {
478
+ subjectSource: { id: aggregateId, ...previous, ...changes },
479
+ ownerFallback: undefined,
480
+ };
481
+ }
482
+ return {
483
+ subjectSource: { id: aggregateId, ...section },
484
+ ownerFallback: section === previous ? changes : undefined,
485
+ };
486
+ }
463
487
  function isRecord(value) {
464
488
  return typeof value === "object" && value !== null && !Array.isArray(value);
465
489
  }
@@ -0,0 +1,10 @@
1
+ import type { FeatureDefinition } from "../types/index.js";
2
+ export type CardMetaOverflow = {
3
+ readonly feature: string;
4
+ readonly screen: string;
5
+ readonly location?: string;
6
+ readonly count: number;
7
+ readonly fields: readonly string[];
8
+ };
9
+ export declare function collectCardMetaOverflow(features: readonly FeatureDefinition[]): readonly CardMetaOverflow[];
10
+ export declare function validateCardMetaOverflow(features: readonly FeatureDefinition[]): void;
@@ -0,0 +1,95 @@
1
+ import { CARD_META_MAX, cardColumnRoles } from "../../ui-types/card-column-roles.js";
2
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
3
+ import { parseRefTarget } from "../parse-ref-target.js";
4
+ import { normalizeListColumn } from "../screen-helpers.js";
5
+ // Mirrors the type resolution of headless computeListViewModel; only the
6
+ // "select" outcome matters for the card status role.
7
+ function resolveColumnType(spec, storedType, entity) {
8
+ const col = normalizeListColumn(spec);
9
+ if (col.refEntity !== undefined && (storedType === undefined || storedType === "text")) {
10
+ return "reference";
11
+ }
12
+ if (storedType !== undefined)
13
+ return storedType;
14
+ const derived = entity?.derivedFields?.[col.field];
15
+ if (derived !== undefined)
16
+ return derived.valueType;
17
+ return LIST_ROW_META_COLUMNS[col.field] ?? "text";
18
+ }
19
+ function countOverflow(feature, screen, location, columns, storedTypeOf, entity) {
20
+ const cardColumns = columns.map((spec) => {
21
+ const col = normalizeListColumn(spec);
22
+ return {
23
+ field: col.field,
24
+ type: resolveColumnType(spec, storedTypeOf(col.field), entity),
25
+ renderer: col.renderer,
26
+ hideOnNarrow: col.hideOnNarrow,
27
+ };
28
+ });
29
+ const { meta } = cardColumnRoles(cardColumns);
30
+ if (meta.length <= CARD_META_MAX)
31
+ return undefined;
32
+ return {
33
+ feature: feature.name,
34
+ screen,
35
+ ...(location !== undefined && { location }),
36
+ count: meta.length,
37
+ fields: meta.map((col) => col.field),
38
+ };
39
+ }
40
+ function relatedListOverflow(features, feature, screen, location, section) {
41
+ let source;
42
+ if (section.entity !== undefined) {
43
+ const target = parseRefTarget(section.entity, feature.name);
44
+ source = features.find((f) => f.name === target.featureName)?.entities?.[target.entityName];
45
+ }
46
+ const synthesizedTypes = new Map(section.columns.map((spec) => {
47
+ const col = normalizeListColumn(spec);
48
+ return [col.field, col.valueType ?? "text"];
49
+ }));
50
+ return countOverflow(feature, screen, location, section.columns, (field) => source?.fields[field]?.type ?? synthesizedTypes.get(field), source);
51
+ }
52
+ // Every declaration whose columns the narrow layout draws as a DataTable card.
53
+ // Dashboard list panels have their own table and are not covered.
54
+ export function collectCardMetaOverflow(features) {
55
+ const found = [];
56
+ const add = (overflow) => {
57
+ if (overflow !== undefined)
58
+ found.push(overflow);
59
+ };
60
+ for (const feature of features) {
61
+ for (const screen of Object.values(feature.screens)) {
62
+ if (screen.type === "entityList") {
63
+ const entity = feature.entities?.[screen.entity];
64
+ add(countOverflow(feature, screen.id, undefined, screen.columns, (field) => entity?.fields[field]?.type, entity));
65
+ if (screen.expandableRow !== undefined) {
66
+ add(relatedListOverflow(features, feature, screen.id, `expandableRow "${screen.expandableRow.title}"`, screen.expandableRow));
67
+ }
68
+ }
69
+ else if (screen.type === "projectionList") {
70
+ // The projection shim types every column "text"; only refEntity changes it.
71
+ add(countOverflow(feature, screen.id, undefined, screen.columns, () => "text", undefined));
72
+ }
73
+ else if (screen.type === "projectionDetail") {
74
+ const isTabs = screen.layout.mode === "tabs";
75
+ for (const section of screen.layout.sections) {
76
+ if (!("kind" in section) || section.kind !== "relatedList")
77
+ continue;
78
+ const location = isTabs
79
+ ? `tab "${section.id ?? section.title}" section "${section.title}"`
80
+ : `section "${section.title}"`;
81
+ add(relatedListOverflow(features, feature, screen.id, location, section));
82
+ }
83
+ }
84
+ }
85
+ }
86
+ return found;
87
+ }
88
+ export function validateCardMetaOverflow(features) {
89
+ const overflows = collectCardMetaOverflow(features);
90
+ // skip: nothing overflows, so there is nothing to report
91
+ if (overflows.length === 0)
92
+ return;
93
+ const lines = overflows.map((o) => ` - Feature "${o.feature}" screen "${o.screen}"${o.location !== undefined ? ` ${o.location}` : ""}: ${o.count} meta columns (${o.fields.join(", ")})`);
94
+ throw new Error(`List cards show at most ${CARD_META_MAX} meta columns (all columns except title, status select and hideOnNarrow ones); more would be cut off on narrow screens:\n${lines.join("\n")}\nReduce each to ${CARD_META_MAX} columns or mark the others hideOnNarrow: true.`);
95
+ }
@@ -710,11 +710,12 @@ function assertNoSelectOptionsQuery(field, path, featureName) {
710
710
  export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
711
711
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
712
712
  for (const [fieldName, field] of Object.entries(entity.fields)) {
713
- if (field.type !== "select" || field.optionsAvailabilityQuery === undefined)
713
+ if ((field.type !== "select" && field.type !== "multiSelect") ||
714
+ field.optionsAvailabilityQuery === undefined)
714
715
  continue;
715
716
  const query = field.optionsAvailabilityQuery;
716
717
  if (query.length === 0 || !queryHandlers.has(query)) {
717
- throw new Error(`[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}" ` +
718
+ throw new Error(`[Feature ${feature.name}] ${field.type === "select" ? "Select" : "MultiSelect"} field "${fieldName}" on entity "${entityName}" ` +
718
719
  `declares optionsAvailabilityQuery "${query}" which is not a registered query-handler. ` +
719
720
  `Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
720
721
  `is declared via r.queryHandler(...).`);
@@ -1,5 +1,6 @@
1
1
  import type { EnvelopeCipher } from "../../secrets/envelope-cipher.js";
2
2
  import type { FeatureDefinition } from "../types/index.js";
3
+ export { type CardMetaOverflow, collectCardMetaOverflow } from "./card-meta-overflow.js";
3
4
  export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
4
5
  export { collectWriteHandlerQns } from "./nav.js";
5
6
  export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline.js";
@@ -7,6 +7,7 @@ import { warnOnUniqueAccessRoles } from "./access-roles.js";
7
7
  import { validateActionWiring, validateFieldWiring } from "./action-wiring.js";
8
8
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.js";
9
9
  import { validateFeatureBootChecks } from "./boot-check.js";
10
+ import { validateCardMetaOverflow } from "./card-meta-overflow.js";
10
11
  import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
11
12
  import { collectDetailForScreens } from "./detail-screens.js";
12
13
  import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectConditionalOptions, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
@@ -29,10 +30,11 @@ import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
29
30
  import { validateTransferGraph } from "./transfer-graph.js";
30
31
  import { validateSubmitPrefilledHiddenFields } from "./url-prefill-fields.js";
31
32
  import { collectWorkspaceQns, resolveNavAllowlist, validateDefaultWorkspaceUniqueness, validateWorkspaces, } from "./workspaces.js";
32
- export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
33
33
  // Re-export: wird von run-dev-app.ts benötigt um Write-Handler-QNs
34
34
  // an den Codegen zu übergeben. Nicht Teil von validateBoot, aber
35
35
  // dieselbe Extraktionslogik.
36
+ export { collectCardMetaOverflow } from "./card-meta-overflow.js";
37
+ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
36
38
  export { collectWriteHandlerQns } from "./nav.js";
37
39
  export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline.js";
38
40
  export { MAX_TRANSFER_DEPTH } from "./transfer-graph.js";
@@ -217,6 +219,7 @@ export function validateBoot(rawFeatures, options) {
217
219
  validateI18nSurfaceKeys(features);
218
220
  validateEntityListScreens(features);
219
221
  validateSubmitPrefilledHiddenFields(features);
222
+ validateCardMetaOverflow(features);
220
223
  // Must run before validateProjectionListScreens: an unresolvable query
221
224
  // there is silently treated as "capability absent" and surfaces as a
222
225
  // misleading "no search parameter in its Zod schema" error instead of
@@ -203,6 +203,13 @@ export function validatePiiAndRetention(feature) {
203
203
  if (ownerField.type !== "reference" && ownerField.type !== "text") {
204
204
  throw new Error(`[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`);
205
205
  }
206
+ const { whenAbsent } = annot.userOwned;
207
+ if (whenAbsent !== undefined && whenAbsent !== "tenant") {
208
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" (no plaintext fallback for stored PII).`);
209
+ }
210
+ if (ownerField.required !== true && whenAbsent === undefined) {
211
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`);
212
+ }
206
213
  // Soft-Warning wenn das reference-target nicht offensichtlich user
207
214
  // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
208
215
  // erlaubt, müssen aber bewusste Wahl sein.
@@ -45,6 +45,9 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
45
45
  }
46
46
  function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
47
47
  for (const [fieldName, field] of Object.entries(fields)) {
48
+ if (field.type === "multiSelect" && field.optionsAvailabilityQuery !== undefined) {
49
+ checkQueryRef(queryHandlers, field.optionsAvailabilityQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) multiSelect field "${fieldName}" optionsAvailabilityQuery`);
50
+ }
48
51
  if (field.type !== "select")
49
52
  continue;
50
53
  if (field.optionsQuery !== undefined) {
@@ -550,6 +550,11 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
550
550
  throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
551
551
  }
552
552
  }
553
+ else if (fdef.type === "multiSelect" && fdef.optionsAvailabilityQuery !== undefined) {
554
+ if (fdef.optionsAvailabilityQuery.length === 0) {
555
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) multiSelect field "${fieldName}" has an empty optionsAvailabilityQuery`);
556
+ }
557
+ }
553
558
  }
554
559
  // Fail-closed currency-source gate (fw#2839), the runtime half of the
555
560
  // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
@@ -1869,7 +1874,7 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1869
1874
  "from-today",
1870
1875
  ]);
1871
1876
  validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
1872
- const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1877
+ const nonBooleanProp = ["brush", "legendTotals", "markerLabels"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1873
1878
  if (nonBooleanProp !== undefined) {
1874
1879
  throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
1875
1880
  }
@@ -1904,6 +1909,7 @@ function validateStackedAreaOnlyProps(where, panel) {
1904
1909
  "dateFormat",
1905
1910
  "initialWindow",
1906
1911
  "markerLegend",
1912
+ "markerLabels",
1907
1913
  ].filter((prop) => panel[prop] !== undefined);
1908
1914
  if (stackedAreaOnly.length > 0) {
1909
1915
  throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
@@ -1 +1 @@
1
- export { collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, type ValidateBootOptions, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator/index.js";
1
+ export { type CardMetaOverflow, collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, type ValidateBootOptions, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator/index.js";
@@ -1 +1 @@
1
- export { collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator/index.js";
1
+ export { collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator/index.js";
@@ -1,6 +1,28 @@
1
1
  // `personal: { of: "id" }` means the row itself is the subject, not a
2
2
  // referenced user — resolves to `recordOwned` instead of `userOwned`.
3
3
  const RECORD_OWNER_FIELD = "id";
4
+ function resolvePersonalFlags(personal, reason) {
5
+ if (personal === "self")
6
+ return { pii: true };
7
+ if (personal === "tenant")
8
+ return { tenantOwned: true };
9
+ if (personal === "ref")
10
+ return { subjectRef: true };
11
+ if (personal === false)
12
+ return { allowPlaintext: reason };
13
+ if (!personal)
14
+ return {};
15
+ if (personal.of === RECORD_OWNER_FIELD) {
16
+ if (personal.whenAbsent !== undefined) {
17
+ throw new Error(`personal: { of: "${RECORD_OWNER_FIELD}", whenAbsent } is meaningless: a row always has an id, so there is never an absent owner (fw#3654)`);
18
+ }
19
+ return { recordOwned: true };
20
+ }
21
+ const { whenAbsent } = personal;
22
+ return {
23
+ userOwned: { ownerField: personal.of, ...(whenAbsent !== undefined && { whenAbsent }) },
24
+ };
25
+ }
4
26
  // Resolves the author-facing `personal`/`find` annotations (kumiko-framework#2250)
5
27
  // into the internal ResolvedPiiFlags every factory below merges into its
6
28
  // return value. `personal`, `find`, `reason` never survive into the field def.
@@ -9,25 +31,7 @@ function expandPersonalAnnotations(overrides) {
9
31
  return {};
10
32
  }
11
33
  const { personal, find, reason, anonymize, ...rest } = overrides;
12
- let personalFlags = {};
13
- if (personal === "self") {
14
- personalFlags = { pii: true };
15
- }
16
- else if (personal === "tenant") {
17
- personalFlags = { tenantOwned: true };
18
- }
19
- else if (personal === "ref") {
20
- personalFlags = { subjectRef: true };
21
- }
22
- else if (personal === false) {
23
- personalFlags = { allowPlaintext: reason };
24
- }
25
- else if (personal && personal.of === RECORD_OWNER_FIELD) {
26
- personalFlags = { recordOwned: true };
27
- }
28
- else if (personal) {
29
- personalFlags = { userOwned: { ownerField: personal.of } };
30
- }
34
+ const personalFlags = resolvePersonalFlags(personal, reason);
31
35
  // `searchable`/`sensitive` live on the individual FieldDef, not on
32
36
  // ResolvedPiiFlags — "fuzzy"/"secret" resolve into those too.
33
37
  let findFlags = {};
@@ -1,6 +1,8 @@
1
1
  import type { SourceFile, VariableDeclaration } from "ts-morph";
2
2
  import { Node } from "ts-morph";
3
- export declare function resolveModuleFile(fromFile: SourceFile, specifier: string): SourceFile | undefined;
3
+ export declare function resolveModuleFile(fromFile: SourceFile, specifier: string, { allowDeclarationFiles }?: {
4
+ readonly allowDeclarationFiles?: boolean;
5
+ }): SourceFile | undefined;
4
6
  export declare function findImportBindingForLocalName(sourceFile: SourceFile, localName: string): {
5
7
  readonly moduleSpecifier: string;
6
8
  readonly importedName: string;