@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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 +8 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +164 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +118 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +17 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +130 -40
- package/src/changes.json +121 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/schema-cli.ts +5 -1
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
- package/src/upgrade-cli.ts +58 -18
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
// Per-role projection of a fully-built AppSchema (fw#3314). GET /api/schema
|
|
2
|
+
// used to ship the same full schema — screens, navs, workspaces, content
|
|
3
|
+
// collections — to every authenticated user regardless of role, relying on
|
|
4
|
+
// client-side UI checks (isUiAccessGranted) alone to hide what a role
|
|
5
|
+
// shouldn't see. That leaves the raw schema JSON itself as a disclosure
|
|
6
|
+
// surface (role-gated screen ids/labels/handler QNs visible to anyone who
|
|
7
|
+
// can read the response). This module removes what a set of roles may not
|
|
8
|
+
// see from the schema BEFORE it's serialized — entities and translations
|
|
9
|
+
// stay complete (decided: option (a), a projected schema is still one
|
|
10
|
+
// screen/nav/workspace registry, not a per-entity capability list).
|
|
11
|
+
//
|
|
12
|
+
// Pure function: never mutates `schema`, returns a new object only where
|
|
13
|
+
// something actually changed — every helper below returns the ORIGINAL
|
|
14
|
+
// reference when nothing needed stripping, so a fully-open role (no access
|
|
15
|
+
// rules anywhere, or a role matching every rule) gets back the exact same
|
|
16
|
+
// object graph it was given, not a structurally-equal copy. That relies on
|
|
17
|
+
// object-spread's `...(cond && { key })` idiom already used throughout
|
|
18
|
+
// build-app-schema.ts: spreading `false` contributes nothing, so an
|
|
19
|
+
// omitted/undefined key never round-trips into an explicit `undefined`
|
|
20
|
+
// (buildAppSchema's own JSON-safety check would reject that).
|
|
21
|
+
//
|
|
22
|
+
// The dispatcher's own `hasAccess` (./access.ts) is UNCHANGED by this file —
|
|
23
|
+
// that's the default-deny handler-invocation gate, still the sole authority
|
|
24
|
+
// over whether a write/query actually executes. This module only decides
|
|
25
|
+
// what a client's schema fetch renders/lists, using the default-VISIBLE
|
|
26
|
+
// `isUiAccessGranted` predicate (see its doc comment in
|
|
27
|
+
// @cosmicdrift/kumiko-types/handlers) — an unset `access` here means every
|
|
28
|
+
// signed-in user may see the entry, the opposite stance from `hasAccess`.
|
|
29
|
+
//
|
|
30
|
+
// Reference-site coverage (every place boot-validator/screens.ts resolves a
|
|
31
|
+
// screen target is a site this file also projects):
|
|
32
|
+
// - nav.screen / nav.parent (author-registered AND settings-hub-generated
|
|
33
|
+
// navs — the latter use short ids relative to the synthetic "config"
|
|
34
|
+
// feature, so target resolution applies the same isValidQn-guarded
|
|
35
|
+
// qualify-or-verbatim rule uniformly, never assuming pre-qualification)
|
|
36
|
+
// - nav/content-collection actions[] and createAction (TreeAction.screen —
|
|
37
|
+
// always fully qualified per validateTreeAction, checked directly)
|
|
38
|
+
// - rowActions/toolbarActions on entityList/projectionList (kind:"navigate"
|
|
39
|
+
// resolves app-wide via a global short-id map, mirroring
|
|
40
|
+
// resolveRowActionNavigateTarget; kind:"drawer" resolves same-feature
|
|
41
|
+
// only, mirroring validateDrawerTargetAction)
|
|
42
|
+
// - projectionDetail header actions + metrics[].navigate
|
|
43
|
+
// - entityEdit/actionForm/secretMint redirect + actionForm cancelTarget
|
|
44
|
+
// (resolveScreenTargetQn: same-feature short id or cross-feature QN)
|
|
45
|
+
// - dashboard DashboardScreenPanel.screen (resolveScreenTargetQn)
|
|
46
|
+
// - every EditSectionSpec kind's own `actions` (fields/extension/writeForm/
|
|
47
|
+
// relatedList all carry one), plus relatedList's rowActions,
|
|
48
|
+
// toolbarActions, rowClick.entity (via detailFor) and
|
|
49
|
+
// emptyState.action — leak sites beyond the spec's literal enumeration,
|
|
50
|
+
// closed here because they're real screen-navigate fields the same
|
|
51
|
+
// boot-validator machinery resolves
|
|
52
|
+
// - QualifiedContentCollection.nav.actions/createAction — a verbatim copy
|
|
53
|
+
// distinct from the "real" nav entry's own actions, not kept in sync by
|
|
54
|
+
// buildAppSchema
|
|
55
|
+
// - WorkspaceSchema.definition.nav — the raw explicit nav-QN list,
|
|
56
|
+
// verbatim in JSON, distinct from the computed/filtered navMembers
|
|
57
|
+
// - listScreenId (projectionDetail/entityEdit/actionForm/secretMint/custom —
|
|
58
|
+
// the breadcrumb "back to list" target). It's serialized, so a visible
|
|
59
|
+
// screen would otherwise disclose a denied screen's short id. Resolved
|
|
60
|
+
// the same way resolveNavParentScreen/renderer breadcrumbs do: a global
|
|
61
|
+
// short-id lookup across every feature's screens (screen-helpers.ts),
|
|
62
|
+
// the same convention as RowAction/ToolbarAction navigate-by-`.screen`,
|
|
63
|
+
// not the same-feature-or-QN rule `resolveScreenTargetQn` uses.
|
|
64
|
+
//
|
|
65
|
+
// Deliberately OUT of scope: `TargetRef`-based `target` fields on nav/tree
|
|
66
|
+
// entries (an EditorPanel dispatch target, not a screen navigate — nothing
|
|
67
|
+
// to resolve against keptScreenQns).
|
|
68
|
+
|
|
69
|
+
import type {
|
|
70
|
+
AppSchema,
|
|
71
|
+
FeatureSchema,
|
|
72
|
+
QualifiedContentCollection,
|
|
73
|
+
WorkspaceSchema,
|
|
74
|
+
} from "../ui-types/app-schema";
|
|
75
|
+
import { resolveScreenTargetQn } from "./boot-validator/screens";
|
|
76
|
+
import { isValidQn, qualifyEntityName } from "./qualified-name";
|
|
77
|
+
import type { AccessRule } from "./types/handlers";
|
|
78
|
+
import { isUiAccessGranted } from "./types/handlers";
|
|
79
|
+
import type { NavDefinition } from "./types/nav";
|
|
80
|
+
import type {
|
|
81
|
+
ActionFormRedirect,
|
|
82
|
+
ActionFormScreenDefinition,
|
|
83
|
+
DashboardPanelDefinition,
|
|
84
|
+
EditLayout,
|
|
85
|
+
EditRelatedListSection,
|
|
86
|
+
EditSectionSpec,
|
|
87
|
+
EntityEditScreenDefinition,
|
|
88
|
+
EntityListScreenDefinition,
|
|
89
|
+
MetricNavigate,
|
|
90
|
+
MetricSpec,
|
|
91
|
+
ProjectionDetailScreenDefinition,
|
|
92
|
+
ProjectionListScreenDefinition,
|
|
93
|
+
RelatedListToolbarAction,
|
|
94
|
+
RowAction,
|
|
95
|
+
ScreenDefinition,
|
|
96
|
+
SecretMintScreenDefinition,
|
|
97
|
+
ToolbarAction,
|
|
98
|
+
} from "./types/screen";
|
|
99
|
+
import type { TreeAction } from "./types/tree-node";
|
|
100
|
+
import type { WorkspaceDefinition } from "./types/workspace";
|
|
101
|
+
|
|
102
|
+
type ScreenLocation = { readonly featureName: string; readonly screen: ScreenDefinition };
|
|
103
|
+
|
|
104
|
+
type IndexedSchema = {
|
|
105
|
+
readonly screensByShortId: ReadonlyMap<string, ScreenLocation>;
|
|
106
|
+
readonly detailForScreens: ReadonlyMap<string, ScreenLocation>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type NavContext = {
|
|
110
|
+
readonly qn: string;
|
|
111
|
+
readonly featureName: string;
|
|
112
|
+
readonly nav: NavDefinition;
|
|
113
|
+
readonly resolvedScreenQn: string | undefined;
|
|
114
|
+
readonly resolvedParentQn: string | undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export function projectAppSchemaForRoles(schema: AppSchema, roles: readonly string[]): AppSchema {
|
|
118
|
+
const keptScreenQns = buildKeptScreenQns(schema, roles);
|
|
119
|
+
const indices = buildScreenIndices(schema);
|
|
120
|
+
const screenAccessByQn = buildScreenAccessMap(schema);
|
|
121
|
+
const navContexts = buildNavContexts(schema);
|
|
122
|
+
const droppedNavQns = buildDroppedNavQns(navContexts, screenAccessByQn, keptScreenQns, roles);
|
|
123
|
+
const survivingNavQns = new Set(
|
|
124
|
+
navContexts.filter((ctx) => !droppedNavQns.has(ctx.qn)).map((ctx) => ctx.qn),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
let featuresChanged = false;
|
|
128
|
+
const nextFeatures = schema.features.map((feature) => {
|
|
129
|
+
const nextFeature = projectFeature(
|
|
130
|
+
feature,
|
|
131
|
+
roles,
|
|
132
|
+
keptScreenQns,
|
|
133
|
+
indices,
|
|
134
|
+
droppedNavQns,
|
|
135
|
+
survivingNavQns,
|
|
136
|
+
);
|
|
137
|
+
if (nextFeature !== feature) featuresChanged = true;
|
|
138
|
+
return nextFeature;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const nextWorkspaces = projectWorkspaces(schema.workspaces, roles, survivingNavQns);
|
|
142
|
+
|
|
143
|
+
if (!featuresChanged && nextWorkspaces === schema.workspaces) return schema;
|
|
144
|
+
|
|
145
|
+
const { workspaces, ...restSchema } = schema;
|
|
146
|
+
return {
|
|
147
|
+
...restSchema,
|
|
148
|
+
features: featuresChanged ? nextFeatures : schema.features,
|
|
149
|
+
...(nextWorkspaces !== undefined && { workspaces: nextWorkspaces }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- indices over the ORIGINAL (unprojected) schema ---
|
|
154
|
+
|
|
155
|
+
function buildKeptScreenQns(schema: AppSchema, roles: readonly string[]): ReadonlySet<string> {
|
|
156
|
+
const kept = new Set<string>();
|
|
157
|
+
for (const feature of schema.features) {
|
|
158
|
+
for (const screen of feature.screens) {
|
|
159
|
+
if (isUiAccessGranted(screen.access, roles)) {
|
|
160
|
+
kept.add(qualifyEntityName(feature.featureName, "screen", screen.id));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return kept;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildScreenIndices(schema: AppSchema): IndexedSchema {
|
|
168
|
+
const screensByShortId = new Map<string, ScreenLocation>();
|
|
169
|
+
const detailForScreens = new Map<string, ScreenLocation>();
|
|
170
|
+
for (const feature of schema.features) {
|
|
171
|
+
for (const screen of feature.screens) {
|
|
172
|
+
if (!screensByShortId.has(screen.id)) {
|
|
173
|
+
screensByShortId.set(screen.id, { featureName: feature.featureName, screen });
|
|
174
|
+
}
|
|
175
|
+
if (screen.detailFor !== undefined && !detailForScreens.has(screen.detailFor)) {
|
|
176
|
+
detailForScreens.set(screen.detailFor, { featureName: feature.featureName, screen });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { screensByShortId, detailForScreens };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildScreenAccessMap(schema: AppSchema): ReadonlyMap<string, AccessRule | undefined> {
|
|
184
|
+
const map = new Map<string, AccessRule | undefined>();
|
|
185
|
+
for (const feature of schema.features) {
|
|
186
|
+
for (const screen of feature.screens) {
|
|
187
|
+
map.set(qualifyEntityName(feature.featureName, "screen", screen.id), screen.access);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return map;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Settings-hub-generated navs share the short-id-relative-to-owning-feature
|
|
194
|
+
// convention with author-registered ones (they're merged verbatim into the
|
|
195
|
+
// synthetic "config" feature's own `navs`) — the isValidQn guard makes this
|
|
196
|
+
// resolution correct for both without needing to know which regime a given
|
|
197
|
+
// nav came from.
|
|
198
|
+
function resolveNavTargetQn(featureName: string, ref: string): string {
|
|
199
|
+
return isValidQn(ref) ? ref : qualifyEntityName(featureName, "nav", ref);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildNavContexts(schema: AppSchema): readonly NavContext[] {
|
|
203
|
+
const contexts: NavContext[] = [];
|
|
204
|
+
for (const feature of schema.features) {
|
|
205
|
+
for (const nav of feature.navs ?? []) {
|
|
206
|
+
contexts.push({
|
|
207
|
+
qn: qualifyEntityName(feature.featureName, "nav", nav.id),
|
|
208
|
+
featureName: feature.featureName,
|
|
209
|
+
nav,
|
|
210
|
+
resolvedScreenQn:
|
|
211
|
+
nav.screen !== undefined
|
|
212
|
+
? resolveScreenTargetQn(feature.featureName, nav.screen)
|
|
213
|
+
: undefined,
|
|
214
|
+
resolvedParentQn:
|
|
215
|
+
nav.parent !== undefined
|
|
216
|
+
? resolveNavTargetQn(feature.featureName, nav.parent)
|
|
217
|
+
: undefined,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return contexts;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Initial per-nav drop decision (access + dead screen target) followed by a
|
|
225
|
+
// fixed-point propagation: a dropped parent drops its children, and a
|
|
226
|
+
// screenless grouping node that loses every child (and isn't a runtime
|
|
227
|
+
// `provider: true` node, whose children never come from other navs'
|
|
228
|
+
// `.parent`) drops itself too — an empty section is not worth keeping.
|
|
229
|
+
function buildDroppedNavQns(
|
|
230
|
+
navContexts: readonly NavContext[],
|
|
231
|
+
screenAccessByQn: ReadonlyMap<string, AccessRule | undefined>,
|
|
232
|
+
keptScreenQns: ReadonlySet<string>,
|
|
233
|
+
roles: readonly string[],
|
|
234
|
+
): ReadonlySet<string> {
|
|
235
|
+
const childrenByParent = groupNavChildrenByParent(navContexts);
|
|
236
|
+
const dropped = new Set<string>(
|
|
237
|
+
navContexts
|
|
238
|
+
.filter((ctx) => isNavDeniedOrDead(ctx, screenAccessByQn, keptScreenQns, roles))
|
|
239
|
+
.map((ctx) => ctx.qn),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
let changed = true;
|
|
243
|
+
while (changed) {
|
|
244
|
+
changed = false;
|
|
245
|
+
for (const ctx of navContexts) {
|
|
246
|
+
if (dropped.has(ctx.qn)) continue;
|
|
247
|
+
if (isDroppedByPropagation(ctx, dropped, childrenByParent)) {
|
|
248
|
+
dropped.add(ctx.qn);
|
|
249
|
+
changed = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return dropped;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function groupNavChildrenByParent(
|
|
257
|
+
navContexts: readonly NavContext[],
|
|
258
|
+
): ReadonlyMap<string, readonly NavContext[]> {
|
|
259
|
+
const childrenByParent = new Map<string, NavContext[]>();
|
|
260
|
+
for (const ctx of navContexts) {
|
|
261
|
+
if (ctx.resolvedParentQn === undefined) continue;
|
|
262
|
+
const siblings = childrenByParent.get(ctx.resolvedParentQn) ?? [];
|
|
263
|
+
siblings.push(ctx);
|
|
264
|
+
childrenByParent.set(ctx.resolvedParentQn, siblings);
|
|
265
|
+
}
|
|
266
|
+
return childrenByParent;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isNavDeniedOrDead(
|
|
270
|
+
ctx: NavContext,
|
|
271
|
+
screenAccessByQn: ReadonlyMap<string, AccessRule | undefined>,
|
|
272
|
+
keptScreenQns: ReadonlySet<string>,
|
|
273
|
+
roles: readonly string[],
|
|
274
|
+
): boolean {
|
|
275
|
+
// `nav.access` already carries `nav.access ?? collection.access` for a
|
|
276
|
+
// content-collection's synthesized nav entry (feature-ui-extensions.ts
|
|
277
|
+
// sets it at registration time) — no separate collection-access lookup
|
|
278
|
+
// needed here.
|
|
279
|
+
if (ctx.resolvedScreenQn === undefined) return !isUiAccessGranted(ctx.nav.access, roles);
|
|
280
|
+
if (!keptScreenQns.has(ctx.resolvedScreenQn)) return true;
|
|
281
|
+
const effectiveAccess = ctx.nav.access ?? screenAccessByQn.get(ctx.resolvedScreenQn);
|
|
282
|
+
return !isUiAccessGranted(effectiveAccess, roles);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isDroppedByPropagation(
|
|
286
|
+
ctx: NavContext,
|
|
287
|
+
dropped: ReadonlySet<string>,
|
|
288
|
+
childrenByParent: ReadonlyMap<string, readonly NavContext[]>,
|
|
289
|
+
): boolean {
|
|
290
|
+
if (ctx.resolvedParentQn !== undefined && dropped.has(ctx.resolvedParentQn)) return true;
|
|
291
|
+
const isPureGroupingNode =
|
|
292
|
+
ctx.nav.screen === undefined && ctx.nav.target === undefined && ctx.nav.provider !== true;
|
|
293
|
+
if (!isPureGroupingNode) return false;
|
|
294
|
+
const children = childrenByParent.get(ctx.qn) ?? [];
|
|
295
|
+
return children.length > 0 && children.every((child) => dropped.has(child.qn));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- screen-target resolution helpers (mirror boot-validator/screens.ts) ---
|
|
299
|
+
|
|
300
|
+
function isScreenTargetKept(
|
|
301
|
+
featureName: string,
|
|
302
|
+
target: string,
|
|
303
|
+
keptScreenQns: ReadonlySet<string>,
|
|
304
|
+
): boolean {
|
|
305
|
+
return keptScreenQns.has(resolveScreenTargetQn(featureName, target));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isRedirectTargetKept(
|
|
309
|
+
featureName: string,
|
|
310
|
+
target: string | ActionFormRedirect,
|
|
311
|
+
keptScreenQns: ReadonlySet<string>,
|
|
312
|
+
): boolean {
|
|
313
|
+
const screenRef = typeof target === "string" ? target : target.screen;
|
|
314
|
+
return isScreenTargetKept(featureName, screenRef, keptScreenQns);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isDrawerTargetKept(
|
|
318
|
+
feature: FeatureSchema,
|
|
319
|
+
shortId: string,
|
|
320
|
+
keptScreenQns: ReadonlySet<string>,
|
|
321
|
+
): boolean {
|
|
322
|
+
return keptScreenQns.has(qualifyEntityName(feature.featureName, "screen", shortId));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isListScreenIdKept(
|
|
326
|
+
listScreenId: string,
|
|
327
|
+
indices: IndexedSchema,
|
|
328
|
+
keptScreenQns: ReadonlySet<string>,
|
|
329
|
+
): boolean {
|
|
330
|
+
const target = indices.screensByShortId.get(listScreenId);
|
|
331
|
+
if (target === undefined) return false;
|
|
332
|
+
return keptScreenQns.has(qualifyEntityName(target.featureName, "screen", target.screen.id));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function resolveNavigateTarget(
|
|
336
|
+
action: { readonly screen?: string; readonly entity?: string },
|
|
337
|
+
indices: IndexedSchema,
|
|
338
|
+
): ScreenLocation | undefined {
|
|
339
|
+
if (action.entity !== undefined) return indices.detailForScreens.get(action.entity);
|
|
340
|
+
if (action.screen !== undefined) return indices.screensByShortId.get(action.screen);
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isNavigateTargetKept(
|
|
345
|
+
action: { readonly screen?: string; readonly entity?: string },
|
|
346
|
+
indices: IndexedSchema,
|
|
347
|
+
keptScreenQns: ReadonlySet<string>,
|
|
348
|
+
): boolean {
|
|
349
|
+
const target = resolveNavigateTarget(action, indices);
|
|
350
|
+
if (target === undefined) return false;
|
|
351
|
+
return keptScreenQns.has(qualifyEntityName(target.featureName, "screen", target.screen.id));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function keepMetricNavigate(
|
|
355
|
+
navigate: MetricNavigate,
|
|
356
|
+
indices: IndexedSchema,
|
|
357
|
+
keptScreenQns: ReadonlySet<string>,
|
|
358
|
+
): boolean {
|
|
359
|
+
if (navigate.screen === undefined && navigate.entity === undefined) return true; // same-record tab jump
|
|
360
|
+
return isNavigateTargetKept(navigate, indices, keptScreenQns);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function keepTreeAction(action: TreeAction, keptScreenQns: ReadonlySet<string>): boolean {
|
|
364
|
+
if (action.screen === undefined) return true; // target-based dispatch, not a screen navigate
|
|
365
|
+
return keptScreenQns.has(action.screen);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- RowAction / ToolbarAction / RelatedListToolbarAction array projectors ---
|
|
369
|
+
// Each returns the ORIGINAL array reference when nothing was filtered, and
|
|
370
|
+
// `undefined` (never an empty array) when everything was — same JSON-safety
|
|
371
|
+
// convention as buildAppSchema.
|
|
372
|
+
|
|
373
|
+
function keepRowAction(
|
|
374
|
+
action: RowAction,
|
|
375
|
+
feature: FeatureSchema,
|
|
376
|
+
indices: IndexedSchema,
|
|
377
|
+
keptScreenQns: ReadonlySet<string>,
|
|
378
|
+
): boolean {
|
|
379
|
+
if (action.kind === "drawer") return isDrawerTargetKept(feature, action.screen, keptScreenQns);
|
|
380
|
+
if (action.kind === "navigate") return isNavigateTargetKept(action, indices, keptScreenQns);
|
|
381
|
+
return true; // writeHandler — no screen target
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function projectRowActionArray(
|
|
385
|
+
actions: readonly RowAction[] | undefined,
|
|
386
|
+
feature: FeatureSchema,
|
|
387
|
+
indices: IndexedSchema,
|
|
388
|
+
keptScreenQns: ReadonlySet<string>,
|
|
389
|
+
): readonly RowAction[] | undefined {
|
|
390
|
+
if (actions === undefined) return undefined;
|
|
391
|
+
const kept = actions.filter((a) => keepRowAction(a, feature, indices, keptScreenQns));
|
|
392
|
+
if (kept.length === actions.length) return actions;
|
|
393
|
+
return kept.length > 0 ? kept : undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function keepToolbarAction(
|
|
397
|
+
action: ToolbarAction,
|
|
398
|
+
feature: FeatureSchema,
|
|
399
|
+
indices: IndexedSchema,
|
|
400
|
+
keptScreenQns: ReadonlySet<string>,
|
|
401
|
+
): boolean {
|
|
402
|
+
if (action.kind === "navigate") return isNavigateTargetKept(action, indices, keptScreenQns);
|
|
403
|
+
if (action.kind === "drawer") return isDrawerTargetKept(feature, action.screen, keptScreenQns);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function projectToolbarActionArray(
|
|
408
|
+
actions: readonly ToolbarAction[] | undefined,
|
|
409
|
+
feature: FeatureSchema,
|
|
410
|
+
indices: IndexedSchema,
|
|
411
|
+
keptScreenQns: ReadonlySet<string>,
|
|
412
|
+
): readonly ToolbarAction[] | undefined {
|
|
413
|
+
if (actions === undefined) return undefined;
|
|
414
|
+
const kept = actions.filter((a) => keepToolbarAction(a, feature, indices, keptScreenQns));
|
|
415
|
+
if (kept.length === actions.length) return actions;
|
|
416
|
+
return kept.length > 0 ? kept : undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function keepRelatedListToolbarAction(
|
|
420
|
+
action: RelatedListToolbarAction,
|
|
421
|
+
feature: FeatureSchema,
|
|
422
|
+
indices: IndexedSchema,
|
|
423
|
+
keptScreenQns: ReadonlySet<string>,
|
|
424
|
+
): boolean {
|
|
425
|
+
if (action.kind === "navigate") return isNavigateTargetKept(action, indices, keptScreenQns);
|
|
426
|
+
if (action.kind === "drawer") return isDrawerTargetKept(feature, action.screen, keptScreenQns);
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function projectRelatedListToolbarActionArray(
|
|
431
|
+
actions: readonly RelatedListToolbarAction[] | undefined,
|
|
432
|
+
feature: FeatureSchema,
|
|
433
|
+
indices: IndexedSchema,
|
|
434
|
+
keptScreenQns: ReadonlySet<string>,
|
|
435
|
+
): readonly RelatedListToolbarAction[] | undefined {
|
|
436
|
+
if (actions === undefined) return undefined;
|
|
437
|
+
const kept = actions.filter((a) =>
|
|
438
|
+
keepRelatedListToolbarAction(a, feature, indices, keptScreenQns),
|
|
439
|
+
);
|
|
440
|
+
if (kept.length === actions.length) return actions;
|
|
441
|
+
return kept.length > 0 ? kept : undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function projectTreeActionArray(
|
|
445
|
+
actions: readonly TreeAction[] | undefined,
|
|
446
|
+
keptScreenQns: ReadonlySet<string>,
|
|
447
|
+
): readonly TreeAction[] | undefined {
|
|
448
|
+
if (actions === undefined) return undefined;
|
|
449
|
+
const kept = actions.filter((a) => keepTreeAction(a, keptScreenQns));
|
|
450
|
+
if (kept.length === actions.length) return actions;
|
|
451
|
+
return kept.length > 0 ? kept : undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function projectTreeAction(
|
|
455
|
+
action: TreeAction | undefined,
|
|
456
|
+
keptScreenQns: ReadonlySet<string>,
|
|
457
|
+
): TreeAction | undefined {
|
|
458
|
+
if (action === undefined) return undefined;
|
|
459
|
+
return keepTreeAction(action, keptScreenQns) ? action : undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isRowClickEntityKept(
|
|
463
|
+
entity: string,
|
|
464
|
+
indices: IndexedSchema,
|
|
465
|
+
keptScreenQns: ReadonlySet<string>,
|
|
466
|
+
): boolean {
|
|
467
|
+
const target = indices.detailForScreens.get(entity);
|
|
468
|
+
if (target === undefined) return false;
|
|
469
|
+
return keptScreenQns.has(qualifyEntityName(target.featureName, "screen", target.screen.id));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- EditLayout / EditSectionSpec projection ---
|
|
473
|
+
|
|
474
|
+
function projectEmptyState(
|
|
475
|
+
emptyState: EditRelatedListSection["emptyState"],
|
|
476
|
+
feature: FeatureSchema,
|
|
477
|
+
indices: IndexedSchema,
|
|
478
|
+
keptScreenQns: ReadonlySet<string>,
|
|
479
|
+
): EditRelatedListSection["emptyState"] {
|
|
480
|
+
if (emptyState === undefined) return undefined;
|
|
481
|
+
if (emptyState.action === undefined) return emptyState;
|
|
482
|
+
if (keepRowAction(emptyState.action, feature, indices, keptScreenQns)) return emptyState;
|
|
483
|
+
const { action, ...rest } = emptyState;
|
|
484
|
+
return rest;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function projectRelatedListSection(
|
|
488
|
+
section: EditRelatedListSection,
|
|
489
|
+
feature: FeatureSchema,
|
|
490
|
+
indices: IndexedSchema,
|
|
491
|
+
keptScreenQns: ReadonlySet<string>,
|
|
492
|
+
): EditRelatedListSection {
|
|
493
|
+
const { actions, rowActions, toolbarActions, rowClick, emptyState, ...rest } = section;
|
|
494
|
+
const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
495
|
+
const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
|
|
496
|
+
const nextToolbarActions = projectRelatedListToolbarActionArray(
|
|
497
|
+
toolbarActions,
|
|
498
|
+
feature,
|
|
499
|
+
indices,
|
|
500
|
+
keptScreenQns,
|
|
501
|
+
);
|
|
502
|
+
const nextRowClick =
|
|
503
|
+
rowClick === undefined || isRowClickEntityKept(rowClick.entity, indices, keptScreenQns)
|
|
504
|
+
? rowClick
|
|
505
|
+
: undefined;
|
|
506
|
+
const nextEmptyState = projectEmptyState(emptyState, feature, indices, keptScreenQns);
|
|
507
|
+
if (
|
|
508
|
+
nextActions === actions &&
|
|
509
|
+
nextRowActions === rowActions &&
|
|
510
|
+
nextToolbarActions === toolbarActions &&
|
|
511
|
+
nextRowClick === rowClick &&
|
|
512
|
+
nextEmptyState === emptyState
|
|
513
|
+
) {
|
|
514
|
+
return section;
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
...rest,
|
|
518
|
+
...(nextActions !== undefined && { actions: nextActions }),
|
|
519
|
+
...(nextRowActions !== undefined && { rowActions: nextRowActions }),
|
|
520
|
+
...(nextToolbarActions !== undefined && { toolbarActions: nextToolbarActions }),
|
|
521
|
+
...(nextRowClick !== undefined && { rowClick: nextRowClick }),
|
|
522
|
+
...(nextEmptyState !== undefined && { emptyState: nextEmptyState }),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function projectEditSection(
|
|
527
|
+
section: EditSectionSpec,
|
|
528
|
+
feature: FeatureSchema,
|
|
529
|
+
indices: IndexedSchema,
|
|
530
|
+
keptScreenQns: ReadonlySet<string>,
|
|
531
|
+
): EditSectionSpec {
|
|
532
|
+
switch (section.kind) {
|
|
533
|
+
case "relatedList":
|
|
534
|
+
return projectRelatedListSection(section, feature, indices, keptScreenQns);
|
|
535
|
+
case "extension": {
|
|
536
|
+
const { actions, ...rest } = section;
|
|
537
|
+
const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
538
|
+
return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
|
|
539
|
+
}
|
|
540
|
+
case "writeForm": {
|
|
541
|
+
const { actions, ...rest } = section;
|
|
542
|
+
const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
543
|
+
return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
|
|
544
|
+
}
|
|
545
|
+
default: {
|
|
546
|
+
// kind undefined or "fields"
|
|
547
|
+
const { actions, ...rest } = section;
|
|
548
|
+
const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
549
|
+
return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function projectEditLayout(
|
|
555
|
+
layout: EditLayout,
|
|
556
|
+
feature: FeatureSchema,
|
|
557
|
+
indices: IndexedSchema,
|
|
558
|
+
keptScreenQns: ReadonlySet<string>,
|
|
559
|
+
): EditLayout {
|
|
560
|
+
const nextSections = layout.sections.map((s) =>
|
|
561
|
+
projectEditSection(s, feature, indices, keptScreenQns),
|
|
562
|
+
);
|
|
563
|
+
const changed = nextSections.some((s, i) => s !== layout.sections[i]);
|
|
564
|
+
if (!changed) return layout;
|
|
565
|
+
return { ...layout, sections: nextSections };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function projectMetrics(
|
|
569
|
+
metrics: readonly MetricSpec[] | undefined,
|
|
570
|
+
indices: IndexedSchema,
|
|
571
|
+
keptScreenQns: ReadonlySet<string>,
|
|
572
|
+
): readonly MetricSpec[] | undefined {
|
|
573
|
+
if (metrics === undefined) return undefined;
|
|
574
|
+
let changed = false;
|
|
575
|
+
const next = metrics.map((m) => {
|
|
576
|
+
if (typeof m === "string" || m.navigate === undefined) return m;
|
|
577
|
+
if (keepMetricNavigate(m.navigate, indices, keptScreenQns)) return m;
|
|
578
|
+
changed = true;
|
|
579
|
+
const { navigate, ...rest } = m;
|
|
580
|
+
return rest;
|
|
581
|
+
});
|
|
582
|
+
return changed ? next : metrics;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function projectDashboardPanels(
|
|
586
|
+
panels: readonly DashboardPanelDefinition[],
|
|
587
|
+
feature: FeatureSchema,
|
|
588
|
+
keptScreenQns: ReadonlySet<string>,
|
|
589
|
+
): readonly DashboardPanelDefinition[] {
|
|
590
|
+
const kept = panels.filter(
|
|
591
|
+
(p) => p.kind !== "screen" || isScreenTargetKept(feature.featureName, p.screen, keptScreenQns),
|
|
592
|
+
);
|
|
593
|
+
return kept.length === panels.length ? panels : kept;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// --- per-screen-type projection ---
|
|
597
|
+
|
|
598
|
+
function projectListScreenId(
|
|
599
|
+
listScreenId: string | undefined,
|
|
600
|
+
indices: IndexedSchema,
|
|
601
|
+
keptScreenQns: ReadonlySet<string>,
|
|
602
|
+
): string | undefined {
|
|
603
|
+
if (listScreenId === undefined) return undefined;
|
|
604
|
+
return isListScreenIdKept(listScreenId, indices, keptScreenQns) ? listScreenId : undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function projectRedirect<Redirect extends string | ActionFormRedirect>(
|
|
608
|
+
featureName: string,
|
|
609
|
+
redirect: Redirect | undefined,
|
|
610
|
+
keptScreenQns: ReadonlySet<string>,
|
|
611
|
+
): Redirect | undefined {
|
|
612
|
+
if (redirect === undefined) return undefined;
|
|
613
|
+
return isRedirectTargetKept(featureName, redirect, keptScreenQns) ? redirect : undefined;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function projectCancelTarget(
|
|
617
|
+
featureName: string,
|
|
618
|
+
cancelTarget: string | false | undefined,
|
|
619
|
+
keptScreenQns: ReadonlySet<string>,
|
|
620
|
+
): string | false | undefined {
|
|
621
|
+
if (cancelTarget === undefined || cancelTarget === false) return cancelTarget;
|
|
622
|
+
return isScreenTargetKept(featureName, cancelTarget, keptScreenQns) ? cancelTarget : undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function projectListScreen(
|
|
626
|
+
screen: EntityListScreenDefinition | ProjectionListScreenDefinition,
|
|
627
|
+
feature: FeatureSchema,
|
|
628
|
+
indices: IndexedSchema,
|
|
629
|
+
keptScreenQns: ReadonlySet<string>,
|
|
630
|
+
): ScreenDefinition {
|
|
631
|
+
const { rowActions, toolbarActions, ...rest } = screen;
|
|
632
|
+
const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
|
|
633
|
+
const nextToolbarActions = projectToolbarActionArray(
|
|
634
|
+
toolbarActions,
|
|
635
|
+
feature,
|
|
636
|
+
indices,
|
|
637
|
+
keptScreenQns,
|
|
638
|
+
);
|
|
639
|
+
if (nextRowActions === rowActions && nextToolbarActions === toolbarActions) return screen;
|
|
640
|
+
return {
|
|
641
|
+
...rest,
|
|
642
|
+
...(nextRowActions !== undefined && { rowActions: nextRowActions }),
|
|
643
|
+
...(nextToolbarActions !== undefined && { toolbarActions: nextToolbarActions }),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function projectProjectionDetailScreen(
|
|
648
|
+
screen: ProjectionDetailScreenDefinition,
|
|
649
|
+
feature: FeatureSchema,
|
|
650
|
+
indices: IndexedSchema,
|
|
651
|
+
keptScreenQns: ReadonlySet<string>,
|
|
652
|
+
): ScreenDefinition {
|
|
653
|
+
const { metrics, actions, layout, listScreenId, ...rest } = screen;
|
|
654
|
+
const nextMetrics = projectMetrics(metrics, indices, keptScreenQns);
|
|
655
|
+
const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
656
|
+
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
657
|
+
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
658
|
+
const unchanged =
|
|
659
|
+
nextMetrics === metrics &&
|
|
660
|
+
nextActions === actions &&
|
|
661
|
+
nextLayout === layout &&
|
|
662
|
+
nextListScreenId === listScreenId;
|
|
663
|
+
if (unchanged) return screen;
|
|
664
|
+
return {
|
|
665
|
+
...rest,
|
|
666
|
+
layout: nextLayout,
|
|
667
|
+
...(nextMetrics !== undefined && { metrics: nextMetrics }),
|
|
668
|
+
...(nextActions !== undefined && { actions: nextActions }),
|
|
669
|
+
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function projectEntityEditScreen(
|
|
674
|
+
screen: EntityEditScreenDefinition,
|
|
675
|
+
feature: FeatureSchema,
|
|
676
|
+
indices: IndexedSchema,
|
|
677
|
+
keptScreenQns: ReadonlySet<string>,
|
|
678
|
+
): ScreenDefinition {
|
|
679
|
+
const { redirect, actions, layout, listScreenId, ...rest } = screen;
|
|
680
|
+
const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
|
|
681
|
+
const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
682
|
+
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
683
|
+
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
684
|
+
const unchanged =
|
|
685
|
+
nextRedirect === redirect &&
|
|
686
|
+
nextActions === actions &&
|
|
687
|
+
nextLayout === layout &&
|
|
688
|
+
nextListScreenId === listScreenId;
|
|
689
|
+
if (unchanged) return screen;
|
|
690
|
+
return {
|
|
691
|
+
...rest,
|
|
692
|
+
layout: nextLayout,
|
|
693
|
+
...(nextRedirect !== undefined && { redirect: nextRedirect }),
|
|
694
|
+
...(nextActions !== undefined && { actions: nextActions }),
|
|
695
|
+
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function projectActionFormScreen(
|
|
700
|
+
screen: ActionFormScreenDefinition,
|
|
701
|
+
feature: FeatureSchema,
|
|
702
|
+
indices: IndexedSchema,
|
|
703
|
+
keptScreenQns: ReadonlySet<string>,
|
|
704
|
+
): ScreenDefinition {
|
|
705
|
+
const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
|
|
706
|
+
const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
|
|
707
|
+
const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
|
|
708
|
+
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
709
|
+
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
710
|
+
const unchanged =
|
|
711
|
+
nextRedirect === redirect &&
|
|
712
|
+
nextCancelTarget === cancelTarget &&
|
|
713
|
+
nextLayout === layout &&
|
|
714
|
+
nextListScreenId === listScreenId;
|
|
715
|
+
if (unchanged) return screen;
|
|
716
|
+
return {
|
|
717
|
+
...rest,
|
|
718
|
+
layout: nextLayout,
|
|
719
|
+
...(nextRedirect !== undefined && { redirect: nextRedirect }),
|
|
720
|
+
...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
|
|
721
|
+
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function projectSecretMintScreen(
|
|
726
|
+
screen: SecretMintScreenDefinition,
|
|
727
|
+
feature: FeatureSchema,
|
|
728
|
+
indices: IndexedSchema,
|
|
729
|
+
keptScreenQns: ReadonlySet<string>,
|
|
730
|
+
): ScreenDefinition {
|
|
731
|
+
const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
|
|
732
|
+
const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
|
|
733
|
+
const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
|
|
734
|
+
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
735
|
+
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
736
|
+
const unchanged =
|
|
737
|
+
nextRedirect === redirect &&
|
|
738
|
+
nextCancelTarget === cancelTarget &&
|
|
739
|
+
nextLayout === layout &&
|
|
740
|
+
nextListScreenId === listScreenId;
|
|
741
|
+
if (unchanged) return screen;
|
|
742
|
+
return {
|
|
743
|
+
...rest,
|
|
744
|
+
layout: nextLayout,
|
|
745
|
+
...(nextRedirect !== undefined && { redirect: nextRedirect }),
|
|
746
|
+
...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
|
|
747
|
+
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function projectScreen(
|
|
752
|
+
screen: ScreenDefinition,
|
|
753
|
+
feature: FeatureSchema,
|
|
754
|
+
indices: IndexedSchema,
|
|
755
|
+
keptScreenQns: ReadonlySet<string>,
|
|
756
|
+
): ScreenDefinition {
|
|
757
|
+
switch (screen.type) {
|
|
758
|
+
case "entityList":
|
|
759
|
+
case "projectionList":
|
|
760
|
+
return projectListScreen(screen, feature, indices, keptScreenQns);
|
|
761
|
+
case "projectionDetail":
|
|
762
|
+
return projectProjectionDetailScreen(screen, feature, indices, keptScreenQns);
|
|
763
|
+
case "dashboard": {
|
|
764
|
+
const nextPanels = projectDashboardPanels(screen.panels, feature, keptScreenQns);
|
|
765
|
+
return nextPanels === screen.panels ? screen : { ...screen, panels: nextPanels };
|
|
766
|
+
}
|
|
767
|
+
case "entityEdit":
|
|
768
|
+
return projectEntityEditScreen(screen, feature, indices, keptScreenQns);
|
|
769
|
+
case "actionForm":
|
|
770
|
+
return projectActionFormScreen(screen, feature, indices, keptScreenQns);
|
|
771
|
+
case "secretMint":
|
|
772
|
+
return projectSecretMintScreen(screen, feature, indices, keptScreenQns);
|
|
773
|
+
case "custom": {
|
|
774
|
+
const { listScreenId, ...rest } = screen;
|
|
775
|
+
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
776
|
+
if (nextListScreenId === listScreenId) return screen;
|
|
777
|
+
return { ...rest, ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }) };
|
|
778
|
+
}
|
|
779
|
+
// configEdit / secretsEdit carry no screen-navigate fields (see
|
|
780
|
+
// screen.ts's own definitions) — nothing to project.
|
|
781
|
+
default:
|
|
782
|
+
return screen;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// --- nav / content-collection / workspace projection ---
|
|
787
|
+
|
|
788
|
+
function projectNav(nav: NavDefinition, keptScreenQns: ReadonlySet<string>): NavDefinition {
|
|
789
|
+
const { actions, createAction, ...rest } = nav;
|
|
790
|
+
const nextActions = projectTreeActionArray(actions, keptScreenQns);
|
|
791
|
+
const nextCreateAction = projectTreeAction(createAction, keptScreenQns);
|
|
792
|
+
if (nextActions === actions && nextCreateAction === createAction) return nav;
|
|
793
|
+
return {
|
|
794
|
+
...rest,
|
|
795
|
+
...(nextActions !== undefined && { actions: nextActions }),
|
|
796
|
+
...(nextCreateAction !== undefined && { createAction: nextCreateAction }),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function projectContentCollectionNav(
|
|
801
|
+
collection: QualifiedContentCollection,
|
|
802
|
+
keptScreenQns: ReadonlySet<string>,
|
|
803
|
+
): QualifiedContentCollection {
|
|
804
|
+
const { actions, createAction, ...restNav } = collection.nav;
|
|
805
|
+
const nextActions = projectTreeActionArray(actions, keptScreenQns);
|
|
806
|
+
const nextCreateAction = projectTreeAction(createAction, keptScreenQns);
|
|
807
|
+
if (nextActions === actions && nextCreateAction === createAction) return collection;
|
|
808
|
+
return {
|
|
809
|
+
...collection,
|
|
810
|
+
nav: {
|
|
811
|
+
...restNav,
|
|
812
|
+
...(nextActions !== undefined && { actions: nextActions }),
|
|
813
|
+
...(nextCreateAction !== undefined && { createAction: nextCreateAction }),
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function projectContentCollections(
|
|
819
|
+
collections: readonly QualifiedContentCollection[] | undefined,
|
|
820
|
+
roles: readonly string[],
|
|
821
|
+
survivingNavQns: ReadonlySet<string>,
|
|
822
|
+
keptScreenQns: ReadonlySet<string>,
|
|
823
|
+
): readonly QualifiedContentCollection[] | undefined {
|
|
824
|
+
if (collections === undefined) return undefined;
|
|
825
|
+
let changed = false;
|
|
826
|
+
const kept: QualifiedContentCollection[] = [];
|
|
827
|
+
for (const collection of collections) {
|
|
828
|
+
if (!isUiAccessGranted(collection.access, roles) || !survivingNavQns.has(collection.navQn)) {
|
|
829
|
+
changed = true;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const nextCollection = projectContentCollectionNav(collection, keptScreenQns);
|
|
833
|
+
if (nextCollection !== collection) changed = true;
|
|
834
|
+
kept.push(nextCollection);
|
|
835
|
+
}
|
|
836
|
+
if (!changed) return collections;
|
|
837
|
+
return kept.length > 0 ? kept : undefined;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function projectWorkspaceMembership(
|
|
841
|
+
ws: WorkspaceSchema,
|
|
842
|
+
survivingNavQns: ReadonlySet<string>,
|
|
843
|
+
): WorkspaceSchema | undefined {
|
|
844
|
+
const nextNavMembers = ws.navMembers.filter((qn) => survivingNavQns.has(qn));
|
|
845
|
+
// Only a workspace the projection emptied is dropped; one declared without
|
|
846
|
+
// members stays exactly as buildAppSchema produced it.
|
|
847
|
+
if (nextNavMembers.length === 0 && ws.navMembers.length > 0) return undefined;
|
|
848
|
+
const definitionNav: WorkspaceDefinition["nav"] = ws.definition.nav;
|
|
849
|
+
const nextDefinitionNav = definitionNav?.filter((qn) => survivingNavQns.has(qn));
|
|
850
|
+
const navMembersUnchanged = nextNavMembers.length === ws.navMembers.length;
|
|
851
|
+
const definitionNavUnchanged =
|
|
852
|
+
definitionNav === undefined || nextDefinitionNav?.length === definitionNav.length;
|
|
853
|
+
if (navMembersUnchanged && definitionNavUnchanged) return ws;
|
|
854
|
+
const { nav, ...restDefinition } = ws.definition;
|
|
855
|
+
return {
|
|
856
|
+
...ws,
|
|
857
|
+
navMembers: nextNavMembers,
|
|
858
|
+
definition: {
|
|
859
|
+
...restDefinition,
|
|
860
|
+
...(nextDefinitionNav !== undefined &&
|
|
861
|
+
nextDefinitionNav.length > 0 && { nav: nextDefinitionNav }),
|
|
862
|
+
},
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function projectWorkspaces(
|
|
867
|
+
workspaces: readonly WorkspaceSchema[] | undefined,
|
|
868
|
+
roles: readonly string[],
|
|
869
|
+
survivingNavQns: ReadonlySet<string>,
|
|
870
|
+
): readonly WorkspaceSchema[] | undefined {
|
|
871
|
+
if (workspaces === undefined) return undefined;
|
|
872
|
+
let changed = false;
|
|
873
|
+
const kept: WorkspaceSchema[] = [];
|
|
874
|
+
for (const ws of workspaces) {
|
|
875
|
+
if (!isUiAccessGranted(ws.definition.access, roles)) {
|
|
876
|
+
changed = true;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
const nextWorkspace = projectWorkspaceMembership(ws, survivingNavQns);
|
|
880
|
+
if (nextWorkspace === undefined) {
|
|
881
|
+
changed = true;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (nextWorkspace !== ws) changed = true;
|
|
885
|
+
kept.push(nextWorkspace);
|
|
886
|
+
}
|
|
887
|
+
if (!changed) return workspaces;
|
|
888
|
+
// An empty result behaves identically to `undefined` on the client
|
|
889
|
+
// (WorkspaceShell's `hasWorkspaceMode` check requires `.length > 0`
|
|
890
|
+
// either way) — omit the key rather than ship `[]`, matching
|
|
891
|
+
// buildAppSchema's own JSON-safety convention.
|
|
892
|
+
return kept.length > 0 ? kept : undefined;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function projectFeature(
|
|
896
|
+
feature: FeatureSchema,
|
|
897
|
+
roles: readonly string[],
|
|
898
|
+
keptScreenQns: ReadonlySet<string>,
|
|
899
|
+
indices: IndexedSchema,
|
|
900
|
+
droppedNavQns: ReadonlySet<string>,
|
|
901
|
+
survivingNavQns: ReadonlySet<string>,
|
|
902
|
+
): FeatureSchema {
|
|
903
|
+
const { screens, navs, contentCollections, workspaces, ...rest } = feature;
|
|
904
|
+
|
|
905
|
+
let screensChanged = false;
|
|
906
|
+
const nextScreens = screens
|
|
907
|
+
.filter((s) => keptScreenQns.has(qualifyEntityName(feature.featureName, "screen", s.id)))
|
|
908
|
+
.map((s) => {
|
|
909
|
+
const projected = projectScreen(s, feature, indices, keptScreenQns);
|
|
910
|
+
if (projected !== s) screensChanged = true;
|
|
911
|
+
return projected;
|
|
912
|
+
});
|
|
913
|
+
if (nextScreens.length !== screens.length) screensChanged = true;
|
|
914
|
+
|
|
915
|
+
const originalNavs = navs ?? [];
|
|
916
|
+
const filteredNavs = originalNavs.filter(
|
|
917
|
+
(nav) => !droppedNavQns.has(qualifyEntityName(feature.featureName, "nav", nav.id)),
|
|
918
|
+
);
|
|
919
|
+
let anyNavProjected = false;
|
|
920
|
+
const projectedNavs = filteredNavs.map((nav) => {
|
|
921
|
+
const next = projectNav(nav, keptScreenQns);
|
|
922
|
+
if (next !== nav) anyNavProjected = true;
|
|
923
|
+
return next;
|
|
924
|
+
});
|
|
925
|
+
const navsChanged = filteredNavs.length !== originalNavs.length || anyNavProjected;
|
|
926
|
+
const finalNavs = projectedNavs.length > 0 ? projectedNavs : undefined;
|
|
927
|
+
|
|
928
|
+
const nextContentCollections = projectContentCollections(
|
|
929
|
+
contentCollections,
|
|
930
|
+
roles,
|
|
931
|
+
survivingNavQns,
|
|
932
|
+
keptScreenQns,
|
|
933
|
+
);
|
|
934
|
+
const nextFeatureWorkspaces = projectWorkspaces(workspaces, roles, survivingNavQns);
|
|
935
|
+
|
|
936
|
+
const contentCollectionsChanged = nextContentCollections !== contentCollections;
|
|
937
|
+
const featureWorkspacesChanged = nextFeatureWorkspaces !== workspaces;
|
|
938
|
+
|
|
939
|
+
if (!screensChanged && !navsChanged && !contentCollectionsChanged && !featureWorkspacesChanged) {
|
|
940
|
+
return feature;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
...rest,
|
|
945
|
+
screens: nextScreens,
|
|
946
|
+
...(finalNavs !== undefined && { navs: finalNavs }),
|
|
947
|
+
...(nextContentCollections !== undefined && { contentCollections: nextContentCollections }),
|
|
948
|
+
...(nextFeatureWorkspaces !== undefined && { workspaces: nextFeatureWorkspaces }),
|
|
949
|
+
};
|
|
950
|
+
}
|