@cosmicdrift/kumiko-framework 0.331.0 → 0.333.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/dist/db/blind-index-cleanup.d.ts +1 -0
- package/dist/db/blind-index-cleanup.js +12 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +2 -1
- package/dist/db/queries/event-consumer.js +12 -2
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/query-output-columns.js +15 -2
- package/dist/engine/boot-validator/query-refs.d.ts +2 -1
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens.d.ts +3 -1
- package/dist/engine/boot-validator/screens.js +33 -2
- package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
- package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +7 -0
- package/dist/engine/build-config-feature-schema.js +31 -6
- package/dist/engine/changeset-changes.js +6 -0
- package/dist/engine/entity-handlers.js +1 -1
- package/dist/engine/extension-names.d.ts +12 -1
- package/dist/engine/extension-names.js +7 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round5.d.ts +1 -0
- package/dist/engine/feature-ast/extractors/round5.js +5 -0
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ui-extensions.d.ts +4 -2
- package/dist/engine/feature-ui-extensions.js +40 -3
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +2 -2
- package/dist/engine/project-app-schema-for-roles.js +20 -1
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-ingest.js +2 -0
- package/dist/engine/registry-state.d.ts +2 -1
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/role-assignment.d.ts +11 -1
- package/dist/engine/role-assignment.js +64 -3
- package/dist/engine/schema-builder.d.ts +3 -1
- package/dist/engine/schema-builder.js +5 -6
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/errors/reasons.js +7 -6
- package/dist/i18n/required-surface-keys.js +19 -3
- package/dist/pipeline/dispatch-write.js +26 -2
- package/dist/pipeline/event-consumer-state.js +6 -0
- package/dist/pipeline/event-dispatcher-admin.js +36 -16
- package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
- package/dist/pipeline/event-dispatcher-delivery.js +22 -2
- package/dist/pipeline/event-dispatcher.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +40 -0
|
@@ -3,3 +3,4 @@ import type { DbRunner } from "./connection.js";
|
|
|
3
3
|
export declare function nullBlindIndexesForSubject(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectKey: string): Promise<void>;
|
|
4
4
|
export declare function subjectRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectId: string, tenantId: string): Promise<boolean>;
|
|
5
5
|
export declare function recordRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string, tenantId: string): Promise<boolean>;
|
|
6
|
+
export declare function recordRowOwningTenantId(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string): Promise<string | undefined>;
|
|
@@ -110,3 +110,15 @@ export async function recordRowExistsInTenant(db, features, entityName, recordId
|
|
|
110
110
|
const rows = await executeRawQueryRead(db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [recordId, tenantId]);
|
|
111
111
|
return rows.length > 0;
|
|
112
112
|
}
|
|
113
|
+
// Owning tenant of a record subject's row, for callers that must evaluate
|
|
114
|
+
// tenant-scoped policy for the row's real owner (not the acting user's tenant).
|
|
115
|
+
export async function recordRowOwningTenantId(db, features, entityName, recordId) {
|
|
116
|
+
const entity = findEntityByExactName(features, entityName);
|
|
117
|
+
if (!entity)
|
|
118
|
+
return undefined;
|
|
119
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
120
|
+
if (!(await tableExists(db, tableName)))
|
|
121
|
+
return undefined;
|
|
122
|
+
const rows = await executeRawQueryRead(db, `SELECT tenant_id FROM ${quoteIdent(tableName)} WHERE id = $1 LIMIT 1`, [recordId]);
|
|
123
|
+
return rows[0]?.tenant_id;
|
|
124
|
+
}
|
package/dist/db/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
package/dist/db/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
|
@@ -24,10 +24,11 @@ export type ConsumerDeliveryOutcome = {
|
|
|
24
24
|
readonly deadLettered: boolean;
|
|
25
25
|
readonly processed: number;
|
|
26
26
|
readonly pendingGaps: readonly PendingGapEntry[];
|
|
27
|
+
readonly failedEventId: bigint | null;
|
|
27
28
|
};
|
|
28
29
|
export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
|
|
29
30
|
export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<Record<string, unknown> | undefined>;
|
|
30
|
-
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint): Promise<Record<string, unknown> | undefined>;
|
|
31
|
+
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, gapToAdd?: PendingGapEntry | null): Promise<Record<string, unknown> | undefined>;
|
|
31
32
|
export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
32
33
|
export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
33
34
|
export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
|
|
@@ -167,6 +167,7 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
|
|
|
167
167
|
"rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
|
|
168
168
|
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
169
169
|
"pending_gaps" = $6::text::jsonb,
|
|
170
|
+
"last_failed_event_id" = $9,
|
|
170
171
|
"updated_at" = now()
|
|
171
172
|
WHERE "name" = $7 AND "instance_id" = $8`, [
|
|
172
173
|
outcome.cursor,
|
|
@@ -177,6 +178,7 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
|
|
|
177
178
|
JSON.stringify(outcome.pendingGaps),
|
|
178
179
|
name,
|
|
179
180
|
instanceId,
|
|
181
|
+
outcome.failedEventId,
|
|
180
182
|
]);
|
|
181
183
|
}
|
|
182
184
|
export async function updateConsumerStatusReturning(db, name, instanceId, status) {
|
|
@@ -185,16 +187,21 @@ export async function updateConsumerStatusReturning(db, name, instanceId, status
|
|
|
185
187
|
RETURNING *`, [status, name, instanceId]));
|
|
186
188
|
return rows[0];
|
|
187
189
|
}
|
|
188
|
-
export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId
|
|
190
|
+
export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
|
|
191
|
+
// Ids skipped over between the old cursor and eventId that were not visible yet.
|
|
192
|
+
gapToAdd = null) {
|
|
189
193
|
const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
190
194
|
"last_processed_event_id" = $1,
|
|
191
195
|
"status" = 'idle',
|
|
192
196
|
"attempts" = 0,
|
|
193
197
|
"last_error" = NULL,
|
|
194
198
|
"rearm_count" = 0,
|
|
199
|
+
"last_failed_event_id" = NULL,
|
|
200
|
+
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
201
|
+
"pending_gaps" = CASE WHEN $4::text IS NULL THEN "pending_gaps" ELSE "pending_gaps" || $4::text::jsonb END,
|
|
195
202
|
"updated_at" = now()
|
|
196
203
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
197
|
-
RETURNING *`, [eventId, name, instanceId]));
|
|
204
|
+
RETURNING *`, [eventId, name, instanceId, gapToAdd === null ? null : JSON.stringify([gapToAdd])]));
|
|
198
205
|
return rows[0];
|
|
199
206
|
}
|
|
200
207
|
export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
@@ -206,6 +213,7 @@ export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
|
206
213
|
"attempts" = 0,
|
|
207
214
|
"last_error" = NULL,
|
|
208
215
|
"pending_gaps" = '[]'::jsonb,
|
|
216
|
+
"last_failed_event_id" = NULL,
|
|
209
217
|
"updated_at" = now()`, [name, instanceId]);
|
|
210
218
|
}
|
|
211
219
|
export async function selectConsumerForUpdate(db, name, instanceId) {
|
|
@@ -220,6 +228,7 @@ export async function updateConsumerRebuildCursor(db, name, instanceId, lastProc
|
|
|
220
228
|
"last_error" = NULL,
|
|
221
229
|
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
222
230
|
"pending_gaps" = $2::text::jsonb,
|
|
231
|
+
"last_failed_event_id" = NULL,
|
|
223
232
|
"updated_at" = now()
|
|
224
233
|
WHERE "name" = $3 AND "instance_id" = $4`, [lastProcessedEventId, JSON.stringify(pendingGaps), name, instanceId]);
|
|
225
234
|
}
|
|
@@ -254,6 +263,7 @@ export async function removePendingGapReturning(db, name, instanceId, newPending
|
|
|
254
263
|
"attempts" = 0,
|
|
255
264
|
"last_error" = NULL,
|
|
256
265
|
"rearm_count" = 0,
|
|
266
|
+
"last_failed_event_id" = NULL,
|
|
257
267
|
"updated_at" = now()
|
|
258
268
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
259
269
|
RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]));
|
|
@@ -24,6 +24,7 @@ import { validateQueryRefs } from "./query-refs.js";
|
|
|
24
24
|
import { validateRecordOwnedSubjects } from "./record-owned.js";
|
|
25
25
|
import { collectScreenQns, collectScreensByShortId, validateScreenShortIdCollisions, validateScreens, } from "./screens.js";
|
|
26
26
|
import { warnOnMissingSecurityBaseline } from "./security-baseline.js";
|
|
27
|
+
import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
|
|
27
28
|
import { validateTransferGraph } from "./transfer-graph.js";
|
|
28
29
|
import { collectWorkspaceQns, resolveNavAllowlist, validateDefaultWorkspaceUniqueness, validateWorkspaces, } from "./workspaces.js";
|
|
29
30
|
export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
|
|
@@ -170,6 +171,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
170
171
|
// misleading "no search parameter in its Zod schema" error instead of
|
|
171
172
|
// the clear typo message below.
|
|
172
173
|
validateQueryRefs(features);
|
|
174
|
+
validateExtensionSelectorPanels(features);
|
|
173
175
|
// Must also run after validateQueryRefs — column/field checks below
|
|
174
176
|
// assume every `query` string already resolves to a registered handler.
|
|
175
177
|
validateQueryOutputColumns(features);
|
|
@@ -62,8 +62,21 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
62
62
|
};
|
|
63
63
|
if (screen.header !== undefined) {
|
|
64
64
|
checkHeaderField("title", screen.header.title);
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const subtitle = screen.header.subtitle;
|
|
66
|
+
if (typeof subtitle === "string")
|
|
67
|
+
checkHeaderField("subtitle", subtitle);
|
|
68
|
+
else if (subtitle !== undefined) {
|
|
69
|
+
for (const part of subtitle) {
|
|
70
|
+
if (typeof part === "string") {
|
|
71
|
+
checkHeaderField("subtitle", part);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
checkHeaderField("subtitle", part.field);
|
|
75
|
+
if (part.navigate?.entityId !== undefined) {
|
|
76
|
+
checkHeaderField("subtitle", part.navigate.entityId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
67
80
|
if (screen.header.status !== undefined)
|
|
68
81
|
checkHeaderField("status", screen.header.status);
|
|
69
82
|
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import type { FeatureDefinition } from "../types/index.js";
|
|
1
|
+
import type { FeatureDefinition, QueryHandlerDef } from "../types/index.js";
|
|
2
|
+
export declare function checkQueryRef(queryHandlers: ReadonlyMap<string, QueryHandlerDef>, query: string, buildPrefix: () => string): void;
|
|
2
3
|
export declare function validateQueryRefs(features: readonly FeatureDefinition[]): void;
|
|
@@ -4,7 +4,7 @@ const NOT_REGISTERED_SUFFIX = "is not a registered query-handler. Check the QN s
|
|
|
4
4
|
function isNonEmptyQueryString(value) {
|
|
5
5
|
return typeof value === "string" && value.length > 0;
|
|
6
6
|
}
|
|
7
|
-
function checkQueryRef(queryHandlers, query, buildPrefix) {
|
|
7
|
+
export function checkQueryRef(queryHandlers, query, buildPrefix) {
|
|
8
8
|
// skip: empty/non-string queries get their own message in screens.ts;
|
|
9
9
|
// a registered query has nothing left to check.
|
|
10
10
|
if (!isNonEmptyQueryString(query) || queryHandlers.has(query))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
2
2
|
import type { FeatureDefinition } from "../types/index.js";
|
|
3
|
-
import type { RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
3
|
+
import type { DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
4
4
|
export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
|
|
5
5
|
export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
|
|
6
6
|
export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit", context: string, action: {
|
|
@@ -20,6 +20,8 @@ export declare function validateScreens(feature: FeatureDefinition, featureMap:
|
|
|
20
20
|
readonly featureName: string;
|
|
21
21
|
readonly screen: ScreenDefinition;
|
|
22
22
|
}>): void;
|
|
23
|
+
export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
24
|
+
export declare function validateDashboardCustomPanel(featureName: string, screenId: string, panel: DashboardCustomPanel): void;
|
|
23
25
|
export declare function validateColumnRendererForm(featureName: string, screenId: string, column: {
|
|
24
26
|
readonly field: string;
|
|
25
27
|
readonly renderer?: unknown;
|
|
@@ -942,6 +942,37 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
942
942
|
}
|
|
943
943
|
}
|
|
944
944
|
}
|
|
945
|
+
if (Array.isArray(screen.header?.subtitle)) {
|
|
946
|
+
if (screen.header.subtitleHref !== undefined) {
|
|
947
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
|
|
948
|
+
`together with a multi-part header.subtitle — subtitleHref only works with the string form.`);
|
|
949
|
+
}
|
|
950
|
+
const seenPartFields = new Set();
|
|
951
|
+
for (const part of screen.header.subtitle) {
|
|
952
|
+
const partField = typeof part === "string" ? part : part.field;
|
|
953
|
+
if (seenPartFields.has(partField)) {
|
|
954
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle ` +
|
|
955
|
+
`lists field "${partField}" twice.`);
|
|
956
|
+
}
|
|
957
|
+
seenPartFields.add(partField);
|
|
958
|
+
const navigate = typeof part === "string" ? undefined : part.navigate;
|
|
959
|
+
if (navigate === undefined)
|
|
960
|
+
continue;
|
|
961
|
+
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
962
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle part ` +
|
|
963
|
+
`"${partField}" navigates with neither "screen" nor "entity" — a tab-only navigate has no link target.`);
|
|
964
|
+
}
|
|
965
|
+
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "subtitle part", {
|
|
966
|
+
kind: "navigate",
|
|
967
|
+
id: partField,
|
|
968
|
+
label: partField,
|
|
969
|
+
...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
|
|
970
|
+
...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
|
|
971
|
+
...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
|
|
972
|
+
...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
|
|
973
|
+
}, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
945
976
|
for (const section of screen.layout.sections) {
|
|
946
977
|
const sectionLabel = section.title ?? section.id ?? "(untitled)";
|
|
947
978
|
if (section.actions !== undefined) {
|
|
@@ -1562,7 +1593,7 @@ const EMBEDDABLE_SCREEN_TYPES = new Set([
|
|
|
1562
1593
|
"configEdit",
|
|
1563
1594
|
"secretsEdit",
|
|
1564
1595
|
]);
|
|
1565
|
-
function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
1596
|
+
export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
1566
1597
|
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
|
|
1567
1598
|
const targetQn = resolveScreenTargetQn(featureName, panel.screen);
|
|
1568
1599
|
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
@@ -1594,7 +1625,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
|
|
|
1594
1625
|
}
|
|
1595
1626
|
}
|
|
1596
1627
|
}
|
|
1597
|
-
function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
1628
|
+
export function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
1598
1629
|
if (panel.component.react === undefined && panel.component.native === undefined) {
|
|
1599
1630
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
|
|
1600
1631
|
`declare a react/native component marker.`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { buildQueryHandlerMap } from "./projection-list-screens.js";
|
|
2
|
+
import { checkQueryRef } from "./query-refs.js";
|
|
3
|
+
import { validateDashboardCustomPanel, validateDashboardScreenPanel } from "./screens.js";
|
|
4
|
+
// Owner panels are merged into a dashboard that is built after boot, so the screen and
|
|
5
|
+
// query refs are checked here against the registered features instead.
|
|
6
|
+
export function validateExtensionSelectorPanels(features) {
|
|
7
|
+
const featureMap = new Map(features.map((feature) => [feature.name, feature]));
|
|
8
|
+
const queryHandlers = buildQueryHandlerMap(features);
|
|
9
|
+
for (const feature of features) {
|
|
10
|
+
for (const selector of feature.extensionSelectors ?? []) {
|
|
11
|
+
const where = `extensionSelector("${selector.extensionName}")`;
|
|
12
|
+
for (const panel of selector.panels ?? []) {
|
|
13
|
+
const context = `[Feature ${feature.name}] ${where} panel "${panel.id}"`;
|
|
14
|
+
if (panel.kind === "custom") {
|
|
15
|
+
validateDashboardCustomPanel(feature.name, where, panel);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
validateDashboardScreenPanel(feature.name, where, panel, featureMap);
|
|
19
|
+
if (panel.visibleWhen !== undefined) {
|
|
20
|
+
checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `${context} visibleWhen`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -11,6 +11,12 @@ function scopeActions(actions, sourceScreenEntity, implicitParams) {
|
|
|
11
11
|
function relatedListParentParamField(section) {
|
|
12
12
|
return section.parentFilter?.field ?? section.parentParam ?? "id";
|
|
13
13
|
}
|
|
14
|
+
function subtitlePartNavigates(screen) {
|
|
15
|
+
const subtitle = screen.header?.subtitle;
|
|
16
|
+
if (typeof subtitle === "string" || subtitle === undefined)
|
|
17
|
+
return [];
|
|
18
|
+
return subtitle.flatMap((part) => typeof part === "string" || part.navigate === undefined ? [] : [part.navigate]);
|
|
19
|
+
}
|
|
14
20
|
function projectionDetailNavigateActions(screen) {
|
|
15
21
|
return [
|
|
16
22
|
...scopeActions(screen.actions, screen.detailFor),
|
|
@@ -23,6 +29,7 @@ function projectionDetailNavigateActions(screen) {
|
|
|
23
29
|
...(screen.metrics ?? []).flatMap((metric) => typeof metric === "string" || metric.navigate === undefined
|
|
24
30
|
? []
|
|
25
31
|
: scopeActions([metric.navigate], undefined)),
|
|
32
|
+
...subtitlePartNavigates(screen).flatMap((navigate) => scopeActions([navigate], undefined)),
|
|
26
33
|
];
|
|
27
34
|
}
|
|
28
35
|
function navigateActionsOf(screen) {
|
|
@@ -56,6 +56,7 @@ const ELEVATED_ROLES = {
|
|
|
56
56
|
const MACHINE_WRITE_ROLE = "system";
|
|
57
57
|
const NO_SELECTOR_GATING = {
|
|
58
58
|
pluginsByOwnerGroup: new Map(),
|
|
59
|
+
ownerPanelsByGroup: new Map(),
|
|
59
60
|
pluginFeatures: new Set(),
|
|
60
61
|
secretQns: new Set(),
|
|
61
62
|
};
|
|
@@ -93,6 +94,16 @@ function collectVisibleSelectorOwners(registry, visible) {
|
|
|
93
94
|
}
|
|
94
95
|
return { ownerGroupByExtension, selectorFeatures };
|
|
95
96
|
}
|
|
97
|
+
function collectOwnerPanelsByGroup(registry, ownerGroupByExtension) {
|
|
98
|
+
const panelsByGroup = new Map();
|
|
99
|
+
for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
|
|
100
|
+
const panels = registry.getExtensionSelectorPanels(extension);
|
|
101
|
+
if (panels.length === 0)
|
|
102
|
+
continue;
|
|
103
|
+
panelsByGroup.set(ownerGroup, [...(panelsByGroup.get(ownerGroup) ?? []), ...panels]);
|
|
104
|
+
}
|
|
105
|
+
return panelsByGroup;
|
|
106
|
+
}
|
|
96
107
|
function countSelectorUsagesByFeature(registry) {
|
|
97
108
|
const usageCountByFeature = new Map();
|
|
98
109
|
for (const [extensionName] of registry.getAllExtensionSelectors()) {
|
|
@@ -112,7 +123,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
112
123
|
if (ownerGroupByExtension.size === 0)
|
|
113
124
|
return NO_SELECTOR_GATING;
|
|
114
125
|
const usageCountByFeature = countSelectorUsagesByFeature(registry);
|
|
115
|
-
const
|
|
126
|
+
const ownerPanelsByGroup = collectOwnerPanelsByGroup(registry, ownerGroupByExtension);
|
|
127
|
+
// A selector with panels but no gated plugin still becomes a dashboard.
|
|
128
|
+
const pluginsByOwnerGroup = new Map([...ownerPanelsByGroup.keys()].map((ownerGroup) => [ownerGroup, []]));
|
|
116
129
|
const pluginFeatures = new Set();
|
|
117
130
|
const secretQns = new Set();
|
|
118
131
|
for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
|
|
@@ -135,7 +148,7 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
135
148
|
secretQns.add(s.qn);
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
|
-
return { pluginsByOwnerGroup, pluginFeatures, secretQns };
|
|
151
|
+
return { pluginsByOwnerGroup, ownerPanelsByGroup, pluginFeatures, secretQns };
|
|
139
152
|
}
|
|
140
153
|
// A tenant/user-home key with an elevated write role (SystemAdmin on a
|
|
141
154
|
// tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
|
|
@@ -173,7 +186,7 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
173
186
|
out.navs.push(buildFeatureNav(scope, feature, ordered, access, hub.declaredTranslationKeys));
|
|
174
187
|
continue;
|
|
175
188
|
}
|
|
176
|
-
const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, hub, secretsAccess);
|
|
189
|
+
const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, gating.ownerPanelsByGroup.get(feature) ?? [], hub, secretsAccess);
|
|
177
190
|
out.screens.push(...ownerHub.screens);
|
|
178
191
|
out.navs.push(buildFeatureNav(scope, feature, ordered, ownerHub.access, hub.declaredTranslationKeys));
|
|
179
192
|
Object.assign(out.translations, ownerHub.translations);
|
|
@@ -183,9 +196,20 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
183
196
|
function pluginTenantScreenId(plugin) {
|
|
184
197
|
return `${plugin.feature}-tenant`;
|
|
185
198
|
}
|
|
199
|
+
function assertUniquePanelIds(ownerGroup, panels) {
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
for (const panel of panels) {
|
|
202
|
+
if (seen.has(panel.id)) {
|
|
203
|
+
throw new Error(`Settings dashboard "${ownerGroup}-tenant" has duplicate panel id "${panel.id}" — an ` +
|
|
204
|
+
`extensionSelector panel id collides with another panel of the same dashboard.`);
|
|
205
|
+
}
|
|
206
|
+
seen.add(panel.id);
|
|
207
|
+
}
|
|
208
|
+
return panels;
|
|
209
|
+
}
|
|
186
210
|
// Plugin screens keep their `${F}-tenant` ids so consumer title keys stay valid;
|
|
187
211
|
// they are dormant because only this dashboard embeds them.
|
|
188
|
-
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, hub, secretsAccess) {
|
|
212
|
+
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
189
213
|
const selectionScreenId = `${ownerGroup}-tenant-selection`;
|
|
190
214
|
const selectionScreen = {
|
|
191
215
|
...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub),
|
|
@@ -240,11 +264,12 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
240
264
|
id: `${ownerGroup}-tenant`,
|
|
241
265
|
type: "dashboard",
|
|
242
266
|
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
243
|
-
panels: [
|
|
267
|
+
panels: assertUniquePanelIds(ownerGroup, [
|
|
244
268
|
{ kind: "screen", id: "selection", screen: selectionScreenId },
|
|
269
|
+
...ownerPanels,
|
|
245
270
|
...configPanels,
|
|
246
271
|
...secretsPanels,
|
|
247
|
-
],
|
|
272
|
+
]),
|
|
248
273
|
access,
|
|
249
274
|
};
|
|
250
275
|
return { screens: [dashboard, ...screens], access, translations };
|
|
@@ -67,6 +67,12 @@ export function parseChangesetChanges(markdown, source) {
|
|
|
67
67
|
let match = BLOCK_RE.exec(markdown);
|
|
68
68
|
while (match !== null) {
|
|
69
69
|
const block = match[1];
|
|
70
|
+
// BLOCK_RE closes at the first line starting with "-->"; text after it on
|
|
71
|
+
// that line means a field value contained one and the block ended early.
|
|
72
|
+
const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
|
|
73
|
+
if (restOfLine.trim() !== "") {
|
|
74
|
+
throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
|
|
75
|
+
}
|
|
70
76
|
if (!block)
|
|
71
77
|
throw new Error(`${source}: empty kumiko-changes block`);
|
|
72
78
|
const fields = parseBlock(block, source);
|
|
@@ -224,7 +224,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
224
224
|
let handler;
|
|
225
225
|
switch (verb) {
|
|
226
226
|
case "create":
|
|
227
|
-
schema = buildInsertSchema(entity, undefined, excludedFields);
|
|
227
|
+
schema = buildInsertSchema(entity, undefined, excludedFields, { allowCallerId: true });
|
|
228
228
|
handler = async (event, ctx) => {
|
|
229
229
|
const { runPreSave } = ctx;
|
|
230
230
|
// A caller-chosen id (deterministic-id idempotent creates) is only
|
|
@@ -5,6 +5,7 @@ import type { PrincipalStatusPlugin, TenantLifecycleStatusPlugin } from "./activ
|
|
|
5
5
|
import type { TenantDataExtensionHooks } from "./extensions/tenant-data.js";
|
|
6
6
|
import type { TenantResourceExtensionHooks } from "./extensions/tenant-resource.js";
|
|
7
7
|
import type { UserDataExtensionOptions } from "./extensions/user-data.js";
|
|
8
|
+
import type { AssignableFromRole } from "./role-assignment.js";
|
|
8
9
|
import { TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin } from "./tier-resolver-extension.js";
|
|
9
10
|
/**
|
|
10
11
|
* `userData` — User-Daten-Rights-Hooks (DSGVO Art. 15 + 17 + 20).
|
|
@@ -137,13 +138,20 @@ export declare const EXT_PRINCIPAL_STATUS: "principalStatus";
|
|
|
137
138
|
* the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
|
|
138
139
|
*/
|
|
139
140
|
export declare const EXT_TENANT_LIFECYCLE_STATUS: "tenantLifecycleStatus";
|
|
141
|
+
/**
|
|
142
|
+
* `assignableRole` — an app declares a membership role as assignable:
|
|
143
|
+
* `r.useExtension(EXT_ASSIGNABLE_ROLE, "<RoleName>", { assignableFrom? })`
|
|
144
|
+
* (`assignableFrom` defaults to "Admin").
|
|
145
|
+
* Provider is the bundled `tenant` feature; consumers are the membership-role guards.
|
|
146
|
+
*/
|
|
147
|
+
export declare const EXT_ASSIGNABLE_ROLE: "assignableRole";
|
|
140
148
|
export declare const TENANT_MEMBERSHIPS_QUERY: "tenant:query:memberships";
|
|
141
149
|
/**
|
|
142
150
|
* Union aller standardisierten Extension-Namen der Datenschutz-Surface.
|
|
143
151
|
* Nicht alle Extensions im System sind in dieser Liste — andere
|
|
144
152
|
* Features koennen weiterhin eigene Extension-Namen registrieren.
|
|
145
153
|
*/
|
|
146
|
-
export type KumikoExtensionName = typeof EXT_USER_DATA | typeof EXT_TENANT_DATA | typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE | typeof EXT_FILE_PROVIDER | typeof EXT_DERIVATIVE_RENDERER | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER | typeof EXT_PRINCIPAL_STATUS | typeof EXT_TENANT_LIFECYCLE_STATUS;
|
|
154
|
+
export type KumikoExtensionName = typeof EXT_USER_DATA | typeof EXT_TENANT_DATA | typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE | typeof EXT_FILE_PROVIDER | typeof EXT_DERIVATIVE_RENDERER | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER | typeof EXT_PRINCIPAL_STATUS | typeof EXT_TENANT_LIFECYCLE_STATUS | typeof EXT_ASSIGNABLE_ROLE;
|
|
147
155
|
/** The four `destroyTenant(tenantId, ctx)`-only resource-cleanup extension points. */
|
|
148
156
|
export type TenantResourceExtensionName = typeof EXT_STORAGE_PROVIDER | typeof EXT_SEARCH_ADAPTER | typeof EXT_EXTERNAL_RESOURCE | typeof EXT_INFRA_RESOURCE;
|
|
149
157
|
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
@@ -155,6 +163,9 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
|
155
163
|
[EXT_DERIVATIVE_OVERLAY_RESOLVER]: OverlayResolverPlugin;
|
|
156
164
|
[EXT_PRINCIPAL_STATUS]: PrincipalStatusPlugin;
|
|
157
165
|
[EXT_TENANT_LIFECYCLE_STATUS]: TenantLifecycleStatusPlugin;
|
|
166
|
+
[EXT_ASSIGNABLE_ROLE]: {
|
|
167
|
+
readonly assignableFrom?: AssignableFromRole;
|
|
168
|
+
};
|
|
158
169
|
[EXT_STORAGE_PROVIDER]: TenantResourceExtensionHooks;
|
|
159
170
|
[EXT_SEARCH_ADAPTER]: TenantResourceExtensionHooks;
|
|
160
171
|
[EXT_EXTERNAL_RESOURCE]: TenantResourceExtensionHooks;
|
|
@@ -167,6 +167,13 @@ export const EXT_PRINCIPAL_STATUS = "principalStatus";
|
|
|
167
167
|
* the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
|
|
168
168
|
*/
|
|
169
169
|
export const EXT_TENANT_LIFECYCLE_STATUS = "tenantLifecycleStatus";
|
|
170
|
+
/**
|
|
171
|
+
* `assignableRole` — an app declares a membership role as assignable:
|
|
172
|
+
* `r.useExtension(EXT_ASSIGNABLE_ROLE, "<RoleName>", { assignableFrom? })`
|
|
173
|
+
* (`assignableFrom` defaults to "Admin").
|
|
174
|
+
* Provider is the bundled `tenant` feature; consumers are the membership-role guards.
|
|
175
|
+
*/
|
|
176
|
+
export const EXT_ASSIGNABLE_ROLE = "assignableRole";
|
|
170
177
|
// Default membership-list query handler name. Accepted coupling, same
|
|
171
178
|
// pattern as FILE_PROVIDER_CONFIG_KEY — the `tenant` feature registers a handler under this literal.
|
|
172
179
|
export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships";
|
|
@@ -8,7 +8,7 @@ export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-imp
|
|
|
8
8
|
export { extractDescribe, extractOptionalRequires, extractReadsConfig, extractRequires, extractSystemScope, extractToggleable, extractUiHints, } from "./round1.js";
|
|
9
9
|
export { extractEntity, extractNav, extractRelation, extractWorkspace, } from "./round2.js";
|
|
10
10
|
export { extractClaimKey, extractConfig, extractMetric, extractReferenceData, extractSecret, extractTranslations, extractUseExtension, isClaimKeyType, type NamedOptionsResult, readNamedOptions, } from "./round3.js";
|
|
11
|
-
export { extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
11
|
+
export { extractBootCheck, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
12
12
|
export { extractTreeActions } from "./round6.js";
|
|
13
13
|
export type { ExtractOutput } from "./shared.js";
|
|
14
14
|
export { fail, findFunctionLiteral, isPlainObject, ok, readBooleanProperty, readDataLiteralNode, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, readPropertyKey, readStringLiteralArgs, readVarargsOrArrayProp, resolveSameFileObjectLiteral, } from "./shared.js";
|
|
@@ -8,6 +8,6 @@ export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-imp
|
|
|
8
8
|
export { extractDescribe, extractOptionalRequires, extractReadsConfig, extractRequires, extractSystemScope, extractToggleable, extractUiHints, } from "./round1.js";
|
|
9
9
|
export { extractEntity, extractNav, extractRelation, extractWorkspace, } from "./round2.js";
|
|
10
10
|
export { extractClaimKey, extractConfig, extractMetric, extractReferenceData, extractSecret, extractTranslations, extractUseExtension, isClaimKeyType, readNamedOptions, } from "./round3.js";
|
|
11
|
-
export { extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
11
|
+
export { extractBootCheck, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractStoreTable, extractUsesApi, } from "./round5.js";
|
|
12
12
|
export { extractTreeActions } from "./round6.js";
|
|
13
13
|
export { fail, findFunctionLiteral, isPlainObject, ok, readBooleanProperty, readDataLiteralNode, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, readPropertyKey, readStringLiteralArgs, readVarargsOrArrayProp, resolveSameFileObjectLiteral, } from "./shared.js";
|
|
@@ -6,3 +6,4 @@ export declare function extractExtendsRegistrar(call: CallExpression, sourceFile
|
|
|
6
6
|
export declare function extractUsesApi(call: CallExpression, sourceFile: SourceFile): ExtractOutput<UsesApiPattern>;
|
|
7
7
|
export declare function extractExposesApi(call: CallExpression, sourceFile: SourceFile): ExtractOutput<ExposesApiPattern>;
|
|
8
8
|
export declare function extractStoreTable(call: CallExpression, sourceFile: SourceFile): ExtractOutput<never>;
|
|
9
|
+
export declare function extractBootCheck(call: CallExpression, sourceFile: SourceFile): ExtractOutput<never>;
|
|
@@ -61,3 +61,8 @@ export function extractStoreTable(call, sourceFile) {
|
|
|
61
61
|
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
62
62
|
return fail("storeTable", sourceLocationFromNode(call, sourceFile), "storeTable meta is a factory/identifier argument, not a static literal");
|
|
63
63
|
}
|
|
64
|
+
export function extractBootCheck(call, sourceFile) {
|
|
65
|
+
// The argument is a runtime closure over the composed feature list —
|
|
66
|
+
// nothing to extract statically, so a clean ParseError like storeTable.
|
|
67
|
+
return fail("bootCheck", sourceLocationFromNode(call, sourceFile), "bootCheck takes a runtime closure, not a static literal");
|
|
68
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// Per-pattern extractors fill in iteratively (C1.5) — each round adds
|
|
22
22
|
// one extractor + a focused test.
|
|
23
23
|
import { Project, SyntaxKind } from "ts-morph";
|
|
24
|
-
import { extractAiClassify, extractAiExtract, extractAiGenerate, extractAuthClaims, extractClaimKey, extractConfig, extractDefineEvent, extractDescribe, extractEntity, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractHook, extractHttpRoute, extractJob, extractMetric, extractMultiStreamProjection, extractNav, extractNotification, extractOptionalRequires, extractProjection, extractQueryHandler, extractReadsConfig, extractReferenceData, extractRelation, extractRequires, extractScreen, extractSecret, extractStoreTable, extractStreamHandler, extractSystemScope, extractToggleable, extractTranslations, extractTreeActions, extractUiHints, extractUseExtension, extractUsesApi, extractWorkspace, extractWriteHandler, findFunctionLiteral, findImportBindingForLocalName, readObjectPropertyInitializer, resolveModuleFile, resolveSameFileObjectLiteral, } from "./extractors/index.js";
|
|
24
|
+
import { extractAiClassify, extractAiExtract, extractAiGenerate, extractAuthClaims, extractBootCheck, extractClaimKey, extractConfig, extractDefineEvent, extractDescribe, extractEntity, extractEnvSchema, extractExposesApi, extractExtendsRegistrar, extractHook, extractHttpRoute, extractJob, extractMetric, extractMultiStreamProjection, extractNav, extractNotification, extractOptionalRequires, extractProjection, extractQueryHandler, extractReadsConfig, extractReferenceData, extractRelation, extractRequires, extractScreen, extractSecret, extractStoreTable, extractStreamHandler, extractSystemScope, extractToggleable, extractTranslations, extractTreeActions, extractUiHints, extractUseExtension, extractUsesApi, extractWorkspace, extractWriteHandler, findFunctionLiteral, findImportBindingForLocalName, readObjectPropertyInitializer, resolveModuleFile, resolveSameFileObjectLiteral, } from "./extractors/index.js";
|
|
25
25
|
import { sourceLocationFromNode } from "./source-location.js";
|
|
26
26
|
/**
|
|
27
27
|
* Parse the given feature file and return all recognised r.* calls
|
|
@@ -440,6 +440,8 @@ function dispatchExtractor(methodName, call, sourceFile) {
|
|
|
440
440
|
return extractExposesApi(call, sourceFile);
|
|
441
441
|
case "storeTable":
|
|
442
442
|
return extractStoreTable(call, sourceFile);
|
|
443
|
+
case "bootCheck":
|
|
444
|
+
return extractBootCheck(call, sourceFile);
|
|
443
445
|
// Round 6 — Tree-Actions pattern
|
|
444
446
|
case "treeActions":
|
|
445
447
|
return extractTreeActions(call, sourceFile);
|
|
@@ -2,7 +2,7 @@ import { type WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websock
|
|
|
2
2
|
import type { EntityTableMeta } from "../db/entity-table-meta.js";
|
|
3
3
|
import type { FeatureBuilderState } from "./feature-builder-state.js";
|
|
4
4
|
import type { HttpRouteDefinition } from "./types/http-route.js";
|
|
5
|
-
import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
|
|
5
|
+
import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, ExtensionSelectorPanel, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
|
|
6
6
|
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
|
|
7
7
|
import type { ScreenDefinition } from "./types/screen.js";
|
|
8
8
|
import type { WorkspaceDefinition } from "./types/workspace.js";
|
|
@@ -21,7 +21,9 @@ export declare function buildUiExtensionsMethods<TName extends string>(state: Fe
|
|
|
21
21
|
} & object), entityRef?: NameOrRef, options?: object): void;
|
|
22
22
|
extensionSelector(extensionName: string, key: {
|
|
23
23
|
readonly name: string;
|
|
24
|
-
} | string
|
|
24
|
+
} | string, options?: {
|
|
25
|
+
readonly panels?: readonly ExtensionSelectorPanel[];
|
|
26
|
+
}): void;
|
|
25
27
|
/**
|
|
26
28
|
* Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
|
|
27
29
|
* unter dem genannten Namen bereit. Die eigentliche Implementation
|
|
@@ -2,8 +2,39 @@ import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES,
|
|
|
2
2
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
3
3
|
import { LifecycleHookTypes } from "./constants.js";
|
|
4
4
|
import { resolveName } from "./handler-helpers.js";
|
|
5
|
-
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
5
|
+
import { isKebabSegment, isValidQn, qualifyEntityName, toKebab } from "./qualified-name.js";
|
|
6
6
|
import { HookPhases } from "./types/index.js";
|
|
7
|
+
// Builds hooks/extensions/projections/screens/nav/workspace/tables/tree-actions
|
|
8
|
+
// registrar methods.
|
|
9
|
+
// Id the generated owner dashboard reserves for its selector panel.
|
|
10
|
+
const RESERVED_SELECTION_PANEL_ID = "selection";
|
|
11
|
+
function assertSelectorPanelIds(featureName, extensionName, panels) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const { id } of panels) {
|
|
14
|
+
if (id === "" || id === RESERVED_SELECTION_PANEL_ID || seen.has(id)) {
|
|
15
|
+
throw new Error(`[Feature ${featureName}] extensionSelector("${extensionName}") panel id "${id}" must be unique, ` +
|
|
16
|
+
`non-empty and not "${RESERVED_SELECTION_PANEL_ID}" (reserved for the selector panel).`);
|
|
17
|
+
}
|
|
18
|
+
seen.add(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// The owner dashboard lives in the "config" namespace, so a short screen ref must be
|
|
22
|
+
// qualified against the declaring feature here. Untyped authors can pass other kinds.
|
|
23
|
+
function qualifySelectorPanel(featureName, panel) {
|
|
24
|
+
if (panel.kind === "custom")
|
|
25
|
+
return panel;
|
|
26
|
+
if (panel.kind === "screen") {
|
|
27
|
+
if ("label" in panel) {
|
|
28
|
+
throw new Error(`[Feature ${featureName}] extensionSelector screen panel "${panel.id}" must not set a label — ` +
|
|
29
|
+
`the settings dashboard has no i18n namespace for it; the embedded screen carries its own title.`);
|
|
30
|
+
}
|
|
31
|
+
if (panel.screen === "" || isValidQn(panel.screen))
|
|
32
|
+
return panel;
|
|
33
|
+
return { ...panel, screen: qualifyEntityName(featureName, "screen", panel.screen) };
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`[Feature ${featureName}] extensionSelector panel ${JSON.stringify(panel)} has an unsupported kind — ` +
|
|
36
|
+
`only "custom" and "screen" panels are allowed.`);
|
|
37
|
+
}
|
|
7
38
|
function isEntityWideHookType(type) {
|
|
8
39
|
return (type === LifecycleHookTypes.postSave ||
|
|
9
40
|
type === LifecycleHookTypes.preDelete ||
|
|
@@ -184,13 +215,19 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
184
215
|
options: optionsBag,
|
|
185
216
|
});
|
|
186
217
|
},
|
|
187
|
-
extensionSelector(extensionName, key) {
|
|
218
|
+
extensionSelector(extensionName, key, options) {
|
|
188
219
|
if (state.extensionSelectors.some((s) => s.extensionName === extensionName)) {
|
|
189
220
|
throw new Error(`[Feature ${name}] extensionSelector("${extensionName}") declared twice — ` +
|
|
190
221
|
`one selector key per extension point.`);
|
|
191
222
|
}
|
|
192
223
|
const qualifiedKey = typeof key === "string" ? key : key.name;
|
|
193
|
-
|
|
224
|
+
const panels = options?.panels?.map((panel) => qualifySelectorPanel(name, panel));
|
|
225
|
+
assertSelectorPanelIds(name, extensionName, panels ?? []);
|
|
226
|
+
state.extensionSelectors.push({
|
|
227
|
+
extensionName,
|
|
228
|
+
qualifiedKey,
|
|
229
|
+
...(panels !== undefined && { panels }),
|
|
230
|
+
});
|
|
194
231
|
},
|
|
195
232
|
/**
|
|
196
233
|
* Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
|