@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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/package.json +3 -3
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +91 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { parseQn, QnTypes, qualifyEntityName } from "../qualified-name";
|
|
2
3
|
import { isEditLayoutScreen } from "../screen-helpers";
|
|
3
4
|
import type {
|
|
4
5
|
EditRelatedListSection,
|
|
6
|
+
EntityDefinition,
|
|
5
7
|
FeatureDefinition,
|
|
6
8
|
ProjectionListScreenDefinition,
|
|
7
9
|
QueryHandlerDef,
|
|
8
10
|
} from "../types";
|
|
9
11
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
|
|
12
|
+
import { findEntity } from "./parent-ref";
|
|
10
13
|
import { getZodObjectShape } from "./zod-shape";
|
|
11
14
|
|
|
12
15
|
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
@@ -154,6 +157,65 @@ function validateRelatedListFacets(
|
|
|
154
157
|
);
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
// parentFilter resolves an entity field map to
|
|
161
|
+
// check `field` against ONLY when the query is one of the entity-convention
|
|
162
|
+
// factories (defineEntityListHandler et al., branded by
|
|
163
|
+
// ENTITY_CONVENTION_QUERY_BRAND) — a hand-written handler carries no entity,
|
|
164
|
+
// same "capability absent, no throw" policy as schemaAccepts above. The
|
|
165
|
+
// entity name comes from the handler's own `<entityName>:list` naming
|
|
166
|
+
// convention (parseHandlerName in entity-handlers.ts), recovered here from
|
|
167
|
+
// the query's fully qualified name.
|
|
168
|
+
function resolveEntityBehindQuery(
|
|
169
|
+
queryQn: string,
|
|
170
|
+
handler: QueryHandlerDef | undefined,
|
|
171
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
172
|
+
): EntityDefinition | undefined {
|
|
173
|
+
if (handler === undefined || handler[ENTITY_CONVENTION_QUERY_BRAND] !== true) return undefined;
|
|
174
|
+
let entityName: string;
|
|
175
|
+
try {
|
|
176
|
+
entityName = parseQn(queryQn).name.split(":")[0] ?? "";
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
return entityName === "" ? undefined : findEntity(featureMap, entityName);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// parentFilter lets a relatedList reuse the
|
|
184
|
+
// generic `<entity>:list` query instead of a bespoke child-rows handler —
|
|
185
|
+
// same 422 footgun as facets/filter above, plus its own mutual-exclusivity
|
|
186
|
+
// and field-existence checks.
|
|
187
|
+
function validateRelatedListParentFilter(
|
|
188
|
+
prefix: string,
|
|
189
|
+
section: EditRelatedListSection,
|
|
190
|
+
schema: QueryHandlerDef["schema"] | undefined,
|
|
191
|
+
handler: QueryHandlerDef | undefined,
|
|
192
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
193
|
+
): void {
|
|
194
|
+
// skip: no parentFilter declared — nothing to reject.
|
|
195
|
+
if (section.parentFilter === undefined) return;
|
|
196
|
+
if (section.parentParam !== undefined) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${prefix}: declares both parentFilter and parentParam — parentFilter replaces the implicit ` +
|
|
199
|
+
`{ [parentParam]: parentId } payload key with a server-side "filter" clause, so the two can't ` +
|
|
200
|
+
`be combined. Remove parentParam or drop parentFilter.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (!schemaAccepts(schema, "filter")) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`${prefix}: declares parentFilter but query "${section.query}" has no "filter" parameter in its ` +
|
|
206
|
+
`Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
|
|
207
|
+
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const entity = resolveEntityBehindQuery(section.query, handler, featureMap);
|
|
211
|
+
if (entity !== undefined && entity.fields[section.parentFilter.field] === undefined) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`${prefix}: parentFilter.field "${section.parentFilter.field}" is not a declared field on the ` +
|
|
214
|
+
`entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
// relatedList sections declare search/facets the same way a projectionList
|
|
158
220
|
// screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
|
|
159
221
|
// auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
|
|
@@ -163,15 +225,18 @@ function validateRelatedListFacets(
|
|
|
163
225
|
// doesn't quietly skip these checks.
|
|
164
226
|
export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
|
|
165
227
|
const queryHandlers = buildQueryHandlerMap(features);
|
|
228
|
+
const featureMap = new Map(features.map((f) => [f.name, f] as const));
|
|
166
229
|
for (const feature of features) {
|
|
167
230
|
for (const screen of Object.values(feature.screens)) {
|
|
168
231
|
if (!isEditLayoutScreen(screen)) continue;
|
|
169
232
|
for (const section of screen.layout.sections) {
|
|
170
233
|
if (section.kind !== "relatedList") continue;
|
|
171
234
|
const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
|
|
172
|
-
const
|
|
235
|
+
const handler = queryHandlers.get(section.query);
|
|
236
|
+
const schema = handler?.schema;
|
|
173
237
|
validateRelatedListSearchable(prefix, section, schema);
|
|
174
238
|
validateRelatedListFacets(prefix, section, schema);
|
|
239
|
+
validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
|
|
175
240
|
}
|
|
176
241
|
}
|
|
177
242
|
}
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from "../types";
|
|
15
15
|
import { metricField } from "../types";
|
|
16
16
|
import { buildQueryHandlerMap } from "./projection-list-screens";
|
|
17
|
+
import { validateActionFieldRefs } from "./screens";
|
|
17
18
|
import { getZodObjectShape, getZodRowShape } from "./zod-shape";
|
|
18
19
|
|
|
19
20
|
// fw#2493: query handlers can now declare `outputSchema` (the Zod shape of
|
|
@@ -139,6 +140,38 @@ function checkProjectionDetailOutputFields(
|
|
|
139
140
|
`${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
|
|
140
141
|
);
|
|
141
142
|
}
|
|
143
|
+
checkRelatedListToolbarActionFields(recordShape, featureName, screenId, screen);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// relatedList toolbarActions' visible/params evaluate against the ENCLOSING
|
|
147
|
+
// projectionDetail's record, not the relatedList's own row — reuses the
|
|
148
|
+
// same per-action field-ref check as rowActions/toolbarActions elsewhere
|
|
149
|
+
// (validateActionFieldRefs), pinned to this screen's own outputSchema shape
|
|
150
|
+
// instead of an entity's field map. Same additive policy as the rest of this
|
|
151
|
+
// module: no introspectable outputSchema → no-op, not a throw.
|
|
152
|
+
function checkRelatedListToolbarActionFields(
|
|
153
|
+
recordShape: ShapeLookup | undefined,
|
|
154
|
+
featureName: string,
|
|
155
|
+
screenId: string,
|
|
156
|
+
screen: ProjectionDetailScreenDefinition,
|
|
157
|
+
): void {
|
|
158
|
+
// skip: no introspectable outputSchema — same additive policy as the rest of this module.
|
|
159
|
+
if (recordShape === undefined) return;
|
|
160
|
+
const fieldNames = new Set(Object.keys(recordShape));
|
|
161
|
+
for (const section of screen.layout.sections) {
|
|
162
|
+
if (section.kind !== "relatedList") continue;
|
|
163
|
+
for (const action of section.toolbarActions ?? []) {
|
|
164
|
+
validateActionFieldRefs(
|
|
165
|
+
featureName,
|
|
166
|
+
screenId,
|
|
167
|
+
"toolbarAction",
|
|
168
|
+
action.id,
|
|
169
|
+
action,
|
|
170
|
+
fieldNames,
|
|
171
|
+
new Set(),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
142
175
|
}
|
|
143
176
|
|
|
144
177
|
// Stat panels' query contract is a flat record (not the paged `{ rows }`
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
9
9
|
import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
|
|
10
|
+
import { parseRefTarget } from "../parse-ref-target";
|
|
10
11
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
|
|
11
12
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
12
13
|
import {
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
isWriteFormEditSection,
|
|
15
16
|
normalizeEditField,
|
|
16
17
|
normalizeListColumn,
|
|
18
|
+
resolveNavParentScreen,
|
|
17
19
|
} from "../screen-helpers";
|
|
18
20
|
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
19
21
|
import { metricField } from "../types";
|
|
@@ -31,6 +33,7 @@ import type {
|
|
|
31
33
|
FieldCondition,
|
|
32
34
|
ListColumnSpec,
|
|
33
35
|
ListFacetSpec,
|
|
36
|
+
RelatedListToolbarAction,
|
|
34
37
|
RowAction,
|
|
35
38
|
RowActionNavigateBase,
|
|
36
39
|
RowFieldExtractor,
|
|
@@ -270,13 +273,15 @@ function validateWizardLayout(
|
|
|
270
273
|
// Felder pinnen — ein Tippfehler in pick/map-Quellfeldern oder
|
|
271
274
|
// visible.field erzeugte sonst still `undefined` im Payload bzw. dauerhaft
|
|
272
275
|
// falsche Sichtbarkeit (gleiche "Typo fällt erst beim Klick"-Klasse wie
|
|
273
|
-
// navigate/handler).
|
|
274
|
-
|
|
276
|
+
// navigate/handler). Exported for query-output-columns.ts, which reuses it
|
|
277
|
+
// against a projectionDetail's outputSchema-derived record shape instead of
|
|
278
|
+
// an entity's field map (relatedList toolbarActions' visible/params).
|
|
279
|
+
export function validateActionFieldRefs(
|
|
275
280
|
featureName: string,
|
|
276
281
|
screenId: string,
|
|
277
282
|
actionKind: "rowAction" | "toolbarAction",
|
|
278
283
|
actionId: string,
|
|
279
|
-
action: RowAction | ToolbarAction,
|
|
284
|
+
action: RowAction | ToolbarAction | RelatedListToolbarAction,
|
|
280
285
|
fieldNames: ReadonlySet<string>,
|
|
281
286
|
rowMeta: ReadonlySet<string>,
|
|
282
287
|
): void {
|
|
@@ -581,6 +586,37 @@ function validateFormFieldsMap(
|
|
|
581
586
|
// (an input-less secretMint declares BOTH `fields: {}` and
|
|
582
587
|
// `layout.sections: []`; fields declared with no layout to show them stays
|
|
583
588
|
// an error).
|
|
589
|
+
// `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
|
|
590
|
+
// every EditLayout-walking screen type instead of four hand-rolled copies.
|
|
591
|
+
function validateFieldsXorGroups(
|
|
592
|
+
errorPrefix: string,
|
|
593
|
+
section: { readonly title?: string; readonly fields: readonly EditFieldSpec[] } & {
|
|
594
|
+
readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
|
|
595
|
+
},
|
|
596
|
+
): void {
|
|
597
|
+
if (section.fields.length > 0 && section.groups !== undefined) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
|
|
600
|
+
`when using groups.`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
if (section.fields.length === 0 && section.groups === undefined) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
|
|
606
|
+
`fields (or groups) to it.`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function flattenFieldsOrGroups(section: {
|
|
612
|
+
readonly fields: readonly EditFieldSpec[];
|
|
613
|
+
readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
|
|
614
|
+
}): readonly EditFieldSpec[] {
|
|
615
|
+
return section.groups !== undefined
|
|
616
|
+
? section.groups.flatMap((group) => group.fields)
|
|
617
|
+
: section.fields;
|
|
618
|
+
}
|
|
619
|
+
|
|
584
620
|
function validateFormLayoutSections(
|
|
585
621
|
featureName: string,
|
|
586
622
|
screenId: string,
|
|
@@ -620,13 +656,8 @@ function validateFormLayoutSections(
|
|
|
620
656
|
`"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
|
|
621
657
|
);
|
|
622
658
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
`[Feature ${featureName}] Screen "${screenId}" (${context}) has a section "${section.title}" ` +
|
|
626
|
-
`with zero fields — drop the section or add fields to it.`,
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
for (const fieldSpec of section.fields) {
|
|
659
|
+
validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
|
|
660
|
+
for (const fieldSpec of flattenFieldsOrGroups(section)) {
|
|
630
661
|
const normalized = normalizeEditField(fieldSpec);
|
|
631
662
|
if (!fieldNames.has(normalized.field)) {
|
|
632
663
|
throw new Error(
|
|
@@ -792,6 +823,52 @@ function validateSecretMintConfirm(
|
|
|
792
823
|
}
|
|
793
824
|
}
|
|
794
825
|
|
|
826
|
+
// nav.screen is a full QN (cross-feature), so a standalone r.nav() pointing
|
|
827
|
+
// at `targetQn` must be found by scanning every feature's nav entries and actions.
|
|
828
|
+
function hasStandaloneNavEntry(
|
|
829
|
+
targetQn: string,
|
|
830
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
831
|
+
): boolean {
|
|
832
|
+
for (const f of featureMap.values()) {
|
|
833
|
+
for (const nav of Object.values(f.navs)) {
|
|
834
|
+
if (nav.screen === targetQn) return true;
|
|
835
|
+
if (nav.createAction?.screen === targetQn) return true;
|
|
836
|
+
if (nav.actions?.some((a) => a.screen === targetQn)) return true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
|
|
843
|
+
// `dormant: true`. Skipped when the composed set has no nav entries at all.
|
|
844
|
+
function validateScreenHasNavArea(
|
|
845
|
+
feature: FeatureDefinition,
|
|
846
|
+
screenId: string,
|
|
847
|
+
screen: ScreenDefinition,
|
|
848
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
849
|
+
): void {
|
|
850
|
+
// skip: the screen already declares its own nav entry.
|
|
851
|
+
if (screen.nav !== undefined) return;
|
|
852
|
+
// skip: explicitly opted out via `dormant: true`.
|
|
853
|
+
if (screen.dormant === true) return;
|
|
854
|
+
const anyNavRegistered = [...featureMap.values()].some((f) => Object.keys(f.navs).length > 0);
|
|
855
|
+
// skip: no nav entries exist anywhere in the composed set — a feature/
|
|
856
|
+
// recipe/test fixture booted without an app shell, not an omission.
|
|
857
|
+
if (!anyNavRegistered) return;
|
|
858
|
+
const targetQn = qualifyEntityName(feature.name, "screen", screenId);
|
|
859
|
+
// skip: a standalone r.nav() elsewhere already points at this screen.
|
|
860
|
+
if (hasStandaloneNavEntry(targetQn, featureMap)) return;
|
|
861
|
+
const allScreens = [...featureMap.values()].flatMap((f) => Object.values(f.screens));
|
|
862
|
+
// skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
|
|
863
|
+
if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined) return;
|
|
864
|
+
throw new Error(
|
|
865
|
+
`[Feature ${feature.name}] Screen "${targetQn}" has no nav entry and no resolvable list — ` +
|
|
866
|
+
`declare listScreenId (or a rowAction/toolbarAction navigate target from a list screen), add ` +
|
|
867
|
+
`a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
|
|
868
|
+
`only via a direct link/redirect or a consuming app's own r.nav().`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
795
872
|
export function validateScreens(
|
|
796
873
|
feature: FeatureDefinition,
|
|
797
874
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -816,6 +893,7 @@ export function validateScreens(
|
|
|
816
893
|
// #1946) — kurze IDs bleiben same-feature wie zuvor.
|
|
817
894
|
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
818
895
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
896
|
+
validateScreenHasNavArea(feature, screenId, screen, featureMap);
|
|
819
897
|
if (screen.type === "custom") {
|
|
820
898
|
if (!screen.renderer.react && !screen.renderer.native) {
|
|
821
899
|
throw new Error(
|
|
@@ -863,6 +941,8 @@ export function validateScreens(
|
|
|
863
941
|
`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
|
|
864
942
|
screen.facets,
|
|
865
943
|
screen.columns,
|
|
944
|
+
feature.name,
|
|
945
|
+
featureMap,
|
|
866
946
|
);
|
|
867
947
|
}
|
|
868
948
|
if (screen.rowActions !== undefined) {
|
|
@@ -1158,8 +1238,26 @@ export function validateScreens(
|
|
|
1158
1238
|
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
|
|
1159
1239
|
section.facets,
|
|
1160
1240
|
section.columns,
|
|
1241
|
+
feature.name,
|
|
1242
|
+
featureMap,
|
|
1161
1243
|
);
|
|
1162
1244
|
}
|
|
1245
|
+
// Only drawer-kind is validated here — navigate/writeHandler
|
|
1246
|
+
// toolbarActions have no boot check yet, same gap as above.
|
|
1247
|
+
if (section.toolbarActions !== undefined) {
|
|
1248
|
+
for (const action of section.toolbarActions) {
|
|
1249
|
+
if (action.kind === "drawer") {
|
|
1250
|
+
validateDrawerTargetAction(
|
|
1251
|
+
feature.name,
|
|
1252
|
+
screenId,
|
|
1253
|
+
"projectionDetail",
|
|
1254
|
+
"toolbarAction",
|
|
1255
|
+
action,
|
|
1256
|
+
feature.screens,
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1163
1261
|
continue;
|
|
1164
1262
|
}
|
|
1165
1263
|
if (isWriteFormEditSection(section)) {
|
|
@@ -1202,12 +1300,10 @@ export function validateScreens(
|
|
|
1202
1300
|
}
|
|
1203
1301
|
continue;
|
|
1204
1302
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
);
|
|
1210
|
-
}
|
|
1303
|
+
validateFieldsXorGroups(
|
|
1304
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
|
|
1305
|
+
section,
|
|
1306
|
+
);
|
|
1211
1307
|
}
|
|
1212
1308
|
// Header actions reuse RowAction (the displayed record stands in for
|
|
1213
1309
|
// the row), so the same navigate/writeHandler existence checks as
|
|
@@ -1320,13 +1416,11 @@ export function validateScreens(
|
|
|
1320
1416
|
`"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
|
|
1321
1417
|
);
|
|
1322
1418
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
}
|
|
1329
|
-
for (const fieldSpec of section.fields) {
|
|
1419
|
+
validateFieldsXorGroups(
|
|
1420
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
|
|
1421
|
+
section,
|
|
1422
|
+
);
|
|
1423
|
+
for (const fieldSpec of flattenFieldsOrGroups(section)) {
|
|
1330
1424
|
const normalized = normalizeEditField(fieldSpec);
|
|
1331
1425
|
if (!fieldNames.has(normalized.field)) {
|
|
1332
1426
|
throw new Error(
|
|
@@ -1739,13 +1833,11 @@ export function validateScreens(
|
|
|
1739
1833
|
`"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
|
|
1740
1834
|
);
|
|
1741
1835
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
}
|
|
1748
|
-
for (const fieldSpec of section.fields) {
|
|
1836
|
+
validateFieldsXorGroups(
|
|
1837
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
|
|
1838
|
+
section,
|
|
1839
|
+
);
|
|
1840
|
+
for (const fieldSpec of flattenFieldsOrGroups(section)) {
|
|
1749
1841
|
const normalized = normalizeEditField(fieldSpec);
|
|
1750
1842
|
if (!fieldNames.has(normalized.field)) {
|
|
1751
1843
|
throw new Error(
|
|
@@ -2052,10 +2144,25 @@ export function validateColumnRendererForm(
|
|
|
2052
2144
|
// typo (the user never sees the field anywhere), so this is hard-checked
|
|
2053
2145
|
// rather than just documented. Shared by projectionList and relatedList
|
|
2054
2146
|
// (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
|
|
2147
|
+
//
|
|
2148
|
+
// `type: "reference"` is exempt from the same-name-column rule (fw akte-
|
|
2149
|
+
// bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
|
|
2150
|
+
// displays a different, human-readable column (e.g. "propertyLabel") — a
|
|
2151
|
+
// column named after the filter field would almost never exist, and
|
|
2152
|
+
// requiring one anyway would force apps to add a column no design calls
|
|
2153
|
+
// for just to satisfy this check. Its own `entity` is mandatory on the type
|
|
2154
|
+
// (TS, not just this validator) and is resolved below — that explicit,
|
|
2155
|
+
// checked declaration is this facet type's field inventory, filling the
|
|
2156
|
+
// same "not just a typo" role the column-name check plays for select/
|
|
2157
|
+
// boolean facets. select/boolean facets keep the column requirement
|
|
2158
|
+
// unchanged: both filter and display the same field, so a facet with no
|
|
2159
|
+
// matching column is still almost always a typo.
|
|
2055
2160
|
function validateListFacets(
|
|
2056
2161
|
prefix: string,
|
|
2057
2162
|
facets: readonly ListFacetSpec[],
|
|
2058
2163
|
columns: readonly ListColumnSpec[],
|
|
2164
|
+
currentFeatureName: string,
|
|
2165
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
2059
2166
|
): void {
|
|
2060
2167
|
const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
|
|
2061
2168
|
const seenFacetFields = new Set<string>();
|
|
@@ -2064,7 +2171,7 @@ function validateListFacets(
|
|
|
2064
2171
|
throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
|
|
2065
2172
|
}
|
|
2066
2173
|
seenFacetFields.add(facet.field);
|
|
2067
|
-
if (!columnFieldNames.has(facet.field)) {
|
|
2174
|
+
if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {
|
|
2068
2175
|
throw new Error(
|
|
2069
2176
|
`${prefix} facet references field "${facet.field}" which is not a declared column. ` +
|
|
2070
2177
|
`Known columns: ${[...columnFieldNames].sort().join(", ")}`,
|
|
@@ -2076,6 +2183,22 @@ function validateListFacets(
|
|
|
2076
2183
|
`declare at least one option.`,
|
|
2077
2184
|
);
|
|
2078
2185
|
}
|
|
2186
|
+
if (facet.type === "reference") {
|
|
2187
|
+
const target = parseRefTarget(facet.entity, currentFeatureName);
|
|
2188
|
+
const targetFeature = featureMap.get(target.featureName);
|
|
2189
|
+
if (targetFeature?.entities?.[target.entityName] === undefined) {
|
|
2190
|
+
throw new Error(
|
|
2191
|
+
`${prefix} facet "${facet.field}" (type "reference") targets entity "${facet.entity}", ` +
|
|
2192
|
+
`which does not resolve to a registered entity. Known entities in feature ` +
|
|
2193
|
+
`"${target.featureName}": ` +
|
|
2194
|
+
`${
|
|
2195
|
+
Object.keys(targetFeature?.entities ?? {})
|
|
2196
|
+
.sort()
|
|
2197
|
+
.join(", ") || "(none)"
|
|
2198
|
+
}.`,
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2079
2202
|
}
|
|
2080
2203
|
}
|
|
2081
2204
|
|
|
@@ -498,9 +498,6 @@ function minMaskOrder(keys: readonly MaskedKey[]): number {
|
|
|
498
498
|
// opt-int, und zeigt user-scope (write `all`) jedem. `all` lässt sich in
|
|
499
499
|
// AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
|
|
500
500
|
// per Key gegated.
|
|
501
|
-
const ALL_ROLE_OPEN_TO_ALL_REASON =
|
|
502
|
-
'config key declares the "all" role, so every signed-in user of the tenant may read/write it';
|
|
503
|
-
|
|
504
501
|
// Union der Navs-Access-Regeln: ein openToAll-Nav öffnet das ganze Gate, sonst
|
|
505
502
|
// die Vereinigung der Rollen. undefined-access-Navs tragen nichts bei.
|
|
506
503
|
function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRule {
|
|
@@ -519,6 +516,12 @@ function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRul
|
|
|
519
516
|
// `all` lässt sich in AccessRule nur als openToAll ausdrücken; der Write bleibt
|
|
520
517
|
// server-seitig per Key gegated.
|
|
521
518
|
function rolesToAccess(roles: readonly string[]): AccessRule {
|
|
522
|
-
if (roles.includes("all"))
|
|
519
|
+
if (roles.includes("all"))
|
|
520
|
+
return {
|
|
521
|
+
openToAll: {
|
|
522
|
+
reason:
|
|
523
|
+
'config key declares the "all" role, so every signed-in user of the tenant may read/write it',
|
|
524
|
+
},
|
|
525
|
+
};
|
|
523
526
|
return { roles: [...new Set(roles)] };
|
|
524
527
|
}
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
} from "../db/eagerload";
|
|
9
9
|
import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
|
|
10
10
|
import { buildEntityTable, type EntityTable } from "../db/table-builder";
|
|
11
|
-
import {
|
|
11
|
+
import { acknowledgeConventionCrossTenant, type TenantDb } from "../db/tenant-db";
|
|
12
|
+
import { isSystemIdentity } from "../pipeline/system-identity-switch";
|
|
12
13
|
import { assertUnreachable } from "../utils";
|
|
13
14
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
|
|
14
15
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
AccessRule,
|
|
17
18
|
DeriveContext,
|
|
18
19
|
EntityDefinition,
|
|
20
|
+
EscapeHatchDeclaration,
|
|
19
21
|
HandlerContext,
|
|
20
22
|
QueryHandlerDef,
|
|
21
23
|
SessionUser,
|
|
@@ -107,6 +109,33 @@ type ListPayload = {
|
|
|
107
109
|
|
|
108
110
|
const idSchema = z.object({ id: z.uuid() });
|
|
109
111
|
|
|
112
|
+
// Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
|
|
113
|
+
const deprecatedCrossTenantHandlers = new WeakSet<object>();
|
|
114
|
+
|
|
115
|
+
export function isDeprecatedCrossTenantHandler(handler: object): boolean {
|
|
116
|
+
return deprecatedCrossTenantHandlers.has(handler);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
|
|
120
|
+
|
|
121
|
+
function resolveCrossTenantReason(name: string, options: EntityHandlerOptions): string | undefined {
|
|
122
|
+
if (options.escapeHatch !== undefined && options.crossTenant === true) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (options.escapeHatch !== undefined) {
|
|
128
|
+
if (options.escapeHatch.reason.trim().length === 0) {
|
|
129
|
+
throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
|
|
130
|
+
}
|
|
131
|
+
return options.escapeHatch.reason;
|
|
132
|
+
}
|
|
133
|
+
if (options.crossTenant === true) {
|
|
134
|
+
return `deprecated crossTenant: true on entity convention handler ${name}`;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
// Upper bound on entity-list `limit`: unbounded/fractional values ran
|
|
111
140
|
// straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
|
|
112
141
|
// read.ts), letting a client demand a full-table materialisation or trip a
|
|
@@ -194,10 +223,10 @@ export function defineEntityWriteHandler(
|
|
|
194
223
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
195
224
|
// TenantDb for these handlers before the ctx.db cutover.
|
|
196
225
|
//
|
|
197
|
-
//
|
|
198
|
-
// write handler reads/writes across
|
|
199
|
-
// whole feature r.systemScope().
|
|
200
|
-
const
|
|
226
|
+
// crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
|
|
227
|
+
// query-handler branch below: this ONE write handler reads/writes across
|
|
228
|
+
// every tenant without making the whole feature r.systemScope().
|
|
229
|
+
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
201
230
|
|
|
202
231
|
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
203
232
|
if (ctx.systemDb) {
|
|
@@ -205,7 +234,9 @@ export function defineEntityWriteHandler(
|
|
|
205
234
|
`entity convention handler for r.systemScope() feature (${name})`,
|
|
206
235
|
);
|
|
207
236
|
}
|
|
208
|
-
return
|
|
237
|
+
return crossTenantReason !== undefined
|
|
238
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
239
|
+
: ctx.db;
|
|
209
240
|
};
|
|
210
241
|
|
|
211
242
|
// The event stream is keyed by the acting user's tenantId (streamTenantFor in
|
|
@@ -219,7 +250,7 @@ export function defineEntityWriteHandler(
|
|
|
219
250
|
id: unknown,
|
|
220
251
|
db: TenantDb,
|
|
221
252
|
): Promise<SessionUser> => {
|
|
222
|
-
if (
|
|
253
|
+
if (crossTenantReason === undefined || entity.systemStream) return user;
|
|
223
254
|
const row = await db.fetchOne<Record<string, unknown>>(table, { id });
|
|
224
255
|
const rowTenantId = row?.["tenantId"];
|
|
225
256
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
|
|
@@ -234,7 +265,14 @@ export function defineEntityWriteHandler(
|
|
|
234
265
|
schema = buildInsertSchema(entity);
|
|
235
266
|
handler = async (event, ctx) => {
|
|
236
267
|
const { runPreSave } = ctx;
|
|
237
|
-
|
|
268
|
+
// A caller-chosen id (deterministic-id idempotent creates) is only
|
|
269
|
+
// honored for a system-identity caller (jobs/hooks via
|
|
270
|
+
// createSystemUser) — an HTTP-authenticated end user can never
|
|
271
|
+
// present that identity, so their payload.id is dropped exactly as
|
|
272
|
+
// before this field became part of the schema.
|
|
273
|
+
const { id: _callerId, ...payloadWithoutId } = event.payload as DbRow;
|
|
274
|
+
const payload = isSystemIdentity(event.user) ? (event.payload as DbRow) : payloadWithoutId;
|
|
275
|
+
return executor.create(payload, event.user, dbFor(ctx), {
|
|
238
276
|
preSave:
|
|
239
277
|
runPreSave &&
|
|
240
278
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -288,6 +326,9 @@ export function defineEntityWriteHandler(
|
|
|
288
326
|
assertUnreachable(verb, "write verb");
|
|
289
327
|
}
|
|
290
328
|
|
|
329
|
+
if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
|
|
330
|
+
|
|
331
|
+
// escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
|
|
291
332
|
return {
|
|
292
333
|
name,
|
|
293
334
|
schema,
|
|
@@ -340,21 +381,22 @@ export function defineEntityQueryHandler(
|
|
|
340
381
|
// useReferenceLookup stays as a fallback (for apps that write custom
|
|
341
382
|
// handlers by hand without this wrapper).
|
|
342
383
|
const hasRefFields = collectReferenceFields(entity).length > 0;
|
|
384
|
+
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
343
385
|
|
|
344
386
|
// Preference order:
|
|
345
387
|
// 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
|
|
346
388
|
// cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
|
|
347
389
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
348
390
|
// TenantDb for these handlers before that cutover.
|
|
349
|
-
// 2.
|
|
350
|
-
// (e.g. a SystemAdmin-only
|
|
351
|
-
// whole feature r.systemScope()
|
|
352
|
-
// from every OTHER handler the
|
|
353
|
-
// list()/detail() only add a
|
|
354
|
-
// (see event-store-executor.ts),
|
|
355
|
-
// TenantDb built from the same raw
|
|
356
|
-
// access-control (who may call this
|
|
357
|
-
// still gated by `options.access`.
|
|
391
|
+
// 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
|
|
392
|
+
// ONE handler reads across every tenant (e.g. a SystemAdmin-only
|
|
393
|
+
// operator inspector) without making the whole feature r.systemScope()
|
|
394
|
+
// — that would drop tenant isolation from every OTHER handler the
|
|
395
|
+
// feature registers too. The executor's list()/detail() only add a
|
|
396
|
+
// tenant filter when db.mode === "tenant" (see event-store-executor.ts),
|
|
397
|
+
// so handing them a "system"-mode TenantDb built from the same raw
|
|
398
|
+
// connection is enough to skip it — access-control (who may call this
|
|
399
|
+
// handler at all) is unaffected, still gated by `options.access`.
|
|
358
400
|
// 3. plain ctx.db — the common case, tenant-filtered.
|
|
359
401
|
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
360
402
|
if (ctx.systemDb) {
|
|
@@ -362,7 +404,9 @@ export function defineEntityQueryHandler(
|
|
|
362
404
|
`entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
|
|
363
405
|
);
|
|
364
406
|
}
|
|
365
|
-
return
|
|
407
|
+
return crossTenantReason !== undefined
|
|
408
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
409
|
+
: ctx.db;
|
|
366
410
|
};
|
|
367
411
|
|
|
368
412
|
switch (verb) {
|
|
@@ -418,6 +462,9 @@ export function defineEntityQueryHandler(
|
|
|
418
462
|
assertUnreachable(verb, "query verb");
|
|
419
463
|
}
|
|
420
464
|
|
|
465
|
+
if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
|
|
466
|
+
|
|
467
|
+
// escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
|
|
421
468
|
return {
|
|
422
469
|
name,
|
|
423
470
|
schema,
|
|
@@ -543,7 +590,11 @@ export function createEntityExecutor(
|
|
|
543
590
|
export function defineProjectionQueryHandler(
|
|
544
591
|
name: string,
|
|
545
592
|
projectionQualifiedName: string,
|
|
546
|
-
options: {
|
|
593
|
+
options: {
|
|
594
|
+
access: AccessRule;
|
|
595
|
+
unsafeAllTenants?: boolean;
|
|
596
|
+
escapeHatch?: EscapeHatchDeclaration;
|
|
597
|
+
},
|
|
547
598
|
): QueryHandlerDef {
|
|
548
599
|
return {
|
|
549
600
|
name,
|
|
@@ -558,6 +609,7 @@ export function defineProjectionQueryHandler(
|
|
|
558
609
|
options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
|
|
559
610
|
), // @wrapper-known semantic-alias
|
|
560
611
|
access: options.access,
|
|
612
|
+
...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
|
|
561
613
|
};
|
|
562
614
|
}
|
|
563
615
|
|