@cosmicdrift/kumiko-framework 0.257.0 → 0.258.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/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.test.ts +3 -3
- package/src/__tests__/derived-fields.integration.test.ts +1 -1
- package/src/__tests__/entity-list-limits.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +7 -3
- package/src/__tests__/schema-cli-rebuild.integration.test.ts +2 -2
- package/src/__tests__/transition-guard.integration.test.ts +3 -3
- package/src/api/__tests__/api.test.ts +21 -3
- package/src/api/__tests__/batch.integration.test.ts +3 -3
- package/src/api/__tests__/body-limit.test.ts +7 -1
- package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +7 -1
- package/src/api/__tests__/metrics-endpoint.test.ts +7 -1
- package/src/api/__tests__/nested-write.integration.test.ts +3 -3
- package/src/api/__tests__/server-boot-guards.test.ts +11 -4
- package/src/bun-db/__tests__/write-brand.test.ts +4 -1
- package/src/db/__tests__/apply-entity-event-tenant.integration.test.ts +1 -1
- package/src/db/__tests__/assert-exists-in.integration.test.ts +1 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +2 -2
- package/src/db/__tests__/collect-table-metas.test.ts +9 -5
- package/src/db/__tests__/compound-types.test.ts +1 -1
- package/src/db/__tests__/config-seed.integration.test.ts +3 -3
- package/src/db/__tests__/eagerload.integration.test.ts +7 -7
- package/src/db/__tests__/entity-table-from-registry.test.ts +8 -2
- package/src/db/__tests__/entity-table-meta-source.test.ts +7 -3
- package/src/db/__tests__/entity-table-types.test.ts +4 -4
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +9 -2
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +4 -4
- package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +3 -3
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +4 -4
- package/src/db/__tests__/event-store-executor.integration.test.ts +11 -6
- package/src/db/__tests__/event-store-system-stream.integration.test.ts +2 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +6 -4
- package/src/db/__tests__/located-timestamp.test.ts +1 -1
- package/src/db/__tests__/money.test.ts +1 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +3 -3
- package/src/db/__tests__/parent-ref-clause.test.ts +213 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +30 -14
- package/src/db/__tests__/source-shadow-create.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-indexes.test.ts +14 -12
- package/src/db/__tests__/table-builder-required.test.ts +2 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +5 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +2 -2
- package/src/db/__tests__/unique-violation-mapping.integration.test.ts +2 -2
- package/src/db/event-store-executor-read.ts +97 -19
- package/src/db/parent-ref-clause.ts +182 -0
- package/src/db/table-builder.ts +7 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +50 -8
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +28 -4
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +20 -4
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +9 -3
- package/src/engine/__tests__/boot-validator.test.ts +716 -91
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +2 -2
- package/src/engine/__tests__/engine.test.ts +128 -37
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +4 -4
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +1 -1
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +1 -1
- package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +13 -3
- package/src/engine/__tests__/factories-long-text.test.ts +10 -6
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +1 -1
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/nav.test.ts +1 -1
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-actions.test.ts +6 -1
- package/src/engine/__tests__/projection-detail-singleton.test.ts +6 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +6 -1
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +7 -2
- package/src/engine/__tests__/projection.test.ts +1 -1
- package/src/engine/__tests__/registry.test.ts +13 -3
- package/src/engine/__tests__/schema-builder.test.ts +39 -11
- package/src/engine/__tests__/screen.test.ts +21 -15
- package/src/engine/__tests__/search-payload-extension.test.ts +15 -3
- package/src/engine/__tests__/store-table.test.ts +6 -3
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +12 -4
- package/src/engine/__tests__/validation-hooks.test.ts +7 -1
- package/src/engine/boot-validator/__tests__/ownership.test.ts +2 -2
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +149 -0
- package/src/engine/boot-validator/action-wiring.ts +7 -7
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/parent-ref.ts +96 -0
- package/src/engine/boot-validator/projection-list-screens.ts +36 -23
- package/src/engine/boot-validator/query-output-columns.ts +2 -1
- package/src/engine/boot-validator/query-refs.ts +2 -1
- package/src/engine/boot-validator/screens.ts +322 -131
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/define-handler.ts +6 -0
- package/src/engine/entity-handlers.ts +6 -1
- package/src/engine/factories.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +5 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/ownership.ts +15 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- package/src/engine/screen-helpers.ts +25 -0
- package/src/engine/types/index.ts +5 -0
- package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/files/__tests__/file-field-pipeline.integration.test.ts +1 -1
- package/src/files/__tests__/files.integration.test.ts +1 -1
- package/src/files/file-ref-entity.ts +9 -9
- package/src/i18n/__tests__/i18n.test.ts +7 -1
- package/src/i18n/required-surface-keys.ts +48 -0
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +3 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +2 -2
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
- package/src/pipeline/__tests__/cascade-handler.integration.test.ts +23 -8
- package/src/pipeline/__tests__/cascade-handler.test.ts +18 -3
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -3
- package/src/pipeline/__tests__/dispatcher.test.ts +42 -6
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +2 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +42 -6
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +6 -2
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +6 -2
- package/src/pipeline/__tests__/perf-rebuild.integration.test.ts +1 -1
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +3 -3
- package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +2 -2
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +13 -2
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
- package/src/search/__tests__/reindex-entity.integration.test.ts +6 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +7 -1
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +15 -5
- package/src/testing/e2e-generator.ts +2 -0
- package/src/testing/shared-entities.ts +4 -2
- package/src/ui-types/index.ts +4 -0
|
@@ -15,9 +15,10 @@ import {
|
|
|
15
15
|
normalizeEditField,
|
|
16
16
|
normalizeListColumn,
|
|
17
17
|
} from "../screen-helpers";
|
|
18
|
-
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
18
|
+
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
19
19
|
import { metricField } from "../types";
|
|
20
20
|
import type {
|
|
21
|
+
ActionFormScreenDefinition,
|
|
21
22
|
DashboardCustomPanel,
|
|
22
23
|
DashboardFilterDefinition,
|
|
23
24
|
DashboardPanelDefinition,
|
|
@@ -32,6 +33,7 @@ import type {
|
|
|
32
33
|
RowActionNavigateBase,
|
|
33
34
|
RowFieldExtractor,
|
|
34
35
|
ScreenDefinition,
|
|
36
|
+
SecretMintScreenDefinition,
|
|
35
37
|
ToolbarAction,
|
|
36
38
|
} from "../types/screen";
|
|
37
39
|
|
|
@@ -157,7 +159,9 @@ function validateRowActionNavigateParams(
|
|
|
157
159
|
(action.entityId !== undefined ||
|
|
158
160
|
(screenEntity !== undefined && target.screen.entity === screenEntity));
|
|
159
161
|
if (
|
|
160
|
-
(target.screen.type !== "actionForm" &&
|
|
162
|
+
(target.screen.type !== "actionForm" &&
|
|
163
|
+
target.screen.type !== "secretMint" &&
|
|
164
|
+
target.screen.type !== "entityEdit") ||
|
|
161
165
|
isEntityEditUpdate
|
|
162
166
|
) {
|
|
163
167
|
const reason = isEntityEditUpdate
|
|
@@ -171,9 +175,10 @@ function validateRowActionNavigateParams(
|
|
|
171
175
|
action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
172
176
|
throw new Error(
|
|
173
177
|
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
|
|
174
|
-
`sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm ` +
|
|
175
|
-
`and entityEdit-create targets read URL search params as initial values. Remove
|
|
176
|
-
`params extractor or retarget to an actionForm / cross-entity
|
|
178
|
+
`sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
|
|
179
|
+
`secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
|
|
180
|
+
`the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
|
|
181
|
+
`entityEdit-create screen.`,
|
|
177
182
|
);
|
|
178
183
|
}
|
|
179
184
|
}
|
|
@@ -184,7 +189,7 @@ function validateRowActionNavigateParams(
|
|
|
184
189
|
function validateWizardLayout(
|
|
185
190
|
featureName: string,
|
|
186
191
|
screenId: string,
|
|
187
|
-
screenType: "entityEdit" | "actionForm" | "configEdit",
|
|
192
|
+
screenType: "entityEdit" | "actionForm" | "configEdit" | "secretMint",
|
|
188
193
|
layout: EditLayout,
|
|
189
194
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
190
195
|
): void {
|
|
@@ -395,6 +400,8 @@ function validateDrawerTargetAction(
|
|
|
395
400
|
`Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`,
|
|
396
401
|
);
|
|
397
402
|
}
|
|
403
|
+
// secretMint is intentionally excluded — a one-time secret reveal belongs on
|
|
404
|
+
// its own page, not layered in a Drawer above a list.
|
|
398
405
|
if (target.type !== "actionForm") {
|
|
399
406
|
throw new Error(
|
|
400
407
|
`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
@@ -496,6 +503,277 @@ function resolveRowActionNavigateTarget(
|
|
|
496
503
|
return screensByShortId.get(action.screen)?.[0];
|
|
497
504
|
}
|
|
498
505
|
|
|
506
|
+
// Shared by the mint step's own handler (screen.handler) and its optional
|
|
507
|
+
// confirm step (confirm.handler) — both are a bare write-handler QN with the
|
|
508
|
+
// same registration requirement.
|
|
509
|
+
function validateWriteHandlerRegistered(
|
|
510
|
+
featureName: string,
|
|
511
|
+
screenId: string,
|
|
512
|
+
context: string,
|
|
513
|
+
handler: unknown,
|
|
514
|
+
allWriteHandlerQns: ReadonlySet<string>,
|
|
515
|
+
): void {
|
|
516
|
+
if (!handler || typeof handler !== "string") {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler.`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (!allWriteHandlerQns.has(handler)) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
|
|
524
|
+
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
525
|
+
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Every field entry must carry a `type` discriminator. An author typo
|
|
531
|
+
// (`title: { required: true }` without a type) would otherwise let
|
|
532
|
+
// RenderField silently fall through to the default renderer and submit an
|
|
533
|
+
// empty string — failing at boot is clearer. `type as unknown` because
|
|
534
|
+
// FieldDefinition, as a union, only allows known type strings; here we're
|
|
535
|
+
// checking author code that may have circumvented the type check.
|
|
536
|
+
//
|
|
537
|
+
// Parametrized over (fields, layout) instead of a whole screen so a
|
|
538
|
+
// secretMint's own `confirm` step — a second, independent inline form on the
|
|
539
|
+
// same screen — can reuse the same checks as its `fields`/`layout` pair.
|
|
540
|
+
// `allowEmpty` is only ever true for a secretMint's own mint step (an
|
|
541
|
+
// input-less mint, fw#2838): the secret is server-generated and the mint
|
|
542
|
+
// step is just its submit button — actionForm and the confirm step always
|
|
543
|
+
// require at least one field.
|
|
544
|
+
function validateFormFieldsMap(
|
|
545
|
+
featureName: string,
|
|
546
|
+
screenId: string,
|
|
547
|
+
context: string,
|
|
548
|
+
fields: Readonly<Record<string, FieldDefinition>>,
|
|
549
|
+
allowEmpty: boolean,
|
|
550
|
+
): Set<string> {
|
|
551
|
+
const fieldNames = new Set(Object.keys(fields));
|
|
552
|
+
if (fieldNames.size === 0 && !allowEmpty) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty fields map — ` +
|
|
555
|
+
`declare at least one field.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
for (const [fname, fdef] of Object.entries(fields)) {
|
|
559
|
+
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
560
|
+
const ftype = (fdef as { type?: unknown }).type;
|
|
561
|
+
if (typeof ftype !== "string" || ftype.length === 0) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fname}" has no ` +
|
|
564
|
+
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return fieldNames;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
|
|
572
|
+
// caller only ever passes true together with an actually-empty fields map
|
|
573
|
+
// (an input-less secretMint declares BOTH `fields: {}` and
|
|
574
|
+
// `layout.sections: []`; fields declared with no layout to show them stays
|
|
575
|
+
// an error).
|
|
576
|
+
function validateFormLayoutSections(
|
|
577
|
+
featureName: string,
|
|
578
|
+
screenId: string,
|
|
579
|
+
context: string,
|
|
580
|
+
layout: EditLayout,
|
|
581
|
+
fieldNames: ReadonlySet<string>,
|
|
582
|
+
allowEmptySections: boolean,
|
|
583
|
+
): void {
|
|
584
|
+
if (layout.sections.length === 0) {
|
|
585
|
+
// skip: an input-less secretMint declares fields: {} and sections: [] together — no sections to validate.
|
|
586
|
+
if (allowEmptySections) return;
|
|
587
|
+
throw new Error(
|
|
588
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) has an empty sections list — ` +
|
|
589
|
+
`declare at least one section.`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
for (const section of layout.sections) {
|
|
593
|
+
if (isExtensionEditSection(section)) {
|
|
594
|
+
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) extension section ` +
|
|
597
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (section.kind === "relatedList") {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) relatedList section ` +
|
|
605
|
+
`"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
|
|
606
|
+
`primitive (fw#2166).`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (isWriteFormEditSection(section)) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) writeForm section ` +
|
|
612
|
+
`"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (section.fields.length === 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) has a section "${section.title}" ` +
|
|
618
|
+
`with zero fields — drop the section or add fields to it.`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
for (const fieldSpec of section.fields) {
|
|
622
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
623
|
+
if (!fieldNames.has(normalized.field)) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`[Feature ${featureName}] Screen "${screenId}" (${context}) layout references unknown field ` +
|
|
626
|
+
`"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function validateInlineFormNavTargets(
|
|
634
|
+
feature: FeatureDefinition,
|
|
635
|
+
screenId: string,
|
|
636
|
+
screen: ActionFormScreenDefinition | SecretMintScreenDefinition,
|
|
637
|
+
kind: "actionForm" | "secretMint",
|
|
638
|
+
allScreenQns: ReadonlySet<string>,
|
|
639
|
+
): void {
|
|
640
|
+
if (screen.redirect !== undefined) {
|
|
641
|
+
// redirect is either a short screen id (same-feature, e.g. "item-list")
|
|
642
|
+
// or a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
643
|
+
// renderer strips the latter to the short id (lastSegment) when
|
|
644
|
+
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
645
|
+
// form (fw#2670, actionForm only) carries the same target under `screen`
|
|
646
|
+
// plus the payload field `idFrom`.
|
|
647
|
+
const redirectTarget =
|
|
648
|
+
typeof screen.redirect === "string" ? screen.redirect : screen.redirect.screen;
|
|
649
|
+
if (
|
|
650
|
+
typeof screen.redirect !== "string" &&
|
|
651
|
+
(typeof screen.redirect.idFrom !== "string" || screen.redirect.idFrom.trim() === "")
|
|
652
|
+
) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${kind}) redirect.idFrom is empty or not a string — ` +
|
|
655
|
+
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
656
|
+
`redirect form to navigate with the handler's own "id".`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
validateScreenNavTarget(
|
|
660
|
+
feature.name,
|
|
661
|
+
screenId,
|
|
662
|
+
kind,
|
|
663
|
+
"redirect",
|
|
664
|
+
redirectTarget,
|
|
665
|
+
allScreenQns,
|
|
666
|
+
feature.screens,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
if (typeof screen.cancelTarget === "string") {
|
|
670
|
+
// Same rule as redirect — `false` (no Cancel button) needs no validation.
|
|
671
|
+
validateScreenNavTarget(
|
|
672
|
+
feature.name,
|
|
673
|
+
screenId,
|
|
674
|
+
kind,
|
|
675
|
+
"cancelTarget",
|
|
676
|
+
screen.cancelTarget,
|
|
677
|
+
allScreenQns,
|
|
678
|
+
feature.screens,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// actionForm/secretMint have no entity link, only a write-handler QN +
|
|
684
|
+
// inline fields: checks handler registration, field types, layout refs, and nav targets.
|
|
685
|
+
function validateInlineFormScreen(
|
|
686
|
+
feature: FeatureDefinition,
|
|
687
|
+
screenId: string,
|
|
688
|
+
screen: ActionFormScreenDefinition | SecretMintScreenDefinition,
|
|
689
|
+
kind: "actionForm" | "secretMint",
|
|
690
|
+
allWriteHandlerQns: ReadonlySet<string>,
|
|
691
|
+
allScreenQns: ReadonlySet<string>,
|
|
692
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
693
|
+
): void {
|
|
694
|
+
validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
|
|
695
|
+
// Only a secretMint's own mint step may skip fields/sections entirely
|
|
696
|
+
// (input-less mint, fw#2838) — actionForm always needs at least one field.
|
|
697
|
+
const allowEmptyMintForm = kind === "secretMint";
|
|
698
|
+
const fieldNames = validateFormFieldsMap(
|
|
699
|
+
feature.name,
|
|
700
|
+
screenId,
|
|
701
|
+
kind,
|
|
702
|
+
screen.fields,
|
|
703
|
+
allowEmptyMintForm,
|
|
704
|
+
);
|
|
705
|
+
validateFormLayoutSections(
|
|
706
|
+
feature.name,
|
|
707
|
+
screenId,
|
|
708
|
+
kind,
|
|
709
|
+
screen.layout,
|
|
710
|
+
fieldNames,
|
|
711
|
+
allowEmptyMintForm && fieldNames.size === 0,
|
|
712
|
+
);
|
|
713
|
+
validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
|
|
714
|
+
validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
|
|
718
|
+
// on the reveal card in place of the bare acknowledge button (TOTP enrollment:
|
|
719
|
+
// scan the code, then enter one). Unlike the mint step, confirm always needs
|
|
720
|
+
// at least one field — a step whose only job is proving receipt with no input
|
|
721
|
+
// would be meaningless — and its layout is a single plain form, never a
|
|
722
|
+
// wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
|
|
723
|
+
// not a multi-step flow, and persisting a draft of it would re-open the leak
|
|
724
|
+
// fw#2548 closed (a lingering copy of the just-revealed secret in storage).
|
|
725
|
+
function validateSecretMintConfirm(
|
|
726
|
+
feature: FeatureDefinition,
|
|
727
|
+
screenId: string,
|
|
728
|
+
screen: SecretMintScreenDefinition,
|
|
729
|
+
allWriteHandlerQns: ReadonlySet<string>,
|
|
730
|
+
): void {
|
|
731
|
+
const confirm = screen.confirm;
|
|
732
|
+
// skip: the screen declares no confirm step — nothing to validate.
|
|
733
|
+
if (confirm === undefined) return;
|
|
734
|
+
const context = "secretMint confirm";
|
|
735
|
+
validateWriteHandlerRegistered(
|
|
736
|
+
feature.name,
|
|
737
|
+
screenId,
|
|
738
|
+
context,
|
|
739
|
+
confirm.handler,
|
|
740
|
+
allWriteHandlerQns,
|
|
741
|
+
);
|
|
742
|
+
const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
|
|
743
|
+
validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, false);
|
|
744
|
+
if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
|
|
747
|
+
`"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
|
|
748
|
+
`Remove mode or set it to "single".`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (confirm.layout.draft === true) {
|
|
752
|
+
throw new Error(
|
|
753
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.draft: true — ` +
|
|
754
|
+
`persisting a draft of the reveal-confirm step would leak the one-time secret into storage, ` +
|
|
755
|
+
`the exact leak fw#2548 closed. Remove draft: true.`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
// skip: no carry list declared — no carried fields to cross-check against confirm.fields.
|
|
759
|
+
if (confirm.carry === undefined) return;
|
|
760
|
+
for (const carryField of confirm.carry) {
|
|
761
|
+
if (typeof carryField !== "string" || carryField.trim() === "") {
|
|
762
|
+
throw new Error(
|
|
763
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${context}) has an empty or non-string ` +
|
|
764
|
+
`entry in "carry".`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (fieldNames.has(carryField)) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${context}) carry field "${carryField}" ` +
|
|
770
|
+
`also names a confirm.fields entry — a carried mint-payload value would silently ` +
|
|
771
|
+
`overwrite the user-entered field of the same name. Rename one of them.`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
499
777
|
export function validateScreens(
|
|
500
778
|
feature: FeatureDefinition,
|
|
501
779
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -1052,144 +1330,57 @@ export function validateScreens(
|
|
|
1052
1330
|
}
|
|
1053
1331
|
|
|
1054
1332
|
if (screen.type === "actionForm") {
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
|
|
1079
|
-
);
|
|
1080
|
-
}
|
|
1081
|
-
const fieldNames = new Set(Object.keys(screen.fields));
|
|
1082
|
-
if (fieldNames.size === 0) {
|
|
1333
|
+
validateInlineFormScreen(
|
|
1334
|
+
feature,
|
|
1335
|
+
screenId,
|
|
1336
|
+
screen,
|
|
1337
|
+
"actionForm",
|
|
1338
|
+
allWriteHandlerQns,
|
|
1339
|
+
allScreenQns,
|
|
1340
|
+
featureMap,
|
|
1341
|
+
);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (screen.type === "secretMint") {
|
|
1346
|
+
validateInlineFormScreen(
|
|
1347
|
+
feature,
|
|
1348
|
+
screenId,
|
|
1349
|
+
screen,
|
|
1350
|
+
"secretMint",
|
|
1351
|
+
allWriteHandlerQns,
|
|
1352
|
+
allScreenQns,
|
|
1353
|
+
featureMap,
|
|
1354
|
+
);
|
|
1355
|
+
if (screen.reveal.fields.length === 0) {
|
|
1083
1356
|
throw new Error(
|
|
1084
|
-
`[Feature ${feature.name}] Screen "${screenId}" (
|
|
1085
|
-
`declare at least one field.`,
|
|
1357
|
+
`[Feature ${feature.name}] Screen "${screenId}" (secretMint) has an empty reveal.fields ` +
|
|
1358
|
+
`list — declare at least one field to reveal.`,
|
|
1086
1359
|
);
|
|
1087
1360
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
// schickt einen leeren String — silent broken. Boot-Fail ist
|
|
1092
|
-
// klarer. `type as unknown` weil FieldDefinition als Union nur
|
|
1093
|
-
// bekannte Strings erlaubt; wir prüfen Author-Code, der ggf.
|
|
1094
|
-
// den Type-Check umgangen hat.
|
|
1095
|
-
for (const [fname, fdef] of Object.entries(screen.fields)) {
|
|
1096
|
-
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
1097
|
-
const ftype = (fdef as { type?: unknown }).type;
|
|
1098
|
-
if (typeof ftype !== "string" || ftype.length === 0) {
|
|
1361
|
+
const revealFieldNames = new Set<string>();
|
|
1362
|
+
for (const revealField of screen.reveal.fields) {
|
|
1363
|
+
if (typeof revealField.field !== "string" || revealField.field.trim() === "") {
|
|
1099
1364
|
throw new Error(
|
|
1100
|
-
`[Feature ${feature.name}] Screen "${screenId}" (
|
|
1101
|
-
|
|
1365
|
+
`[Feature ${feature.name}] Screen "${screenId}" (secretMint) has a reveal.fields entry ` +
|
|
1366
|
+
`with an empty or non-string "field".`,
|
|
1102
1367
|
);
|
|
1103
1368
|
}
|
|
1104
|
-
|
|
1105
|
-
if (screen.layout.sections.length === 0) {
|
|
1106
|
-
throw new Error(
|
|
1107
|
-
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has an empty sections list — ` +
|
|
1108
|
-
`declare at least one section.`,
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
for (const section of screen.layout.sections) {
|
|
1112
|
-
if (isExtensionEditSection(section)) {
|
|
1113
|
-
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
1114
|
-
throw new Error(
|
|
1115
|
-
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) extension section ` +
|
|
1116
|
-
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
1117
|
-
);
|
|
1118
|
-
}
|
|
1119
|
-
continue;
|
|
1120
|
-
}
|
|
1121
|
-
if (section.kind === "relatedList") {
|
|
1369
|
+
if (revealFieldNames.has(revealField.field)) {
|
|
1122
1370
|
throw new Error(
|
|
1123
|
-
`[Feature ${feature.name}] Screen "${screenId}" (
|
|
1124
|
-
`"${
|
|
1125
|
-
`primitive (fw#2166).`,
|
|
1371
|
+
`[Feature ${feature.name}] Screen "${screenId}" (secretMint) reveal.fields has a ` +
|
|
1372
|
+
`duplicate field "${revealField.field}" — each revealed field must be unique.`,
|
|
1126
1373
|
);
|
|
1127
1374
|
}
|
|
1128
|
-
|
|
1375
|
+
revealFieldNames.add(revealField.field);
|
|
1376
|
+
if (typeof revealField.label !== "string" || revealField.label.trim() === "") {
|
|
1129
1377
|
throw new Error(
|
|
1130
|
-
`[Feature ${feature.name}] Screen "${screenId}" (
|
|
1131
|
-
`"${
|
|
1378
|
+
`[Feature ${feature.name}] Screen "${screenId}" (secretMint) reveal.fields entry ` +
|
|
1379
|
+
`"${revealField.field}" has an empty or non-string "label".`,
|
|
1132
1380
|
);
|
|
1133
1381
|
}
|
|
1134
|
-
if (section.fields.length === 0) {
|
|
1135
|
-
throw new Error(
|
|
1136
|
-
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
|
|
1137
|
-
`with zero fields — drop the section or add fields to it.`,
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
for (const fieldSpec of section.fields) {
|
|
1141
|
-
const normalized = normalizeEditField(fieldSpec);
|
|
1142
|
-
if (!fieldNames.has(normalized.field)) {
|
|
1143
|
-
throw new Error(
|
|
1144
|
-
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) layout references unknown field ` +
|
|
1145
|
-
`"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
|
|
1146
|
-
);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
|
|
1151
|
-
if (screen.redirect !== undefined) {
|
|
1152
|
-
// redirect is either a short screen id (same-feature, e.g.
|
|
1153
|
-
// "item-list") or a fully-qualified cross-feature QN
|
|
1154
|
-
// (`<feature>:screen:<id>`) — the renderer strips the latter to the
|
|
1155
|
-
// short id (lastSegment) when navigating, which the nav-router
|
|
1156
|
-
// resolves app-wide (#1946). The object form (fw#2670) carries the
|
|
1157
|
-
// same target under `screen` plus the payload field `idFrom`.
|
|
1158
|
-
const redirectTarget =
|
|
1159
|
-
typeof screen.redirect === "string" ? screen.redirect : screen.redirect.screen;
|
|
1160
|
-
if (
|
|
1161
|
-
typeof screen.redirect !== "string" &&
|
|
1162
|
-
(typeof screen.redirect.idFrom !== "string" || screen.redirect.idFrom.trim() === "")
|
|
1163
|
-
) {
|
|
1164
|
-
throw new Error(
|
|
1165
|
-
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect.idFrom is empty or not a string — ` +
|
|
1166
|
-
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
1167
|
-
`redirect form to navigate with the handler's own "id".`,
|
|
1168
|
-
);
|
|
1169
|
-
}
|
|
1170
|
-
validateScreenNavTarget(
|
|
1171
|
-
feature.name,
|
|
1172
|
-
screenId,
|
|
1173
|
-
"actionForm",
|
|
1174
|
-
"redirect",
|
|
1175
|
-
redirectTarget,
|
|
1176
|
-
allScreenQns,
|
|
1177
|
-
feature.screens,
|
|
1178
|
-
);
|
|
1179
|
-
}
|
|
1180
|
-
if (typeof screen.cancelTarget === "string") {
|
|
1181
|
-
// Gleiche Regel wie redirect — `false` (kein Cancel-Button)
|
|
1182
|
-
// braucht keine Validierung.
|
|
1183
|
-
validateScreenNavTarget(
|
|
1184
|
-
feature.name,
|
|
1185
|
-
screenId,
|
|
1186
|
-
"actionForm",
|
|
1187
|
-
"cancelTarget",
|
|
1188
|
-
screen.cancelTarget,
|
|
1189
|
-
allScreenQns,
|
|
1190
|
-
feature.screens,
|
|
1191
|
-
);
|
|
1192
1382
|
}
|
|
1383
|
+
validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns);
|
|
1193
1384
|
continue;
|
|
1194
1385
|
}
|
|
1195
1386
|
|
|
@@ -442,9 +442,9 @@ function deriveField(def: ConfigKeyDefinition): FieldDefinition {
|
|
|
442
442
|
case "select":
|
|
443
443
|
return def.options !== undefined && def.options.length > 0
|
|
444
444
|
? createSelectField({ options: def.options })
|
|
445
|
-
: createTextField();
|
|
445
|
+
: createTextField({ personal: false, reason: "system_metadata" });
|
|
446
446
|
default:
|
|
447
|
-
return createTextField();
|
|
447
|
+
return createTextField({ personal: false, reason: "system_metadata" });
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
450
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
|
|
2
|
+
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
3
|
import type { ZodType, z } from "zod";
|
|
3
4
|
import type { ContainsSecret } from "../secrets/types";
|
|
4
5
|
import { runPipeline } from "./run-pipeline";
|
|
@@ -147,6 +148,11 @@ export function isPagedQueryHandler(def: object): boolean {
|
|
|
147
148
|
return (def as Record<string, unknown>)[PAGED_QUERY_HANDLER_BRAND] === true;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
export function isEntityConventionQueryHandler(def: object): boolean {
|
|
152
|
+
// @cast-boundary brand-probe — same shape as isPagedQueryHandler above.
|
|
153
|
+
return (def as Record<string, unknown>)[ENTITY_CONVENTION_QUERY_BRAND] === true;
|
|
154
|
+
}
|
|
155
|
+
|
|
150
156
|
export type PagedQueryHandlerDefinition<
|
|
151
157
|
TName extends string = string,
|
|
152
158
|
TSchema extends ZodType = ZodType,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { type ZodType, z } from "zod";
|
|
2
3
|
import type { DbRow } from "../db/connection";
|
|
3
4
|
import {
|
|
@@ -388,6 +389,7 @@ export function defineEntityQueryHandler(
|
|
|
388
389
|
fields: ctx.registry.getSortableReferences(entityName),
|
|
389
390
|
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
390
391
|
},
|
|
392
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
391
393
|
});
|
|
392
394
|
const enrichedRows = hasRefFields
|
|
393
395
|
? await enrichWithReferences(
|
|
@@ -404,7 +406,9 @@ export function defineEntityQueryHandler(
|
|
|
404
406
|
schema = idSchema;
|
|
405
407
|
handler = async (query, ctx) => {
|
|
406
408
|
const db = dbFor(ctx);
|
|
407
|
-
const row = await executor.detail(query.payload as IdPayload, query.user, db
|
|
409
|
+
const row = await executor.detail(query.payload as IdPayload, query.user, db, {
|
|
410
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
411
|
+
}); // @cast-boundary engine-payload
|
|
408
412
|
if (row === null || !hasRefFields) return row;
|
|
409
413
|
return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db);
|
|
410
414
|
};
|
|
@@ -426,6 +430,7 @@ export function defineEntityQueryHandler(
|
|
|
426
430
|
// definePagedQueryHandler on top. The "detail" verb returns a single
|
|
427
431
|
// row/null, never this shape.
|
|
428
432
|
...(verb === "list" && { [PAGED_QUERY_HANDLER_BRAND]: true }),
|
|
433
|
+
[ENTITY_CONVENTION_QUERY_BRAND]: true,
|
|
429
434
|
};
|
|
430
435
|
}
|
|
431
436
|
|
package/src/engine/factories.ts
CHANGED
|
@@ -456,6 +456,8 @@ export function createEntity<F>(def: {
|
|
|
456
456
|
readonly indexes?: readonly EntityIndexDef[];
|
|
457
457
|
readonly idType?: "serial" | "uuid";
|
|
458
458
|
readonly access?: EntityDefinition["access"];
|
|
459
|
+
/** Join-row carrier reference — see EntityDefinition.parentRef. */
|
|
460
|
+
readonly parentRef?: EntityDefinition["parentRef"];
|
|
459
461
|
readonly retention?: RetentionDef;
|
|
460
462
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
461
463
|
}): F extends FieldsMap ? EntityDefinition<F> : never {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import type { ZodType, z } from "zod";
|
|
2
3
|
import { toTableName } from "../db/table-builder";
|
|
3
4
|
import {
|
|
5
|
+
isEntityConventionQueryHandler,
|
|
4
6
|
isPagedQueryHandler,
|
|
5
7
|
PAGED_QUERY_HANDLER_BRAND,
|
|
6
8
|
type QueryHandlerDefinition,
|
|
@@ -184,6 +186,9 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
184
186
|
// Carry the definePagedQueryHandler brand through — this rebuild
|
|
185
187
|
// drops any field not explicitly listed.
|
|
186
188
|
...(isPagedQueryHandler(def) && { [PAGED_QUERY_HANDLER_BRAND]: true }),
|
|
189
|
+
...(isEntityConventionQueryHandler(def) && {
|
|
190
|
+
[ENTITY_CONVENTION_QUERY_BRAND]: true as const,
|
|
191
|
+
}),
|
|
187
192
|
};
|
|
188
193
|
tryMapEntity(state, name, def.name);
|
|
189
194
|
return { name: def.name };
|
package/src/engine/index.ts
CHANGED
|
@@ -221,6 +221,7 @@ export {
|
|
|
221
221
|
export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
|
|
222
222
|
export {
|
|
223
223
|
buildOwnershipClause,
|
|
224
|
+
combineClauses,
|
|
224
225
|
from,
|
|
225
226
|
normalizeAccessEntry,
|
|
226
227
|
userCanCreateFieldRow,
|
|
@@ -411,6 +412,7 @@ export type {
|
|
|
411
412
|
NotifyPriority,
|
|
412
413
|
NumberFieldDef,
|
|
413
414
|
OnDeleteStrategy,
|
|
415
|
+
ParentRefDef,
|
|
414
416
|
PersonalAnnotations,
|
|
415
417
|
PersonalAnnotationsLongText,
|
|
416
418
|
PersonalAnnotationsNoFind,
|
|
@@ -447,6 +449,10 @@ export type {
|
|
|
447
449
|
ScreenNavSugar,
|
|
448
450
|
ScreenSlots,
|
|
449
451
|
SecretKeyHandle,
|
|
452
|
+
SecretMintConfirmStep,
|
|
453
|
+
SecretMintScreenDefinition,
|
|
454
|
+
SecretReveal,
|
|
455
|
+
SecretRevealField,
|
|
450
456
|
SecretsEditScreenDefinition,
|
|
451
457
|
SecretsEditSection,
|
|
452
458
|
SelectFieldDef,
|
package/src/engine/ownership.ts
CHANGED
|
@@ -336,6 +336,21 @@ export function buildOwnershipClause(
|
|
|
336
336
|
return { kind: "sql", sqlText: `(${sqlText})`, params };
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
// AND-combine two independently built read clauses. Both are built at
|
|
340
|
+
// paramStart=1; `b`'s placeholders are shifted past `a`'s params so the
|
|
341
|
+
// concatenated fragment binds positionally.
|
|
342
|
+
export function combineClauses(a: OwnershipClause, b: OwnershipClause): OwnershipClause {
|
|
343
|
+
if (a.kind === "empty" || b.kind === "empty") return { kind: "empty" };
|
|
344
|
+
if (a.kind === "pass") return b;
|
|
345
|
+
if (b.kind === "pass") return a;
|
|
346
|
+
const shifted = shiftParams({ sqlText: b.sqlText, params: b.params }, a.params.length);
|
|
347
|
+
return {
|
|
348
|
+
kind: "sql",
|
|
349
|
+
sqlText: `(${a.sqlText} AND ${shifted.sqlText})`,
|
|
350
|
+
params: [...a.params, ...shifted.params],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
339
354
|
type RuleFragmentResult =
|
|
340
355
|
| { readonly kind: "empty" }
|
|
341
356
|
| { readonly kind: "sql"; readonly sqlText: string; readonly params: readonly unknown[] };
|
|
@@ -26,6 +26,7 @@ export const screenSchema: PatternFormSchema = {
|
|
|
26
26
|
{ value: "entityList", label: { en: "Entity list" } },
|
|
27
27
|
{ value: "entityEdit", label: { en: "Entity edit" } },
|
|
28
28
|
{ value: "actionForm", label: { en: "Action form" } },
|
|
29
|
+
{ value: "secretMint", label: { en: "Secret mint" } },
|
|
29
30
|
{ value: "custom", label: { en: "Custom" } },
|
|
30
31
|
],
|
|
31
32
|
required: true,
|