@cosmicdrift/kumiko-framework 0.331.0 → 0.332.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/feature-ui-extensions.d.ts +4 -2
- package/dist/engine/feature-ui-extensions.js +40 -3
- package/dist/engine/index.d.ts +1 -1
- 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/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/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
|
|
@@ -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
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
|
|
|
71
71
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
72
72
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
73
73
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
74
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RecordHeaderSpec, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
74
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
75
75
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
76
76
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
|
|
77
77
|
export { runValidation } from "./validation.js";
|
|
@@ -401,6 +401,22 @@ function projectMetrics(metrics, indices, keptScreenQns) {
|
|
|
401
401
|
});
|
|
402
402
|
return changed ? next : metrics;
|
|
403
403
|
}
|
|
404
|
+
function projectHeader(header, indices, keptScreenQns) {
|
|
405
|
+
const subtitle = header?.subtitle;
|
|
406
|
+
if (header === undefined || typeof subtitle === "string" || subtitle === undefined) {
|
|
407
|
+
return header;
|
|
408
|
+
}
|
|
409
|
+
let changed = false;
|
|
410
|
+
const nextParts = subtitle.map((part) => {
|
|
411
|
+
if (typeof part === "string" || part.navigate === undefined)
|
|
412
|
+
return part;
|
|
413
|
+
if (keepMetricNavigate(part.navigate, indices, keptScreenQns))
|
|
414
|
+
return part;
|
|
415
|
+
changed = true;
|
|
416
|
+
return part.field;
|
|
417
|
+
});
|
|
418
|
+
return changed ? { ...header, subtitle: nextParts } : header;
|
|
419
|
+
}
|
|
404
420
|
function projectDashboardPanels(panels, feature, keptScreenQns) {
|
|
405
421
|
const kept = panels.filter((p) => p.kind !== "screen" || isScreenTargetKept(feature.featureName, p.screen, keptScreenQns));
|
|
406
422
|
return kept.length === panels.length ? panels : kept;
|
|
@@ -451,12 +467,14 @@ function projectListScreen(original, feature, indices, keptScreenQns) {
|
|
|
451
467
|
};
|
|
452
468
|
}
|
|
453
469
|
function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns) {
|
|
454
|
-
const { metrics, actions, layout, listScreenId, ...rest } = screen;
|
|
470
|
+
const { metrics, header, actions, layout, listScreenId, ...rest } = screen;
|
|
455
471
|
const nextMetrics = projectMetrics(metrics, indices, keptScreenQns);
|
|
472
|
+
const nextHeader = projectHeader(header, indices, keptScreenQns);
|
|
456
473
|
const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
|
|
457
474
|
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
458
475
|
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
459
476
|
const unchanged = nextMetrics === metrics &&
|
|
477
|
+
nextHeader === header &&
|
|
460
478
|
nextActions === actions &&
|
|
461
479
|
nextLayout === layout &&
|
|
462
480
|
nextListScreenId === listScreenId;
|
|
@@ -465,6 +483,7 @@ function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns)
|
|
|
465
483
|
return {
|
|
466
484
|
...rest,
|
|
467
485
|
layout: nextLayout,
|
|
486
|
+
...(nextHeader !== undefined && { header: nextHeader }),
|
|
468
487
|
...(nextMetrics !== undefined && { metrics: nextMetrics }),
|
|
469
488
|
...(nextActions !== undefined && { actions: nextActions }),
|
|
470
489
|
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
@@ -149,6 +149,9 @@ export function buildRegistryFacade(state) {
|
|
|
149
149
|
getAllExtensionSelectors() {
|
|
150
150
|
return state.extensionSelectorMap;
|
|
151
151
|
},
|
|
152
|
+
getExtensionSelectorPanels(extensionName) {
|
|
153
|
+
return state.extensionSelectorPanelsMap.get(extensionName) ?? [];
|
|
154
|
+
},
|
|
152
155
|
getAllNotifications() {
|
|
153
156
|
return state.notificationMap;
|
|
154
157
|
},
|
|
@@ -189,6 +189,8 @@ export function populateExtensionsAndSeeds(state, feature) {
|
|
|
189
189
|
`(feature "${feature.name}") — one owning feature declares the selector.`);
|
|
190
190
|
}
|
|
191
191
|
state.extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
|
|
192
|
+
if (sel.panels !== undefined)
|
|
193
|
+
state.extensionSelectorPanelsMap.set(sel.extensionName, sel.panels);
|
|
192
194
|
}
|
|
193
195
|
state.allReferenceData.push(...(feature.referenceData ?? []));
|
|
194
196
|
state.allConfigSeeds.push(...(feature.configSeeds ?? []));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type QnType, qualifyEntityName } from "./qualified-name.js";
|
|
2
|
-
import type { AuthClaimsHookDef, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventUpcastFn, FeatureDefinition, FeatureMetricDef, HookPhase, JobDefinition, MultiStreamProjectionDefinition, NavDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, ScreenDefinition, SearchPayloadContributorFn, SecretKeyDefinition, StoreTableDef, StreamHandlerDef, TranslationValue, TreeActionDef, WorkspaceDefinition, WriteHandlerDef } from "./types/index.js";
|
|
2
|
+
import type { AuthClaimsHookDef, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventUpcastFn, ExtensionSelectorPanel, FeatureDefinition, FeatureMetricDef, HookPhase, JobDefinition, MultiStreamProjectionDefinition, NavDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, ScreenDefinition, SearchPayloadContributorFn, SecretKeyDefinition, StoreTableDef, StreamHandlerDef, TranslationValue, TreeActionDef, WorkspaceDefinition, WriteHandlerDef } from "./types/index.js";
|
|
3
3
|
export type IncomingRelation = {
|
|
4
4
|
sourceEntity: string;
|
|
5
5
|
relationName: string;
|
|
@@ -46,6 +46,7 @@ export type RegistryState = {
|
|
|
46
46
|
extensionMap: Map<string, RegistrarExtensionDef>;
|
|
47
47
|
extensionUsages: RegistrarExtensionRegistration[];
|
|
48
48
|
extensionSelectorMap: Map<string, string>;
|
|
49
|
+
extensionSelectorPanelsMap: Map<string, readonly ExtensionSelectorPanel[]>;
|
|
49
50
|
allReferenceData: ReferenceDataDef[];
|
|
50
51
|
allConfigSeeds: ConfigSeedDef[];
|
|
51
52
|
mergedTranslations: Record<string, Record<string, TranslationValue>>;
|
|
@@ -4,5 +4,7 @@ export declare function isRepresentableAtScale(n: number, scale: number): boolea
|
|
|
4
4
|
export declare function fieldToZod(field: FieldDefinition, currencies: readonly string[], opts?: {
|
|
5
5
|
readonly applyDefaults?: boolean;
|
|
6
6
|
}): z.ZodTypeAny;
|
|
7
|
-
export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[]
|
|
7
|
+
export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[], options?: {
|
|
8
|
+
readonly allowCallerId?: boolean;
|
|
9
|
+
}): z.ZodObject<Record<string, z.ZodTypeAny>>;
|
|
8
10
|
export declare function buildUpdateSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
|
|
@@ -366,7 +366,7 @@ function applyTotalsMatchRefinements(entity, schema) {
|
|
|
366
366
|
function notWritableField() {
|
|
367
367
|
return z.never().optional();
|
|
368
368
|
}
|
|
369
|
-
export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
|
|
369
|
+
export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = [], options = {}) {
|
|
370
370
|
const shape = {};
|
|
371
371
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
372
372
|
if (excludedFields.includes(name)) {
|
|
@@ -378,11 +378,10 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
378
378
|
const isRequired = "required" in field && field.required === true;
|
|
379
379
|
shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
|
|
380
380
|
}
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
shape["id"] = z.uuid().optional();
|
|
381
|
+
// Opt-in only for the generic create verb, which honors the id for system identities;
|
|
382
|
+
// other schema consumers must keep stripping an unknown `id`.
|
|
383
|
+
if (options.allowCallerId)
|
|
384
|
+
shape["id"] = z.uuid().optional();
|
|
386
385
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
387
386
|
}
|
|
388
387
|
export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
|
|
2
|
-
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
3
3
|
export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
|
|
4
4
|
export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
5
5
|
export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
|
|
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
|
|
|
17
17
|
export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
|
|
18
18
|
export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
|
|
19
19
|
export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
|
|
20
|
-
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
20
|
+
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
21
21
|
export { metricField } from "@cosmicdrift/kumiko-types/screen";
|
|
22
22
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
23
23
|
export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
|
package/dist/errors/reasons.js
CHANGED
|
@@ -66,16 +66,17 @@ export const FrameworkReasons = {
|
|
|
66
66
|
// sister catalog to FrameworkReasons instead of extending it — see the
|
|
67
67
|
// file-header note above for why these carry the `agent.` prefix.
|
|
68
68
|
export const AgentReasons = {
|
|
69
|
-
//
|
|
70
|
-
// catalog
|
|
69
|
+
// Error turn in the agent loop when the model requests a tool outside the
|
|
70
|
+
// run's catalog; UnprocessableError in rule validation (handler missing from
|
|
71
|
+
// the caller's role-filtered manifest, or `ai-agent-edit` not mounted).
|
|
71
72
|
toolNotAllowed: "agent.tool_not_allowed",
|
|
72
|
-
//
|
|
73
|
-
//
|
|
73
|
+
// Not thrown: the run ends with a `clarify` turn (`payload.reason`) once
|
|
74
|
+
// MAX_AGENT_ROUNDS is reached.
|
|
74
75
|
iterationLimit: "agent.iteration_limit",
|
|
75
76
|
// AccessDeniedError: the tool exists, but the caller's roles don't cover
|
|
76
77
|
// the handler behind it.
|
|
77
78
|
permissionDenied: "agent.permission_denied",
|
|
78
|
-
//
|
|
79
|
-
//
|
|
79
|
+
// UnprocessableError: a permission rule with `always` was requested for an
|
|
80
|
+
// `agent: { risk: "high" }` handler; `details.handlerQn` names it.
|
|
80
81
|
highRiskNoAlways: "agent.high_risk_no_always",
|
|
81
82
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectRowForUpdateById } from "../db/queries/entity-read.js";
|
|
2
2
|
import { asEntityTableMeta, selectMany } from "../db/query.js";
|
|
3
3
|
import { buildEntityTable, toSnakeCase } from "../db/table-builder.js";
|
|
4
|
+
import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
|
|
4
5
|
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
5
6
|
import { hasAccess } from "../engine/access.js";
|
|
6
7
|
import { ConfigScopes } from "../engine/constants.js";
|
|
@@ -126,6 +127,24 @@ function isForeignTenantParentRow(parentRow, user) {
|
|
|
126
127
|
const rowTenantId = parentRow["tenantId"];
|
|
127
128
|
return typeof rowTenantId === "string" && rowTenantId !== user.tenantId;
|
|
128
129
|
}
|
|
130
|
+
// A custom create handler may return a row without a string tenantId; the row's
|
|
131
|
+
// tenant is then unknown from the payload, so re-read it through the caller's tenant
|
|
132
|
+
// filter. Fail-closed: anything that cannot be verified counts as not visible.
|
|
133
|
+
// Tenant reads also return SYSTEM reference rows, so the row's own tenantId must match
|
|
134
|
+
// (same rule as the string path) unless the entity has no tenant column.
|
|
135
|
+
async function isParentRowHiddenFromCaller(ctx, parentEntityName, parentId, user, tx) {
|
|
136
|
+
if (parentEntityName === undefined)
|
|
137
|
+
return true;
|
|
138
|
+
const table = getTable(ctx, parentEntityName);
|
|
139
|
+
const source = resolveDbSource(ctx, tx);
|
|
140
|
+
if (!table || !source)
|
|
141
|
+
return true;
|
|
142
|
+
const callerDb = createTenantDb(source, user.tenantId, "tenant");
|
|
143
|
+
const visibleRow = await callerDb.fetchOne(table, { id: parentId });
|
|
144
|
+
if (visibleRow === undefined)
|
|
145
|
+
return true;
|
|
146
|
+
return hasTenantColumn(table) && visibleRow["tenantId"] !== user.tenantId;
|
|
147
|
+
}
|
|
129
148
|
// Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
|
|
130
149
|
//
|
|
131
150
|
// When a parent `:create` handler's payload carries values under keys
|
|
@@ -199,13 +218,18 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
|
|
|
199
218
|
}));
|
|
200
219
|
}
|
|
201
220
|
// A custom create handler may return an existing foreign row; verify it before attaching children.
|
|
202
|
-
|
|
221
|
+
const parentEntityName = registry.getHandlerEntity(type);
|
|
222
|
+
const isCallerScoped = !registry.isHandlerSystemScoped(type);
|
|
223
|
+
const isParentForeign = isCallerScoped &&
|
|
224
|
+
(typeof parentRow["tenantId"] === "string"
|
|
225
|
+
? isForeignTenantParentRow(parentRow, user)
|
|
226
|
+
: await isParentRowHiddenFromCaller(ctx, parentEntityName, parentId, user, tx));
|
|
227
|
+
if (isParentForeign) {
|
|
203
228
|
return writeFailure(new AccessDeniedError({
|
|
204
229
|
message: `nested-write: parent row belongs to another tenant — refusing to attach children to "${type}"`,
|
|
205
230
|
details: { reason: FrameworkReasons.fieldAccessDenied, handler: type },
|
|
206
231
|
}));
|
|
207
232
|
}
|
|
208
|
-
const parentEntityName = registry.getHandlerEntity(type);
|
|
209
233
|
if (parentEntityName) {
|
|
210
234
|
const parentEntity = registry.getEntity(parentEntityName);
|
|
211
235
|
if (parentEntity) {
|
|
@@ -29,6 +29,10 @@ export const eventConsumerStateTable = pgTable("kumiko_event_consumers", {
|
|
|
29
29
|
// skipPoisonEvent() — an operator vouching the consumer is healthy.
|
|
30
30
|
rearmCount: integer("rearm_count").notNull().default(0),
|
|
31
31
|
pendingGaps: jsonb("pending_gaps").$type().default([]).notNull(),
|
|
32
|
+
// Id of the event whose handler failed last (halt-on-poison). skipPoisonEvent
|
|
33
|
+
// skips exactly this id; guessing the smallest visible id would drop a healthy
|
|
34
|
+
// writer that committed late into a pending gap. Null once a delivery succeeds.
|
|
35
|
+
lastFailedEventId: bigint("last_failed_event_id", { mode: "bigint" }),
|
|
32
36
|
lastError: text("last_error"),
|
|
33
37
|
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
|
|
34
38
|
}, (t) => ({
|
|
@@ -78,6 +82,8 @@ export async function createEventConsumerStateTable(db) {
|
|
|
78
82
|
/* ifNotExists */ true);
|
|
79
83
|
await alterTableAddColumn(db, "kumiko_event_consumers", "pending_gaps", "jsonb", " DEFAULT '[]'::jsonb", " NOT NULL",
|
|
80
84
|
/* ifNotExists */ true);
|
|
85
|
+
await alterTableAddColumn(db, "kumiko_event_consumers", "last_failed_event_id", "bigint", "", "",
|
|
86
|
+
/* ifNotExists */ true);
|
|
81
87
|
// Runs on every boot, including mid-rolling-deploy: a new pod can delete
|
|
82
88
|
// an old pod's still-in-use per-instance row out from under it. That old
|
|
83
89
|
// pod's next acquireConsumer then finds no row, delivers no more SSE/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { advanceConsumerPastEventReturning, removePendingGapReturning, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
|
|
1
|
+
import { advanceConsumerPastEventReturning, removePendingGapReturning, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
|
|
2
2
|
import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/queries/event-store.js";
|
|
3
3
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
|
|
4
4
|
import { getEventsHighWaterMark } from "../event-store/index.js";
|
|
5
|
-
import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
|
|
5
|
+
import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
|
|
6
6
|
import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
|
|
7
7
|
// --- Ops recovery surface ---
|
|
8
8
|
//
|
|
@@ -76,29 +76,38 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
|
|
|
76
76
|
}
|
|
77
77
|
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
78
78
|
}
|
|
79
|
-
// skipPoisonEvent
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// TX so concurrent dispatcher passes can't
|
|
85
|
-
// idempotent no-op.
|
|
79
|
+
// skipPoisonEvent skips the event whose handler failed last
|
|
80
|
+
// (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
|
|
81
|
+
// pending_gaps it is split out of its range, otherwise the cursor moves onto
|
|
82
|
+
// it. Without a recorded id (consumer parked before that column existed) it
|
|
83
|
+
// falls back to a guess: the smallest visible pending-gap id, else the next
|
|
84
|
+
// event after the cursor. Single TX so concurrent dispatcher passes can't
|
|
85
|
+
// double-advance. Nothing to skip → idempotent no-op.
|
|
86
86
|
export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
|
|
87
87
|
const before = await requireConsumerRow(db, name, instanceId);
|
|
88
88
|
return db.begin(async (tx) => {
|
|
89
89
|
const pendingGaps = before.pendingGaps;
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
const failedEventId = before.lastFailedEventId;
|
|
91
|
+
const failedIsPending = failedEventId !== null && pendingGaps.some((gap) => rangeContainsId(gap, failedEventId));
|
|
92
|
+
const failedIsAboveCursor = failedEventId !== null && failedEventId > before.lastProcessedEventId;
|
|
93
|
+
const pendingPoisonId = failedIsPending
|
|
94
|
+
? failedEventId
|
|
95
|
+
: failedIsAboveCursor
|
|
96
|
+
? null
|
|
97
|
+
: await selectSmallestVisibleIdInRanges(tx, toIdRanges(pendingGaps));
|
|
98
|
+
if (pendingPoisonId !== null) {
|
|
99
|
+
const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, pendingPoisonId)
|
|
100
|
+
? splitRangeExcludingIds(gap, [pendingPoisonId])
|
|
94
101
|
: [gap]);
|
|
95
102
|
const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
|
|
96
103
|
const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
97
104
|
if (!updated)
|
|
98
105
|
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
|
|
99
|
-
return { ...normalizeConsumerState(updated), skippedEventId:
|
|
106
|
+
return { ...normalizeConsumerState(updated), skippedEventId: pendingPoisonId };
|
|
100
107
|
}
|
|
101
|
-
const poisonId =
|
|
108
|
+
const poisonId = failedIsAboveCursor
|
|
109
|
+
? failedEventId
|
|
110
|
+
: await selectNextEventIdAfter(tx, before.lastProcessedEventId);
|
|
102
111
|
if (poisonId === null) {
|
|
103
112
|
const [unchanged] = await selectMany(tx, eventConsumerStateTable, {
|
|
104
113
|
name,
|
|
@@ -108,7 +117,18 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
|
|
|
108
117
|
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
|
|
109
118
|
return { ...normalizeConsumerState(unchanged), skippedEventId: null };
|
|
110
119
|
}
|
|
111
|
-
|
|
120
|
+
// Delivery stops at the poison event, so ids between the cursor and it were
|
|
121
|
+
// invisible during that pass (in-flight commits). Jumping the cursor over them
|
|
122
|
+
// would lose them; keep them as a pending gap like the dispatcher's own gaps.
|
|
123
|
+
const skippedWindowStart = before.lastProcessedEventId + 1n;
|
|
124
|
+
const gapToAdd = poisonId > skippedWindowStart
|
|
125
|
+
? {
|
|
126
|
+
from: skippedWindowStart.toString(),
|
|
127
|
+
to: (poisonId - 1n).toString(),
|
|
128
|
+
xmax: await selectSnapshotXmax(tx),
|
|
129
|
+
}
|
|
130
|
+
: null;
|
|
131
|
+
const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, gapToAdd);
|
|
112
132
|
const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
113
133
|
if (!updated)
|
|
114
134
|
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
|
|
@@ -13,6 +13,7 @@ export type ConsumerStateRowShape = {
|
|
|
13
13
|
readonly attempts: number;
|
|
14
14
|
readonly rearmCount: number;
|
|
15
15
|
readonly pendingGaps: readonly PendingGapEntry[];
|
|
16
|
+
readonly lastFailedEventId: bigint | null;
|
|
16
17
|
readonly lastError: string | null;
|
|
17
18
|
readonly updatedAt: Temporal.Instant;
|
|
18
19
|
};
|
|
@@ -58,6 +59,7 @@ export type DeliveryOutcome = {
|
|
|
58
59
|
readonly deadLettered: boolean;
|
|
59
60
|
readonly processed: number;
|
|
60
61
|
readonly failed: number;
|
|
62
|
+
readonly failedEventId: bigint | null;
|
|
61
63
|
readonly resolvedPendingIds: readonly bigint[];
|
|
62
64
|
};
|
|
63
65
|
export declare function deliverEvents(consumer: EventConsumer, events: ReadonlyArray<StoredEventRow>, context: AppContext, maxAttempts: number, state: ConsumerStateRow): Promise<DeliveryOutcome>;
|
|
@@ -171,6 +171,7 @@ export async function deliverEvents(consumer, events, context, maxAttempts, stat
|
|
|
171
171
|
const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
|
|
172
172
|
let processed = 0;
|
|
173
173
|
let failed = 0;
|
|
174
|
+
let failedEventId = null;
|
|
174
175
|
const resolvedPendingIds = [];
|
|
175
176
|
// A pending row sits below startCursor: it resolves its gap but never moves
|
|
176
177
|
// the cursor backward. ORDER BY id walks all pending rows first.
|
|
@@ -188,7 +189,16 @@ export async function deliverEvents(consumer, events, context, maxAttempts, stat
|
|
|
188
189
|
for (const row of events)
|
|
189
190
|
resolve(row.id);
|
|
190
191
|
processed = events.length;
|
|
191
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
cursor,
|
|
194
|
+
attempts,
|
|
195
|
+
lastError,
|
|
196
|
+
deadLettered,
|
|
197
|
+
processed,
|
|
198
|
+
failed,
|
|
199
|
+
failedEventId,
|
|
200
|
+
resolvedPendingIds,
|
|
201
|
+
};
|
|
192
202
|
}
|
|
193
203
|
// fall through to the per-event loop below; the batch failure itself
|
|
194
204
|
// does NOT bump attempts — only a per-event failure does.
|
|
@@ -209,12 +219,22 @@ export async function deliverEvents(consumer, events, context, maxAttempts, stat
|
|
|
209
219
|
}
|
|
210
220
|
attempts += 1;
|
|
211
221
|
lastError = errMessage;
|
|
222
|
+
failedEventId = row.id;
|
|
212
223
|
if (attempts >= effectiveMaxAttempts)
|
|
213
224
|
deadLettered = true;
|
|
214
225
|
break;
|
|
215
226
|
}
|
|
216
227
|
}
|
|
217
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
cursor,
|
|
230
|
+
attempts,
|
|
231
|
+
lastError,
|
|
232
|
+
deadLettered,
|
|
233
|
+
processed,
|
|
234
|
+
failed,
|
|
235
|
+
failedEventId,
|
|
236
|
+
resolvedPendingIds,
|
|
237
|
+
};
|
|
218
238
|
}
|
|
219
239
|
// Shared requestContext scope for one apply — event-derived fields
|
|
220
240
|
// (correlationId, causationId, writeOrigin) come from `causationSource`;
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
|
|
|
9
9
|
export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
|
|
10
10
|
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
|
|
11
11
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
|
|
12
|
-
export type { ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
|
|
12
|
+
export type { ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
|
|
13
13
|
export { metricField } from "../engine/types/screen.js";
|
|
14
14
|
export type { TargetRef } from "../engine/types/target-ref.js";
|
|
15
15
|
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.332.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.332.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.332.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.332.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.332.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "agent reason developer texts match the actual error types"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.332.0",
|
|
9
|
+
"type": "breaking",
|
|
10
|
+
"title": "buildInsertSchema strips id again; caller-chosen ids need { allowCallerId: true }",
|
|
11
|
+
"migration": "Custom-Create-Handler, die eine caller-gewaehlte id brauchen, uebergeben { allowCallerId: true } und erlauben sie nur System-Identities."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.332.0",
|
|
15
|
+
"type": "fix",
|
|
16
|
+
"title": "early-closed kumiko-changes blocks fail with a clear error"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.332.0",
|
|
20
|
+
"type": "improvement",
|
|
21
|
+
"title": "A selector owner can add its own custom or screen panels to the generated settings page (extensionSelector panels)",
|
|
22
|
+
"detail": "`r.extensionSelector(extension, key, { panels })` lets the selector owner add its own `custom` or `screen` panels to the generated `<ownerGroup>-tenant` settings dashboard, after the selection panel and before the plugin panels. Short `screen` refs resolve against the declaring feature. Dead screen or `visibleWhen` query refs, empty, duplicate or `selection` panel ids fail at boot or declaration.",
|
|
23
|
+
"migration": "No code change needed."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"version": "0.332.0",
|
|
27
|
+
"type": "breaking",
|
|
28
|
+
"title": "ctx.fetchForWriting scopes handle.events to the aggregateType since the #2979 release (correction, not purely additive)",
|
|
29
|
+
"detail": "The #2979 changeset described the `aggregateType` filter as \"purely additive\". For `ctx.fetchForWriting` that was wrong: since that release, `handle.events` only contains events of the fetched aggregateType, even when a handler never passed an `aggregateType` option. Events of another aggregateType on the same aggregateId are no longer part of the handle. `loadAggregate` without `aggregateType` is unchanged.",
|
|
30
|
+
"migration": "handle.events von ctx.fetchForWriting ist seit Einführung des aggregateType-Filters\ntyp-gescoped; Handler prüfen, die Events eines anderen aggregateType auf derselben\naggregateId erwartet haben, und diese per loadAggregate ohne aggregateType lesen."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.332.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "Nested-write parent tenant check fails closed for parent rows without tenantId"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"version": "0.332.0",
|
|
39
|
+
"type": "fix",
|
|
40
|
+
"title": "skipPoisonEvent skips the actually failed event via kumiko_event_consumers.last_failed_event_id"
|
|
41
|
+
},
|
|
2
42
|
{
|
|
3
43
|
"version": "0.331.0",
|
|
4
44
|
"type": "fix",
|