@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +28 -3
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +2 -6
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +29 -2
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +59 -18
- package/dist/db/event-store-executor-read.js +81 -31
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +11 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +5 -0
- package/dist/db/queries/event-store.js +51 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +32 -3
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +5 -0
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +40 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +98 -7
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +55 -34
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +41 -4
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +2 -3
- package/dist/engine/index.d.ts +8 -2
- package/dist/engine/index.js +6 -1
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/project-app-schema-for-roles.js +3 -1
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +35 -2
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.js +45 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +14 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-routes.js +10 -4
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +209 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
package/dist/db/table-builder.js
CHANGED
|
@@ -253,17 +253,17 @@ const ROW_META_WITH_SOFT_DELETE = new Set(Object.keys(buildBaseColumns(true)));
|
|
|
253
253
|
export function rowMetaFieldNames(softDelete) {
|
|
254
254
|
return softDelete ? ROW_META_WITH_SOFT_DELETE : ROW_META_BASE;
|
|
255
255
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return;
|
|
256
|
+
// pgTable's own meta stamp sees neither entity.tenancy nor that this table is an executor-managed projection;
|
|
257
|
+
// patch both so db.global()'s runtime guards match the type brands.
|
|
258
|
+
function stampEntityTableMeta(table, entity) {
|
|
260
259
|
const meta = table[KUMIKO_META_SYMBOL];
|
|
261
260
|
// skip: no meta stamp present — nothing to patch
|
|
262
261
|
if (!meta)
|
|
263
262
|
return;
|
|
264
263
|
table[KUMIKO_META_SYMBOL] = {
|
|
265
264
|
...meta,
|
|
266
|
-
|
|
265
|
+
source: "managed",
|
|
266
|
+
...(entity.tenancy === "global" && { tenancy: "global" }),
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
269
|
/**
|
|
@@ -416,7 +416,6 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
416
416
|
}
|
|
417
417
|
return indexes;
|
|
418
418
|
});
|
|
419
|
-
|
|
420
|
-
stampGlobalTenancyMeta(built, entity);
|
|
419
|
+
stampEntityTableMeta(built, entity);
|
|
421
420
|
return built;
|
|
422
421
|
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
|
-
import type { EscapeHatchDeclaration, EscapeHatchReporter } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import type { EscapeHatchDeclaration, EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import { type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
4
|
import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
5
5
|
import { asRawClient } from "../db/query.js";
|
|
@@ -28,7 +28,10 @@ export type TenantDbGrants = {
|
|
|
28
28
|
};
|
|
29
29
|
export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
|
|
30
30
|
export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
|
|
31
|
-
export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string
|
|
31
|
+
export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string, options?: {
|
|
32
|
+
readonly deferReport?: boolean;
|
|
33
|
+
}): TenantDb;
|
|
34
|
+
export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
|
|
32
35
|
export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
|
|
33
36
|
export declare function createTenantDb(db: DbRunner, tenantId: TenantId, mode?: TenantDbMode, tracer?: Tracer, meter?: Meter, signal?: AbortSignal, grants?: TenantDbGrants): TenantDb;
|
|
34
37
|
export { asRawClient };
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -27,7 +27,17 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
27
27
|
const value = Reflect.get(target, prop, target);
|
|
28
28
|
if (typeof value !== "function")
|
|
29
29
|
return value;
|
|
30
|
-
if (prop === "
|
|
30
|
+
if (prop === "reserve") {
|
|
31
|
+
// Bun.SQL and postgres.js hand back a raw connection; re-wrap it so the gate survives.
|
|
32
|
+
return async (...args) => {
|
|
33
|
+
const reserved = await Reflect.apply(value, target, args);
|
|
34
|
+
return boundRunner(reserved, gate, projectionRegistry);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (prop === "begin" ||
|
|
38
|
+
prop === "transaction" ||
|
|
39
|
+
prop === "beginDistributed" ||
|
|
40
|
+
prop === "savepoint") {
|
|
31
41
|
// createTenantDb(tx, ...) inside the callback must inherit the gate too.
|
|
32
42
|
return (...args) => {
|
|
33
43
|
const callback = args[args.length - 1];
|
|
@@ -247,8 +257,11 @@ export function withUnsafeRawGrant(tenantDb, grant) {
|
|
|
247
257
|
return rebind ? rebind(grant) : tenantDb;
|
|
248
258
|
}
|
|
249
259
|
const crossTenantRebinders = new WeakMap();
|
|
260
|
+
const crossTenantUseReporters = new WeakMap();
|
|
250
261
|
// Framework-private (not re-exported from db/index.ts): lifting the tenant filter needs a registered declaration.
|
|
251
|
-
|
|
262
|
+
// `deferReport` leaves the audit entry to reportConventionCrossTenantUse, so a caller that learns
|
|
263
|
+
// which foreign row it touches can attach that row as the entry's target.
|
|
264
|
+
export function acknowledgeConventionCrossTenant(tenantDb, reason, options) {
|
|
252
265
|
if (reason.trim().length === 0) {
|
|
253
266
|
throw new Error("acknowledgeConventionCrossTenant requires a non-empty reason");
|
|
254
267
|
}
|
|
@@ -259,7 +272,19 @@ export function acknowledgeConventionCrossTenant(tenantDb, reason) {
|
|
|
259
272
|
"no cross-tenant rebinder bound.",
|
|
260
273
|
});
|
|
261
274
|
}
|
|
262
|
-
return rebind(reason);
|
|
275
|
+
return rebind(reason, options?.deferReport === true);
|
|
276
|
+
}
|
|
277
|
+
// The report that acknowledgeConventionCrossTenant(..., { deferReport: true }) held back; the
|
|
278
|
+
// target keeps the 60s audit dedup from collapsing touches of different rows into one entry.
|
|
279
|
+
export function reportConventionCrossTenantUse(acknowledged, target) {
|
|
280
|
+
const reporter = crossTenantUseReporters.get(acknowledged);
|
|
281
|
+
if (!reporter) {
|
|
282
|
+
throw new InternalError({
|
|
283
|
+
message: "reportConventionCrossTenantUse received a TenantDb not returned by " +
|
|
284
|
+
"acknowledgeConventionCrossTenant.",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
reporter(target);
|
|
263
288
|
}
|
|
264
289
|
const ownTransactionRebinders = new WeakMap();
|
|
265
290
|
export async function runInOwnTransaction(tenantDb, fn) {
|
|
@@ -363,6 +388,14 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
363
388
|
"writes through db.global().",
|
|
364
389
|
});
|
|
365
390
|
}
|
|
391
|
+
// The ExecutorOnly brand is type-only; a type-erased table must still not reach the read model without an event.
|
|
392
|
+
function executorManagedWriteDenied(table) {
|
|
393
|
+
if (asEntityTableMeta(table)?.source === "unmanaged")
|
|
394
|
+
return undefined;
|
|
395
|
+
return new AccessDeniedError({
|
|
396
|
+
message: `db.global(${tableNameOf(table)}): writes on executor-managed entity tables must go through the entity executor`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
366
399
|
function globalWriteReason() {
|
|
367
400
|
return grants?.globalWrites?.reason ?? "";
|
|
368
401
|
}
|
|
@@ -411,6 +444,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
411
444
|
},
|
|
412
445
|
insertOne(values) {
|
|
413
446
|
const denied = missingEscapeHatch(table) ??
|
|
447
|
+
executorManagedWriteDenied(table) ??
|
|
414
448
|
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
415
449
|
personalDataDenied(table, Object.keys(values));
|
|
416
450
|
if (denied)
|
|
@@ -420,6 +454,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
420
454
|
},
|
|
421
455
|
updateMany(set, where) {
|
|
422
456
|
const denied = missingEscapeHatch(table) ??
|
|
457
|
+
executorManagedWriteDenied(table) ??
|
|
423
458
|
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
424
459
|
personalDataDenied(table, Object.keys(set));
|
|
425
460
|
if (denied)
|
|
@@ -431,7 +466,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
431
466
|
return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, where));
|
|
432
467
|
},
|
|
433
468
|
deleteMany(where) {
|
|
434
|
-
const denied = missingEscapeHatch(table);
|
|
469
|
+
const denied = missingEscapeHatch(table) ?? executorManagedWriteDenied(table);
|
|
435
470
|
if (denied)
|
|
436
471
|
return Promise.reject(denied);
|
|
437
472
|
if (!where || Object.keys(where).length === 0) {
|
|
@@ -522,10 +557,15 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
522
557
|
unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
|
|
523
558
|
...rebindGrants,
|
|
524
559
|
unsafeRaw: grant,
|
|
560
|
+
// db.global() writes are write-handler-only: a rebind (a hook's own grant) must not inherit them.
|
|
561
|
+
globalWrites: undefined,
|
|
525
562
|
}));
|
|
526
|
-
crossTenantRebinders.set(tenantDb, (reason) => {
|
|
527
|
-
|
|
528
|
-
|
|
563
|
+
crossTenantRebinders.set(tenantDb, (reason, deferReport) => {
|
|
564
|
+
if (!deferReport)
|
|
565
|
+
report("acknowledge-cross-tenant", reason);
|
|
566
|
+
const acknowledged = createTenantDb(db, tenantId, "system", tracer, meter, signal, rebindGrants);
|
|
567
|
+
crossTenantUseReporters.set(acknowledged, (target) => report("acknowledge-cross-tenant", reason, target));
|
|
568
|
+
return acknowledged;
|
|
529
569
|
});
|
|
530
570
|
ownTransactionRebinders.set(tenantDb, async (fn) => {
|
|
531
571
|
if (grants?.memberReadOnly) {
|
|
@@ -3,6 +3,7 @@ export declare function canonicalJson(value: unknown): string;
|
|
|
3
3
|
export declare function specHash(spec: VariantSpec): string;
|
|
4
4
|
export declare const VARIANT_NAME_PATTERN: RegExp;
|
|
5
5
|
export declare function variantSuffix(name: string, spec: VariantSpec): string;
|
|
6
|
+
export declare function isDerivativeSuffix(suffix: string): boolean;
|
|
6
7
|
export declare function derivativeListPrefix(originalKey: string): string;
|
|
7
8
|
export declare function isDerivativeKeyOf(originalKey: string, candidateKey: string): boolean;
|
|
8
9
|
export declare function parseDerivativeKey(candidateKey: string): {
|
|
@@ -37,6 +37,9 @@ export function variantSuffix(name, spec) {
|
|
|
37
37
|
// The hash part stays case-sensitive (specHash emits lowercase hex only), so
|
|
38
38
|
// the erasure sweep never matches a differently-cased sibling file.
|
|
39
39
|
const DERIVATIVE_SUFFIX_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}-[0-9a-f]{16}$/;
|
|
40
|
+
export function isDerivativeSuffix(suffix) {
|
|
41
|
+
return DERIVATIVE_SUFFIX_PATTERN.test(suffix);
|
|
42
|
+
}
|
|
40
43
|
// Mirrors deriveKey's own split so callers get the exact same base/ext this
|
|
41
44
|
// key's derivatives were built from.
|
|
42
45
|
function splitKey(key) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { codemodPlaceholderReasonProblem } from "../escape-hatch-reason.js";
|
|
2
3
|
import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
|
|
3
4
|
import { ANONYMOUS_ROLE } from "../system-user.js";
|
|
4
5
|
import { collectZodObjectKeys } from "./zod-shape.js";
|
|
@@ -60,6 +61,10 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
|
|
|
60
61
|
"explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
|
|
61
62
|
"or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
|
|
62
63
|
}
|
|
64
|
+
const placeholderProblem = codemodPlaceholderReasonProblem(escapeHatch.reason);
|
|
65
|
+
if (placeholderProblem !== undefined) {
|
|
66
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${placeholderProblem}`);
|
|
67
|
+
}
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
|
|
@@ -106,6 +106,9 @@ export function validateConfigKeyAllowPerRequest(feature) {
|
|
|
106
106
|
export function validateConfigKeyOptionsQuery(feature) {
|
|
107
107
|
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
108
108
|
const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
|
|
109
|
+
if ("conditionalOptions" in keyDef) {
|
|
110
|
+
throw new Error(`${prefix} declares conditionalOptions — conditionalOptions is only supported on entity select fields (enforced by the entity write path)`);
|
|
111
|
+
}
|
|
109
112
|
if (keyDef.optionsQuery === undefined) {
|
|
110
113
|
if (keyDef.optionsQueryPayload !== undefined) {
|
|
111
114
|
throw new Error(`${prefix} has optionsQueryPayload without optionsQuery`);
|
|
@@ -20,6 +20,7 @@ export declare function validateReferenceFields(feature: FeatureDefinition, feat
|
|
|
20
20
|
export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
21
21
|
export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
|
|
22
22
|
export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDefinition, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
23
|
+
export declare function validateEntitySelectConditionalOptions(feature: FeatureDefinition): void;
|
|
23
24
|
export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
|
|
24
25
|
export declare function validateImageVariants(feature: FeatureDefinition): void;
|
|
25
26
|
export declare function validateTransitions(feature: FeatureDefinition): void;
|
|
@@ -722,6 +722,46 @@ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
|
|
|
722
722
|
}
|
|
723
723
|
}
|
|
724
724
|
}
|
|
725
|
+
export function validateEntitySelectConditionalOptions(feature) {
|
|
726
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
727
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
728
|
+
if (field.type !== "select" || field.conditionalOptions === undefined)
|
|
729
|
+
continue;
|
|
730
|
+
const where = `[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}"`;
|
|
731
|
+
if (field.optionsQuery !== undefined) {
|
|
732
|
+
throw new Error(`${where} combines conditionalOptions with optionsQuery — conditionalOptions filters the static options only.`);
|
|
733
|
+
}
|
|
734
|
+
const knownOptions = new Set(field.options);
|
|
735
|
+
const ruleOfOption = new Map();
|
|
736
|
+
field.conditionalOptions.forEach((rule, ruleIndex) => {
|
|
737
|
+
const ruleLabel = `conditionalOptions[${ruleIndex}]`;
|
|
738
|
+
if (rule.options.length === 0) {
|
|
739
|
+
throw new Error(`${where} has an empty ${ruleLabel}.options — drop the rule.`);
|
|
740
|
+
}
|
|
741
|
+
for (const option of rule.options) {
|
|
742
|
+
if (!knownOptions.has(option)) {
|
|
743
|
+
throw new Error(`${where} lists "${option}" in ${ruleLabel} which is not in options (${field.options.join(", ")}).`);
|
|
744
|
+
}
|
|
745
|
+
const otherRule = ruleOfOption.get(option);
|
|
746
|
+
if (otherRule !== undefined) {
|
|
747
|
+
throw new Error(`${where} lists "${option}" in conditionalOptions[${otherRule}] and ${ruleLabel} — an option may belong to one rule only.`);
|
|
748
|
+
}
|
|
749
|
+
ruleOfOption.set(option, ruleIndex);
|
|
750
|
+
if (option === field.default) {
|
|
751
|
+
throw new Error(`${where} has default "${option}" inside ${ruleLabel} — the default must be available unconditionally.`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const conditionField = rule.when.field;
|
|
755
|
+
if (conditionField === fieldName) {
|
|
756
|
+
throw new Error(`${where} has ${ruleLabel}.when on its own field.`);
|
|
757
|
+
}
|
|
758
|
+
if (!(conditionField in entity.fields)) {
|
|
759
|
+
throw new Error(`${where} has ${ruleLabel}.when.field "${conditionField}" which is not a field of the entity.`);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
725
765
|
export function validateMultiSelectFields(feature) {
|
|
726
766
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
727
767
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { handlerFieldReference } from "../handler-field-references.js";
|
|
2
|
+
import { parseRefTarget } from "../parse-ref-target.js";
|
|
3
|
+
import { getZodObjectShape } from "./zod-shape.js";
|
|
4
|
+
function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
|
|
5
|
+
for (const [handlerName, def] of Object.entries(handlers)) {
|
|
6
|
+
const shape = getZodObjectShape(def.schema);
|
|
7
|
+
if (shape === undefined)
|
|
8
|
+
continue;
|
|
9
|
+
for (const fieldName of Object.keys(shape)) {
|
|
10
|
+
const reference = handlerFieldReference(def.schema, fieldName);
|
|
11
|
+
if (reference === undefined)
|
|
12
|
+
continue;
|
|
13
|
+
const target = parseRefTarget(reference, feature.name);
|
|
14
|
+
const targetEntities = featureMap.get(target.featureName)?.entities;
|
|
15
|
+
if (targetEntities?.[target.entityName] !== undefined)
|
|
16
|
+
continue;
|
|
17
|
+
throw new Error(`[Feature ${feature.name}] Input field "${fieldName}" of ${handlerNoun} "${handlerName}" has meta references: "${reference}", ` +
|
|
18
|
+
`which is not a registered entity. Use "<entity>" for this feature or "<feature>:<entity>" for another one.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function validateHandlerFieldReferences(feature, featureMap) {
|
|
23
|
+
validateHandlerGroup(feature, featureMap, feature.writeHandlers ?? {}, "write handler");
|
|
24
|
+
validateHandlerGroup(feature, featureMap, feature.queryHandlers ?? {}, "query handler");
|
|
25
|
+
}
|
|
@@ -9,10 +9,11 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
|
|
|
9
9
|
import { validateFeatureBootChecks } from "./boot-check.js";
|
|
10
10
|
import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
|
|
11
11
|
import { collectDetailForScreens } from "./detail-screens.js";
|
|
12
|
-
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
12
|
+
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectConditionalOptions, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
13
13
|
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
14
14
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
15
15
|
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
16
|
+
import { validateHandlerFieldReferences } from "./handler-field-references.js";
|
|
16
17
|
import { validateI18nSurfaceKeys } from "./i18n-keys.js";
|
|
17
18
|
import { collectKnownRoles, collectNavQns, collectWriteHandlerQns, validateNavCycles, validateNavs, warnOnNavAccessInversion, warnOnUnreachableNavScreens, } from "./nav.js";
|
|
18
19
|
import { collectClaimKeys, validateOwnershipRules } from "./ownership.js";
|
|
@@ -26,6 +27,7 @@ import { collectScreenQns, collectScreensByShortId, validateScreenShortIdCollisi
|
|
|
26
27
|
import { warnOnMissingSecurityBaseline } from "./security-baseline.js";
|
|
27
28
|
import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
|
|
28
29
|
import { validateTransferGraph } from "./transfer-graph.js";
|
|
30
|
+
import { validateSubmitPrefilledHiddenFields } from "./url-prefill-fields.js";
|
|
29
31
|
import { collectWorkspaceQns, resolveNavAllowlist, validateDefaultWorkspaceUniqueness, validateWorkspaces, } from "./workspaces.js";
|
|
30
32
|
export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
|
|
31
33
|
// Re-export: wird von run-dev-app.ts benötigt um Write-Handler-QNs
|
|
@@ -170,10 +172,12 @@ export function validateBoot(rawFeatures, options) {
|
|
|
170
172
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
171
173
|
validateEntitySelectOptionsQuery(feature);
|
|
172
174
|
validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
|
|
175
|
+
validateEntitySelectConditionalOptions(feature);
|
|
173
176
|
validateEmbeddedFields(feature, featureMap, queryHandlerQns);
|
|
174
177
|
validateMultiSelectFields(feature);
|
|
175
178
|
validateImageVariants(feature);
|
|
176
179
|
validateReferenceFields(feature, featureMap, queryHandlerQns);
|
|
180
|
+
validateHandlerFieldReferences(feature, featureMap);
|
|
177
181
|
validateTransitions(feature);
|
|
178
182
|
validateExtensionUsages(feature, extensionProviders);
|
|
179
183
|
validateExtendSchemaCollisions(feature);
|
|
@@ -212,6 +216,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
212
216
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
213
217
|
validateI18nSurfaceKeys(features);
|
|
214
218
|
validateEntityListScreens(features);
|
|
219
|
+
validateSubmitPrefilledHiddenFields(features);
|
|
215
220
|
// Must run before validateProjectionListScreens: an unresolvable query
|
|
216
221
|
// there is silently treated as "capability absent" and surfaces as a
|
|
217
222
|
// misleading "no search parameter in its Zod schema" error instead of
|
|
@@ -59,7 +59,9 @@ export function validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns)
|
|
|
59
59
|
// `screen`/`target`. Without this check an action with neither is a
|
|
60
60
|
// Hover-Icon that does nothing; one with both is ambiguous about which
|
|
61
61
|
// click-mode wins.
|
|
62
|
-
function validateTreeAction(featureName, navId, actionPath, action,
|
|
62
|
+
function validateTreeAction(featureName, navId, actionPath, action,
|
|
63
|
+
// Loose on purpose: this is the runtime guard for untyped input.
|
|
64
|
+
allScreenQns) {
|
|
63
65
|
const { screen, target } = action;
|
|
64
66
|
const hasScreen = screen !== undefined;
|
|
65
67
|
const hasTarget = target !== undefined;
|
|
@@ -5,7 +5,7 @@ export type WhereRuleProbe = {
|
|
|
5
5
|
readonly tableName: string;
|
|
6
6
|
readonly columns: ReadonlySet<string>;
|
|
7
7
|
};
|
|
8
|
-
export type AccessPath = "read" | "write";
|
|
8
|
+
export type AccessPath = "read" | "fieldRead" | "write";
|
|
9
9
|
export declare function collectClaimKeys(features: readonly FeatureDefinition[]): Map<string, ClaimKeyDefinition>;
|
|
10
10
|
export declare function validateOwnershipBoot(features: readonly FeatureDefinition[]): void;
|
|
11
11
|
export declare function validateOwnershipRules(feature: FeatureDefinition, allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined, knownRoles: ReadonlySet<string> | undefined): void;
|
|
@@ -32,5 +32,6 @@ export declare function checkOwnershipMap(args: {
|
|
|
32
32
|
readonly path: AccessPath;
|
|
33
33
|
}): void;
|
|
34
34
|
export declare function probeWhereRule(rule: WhereRule, roleName: string, probe: WhereRuleProbe | undefined, scope: string, featureName: string): void;
|
|
35
|
+
export declare function buildWhereOnFieldReadMessage(roleName: string, scope: string, featureName: string): string;
|
|
35
36
|
export declare function buildWhereOnWritePathMessage(roleName: string, scope: string, featureName: string): string;
|
|
36
37
|
export declare function buildUnknownRoleMessage(roleName: string, knownRoles: ReadonlySet<string>, scope: string, featureName: string): string;
|
|
@@ -14,6 +14,9 @@ import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule
|
|
|
14
14
|
// lint (see ../where-rule-lint.ts) so a fail-open unqualified-column bug
|
|
15
15
|
// fails the boot instead of waiting for a request from the right role.
|
|
16
16
|
//
|
|
17
|
+
// Field-level `access.read` rejects where-rules too: filterFieldsForRead
|
|
18
|
+
// evaluates it in memory and userCanReadFieldRow fails closed on a where-rule.
|
|
19
|
+
//
|
|
17
20
|
// fw#2626: `access.write` maps reject `{ kind: "where" }` outright. A
|
|
18
21
|
// where-rule is raw SQL and the write path never reaches SQL — it evaluates
|
|
19
22
|
// rules in memory against the concrete row (userCanCreateFieldRow /
|
|
@@ -114,8 +117,11 @@ export function validateOwnershipRules(feature, allClaimKeys, knownRoles) {
|
|
|
114
117
|
scope: `${entityName}.${fieldName}.access.read`,
|
|
115
118
|
featureName: feature.name,
|
|
116
119
|
probe,
|
|
117
|
-
path: "
|
|
120
|
+
path: "fieldRead",
|
|
118
121
|
});
|
|
122
|
+
if (field.type === "embedded") {
|
|
123
|
+
rejectWhereInEmbeddedRead(field.schema, `${entityName}.${fieldName}`, feature.name);
|
|
124
|
+
}
|
|
119
125
|
checkFieldAccess({
|
|
120
126
|
access: field.access?.write,
|
|
121
127
|
columnNames,
|
|
@@ -193,6 +199,9 @@ export function checkOwnershipMap(args) {
|
|
|
193
199
|
if (args.path === "write") {
|
|
194
200
|
throw new Error(buildWhereOnWritePathMessage(roleName, args.scope, args.featureName));
|
|
195
201
|
}
|
|
202
|
+
if (args.path === "fieldRead") {
|
|
203
|
+
throw new Error(buildWhereOnFieldReadMessage(roleName, args.scope, args.featureName));
|
|
204
|
+
}
|
|
196
205
|
probeWhereRule(rule, roleName, args.probe, args.scope, args.featureName);
|
|
197
206
|
continue; // escape hatch — feature author owns the SQL
|
|
198
207
|
}
|
|
@@ -249,6 +258,28 @@ export function probeWhereRule(rule, roleName, probe, scope, featureName) {
|
|
|
249
258
|
return;
|
|
250
259
|
assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}", feature: "${featureName}")`);
|
|
251
260
|
}
|
|
261
|
+
// Embedded sub-fields are filtered in memory too (filterFieldsForRead), so a
|
|
262
|
+
// where-rule there is just as unevaluable as on a top-level field.
|
|
263
|
+
function rejectWhereInEmbeddedRead(schema, parentScope, featureName) {
|
|
264
|
+
for (const [subName, sub] of Object.entries(schema)) {
|
|
265
|
+
const map = sub.access?.read;
|
|
266
|
+
if (!map || Array.isArray(map))
|
|
267
|
+
continue;
|
|
268
|
+
for (const [roleName, rule] of Object.entries(map)) {
|
|
269
|
+
if (rule !== "all" && rule.kind === "where") {
|
|
270
|
+
throw new Error(buildWhereOnFieldReadMessage(roleName, `${parentScope}.${subName}.access.read`, featureName));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export function buildWhereOnFieldReadMessage(roleName, scope, featureName) {
|
|
276
|
+
return (`[Kumiko Ownership] ${scope} uses a \`{ kind: "where" }\` rule for role ` +
|
|
277
|
+
`"${roleName}" (feature: "${featureName}"). Field-level read access is decided ` +
|
|
278
|
+
`in memory against the concrete row (userCanReadFieldRow), which cannot run ` +
|
|
279
|
+
`raw SQL — the rule could only ever hide the field from this role. Use a ` +
|
|
280
|
+
`from()-rule (e.g. from("user:id", "ownerId")) on the field, or move the ` +
|
|
281
|
+
`where-rule to entity access.read.`);
|
|
282
|
+
}
|
|
252
283
|
export function buildWhereOnWritePathMessage(roleName, scope, featureName) {
|
|
253
284
|
return (`[Kumiko Ownership] ${scope} uses a \`{ kind: "where" }\` rule for role ` +
|
|
254
285
|
`"${roleName}" (feature: "${featureName}"). where-rules are raw SQL and only ` +
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createFallbackLogger } from "../../logging/utils.js";
|
|
1
2
|
import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
|
|
3
|
+
const log = createFallbackLogger("kumiko:boot");
|
|
2
4
|
// Framework-managed Timestamp-Spalten — dürfen als retention.reference
|
|
3
5
|
// genutzt werden auch wenn nicht in entity.fields deklariert.
|
|
4
6
|
const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
|
|
@@ -238,6 +240,11 @@ export function validatePiiAndRetention(feature) {
|
|
|
238
240
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
239
241
|
console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
|
|
240
242
|
}
|
|
243
|
+
else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
|
|
244
|
+
// The factories refuse a missing stance, but a raw literal such as
|
|
245
|
+
// { type: "text" } still compiles and reaches the entity unchecked.
|
|
246
|
+
log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } via createTextField / createLongTextField; "false" additionally needs { reason: "..." } stating why the value is not personal data.`);
|
|
247
|
+
}
|
|
241
248
|
}
|
|
242
249
|
}
|
|
243
250
|
// --- Entity-level retention ---
|
|
@@ -266,7 +273,7 @@ export function validatePiiAndRetention(feature) {
|
|
|
266
273
|
});
|
|
267
274
|
if (entityHasAnonymizableField && !hasAnonymize) {
|
|
268
275
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
269
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function.
|
|
276
|
+
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. Once keepFor expires, the data-retention cron anonymizes nothing and the row keeps its PII. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
|
|
270
277
|
}
|
|
271
278
|
}
|
|
272
279
|
}
|
|
@@ -10,13 +10,13 @@ function checkFieldExists(shape, field, buildMessage) {
|
|
|
10
10
|
return;
|
|
11
11
|
throw new Error(buildMessage());
|
|
12
12
|
}
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// Unlike entity-list-screens.ts, `label` does not exempt a column here: query
|
|
14
|
+
// screens label nearly every column, so that exemption would leave the check
|
|
15
|
+
// validating nothing. Only an explicit `virtual: true` skips the shape check.
|
|
16
16
|
function checkColumnField(rowShape, column, buildMessage) {
|
|
17
17
|
const normalized = normalizeListColumn(column);
|
|
18
|
-
// skip:
|
|
19
|
-
if (normalized.
|
|
18
|
+
// skip: virtual column is a computed cell drawn by a renderer, not a row field
|
|
19
|
+
if (normalized.virtual === true)
|
|
20
20
|
return;
|
|
21
21
|
checkFieldExists(rowShape, normalized.field, () => buildMessage(normalized.field));
|
|
22
22
|
}
|
|
@@ -55,7 +55,7 @@ function resolveListRowShape(queryHandlers, queryQn, usage) {
|
|
|
55
55
|
function checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen) {
|
|
56
56
|
const rowShape = resolveListRowShape(queryHandlers, screen.query, `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
|
|
57
57
|
for (const column of screen.columns) {
|
|
58
|
-
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (projectionList) column "${field}" is not present in query "${screen.query}"'s outputSchema — check for a typo, or
|
|
58
|
+
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (projectionList) column "${field}" is not present in query "${screen.query}"'s outputSchema — check for a typo, or set virtual: true on it to mark it a virtual/computed column.`);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
function checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, screenType, layout) {
|
|
@@ -64,7 +64,7 @@ function checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, scre
|
|
|
64
64
|
continue;
|
|
65
65
|
const rowShape = resolveListRowShape(queryHandlers, section.query, `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`);
|
|
66
66
|
for (const column of section.columns) {
|
|
67
|
-
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}" column "${field}" is not present in query "${section.query}"'s outputSchema — check for a typo, or
|
|
67
|
+
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}" column "${field}" is not present in query "${section.query}"'s outputSchema — check for a typo, or set virtual: true on it to mark it a virtual/computed column.`);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -189,7 +189,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
|
|
|
189
189
|
else if (panel.kind === "list") {
|
|
190
190
|
const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
|
|
191
191
|
for (const column of panel.columns) {
|
|
192
|
-
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" column "${field}" is not present in query "${panel.query}"'s outputSchema — check for a typo, or
|
|
192
|
+
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" column "${field}" is not present in query "${panel.query}"'s outputSchema — check for a typo, or set virtual: true on it to mark it a virtual/computed column.`);
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
// chart/feed/progress-list/custom panels have a fixed query-result
|
|
@@ -205,7 +205,7 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
|
|
|
205
205
|
if (screen.expandableRow !== undefined) {
|
|
206
206
|
const rowShape = resolveListRowShape(queryHandlers, screen.expandableRow.query, `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow.title}"`);
|
|
207
207
|
for (const column of screen.expandableRow.columns) {
|
|
208
|
-
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}" column "${field}" is not present in query "${screen.expandableRow?.query}"'s outputSchema — check for a typo, or
|
|
208
|
+
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}" column "${field}" is not present in query "${screen.expandableRow?.query}"'s outputSchema — check for a typo, or set virtual: true on it to mark it a virtual/computed column.`);
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
}
|