@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.
Files changed (39) hide show
  1. package/dist/db/blind-index-cleanup.d.ts +1 -0
  2. package/dist/db/blind-index-cleanup.js +12 -0
  3. package/dist/db/index.d.ts +1 -1
  4. package/dist/db/index.js +1 -1
  5. package/dist/db/queries/event-consumer.d.ts +2 -1
  6. package/dist/db/queries/event-consumer.js +12 -2
  7. package/dist/engine/boot-validator/index.js +2 -0
  8. package/dist/engine/boot-validator/query-output-columns.js +15 -2
  9. package/dist/engine/boot-validator/query-refs.d.ts +2 -1
  10. package/dist/engine/boot-validator/query-refs.js +1 -1
  11. package/dist/engine/boot-validator/screens.d.ts +3 -1
  12. package/dist/engine/boot-validator/screens.js +33 -2
  13. package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
  14. package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
  15. package/dist/engine/boot-validator/url-prefill-fields.js +7 -0
  16. package/dist/engine/build-config-feature-schema.js +31 -6
  17. package/dist/engine/changeset-changes.js +6 -0
  18. package/dist/engine/entity-handlers.js +1 -1
  19. package/dist/engine/feature-ui-extensions.d.ts +4 -2
  20. package/dist/engine/feature-ui-extensions.js +40 -3
  21. package/dist/engine/index.d.ts +1 -1
  22. package/dist/engine/project-app-schema-for-roles.js +20 -1
  23. package/dist/engine/registry-facade.js +3 -0
  24. package/dist/engine/registry-ingest.js +2 -0
  25. package/dist/engine/registry-state.d.ts +2 -1
  26. package/dist/engine/registry-state.js +1 -0
  27. package/dist/engine/schema-builder.d.ts +3 -1
  28. package/dist/engine/schema-builder.js +5 -6
  29. package/dist/engine/types/index.d.ts +2 -2
  30. package/dist/errors/reasons.js +7 -6
  31. package/dist/pipeline/dispatch-write.js +26 -2
  32. package/dist/pipeline/event-consumer-state.js +6 -0
  33. package/dist/pipeline/event-dispatcher-admin.js +36 -16
  34. package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
  35. package/dist/pipeline/event-dispatcher-delivery.js +22 -2
  36. package/dist/pipeline/event-dispatcher.js +1 -0
  37. package/dist/ui-types/index.d.ts +1 -1
  38. package/package.json +4 -4
  39. 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
+ }
@@ -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
- if (screen.header.subtitle !== undefined)
66
- checkHeaderField("subtitle", screen.header.subtitle);
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,2 @@
1
+ import type { FeatureDefinition } from "../types/index.js";
2
+ export declare function validateExtensionSelectorPanels(features: readonly FeatureDefinition[]): void;
@@ -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 pluginsByOwnerGroup = new Map();
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): void;
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
- state.extensionSelectors.push({ extensionName, qualifiedKey });
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
@@ -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>>;
@@ -116,6 +116,7 @@ export function createInitialState() {
116
116
  extensionMap: new Map(),
117
117
  extensionUsages: [],
118
118
  extensionSelectorMap: new Map(),
119
+ extensionSelectorPanelsMap: new Map(),
119
120
  allReferenceData: [],
120
121
  allConfigSeeds: [],
121
122
  mergedTranslations: {},
@@ -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[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
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
- // Caller-chosen aggregate id for idempotent creates (deterministic-id
382
- // patterns). Accepting it here only lets it survive parsing — the create
383
- // handler still honors it only for a system-identity caller (jobs/hooks
384
- // via createSystemUser), never for an ordinary HTTP-authenticated user.
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";
@@ -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
- // AccessDeniedError: the agent called a tool name outside its allowed
70
- // catalog for this run.
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
- // UnprocessableError: the agent loop hit its maximum round count before
73
- // producing a final answer.
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
- // AccessDeniedError: an `agent.risk: "high"` handler was invoked without
79
- // the required explicit confirmation.
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
- if (!registry.isHandlerSystemScoped(type) && isForeignTenantParentRow(parentRow, user)) {
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 advances past whatever is currently blocking the consumer.
80
- // If the smallest visible id inside pending_gaps exists, THAT'S the poison —
81
- // halt-on-poison walks gap ids before any id past the cursor — so it's split
82
- // out of its range directly; the cursor stays put (it's already above this
83
- // id). Otherwise the poison is the usual next event after the cursor. Single
84
- // TX so concurrent dispatcher passes can't double-advance. Neither exists →
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 smallestVisiblePending = await selectSmallestVisibleIdInRanges(tx, toIdRanges(pendingGaps));
91
- if (smallestVisiblePending !== null) {
92
- const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, smallestVisiblePending)
93
- ? splitRangeExcludingIds(gap, [smallestVisiblePending])
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: smallestVisiblePending };
106
+ return { ...normalizeConsumerState(updated), skippedEventId: pendingPoisonId };
100
107
  }
101
- const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
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
- const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId);
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 { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
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 { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
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`;
@@ -331,6 +331,7 @@ export function createEventDispatcher(options) {
331
331
  deadLettered: false,
332
332
  processed: 0,
333
333
  failed: 0,
334
+ failedEventId: acquired.state.lastFailedEventId,
334
335
  resolvedPendingIds: [],
335
336
  };
336
337
  processed = outcome.processed;
@@ -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.331.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.331.0",
210
- "@cosmicdrift/kumiko-types": "0.331.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.331.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",