@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -140,6 +140,7 @@ export function validateHandlerAccess(feature) {
|
|
|
140
140
|
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
141
141
|
validateTenantlessAnonymous(feature.name, name, handler);
|
|
142
142
|
validateAdditionalRateLimits(feature.name, name, handler);
|
|
143
|
+
validatePerCommandTransaction(feature.name, name, handler);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
const IP_BUCKETED_RATE_LIMIT_PER = new Set(["ip", "ip+handler"]);
|
|
@@ -172,7 +173,7 @@ function validateAnonymousHasIpBucket(where, handler) {
|
|
|
172
173
|
IP_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per);
|
|
173
174
|
if (!hasIpBucket) {
|
|
174
175
|
throw new Error(`${where} and allows anonymous callers, but has no ip-keyed rateLimit ` +
|
|
175
|
-
`(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket.`);
|
|
176
|
+
`(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket. Set rateLimit: { per: "ip", ... } on the handler.`);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
function validatePayloadBucket(where, shape, entry) {
|
|
@@ -188,6 +189,23 @@ function validatePayloadBucket(where, shape, entry) {
|
|
|
188
189
|
throw new Error(`${where} for "${field}" with windowSeconds ${entry.windowSeconds} — must be a positive integer.`);
|
|
189
190
|
}
|
|
190
191
|
}
|
|
192
|
+
function perCommandConflict(handler) {
|
|
193
|
+
if (handler.transaction !== "perCommand")
|
|
194
|
+
return undefined;
|
|
195
|
+
if (handler.beforeTransaction)
|
|
196
|
+
return "beforeTransaction";
|
|
197
|
+
if (handler.reserveBeforeTransaction)
|
|
198
|
+
return "reserveBeforeTransaction";
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
export function validatePerCommandTransaction(featureName, handlerName, handler) {
|
|
202
|
+
const conflicting = perCommandConflict(handler);
|
|
203
|
+
if (conflicting !== undefined) {
|
|
204
|
+
throw new Error(`write handler "${featureName}:write:${handlerName}" declares transaction: "perCommand" together with ${conflicting} — ` +
|
|
205
|
+
"a perCommand handler holds no transaction, so move the phase into the commands it issues via ctx.write.");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const IP_SCOPED_RATE_LIMIT_PER = ["ip", "ip+handler"];
|
|
191
209
|
export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
192
210
|
// skip: handler does not opt into tenantless anonymous calls
|
|
193
211
|
if (!handler.tenantlessAnonymous)
|
|
@@ -197,12 +215,16 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
|
197
215
|
const isAnonymousOnly = "roles" in access && access.roles.length === 1 && access.roles[0] === "anonymous";
|
|
198
216
|
if (!isAnonymousOnly) {
|
|
199
217
|
throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
|
|
200
|
-
`carries no principal beyond the anonymous visitor.`);
|
|
218
|
+
`carries no principal beyond the anonymous visitor. Set access to { roles: ["anonymous"] } or remove tenantlessAnonymous.`);
|
|
201
219
|
}
|
|
202
220
|
if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
|
|
203
221
|
throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
|
|
204
222
|
`needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
|
|
205
223
|
}
|
|
224
|
+
if (!IP_SCOPED_RATE_LIMIT_PER.includes(handler.rateLimit.per)) {
|
|
225
|
+
throw new Error(`${where} with rateLimit.per "${handler.rateLimit.per}" — every anonymous visitor shares the same ` +
|
|
226
|
+
`tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((per) => `"${per}"`).join(" or ")}.`);
|
|
227
|
+
}
|
|
206
228
|
}
|
|
207
229
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
208
230
|
// skip: rate limit isn't disabled, so no reason is required
|
|
@@ -393,17 +415,17 @@ export function validateEncryptedFields(feature) {
|
|
|
393
415
|
found = true;
|
|
394
416
|
if (field.type === "text") {
|
|
395
417
|
if (field.searchable) {
|
|
396
|
-
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable
|
|
418
|
+
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable. Drop searchable: true or remove the encryption.`);
|
|
397
419
|
}
|
|
398
420
|
if (field.sortable) {
|
|
399
|
-
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable
|
|
421
|
+
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable. Drop sortable: true or remove the encryption.`);
|
|
400
422
|
}
|
|
401
423
|
// AES-GCM draws a fresh random IV per call, so the ciphertext is
|
|
402
424
|
// non-deterministic — an equality filter on it never matches, even
|
|
403
425
|
// against the identical plaintext.
|
|
404
426
|
if (field.filterable) {
|
|
405
427
|
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and filterable ` +
|
|
406
|
-
`— non-deterministic ciphertext never matches an equality filter.`);
|
|
428
|
+
`— non-deterministic ciphertext never matches an equality filter. Drop filterable: true or remove the encryption.`);
|
|
407
429
|
}
|
|
408
430
|
}
|
|
409
431
|
}
|
|
@@ -537,7 +559,7 @@ export function validateEmbeddedFields(feature, featureMap, queryHandlers) {
|
|
|
537
559
|
if (field.type !== "embedded")
|
|
538
560
|
continue;
|
|
539
561
|
if (!field.schema || Object.keys(field.schema).length === 0) {
|
|
540
|
-
throw new Error(`Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema
|
|
562
|
+
throw new Error(`Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema. Declare at least one sub-field in the embedded schema.`);
|
|
541
563
|
}
|
|
542
564
|
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
543
565
|
if (!VALID_EMBEDDED_SUB_TYPES.has(subField.type)) {
|
|
@@ -547,7 +569,7 @@ export function validateEmbeddedFields(feature, featureMap, queryHandlers) {
|
|
|
547
569
|
throw new Error(`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid scale ${subField.scale}. Must be an integer between 0 and 15.`);
|
|
548
570
|
}
|
|
549
571
|
if (subField.type === "select" && subField.options.length === 0) {
|
|
550
|
-
throw new Error(`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has empty options
|
|
572
|
+
throw new Error(`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has empty options. Add at least one option.`);
|
|
551
573
|
}
|
|
552
574
|
// Reference sub-fields get the same target/labelField/query-handler
|
|
553
575
|
// checks as a top-level reference field — same failure mode
|
|
@@ -571,7 +593,7 @@ function validateEmbeddedListBounds(fieldName, entityName, field) {
|
|
|
571
593
|
if (field.minItems !== undefined &&
|
|
572
594
|
field.maxItems !== undefined &&
|
|
573
595
|
field.minItems > field.maxItems) {
|
|
574
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}.`);
|
|
596
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}. Set minItems at or below maxItems.`);
|
|
575
597
|
}
|
|
576
598
|
// required:true means "at least one row" (schema-builder.ts falls back to
|
|
577
599
|
// minItems:1 for that); an explicit minItems:0 would silently win over
|
|
@@ -587,14 +609,14 @@ function validateEmbeddedDerivedCells(fieldName, entityName, field) {
|
|
|
587
609
|
return;
|
|
588
610
|
for (const [derivedName, derivedDef] of Object.entries(field.derived)) {
|
|
589
611
|
if (!(derivedName in field.schema)) {
|
|
590
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema.`);
|
|
612
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema. Add the sub-field to the schema or remove the derived cell.`);
|
|
591
613
|
}
|
|
592
614
|
for (const sourceName of derivedDef.from) {
|
|
593
615
|
if (!(sourceName in field.schema)) {
|
|
594
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`);
|
|
616
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}". Read only sub-fields that exist in the schema.`);
|
|
595
617
|
}
|
|
596
618
|
if (sourceName in field.derived) {
|
|
597
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}".`);
|
|
619
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}". Read only stored sub-fields, not other derived cells.`);
|
|
598
620
|
}
|
|
599
621
|
}
|
|
600
622
|
}
|
|
@@ -606,7 +628,7 @@ function validateEmbeddedTotalsColumns(fieldName, entityName, field) {
|
|
|
606
628
|
for (const totalName of field.totals) {
|
|
607
629
|
const totalSubField = field.schema[totalName];
|
|
608
630
|
if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) {
|
|
609
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field.`);
|
|
631
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field. List only number, money or decimal sub-fields in totals.`);
|
|
610
632
|
}
|
|
611
633
|
}
|
|
612
634
|
}
|
|
@@ -617,11 +639,11 @@ function validateEmbeddedTotalsMatch(fieldName, entityName, field, entity) {
|
|
|
617
639
|
for (const [subFieldName, siblingFieldName] of Object.entries(field.totalsMatch)) {
|
|
618
640
|
const subField = field.schema[subFieldName];
|
|
619
641
|
if (subField?.type !== "money") {
|
|
620
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry for "${subFieldName}", which is not a money sub-field in its schema.`);
|
|
642
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry for "${subFieldName}", which is not a money sub-field in its schema. Use a money sub-field as the totalsMatch key.`);
|
|
621
643
|
}
|
|
622
644
|
const siblingField = entity.fields[siblingFieldName];
|
|
623
645
|
if (siblingField?.type !== "money") {
|
|
624
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry mapping "${subFieldName}" to sibling field "${siblingFieldName}", which is not a money field on entity "${entityName}".`);
|
|
646
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry mapping "${subFieldName}" to sibling field "${siblingFieldName}", which is not a money field on entity "${entityName}". Map it to a money field of the entity.`);
|
|
625
647
|
}
|
|
626
648
|
}
|
|
627
649
|
}
|
|
@@ -723,6 +745,15 @@ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
|
|
|
723
745
|
}
|
|
724
746
|
}
|
|
725
747
|
}
|
|
748
|
+
function conditionComparisonValues(when) {
|
|
749
|
+
if ("eq" in when)
|
|
750
|
+
return [when.eq];
|
|
751
|
+
if ("ne" in when)
|
|
752
|
+
return [when.ne];
|
|
753
|
+
if ("in" in when)
|
|
754
|
+
return when.in;
|
|
755
|
+
return when.notIn;
|
|
756
|
+
}
|
|
726
757
|
export function validateEntitySelectConditionalOptions(feature) {
|
|
727
758
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
728
759
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -730,7 +761,7 @@ export function validateEntitySelectConditionalOptions(feature) {
|
|
|
730
761
|
continue;
|
|
731
762
|
const where = `[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}"`;
|
|
732
763
|
if (field.optionsQuery !== undefined) {
|
|
733
|
-
throw new Error(`${where} combines conditionalOptions with optionsQuery — conditionalOptions filters the static options only.`);
|
|
764
|
+
throw new Error(`${where} combines conditionalOptions with optionsQuery — conditionalOptions filters the static options only. Remove either conditionalOptions or optionsQuery.`);
|
|
734
765
|
}
|
|
735
766
|
const knownOptions = new Set(field.options);
|
|
736
767
|
const ruleOfOption = new Map();
|
|
@@ -745,7 +776,7 @@ export function validateEntitySelectConditionalOptions(feature) {
|
|
|
745
776
|
}
|
|
746
777
|
const otherRule = ruleOfOption.get(option);
|
|
747
778
|
if (otherRule !== undefined) {
|
|
748
|
-
throw new Error(`${where} lists "${option}" in conditionalOptions[${otherRule}] and ${ruleLabel} — an option may belong to one rule only.`);
|
|
779
|
+
throw new Error(`${where} lists "${option}" in conditionalOptions[${otherRule}] and ${ruleLabel} — an option may belong to one rule only. Remove the option from one of the rules.`);
|
|
749
780
|
}
|
|
750
781
|
ruleOfOption.set(option, ruleIndex);
|
|
751
782
|
if (option === field.default) {
|
|
@@ -754,10 +785,19 @@ export function validateEntitySelectConditionalOptions(feature) {
|
|
|
754
785
|
}
|
|
755
786
|
const conditionField = rule.when.field;
|
|
756
787
|
if (conditionField === fieldName) {
|
|
757
|
-
throw new Error(`${where} has ${ruleLabel}.when on its own field.`);
|
|
788
|
+
throw new Error(`${where} has ${ruleLabel}.when on its own field. Make the condition depend on a different field.`);
|
|
758
789
|
}
|
|
759
790
|
if (!(conditionField in entity.fields)) {
|
|
760
|
-
throw new Error(`${where} has ${ruleLabel}.when.field "${conditionField}" which is not a field of the entity.`);
|
|
791
|
+
throw new Error(`${where} has ${ruleLabel}.when.field "${conditionField}" which is not a field of the entity. Use an existing field of the entity in when.field.`);
|
|
792
|
+
}
|
|
793
|
+
const conditionFieldDef = entity.fields[conditionField];
|
|
794
|
+
if (conditionFieldDef?.type === "select" || conditionFieldDef?.type === "multiSelect") {
|
|
795
|
+
const conditionOptions = conditionFieldDef.options;
|
|
796
|
+
for (const value of conditionComparisonValues(rule.when)) {
|
|
797
|
+
if (typeof value !== "string" || !conditionOptions.includes(value)) {
|
|
798
|
+
throw new Error(`${where} has ${ruleLabel}.when value "${String(value)}" which is not an option of "${conditionField}" (${conditionOptions.join(", ")}).`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
761
801
|
}
|
|
762
802
|
});
|
|
763
803
|
}
|
|
@@ -769,7 +809,7 @@ export function validateMultiSelectFields(feature) {
|
|
|
769
809
|
if (field.type !== "multiSelect")
|
|
770
810
|
continue;
|
|
771
811
|
if (field.options.length === 0) {
|
|
772
|
-
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options
|
|
812
|
+
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options. Add at least one option.`);
|
|
773
813
|
}
|
|
774
814
|
if (field.default !== undefined) {
|
|
775
815
|
const validOptions = new Set(field.options);
|
|
@@ -933,7 +973,7 @@ export function validateExtendSchemaCollisions(feature) {
|
|
|
933
973
|
const extraFields = ext.extendSchema(entityName);
|
|
934
974
|
for (const fieldName of Object.keys(extraFields)) {
|
|
935
975
|
if (existingFields.has(fieldName)) {
|
|
936
|
-
throw new Error(`extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}"
|
|
976
|
+
throw new Error(`extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}". Rename the extension column or the entity field.`);
|
|
937
977
|
}
|
|
938
978
|
}
|
|
939
979
|
}
|
|
@@ -954,7 +994,7 @@ export function validateDerivedFieldCollisions(feature) {
|
|
|
954
994
|
for (const derivedName of Object.keys(entity.derivedFields)) {
|
|
955
995
|
if (fieldNames.has(derivedName)) {
|
|
956
996
|
throw new Error(`Entity "${entityName}": derivedFields key "${derivedName}" conflicts with a stored ` +
|
|
957
|
-
`field of the same name — the derived value would silently overwrite the real one on every read.`);
|
|
997
|
+
`field of the same name — the derived value would silently overwrite the real one on every read. Rename the derivedFields key or the stored field.`);
|
|
958
998
|
}
|
|
959
999
|
}
|
|
960
1000
|
}
|
|
@@ -964,24 +1004,33 @@ export function validateDerivedFieldCollisions(feature) {
|
|
|
964
1004
|
// searchable field and `filterReadFields` only strips the response afterwards.
|
|
965
1005
|
// A role that cannot read the field could still guess its value from match
|
|
966
1006
|
// counts, so the combination is rejected until the index can filter per role.
|
|
1007
|
+
function isUnrestrictedReadAccess(access) {
|
|
1008
|
+
const readAccess = access?.read;
|
|
1009
|
+
if (readAccess === undefined)
|
|
1010
|
+
return true;
|
|
1011
|
+
return Array.isArray(readAccess) ? readAccess.length === 0 : Object.keys(readAccess).length === 0;
|
|
1012
|
+
}
|
|
1013
|
+
function throwSearchableRestricted(subject, entityName) {
|
|
1014
|
+
throw new Error(`${subject} on entity "${entityName}" cannot be searchable with a restricted ` +
|
|
1015
|
+
"access.read — the search index is not filtered per role, so search matches would leak " +
|
|
1016
|
+
'the value to roles that cannot read it. Drop `searchable`/`find: "fuzzy"` or `access.read`.');
|
|
1017
|
+
}
|
|
967
1018
|
export function validateSearchableFieldReadAccess(feature) {
|
|
968
1019
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
969
1020
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
970
|
-
if (field.type
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const readAccess = field.access?.read;
|
|
975
|
-
if (readAccess === undefined)
|
|
976
|
-
continue;
|
|
977
|
-
const isUnrestricted = Array.isArray(readAccess)
|
|
978
|
-
? readAccess.length === 0
|
|
979
|
-
: Object.keys(readAccess).length === 0;
|
|
980
|
-
if (isUnrestricted)
|
|
1021
|
+
if (field.type === "text" && field.searchable && !isUnrestrictedReadAccess(field.access)) {
|
|
1022
|
+
throwSearchableRestricted(`Field "${fieldName}"`, entityName);
|
|
1023
|
+
}
|
|
1024
|
+
if (field.type !== "embedded")
|
|
981
1025
|
continue;
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1026
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
1027
|
+
if (subField.searchable !== true)
|
|
1028
|
+
continue;
|
|
1029
|
+
if (isUnrestrictedReadAccess(field.access) && isUnrestrictedReadAccess(subField.access)) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
throwSearchableRestricted(`Field "${fieldName}.${subName}"`, entityName);
|
|
1033
|
+
}
|
|
985
1034
|
}
|
|
986
1035
|
}
|
|
987
1036
|
}
|
|
@@ -15,7 +15,7 @@ function hasFilterableFields(feature, entityName) {
|
|
|
15
15
|
const entity = entities[entityName];
|
|
16
16
|
if (!entity)
|
|
17
17
|
return false;
|
|
18
|
-
return Object.values(entity.fields).some((raw) => raw.filterable === true);
|
|
18
|
+
return Object.values(entity.fields).some((raw) => "filterable" in raw && raw.filterable === true);
|
|
19
19
|
}
|
|
20
20
|
function hasEntityEditDetail(feature, entityName) {
|
|
21
21
|
return Object.values(feature.screens).some((s) => s.type === "entityEdit" && s.entity === entityName);
|
|
@@ -28,13 +28,13 @@ function validateOneEntityListScreen(feature, screen) {
|
|
|
28
28
|
const filtersActive = hasFilterableFields(feature, screen.entity);
|
|
29
29
|
const searchable = screen.searchable !== false;
|
|
30
30
|
if ((searchable || filtersActive) && screen.defaultSort === undefined) {
|
|
31
|
-
throw new Error(`${prefix}: defaultSort required when searchable or filterable fields are active
|
|
31
|
+
throw new Error(`${prefix}: defaultSort required when searchable or filterable fields are active. Set defaultSort: { field, direction } on the screen.`);
|
|
32
32
|
}
|
|
33
33
|
if (screen.defaultSort !== undefined) {
|
|
34
34
|
const sortField = screen.defaultSort.field;
|
|
35
35
|
const col = screen.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
36
36
|
if (col === undefined) {
|
|
37
|
-
throw new Error(`${prefix}: defaultSort.field "${sortField}" is not a listed column
|
|
37
|
+
throw new Error(`${prefix}: defaultSort.field "${sortField}" is not a listed column. Pick a field from columns or add it there.`);
|
|
38
38
|
}
|
|
39
39
|
const entityDef = feature.entities?.[screen.entity];
|
|
40
40
|
const fieldDef = entityDef?.fields[sortField];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readHandlerFieldReference } from "../handler-field-references.js";
|
|
2
2
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
3
3
|
import { getZodObjectShape } from "./zod-shape.js";
|
|
4
4
|
function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
|
|
@@ -7,15 +7,25 @@ function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
|
|
|
7
7
|
if (shape === undefined)
|
|
8
8
|
continue;
|
|
9
9
|
for (const fieldName of Object.keys(shape)) {
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const where = `Input field "${fieldName}" of ${handlerNoun} "${handlerName}"`;
|
|
11
|
+
const reading = readHandlerFieldReference(def.schema, fieldName);
|
|
12
|
+
if (reading.kind === "none")
|
|
12
13
|
continue;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
if (reading.kind === "invalid") {
|
|
15
|
+
throw new Error(`[Feature ${feature.name}] ${where} has an invalid meta references value ${JSON.stringify(reading.raw)}. ` +
|
|
16
|
+
`Use "<entity>", "<feature>:<entity>" or { entity, by? }.`);
|
|
17
|
+
}
|
|
18
|
+
const { entity, by } = reading.reference;
|
|
19
|
+
const target = parseRefTarget(entity, feature.name);
|
|
20
|
+
const targetEntity = featureMap.get(target.featureName)?.entities?.[target.entityName];
|
|
21
|
+
if (targetEntity === undefined) {
|
|
22
|
+
throw new Error(`[Feature ${feature.name}] ${where} has meta references: "${entity}", ` +
|
|
23
|
+
`which is not a registered entity. Use "<entity>" for this feature or "<feature>:<entity>" for another one.`);
|
|
24
|
+
}
|
|
25
|
+
if (by !== undefined && targetEntity.fields[by] === undefined) {
|
|
26
|
+
throw new Error(`[Feature ${feature.name}] ${where} references entity "${entity}" by "${by}", ` +
|
|
27
|
+
`but that entity has no field "${by}". Use an existing field of that entity for "by".`);
|
|
28
|
+
}
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
}
|
|
@@ -76,13 +76,13 @@ export function validateI18nSurfaceKeys(features) {
|
|
|
76
76
|
if (isFrameworkOwnedI18nKey(key))
|
|
77
77
|
continue;
|
|
78
78
|
if (!hasDefinedTranslation(defined, key)) {
|
|
79
|
-
throw new Error(`[i18n] Feature "${feature.name}": required translation key missing: "${key}"
|
|
79
|
+
throw new Error(`[i18n] Feature "${feature.name}": required translation key missing: "${key}". Add it via r.translations(...) in this feature.`);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
for (const key of requiredKeysFromGeneratedConfigHub(generatedConfigHub)) {
|
|
84
84
|
if (!hasDefinedTranslation(defined, key)) {
|
|
85
|
-
throw new Error(`[i18n] Settings-Hub: required translation key missing: "${key}"
|
|
85
|
+
throw new Error(`[i18n] Settings-Hub: required translation key missing: "${key}". Add it via r.translations(...) in the owning feature.`);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -83,7 +83,7 @@ export function validateNavCycles(allNavQns) {
|
|
|
83
83
|
const stack = new Set();
|
|
84
84
|
function visit(qualified, path) {
|
|
85
85
|
if (stack.has(qualified)) {
|
|
86
|
-
throw new Error(`[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}
|
|
86
|
+
throw new Error(`[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}. Change or remove the parent of one entry in the cycle.`);
|
|
87
87
|
}
|
|
88
88
|
// skip: already visited — cycle-detection only needs to traverse each
|
|
89
89
|
// node once, and the `stack` check above catches any actual cycles
|
|
@@ -48,9 +48,9 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
|
48
48
|
const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
|
|
49
49
|
if (field.type === "embedded") {
|
|
50
50
|
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
51
|
-
if (subField.writeOnly === true) {
|
|
51
|
+
if ("writeOnly" in subField && subField.writeOnly === true) {
|
|
52
52
|
// @cast-boundary schema-walk
|
|
53
|
-
throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields.`);
|
|
53
|
+
throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields. Remove writeOnly from the sub-field or declare it on a top-level text field.`);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
// skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
|
|
@@ -61,7 +61,7 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
|
61
61
|
if (flags.writeOnly !== true)
|
|
62
62
|
return;
|
|
63
63
|
if (field.type !== "text") {
|
|
64
|
-
throw new Error(`${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields.`);
|
|
64
|
+
throw new Error(`${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields. Use a text field or remove writeOnly.`);
|
|
65
65
|
}
|
|
66
66
|
if (field.sensitive !== true) {
|
|
67
67
|
throw new Error(`${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`);
|
|
@@ -69,7 +69,7 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
|
69
69
|
for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
|
|
70
70
|
const value = flags[flag];
|
|
71
71
|
if (value !== undefined && value !== false) {
|
|
72
|
-
throw new Error(`${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up.`);
|
|
72
|
+
throw new Error(`${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up. Remove either writeOnly or the conflicting flag.`);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -89,7 +89,7 @@ function validatePersonalJsonbField(featureName, entityName, entity, fieldName,
|
|
|
89
89
|
const flags = field; // @cast-boundary schema-walk
|
|
90
90
|
for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
|
|
91
91
|
if (flags[flag] !== undefined && flags[flag] !== false) {
|
|
92
|
-
throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}.`);
|
|
92
|
+
throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}. Remove the ${flag} flag or the personal annotation.`);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
|
|
@@ -151,7 +151,7 @@ export function validatePiiAndRetention(feature) {
|
|
|
151
151
|
// zweite (deterministische!) Kopie ohne Nutzen.
|
|
152
152
|
if (annot.lookupable === true) {
|
|
153
153
|
if (field.type !== "text") {
|
|
154
|
-
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") but has type "${field.type}" — blind-index equality lookups only apply to text fields.`);
|
|
154
|
+
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") but has type "${field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`);
|
|
155
155
|
}
|
|
156
156
|
if (annotCount === 0) {
|
|
157
157
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`);
|
|
@@ -188,7 +188,7 @@ export function validatePiiAndRetention(feature) {
|
|
|
188
188
|
if (annot.userOwned) {
|
|
189
189
|
const ownerName = annot.userOwned.ownerField;
|
|
190
190
|
if (!ownerName || typeof ownerName !== "string") {
|
|
191
|
-
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has personal: { of: ... } without an owner field name
|
|
191
|
+
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`);
|
|
192
192
|
}
|
|
193
193
|
const ownerField = fieldsByName[ownerName];
|
|
194
194
|
if (!ownerField) {
|
|
@@ -205,7 +205,7 @@ export function validatePiiAndRetention(feature) {
|
|
|
205
205
|
}
|
|
206
206
|
const { whenAbsent } = annot.userOwned;
|
|
207
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).`);
|
|
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). Remove whenAbsent or set it to "tenant".`);
|
|
209
209
|
}
|
|
210
210
|
if (ownerField.required !== true && whenAbsent === undefined) {
|
|
211
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).`);
|
|
@@ -259,7 +259,7 @@ export function validatePiiAndRetention(feature) {
|
|
|
259
259
|
if (retention) {
|
|
260
260
|
if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
|
|
261
261
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
262
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}"
|
|
262
|
+
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" has an invalid format. Expected: <number><h|d|w|m|y> (e.g. "30d", "10y", "6m"). The cleanup job cannot parse anything else; fix keepFor.`);
|
|
263
263
|
}
|
|
264
264
|
if (retention.reference !== undefined) {
|
|
265
265
|
const refName = retention.reference;
|
|
@@ -32,7 +32,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers) {
|
|
|
32
32
|
const prefix = `[projectionList] Feature "${feature.name}" screen "${screen.id}"`;
|
|
33
33
|
const schema = queryHandlers.get(screen.query)?.schema;
|
|
34
34
|
if (screen.searchable === true && !schemaAccepts(schema, "search")) {
|
|
35
|
-
throw new Error(`${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema
|
|
35
|
+
throw new Error(`${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema. Add a "search" field to the query schema or remove searchable: true.`);
|
|
36
36
|
}
|
|
37
37
|
if (screen.searchable === false &&
|
|
38
38
|
schemaAccepts(schema, "search") &&
|
|
@@ -52,7 +52,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers) {
|
|
|
52
52
|
const searchActive = screen.searchable !== false && schemaAccepts(schema, "search");
|
|
53
53
|
const sortActive = schemaAccepts(schema, "sort");
|
|
54
54
|
if ((searchActive || sortActive) && screen.defaultSort === undefined) {
|
|
55
|
-
throw new Error(`${prefix}: defaultSort required when search or sort is active
|
|
55
|
+
throw new Error(`${prefix}: defaultSort required when search or sort is active. Set defaultSort: { field, direction }.`);
|
|
56
56
|
}
|
|
57
57
|
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
58
58
|
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
@@ -97,7 +97,7 @@ function validateProjectionListDateRangeFacets(prefix, screen, schema) {
|
|
|
97
97
|
continue;
|
|
98
98
|
if (facet.params.from === facet.params.to) {
|
|
99
99
|
throw new Error(`${prefix}: dateRange facet on "${facet.field}" names the same param ` +
|
|
100
|
-
`"${facet.params.from}" for both bounds.`);
|
|
100
|
+
`"${facet.params.from}" for both bounds. Use two different param names for from and to.`);
|
|
101
101
|
}
|
|
102
102
|
for (const param of [facet.params.from, facet.params.to]) {
|
|
103
103
|
if (RESERVED_LIST_PAYLOAD_KEYS.has(param)) {
|
|
@@ -106,7 +106,7 @@ function validateProjectionListDateRangeFacets(prefix, screen, schema) {
|
|
|
106
106
|
}
|
|
107
107
|
if (usedParams.has(param)) {
|
|
108
108
|
throw new Error(`${prefix}: dateRange facet on "${facet.field}" names "${param}" which another dateRange ` +
|
|
109
|
-
`facet on this screen already uses — the facets would overwrite each other's bound.`);
|
|
109
|
+
`facet on this screen already uses — the facets would overwrite each other's bound. Give each dateRange facet its own param names.`);
|
|
110
110
|
}
|
|
111
111
|
usedParams.add(param);
|
|
112
112
|
if (schemaAccepts(schema, param))
|
|
@@ -144,7 +144,7 @@ function validateRelatedListSearchable(prefix, section, schema) {
|
|
|
144
144
|
// skip: not searchable, or the schema already accepts search — nothing to reject.
|
|
145
145
|
if (section.searchable !== true || schemaAccepts(schema, "search"))
|
|
146
146
|
return;
|
|
147
|
-
throw new Error(`${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema
|
|
147
|
+
throw new Error(`${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema. Add a "search" field to the query schema or remove searchable: true.`);
|
|
148
148
|
}
|
|
149
149
|
function validateRelatedListFacets(prefix, section, schema) {
|
|
150
150
|
// skip: no facets declared — nothing to reject.
|
|
@@ -73,7 +73,7 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
73
73
|
const prefix = `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`;
|
|
74
74
|
// @wrapper-known semantic-alias
|
|
75
75
|
const checkHeaderField = (part, field) => {
|
|
76
|
-
checkFieldExists(recordShape, field, () => `${prefix} header.${part} references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
76
|
+
checkFieldExists(recordShape, field, () => `${prefix} header.${part} references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
77
77
|
};
|
|
78
78
|
if (screen.header !== undefined) {
|
|
79
79
|
checkHeaderField("title", screen.header.title);
|
|
@@ -103,11 +103,11 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
103
103
|
}
|
|
104
104
|
if (screen.recordTitleField !== undefined) {
|
|
105
105
|
const field = screen.recordTitleField;
|
|
106
|
-
checkFieldExists(recordShape, field, () => `${prefix} recordTitleField references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
106
|
+
checkFieldExists(recordShape, field, () => `${prefix} recordTitleField references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
107
107
|
}
|
|
108
108
|
for (const metric of screen.metrics ?? []) {
|
|
109
109
|
const field = metricField(metric);
|
|
110
|
-
checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
110
|
+
checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
111
111
|
}
|
|
112
112
|
checkDetailSectionFields(recordShape, prefix, screen);
|
|
113
113
|
checkRelatedListToolbarActionFields(recordShape, featureName, screenId, screen);
|
|
@@ -119,13 +119,13 @@ function checkDetailSectionFields(recordShape, prefix, screen) {
|
|
|
119
119
|
for (const section of screen.layout.sections) {
|
|
120
120
|
const countField = "countField" in section ? section.countField : undefined;
|
|
121
121
|
if (countField !== undefined) {
|
|
122
|
-
checkFieldExists(recordShape, countField, () => `${prefix} section "${section.title ?? "(untitled)"}" countField references field "${countField}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
122
|
+
checkFieldExists(recordShape, countField, () => `${prefix} section "${section.title ?? "(untitled)"}" countField references field "${countField}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
123
123
|
}
|
|
124
124
|
if (section.kind !== undefined && section.kind !== "fields")
|
|
125
125
|
continue;
|
|
126
126
|
for (const entry of sectionFieldSpecs(section)) {
|
|
127
127
|
const field = typeof entry === "string" ? entry : entry.field;
|
|
128
|
-
checkFieldExists(recordShape, field, () => `${prefix} section "${section.title ?? section.id ?? "(untitled)"}" references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
128
|
+
checkFieldExists(recordShape, field, () => `${prefix} section "${section.title ?? section.id ?? "(untitled)"}" references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
}
|
|
@@ -158,7 +158,7 @@ function checkDashboardStatPanelFields(queryHandlers, featureName, screenId, pan
|
|
|
158
158
|
// skip: this stat-panel field slot is optional and wasn't declared — nothing to validate
|
|
159
159
|
if (field === undefined)
|
|
160
160
|
return;
|
|
161
|
-
checkFieldExists(recordShape, field, () => `${prefix} ${part} references field "${field}" which is not present in query "${panel.query}"'s outputSchema.`);
|
|
161
|
+
checkFieldExists(recordShape, field, () => `${prefix} ${part} references field "${field}" which is not present in query "${panel.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
162
162
|
};
|
|
163
163
|
checkPanelField("valueField", panel.valueField);
|
|
164
164
|
checkPanelField("subField", panel.subField);
|
|
@@ -174,7 +174,7 @@ function checkVisibleWhenField(queryHandlers, visibleWhen, owner) {
|
|
|
174
174
|
if (visibleWhen === undefined)
|
|
175
175
|
return;
|
|
176
176
|
const { query, field } = visibleWhen;
|
|
177
|
-
checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `${owner} visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
|
|
177
|
+
checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `${owner} visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
178
178
|
}
|
|
179
179
|
function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen) {
|
|
180
180
|
for (const panel of screen.panels) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
2
2
|
import type { ConfigKeyDefinition, FeatureDefinition } from "../types/index.js";
|
|
3
|
-
import type { DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
3
|
+
import type { ActionFormScreenDefinition, DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, SecretMintScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
4
4
|
export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
|
|
5
5
|
export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
|
|
6
6
|
export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList", context: string, action: {
|
|
@@ -13,6 +13,7 @@ export declare function validateScreenShortIdCollisions(screensByShortId: Readon
|
|
|
13
13
|
readonly screen: ScreenDefinition;
|
|
14
14
|
}>>): void;
|
|
15
15
|
export declare function resolveScreenTargetQn(featureName: string, target: string): string;
|
|
16
|
+
export declare function validateDrawerPrefillKeys(where: string, targetLabel: "drawer-target" | "navigate-target", targetScreenId: string, target: ActionFormScreenDefinition | SecretMintScreenDefinition, params: RowFieldExtractor): void;
|
|
16
17
|
export declare function validateScreens(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, allWriteHandlerQns: ReadonlySet<string>, allScreenQns: ReadonlySet<string>, configKeyDefsByQn: ReadonlyMap<string, ConfigKeyDefinition>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
17
18
|
readonly featureName: string;
|
|
18
19
|
readonly screen: ScreenDefinition;
|