@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.
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +1 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +9 -6
- package/dist/db/connection.d.ts +2 -0
- package/dist/db/connection.js +10 -0
- package/dist/db/event-store-executor-context.d.ts +4 -1
- package/dist/db/event-store-executor-context.js +4 -2
- package/dist/db/event-store-executor-write.js +6 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/queries/backfill-pii.js +58 -34
- package/dist/engine/boot-validator/card-meta-overflow.d.ts +10 -0
- package/dist/engine/boot-validator/card-meta-overflow.js +95 -0
- package/dist/engine/boot-validator/entity-handler.js +3 -2
- package/dist/engine/boot-validator/index.d.ts +1 -0
- package/dist/engine/boot-validator/index.js +4 -1
- package/dist/engine/boot-validator/pii-retention.js +7 -0
- package/dist/engine/boot-validator/query-refs.js +3 -0
- package/dist/engine/boot-validator/screens.js +7 -1
- package/dist/engine/boot-validator.d.ts +1 -1
- package/dist/engine/boot-validator.js +1 -1
- package/dist/engine/factories.js +23 -19
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +3 -1
- package/dist/engine/feature-ast/extractors/resolve-import.js +9 -8
- package/dist/engine/feature-ast/extractors/round3.js +3 -2
- package/dist/engine/feature-ast/extractors/shared.d.ts +12 -2
- package/dist/engine/feature-ast/extractors/shared.js +31 -11
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +1 -0
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +51 -13
- package/dist/pipeline/dispatch-shared.d.ts +1 -0
- package/dist/pipeline/dispatch-shared.js +1 -1
- package/dist/pipeline/dispatcher.d.ts +2 -0
- package/dist/pipeline/dispatcher.js +1 -0
- package/dist/pipeline/system-hooks.js +7 -3
- package/dist/stack/db.d.ts +3 -0
- package/dist/stack/db.js +7 -1
- package/dist/stack/test-stack.d.ts +4 -0
- package/dist/stack/test-stack.js +5 -0
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/card-column-roles.d.ts +14 -0
- package/dist/ui-types/card-column-roles.js +9 -0
- package/dist/ui-types/index.d.ts +2 -0
- package/dist/ui-types/index.js +1 -0
- package/package.json +4 -4
- 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(
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/db/connection.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/connection.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/dist/db/index.d.ts
CHANGED
|
@@ -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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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"
|
|
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";
|
package/dist/engine/factories.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|