@cosmicdrift/kumiko-framework 0.377.0 → 0.379.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 (118) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/delivery-notifications.md +25 -2
  5. package/agents/examples/embedded.md +177 -0
  6. package/agents/examples/event-sourcing.md +6 -5
  7. package/agents/examples/legal-pages.md +5 -5
  8. package/agents/examples/reference-data.md +26 -21
  9. package/agents/examples/relations.md +2 -1
  10. package/agents/features.md +5 -5
  11. package/agents/widgets.md +1 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +27 -0
  14. package/dist/api/index.d.ts +1 -0
  15. package/dist/api/index.js +1 -0
  16. package/dist/api/server.js +10 -3
  17. package/dist/api/sse-route.d.ts +5 -0
  18. package/dist/api/sse-route.js +59 -9
  19. package/dist/db/entity-table-from-registry.d.ts +2 -1
  20. package/dist/db/entity-table-meta.d.ts +0 -2
  21. package/dist/db/entity-table-meta.js +1 -3
  22. package/dist/db/event-store-executor-write.js +21 -16
  23. package/dist/db/executor-managed-tables.d.ts +6 -0
  24. package/dist/db/executor-managed-tables.js +40 -0
  25. package/dist/db/index.d.ts +4 -2
  26. package/dist/db/index.js +4 -2
  27. package/dist/db/money.d.ts +3 -2
  28. package/dist/db/money.js +2 -7
  29. package/dist/db/queries/backfill-pii.js +22 -12
  30. package/dist/db/queries/database-now.d.ts +5 -0
  31. package/dist/db/queries/database-now.js +12 -0
  32. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  33. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  34. package/dist/db/query.js +28 -0
  35. package/dist/db/reference-data.d.ts +5 -4
  36. package/dist/db/reference-data.js +23 -21
  37. package/dist/db/table-builder.js +2 -0
  38. package/dist/db/tenant-db-runner.d.ts +2 -0
  39. package/dist/db/tenant-db-runner.js +7 -0
  40. package/dist/db/tenant-db.d.ts +3 -0
  41. package/dist/db/tenant-db.js +54 -18
  42. package/dist/engine/boot-validator/entity-handler.js +12 -4
  43. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  44. package/dist/engine/boot-validator/form-derivations.js +82 -0
  45. package/dist/engine/boot-validator/query-refs.js +8 -0
  46. package/dist/engine/boot-validator/screens.js +50 -12
  47. package/dist/engine/build-config-feature-schema.js +33 -10
  48. package/dist/engine/embedded-derived.d.ts +1 -1
  49. package/dist/engine/embedded-derived.js +7 -1
  50. package/dist/engine/feature-ui-extensions.js +4 -0
  51. package/dist/engine/form-derivations.d.ts +21 -0
  52. package/dist/engine/form-derivations.js +175 -0
  53. package/dist/engine/index.d.ts +4 -1
  54. package/dist/engine/index.js +2 -0
  55. package/dist/engine/minor-units.d.ts +2 -0
  56. package/dist/engine/minor-units.js +8 -0
  57. package/dist/engine/registry-state.js +2 -0
  58. package/dist/engine/registry-validate.js +4 -1
  59. package/dist/engine/schema-builder.js +18 -4
  60. package/dist/engine/screen-helpers.d.ts +1 -0
  61. package/dist/engine/screen-helpers.js +3 -0
  62. package/dist/engine/soft-delete-cleanup.js +53 -24
  63. package/dist/engine/steps/unsafe-projection-delete.js +2 -1
  64. package/dist/engine/template-variables.d.ts +2 -1
  65. package/dist/engine/template-variables.js +4 -0
  66. package/dist/engine/types/index.d.ts +2 -2
  67. package/dist/engine/with-form-derivations.d.ts +9 -0
  68. package/dist/engine/with-form-derivations.js +16 -0
  69. package/dist/entrypoint/index.d.ts +1 -3
  70. package/dist/errors/reasons.d.ts +1 -0
  71. package/dist/errors/reasons.js +4 -1
  72. package/dist/errors/serialize.d.ts +1 -0
  73. package/dist/errors/serialize.js +9 -5
  74. package/dist/errors/write-error-info.js +3 -2
  75. package/dist/errors/zod-bridge.js +7 -0
  76. package/dist/event-store/index.d.ts +1 -0
  77. package/dist/event-store/index.js +1 -0
  78. package/dist/files/action-form-upload.d.ts +16 -0
  79. package/dist/files/action-form-upload.js +45 -0
  80. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  81. package/dist/files/bind-pending-file-refs.js +44 -4
  82. package/dist/files/create-file-ref.d.ts +3 -0
  83. package/dist/files/create-file-ref.js +41 -13
  84. package/dist/files/file-routes.js +29 -2
  85. package/dist/files/index.d.ts +2 -1
  86. package/dist/files/index.js +1 -0
  87. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  88. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  89. package/dist/i18n/settings-hub-keys.js +1 -0
  90. package/dist/internal/testing.d.ts +2 -0
  91. package/dist/internal/testing.js +15 -1
  92. package/dist/migrations/pii-event-backfill.js +14 -7
  93. package/dist/observability/escape-hatch-report.d.ts +1 -1
  94. package/dist/observability/escape-hatch-report.js +14 -3
  95. package/dist/pipeline/cascade-handler.js +79 -10
  96. package/dist/pipeline/dispatch-shared.js +1 -9
  97. package/dist/pipeline/msp-rebuild.js +5 -3
  98. package/dist/pipeline/projection-rebuild.js +4 -2
  99. package/dist/pipeline/projections-runner.js +2 -1
  100. package/dist/rate-limit/index.d.ts +1 -1
  101. package/dist/rate-limit/index.js +1 -1
  102. package/dist/rate-limit/middleware.d.ts +4 -0
  103. package/dist/rate-limit/middleware.js +5 -2
  104. package/dist/search/configure-tenant-index.d.ts +4 -0
  105. package/dist/search/configure-tenant-index.js +8 -0
  106. package/dist/search/index.d.ts +1 -0
  107. package/dist/search/index.js +1 -0
  108. package/dist/ui-types/index.d.ts +4 -2
  109. package/dist/ui-types/index.js +3 -2
  110. package/dist/ui-types/list-row-meta.d.ts +3 -0
  111. package/dist/ui-types/list-row-meta.js +12 -1
  112. package/kumiko-es-tables.json +6 -0
  113. package/package.json +4 -3
  114. package/src/changes.json +142 -0
  115. package/src/scripts/codemod/README.md +20 -0
  116. package/src/scripts/codemod/codemod-support.ts +1 -1
  117. package/src/scripts/codemod/feature-layout.ts +416 -0
  118. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -18,6 +18,7 @@ import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js"
18
18
  import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
19
19
  import { metricField } from "../types/index.js";
20
20
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
21
+ import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
21
22
  import { isSelectOptionTone } from "./select-option-tone.js";
22
23
  function isRowClickAction(action) {
23
24
  return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
@@ -182,21 +183,25 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
182
183
  return;
183
184
  if (target.screen.type === "dashboard") {
184
185
  const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
185
- const filter = target.screen.filter;
186
- if (filter === undefined) {
186
+ const { filter, record, timeRange } = target.screen;
187
+ const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
188
+ if (readIds.length === 0) {
187
189
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
188
190
  `params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
189
- `filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
191
+ `filter, time range or record binding — params would be a no-op. Remove the params extractor, or add a \`filter\` (or a \`record\` binding) to the ` +
190
192
  `target dashboard so it has somewhere to read the value from.`);
191
193
  }
192
194
  const extractedKeys = rowFieldExtractorKeys(action.params);
193
- if (!extractedKeys.includes(filter.id)) {
195
+ if (!readIds.some((id) => extractedKeys.includes(id))) {
196
+ const readDescriptor = filter !== undefined && readIds.length === 1
197
+ ? `filter id is "${filter.id}"`
198
+ : `URL params are [${readIds.join(", ")}]`;
194
199
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
195
200
  `params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
196
- `filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
197
- `unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`);
201
+ `${readDescriptor} — none of the extracted keys match, so the value would stay ` +
202
+ `unset. Fix the params extractor to produce one of these keys, or remove it.`);
198
203
  }
199
- // skip: filter present and the extractor's keys cover it — valid dashboard deep-link.
204
+ // skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
200
205
  return;
201
206
  }
202
207
  if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
@@ -945,6 +950,17 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
945
950
  const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
946
951
  validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
947
952
  validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
953
+ if (screen.type === "actionForm") {
954
+ validateFormDerivations(feature.name, screenId, screen);
955
+ validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
956
+ }
957
+ if (screen.type === "actionForm" &&
958
+ screen.load?.singleton === true &&
959
+ screen.load.idParam !== undefined) {
960
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both load.singleton: true ` +
961
+ `and load.idParam — singleton means the query picks the row from the caller's context, so ` +
962
+ `there is no record id to send under idParam. Remove idParam.`);
963
+ }
948
964
  validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
949
965
  }
950
966
  // SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
@@ -1287,17 +1303,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1287
1303
  else if (action.kind === "clientAction") {
1288
1304
  validateClientActionName(feature.name, screenId, "projectionList", "rowAction", action);
1289
1305
  }
1306
+ else if (!allWriteHandlerQns.has(action.handler)) {
1307
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
1308
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
1309
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
1310
+ }
1290
1311
  }
1291
1312
  validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
1292
1313
  }
1293
- // Only drawer-kind and navigate actions that set tab are validated
1294
- // here — plain navigate (no tab) and writeHandler toolbarActions on
1295
- // projectionList have no boot check yet (pre-existing gap).
1314
+ // Plain navigate toolbarActions (no tab) have no boot check yet.
1296
1315
  if (screen.toolbarActions !== undefined) {
1297
1316
  for (const action of screen.toolbarActions) {
1298
1317
  if (action.kind === "drawer") {
1299
1318
  validateDrawerTargetAction(feature.name, screenId, "projectionList", "toolbarAction", action, feature.screens);
1300
1319
  }
1320
+ else if (action.kind === "writeHandler" && !allWriteHandlerQns.has(action.handler)) {
1321
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) toolbarAction "${action.id}" ` +
1322
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
1323
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
1324
+ }
1301
1325
  if (action.kind === "navigate" && action.tab !== undefined) {
1302
1326
  resolveRowActionNavigateTarget(feature.name, screenId, "projectionList", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1303
1327
  }
@@ -2066,7 +2090,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
2066
2090
  validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
2067
2091
  }
2068
2092
  else if (panel.kind === "text") {
2069
- validateDashboardTextPanel(featureName, screenId, panel);
2093
+ validateDashboardTextPanel(featureName, screenId, panel, featureMap);
2070
2094
  }
2071
2095
  else {
2072
2096
  validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
@@ -2250,7 +2274,7 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
2250
2274
  throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
2251
2275
  }
2252
2276
  }
2253
- function validateDashboardTextPanel(featureName, screenId, panel) {
2277
+ function validateDashboardTextPanel(featureName, screenId, panel, featureMap) {
2254
2278
  const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
2255
2279
  if (panel.blocks.length === 0) {
2256
2280
  throw new Error(`${context} has an empty blocks list. Add at least one block.`);
@@ -2261,6 +2285,20 @@ function validateDashboardTextPanel(featureName, screenId, panel) {
2261
2285
  if (block.kind === "list" && block.items.length === 0) {
2262
2286
  throw new Error(`${context} has a list block without items. Add items or remove the block.`);
2263
2287
  }
2288
+ if (block.kind === "link")
2289
+ validateDashboardTextLinkBlock(context, block, featureName, featureMap);
2290
+ }
2291
+ }
2292
+ function validateDashboardTextLinkBlock(context, block, featureName, featureMap) {
2293
+ const targetQn = resolveScreenTargetQn(featureName, block.screen);
2294
+ const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
2295
+ if (featureMap.get(targetFeatureName)?.screens[targetShortId] === undefined) {
2296
+ throw new Error(`${context} link block screen "${block.screen}" does not resolve to a registered screen ` +
2297
+ `(checked "${targetQn}"). Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
2298
+ }
2299
+ if (block.roles?.length === 0) {
2300
+ throw new Error(`${context} link block "${block.screen}" has an empty roles list, which would hide it from everyone. ` +
2301
+ `List the roles or omit roles.`);
2264
2302
  }
2265
2303
  }
2266
2304
  function validateDashboardPanelVisibleWhen(context, visibleWhen) {
@@ -46,6 +46,10 @@ const PROVIDER_PANEL_TITLE_ENTRY = {
46
46
  es: "Proveedor",
47
47
  };
48
48
  const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
49
+ // The empty option of a provider select means "no own provider, the platform handles it". A selector
50
+ // names it through `<field label key>.platformDefault`, else this generic text applies.
51
+ const PLATFORM_DEFAULT_LABEL_SUFFIX = ".platformDefault";
52
+ const PLATFORM_DEFAULT_FALLBACK_KEY = "config.settings.provider.platformDefault";
49
53
  const SECRETS_SECTION_DESCRIPTION_KEY = "config.secrets.description";
50
54
  const audienceNavShortId = (scope) => `audience-${scope}`;
51
55
  // Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
@@ -214,8 +218,8 @@ function buildFeatureNav(scope, feature, ordered, access, declaredTranslationKey
214
218
  access,
215
219
  };
216
220
  }
217
- // One configEdit screen + child-nav per feature; a tenant-scope selector owner
218
- // becomes a dashboard instead and its gated plugins get no entries of their own.
221
+ // One configEdit screen + child-nav per feature; a tenant-scope selector owner folds its
222
+ // gated plugins into its own screen (a dashboard when it carries extra panels).
219
223
  function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess) {
220
224
  const out = { screens: [], navs: [], translations: {} };
221
225
  const ungated = visible.filter((v) => !gating.pluginFeatures.has(v.key.ownerFeature));
@@ -230,7 +234,7 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
230
234
  out.navs.push(buildFeatureNav(scope, feature, ordered, access, hub.declaredTranslationKeys));
231
235
  continue;
232
236
  }
233
- const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, gating.ownerPanelsByGroup.get(feature) ?? [], hub, secretsAccess);
237
+ const ownerHub = buildSelectorOwnerScreens(feature, ordered, access, plugins, gating.ownerPanelsByGroup.get(feature) ?? [], hub, secretsAccess);
234
238
  out.screens.push(...ownerHub.screens);
235
239
  out.navs.push(buildFeatureNav(scope, feature, ordered, ownerHub.access, hub.declaredTranslationKeys));
236
240
  Object.assign(out.translations, ownerHub.translations);
@@ -251,15 +255,19 @@ function assertUniquePanelIds(ownerGroup, panels) {
251
255
  // One form for the provider and the settings of every provider: a single save writes
252
256
  // all of it, so switching sections can't drop typed input. Each plugin's fields are
253
257
  // shown only while its provider is picked in the form; hidden fields are not saved.
254
- function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
255
- const selectionScreenId = `${ownerGroup}-tenant-selection`;
258
+ // Without extra panels the form is the owner's screen itself; with panels it sits
259
+ // chromeless in a dashboard.
260
+ function buildSelectorOwnerScreens(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
261
+ const hasPanels = ownerPanels.length > 0;
262
+ const selectionScreenId = hasPanels ? `${ownerGroup}-tenant-selection` : `${ownerGroup}-tenant`;
256
263
  const form = new ProviderFormBuilder(selectionScreenId, buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
257
264
  description: EXTENSION_SELECTOR_HINT_KEY,
258
- sectionTitle: PROVIDER_SECTION_TITLE_KEY,
265
+ ...(hasPanels && { sectionTitle: PROVIDER_SECTION_TITLE_KEY }),
259
266
  }));
260
267
  const accessRules = [ownerAccess];
261
268
  const translations = {};
262
- translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
269
+ if (hasPanels)
270
+ translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
263
271
  Object.assign(translations, providerOptionTranslations(form.base, hub));
264
272
  for (const plugin of plugins) {
265
273
  const providerFieldId = form.fieldIdOfConfigKey(plugin.selectorQn);
@@ -279,7 +287,10 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
279
287
  }
280
288
  }
281
289
  const access = unionAccessRules(accessRules);
282
- const selectionScreen = { ...form.build(access), dormant: true };
290
+ const providerForm = form.build(access);
291
+ if (!hasPanels)
292
+ return { screens: [providerForm], access, translations };
293
+ const selectionScreen = { ...providerForm, dormant: true };
283
294
  const dashboard = {
284
295
  id: `${ownerGroup}-tenant`,
285
296
  type: "dashboard",
@@ -726,6 +737,14 @@ function providerOptionTranslations(selectionScreen, hub) {
726
737
  const featureByPluginId = hub.selectorOptionFeatures.get(qn);
727
738
  if (featureByPluginId === undefined)
728
739
  continue;
740
+ const fieldLabelTranslationKey = selectionScreen.fieldLabels?.[fieldId];
741
+ const platformDefaultLabel = (fieldLabelTranslationKey !== undefined
742
+ ? hub.translationsByKey.get(`${fieldLabelTranslationKey}${PLATFORM_DEFAULT_LABEL_SUFFIX}`)
743
+ : undefined) ?? hub.translationsByKey.get(PLATFORM_DEFAULT_FALLBACK_KEY);
744
+ if (platformDefaultLabel !== undefined) {
745
+ out[`${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:`] =
746
+ platformDefaultLabel;
747
+ }
729
748
  for (const [pluginId, feature] of featureByPluginId) {
730
749
  const label = hub.translationsByKey.get(`${feature}.settings`);
731
750
  if (label === undefined)
@@ -738,8 +757,12 @@ function providerOptionTranslations(selectionScreen, hub) {
738
757
  }
739
758
  function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
740
759
  const def = key.def;
741
- if (selectorPluginIds !== undefined)
742
- return createSelectField({ options: selectorPluginIds });
760
+ if (selectorPluginIds !== undefined) {
761
+ return createSelectField({
762
+ options: selectorPluginIds.length > 0 ? ["", ...selectorPluginIds] : selectorPluginIds,
763
+ display: "radio",
764
+ });
765
+ }
743
766
  switch (def.type) {
744
767
  case "number":
745
768
  return createNumberField({
@@ -1,6 +1,6 @@
1
1
  import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types/index.ts";
2
2
  /** Computes a derived cell from its source values. Missing/non-numeric
3
- * sources: "multiply" with any missing source → undefined; "sum"/"subtract"
3
+ * sources: "multiply"/"percent" with any missing source → undefined; "sum"/"subtract"
4
4
  * treat missing as 0 only when at least one source is present — if every
5
5
  * source is missing, return undefined (do not invent 0). Money cells are
6
6
  * minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
@@ -1,6 +1,6 @@
1
1
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
2
  /** Computes a derived cell from its source values. Missing/non-numeric
3
- * sources: "multiply" with any missing source → undefined; "sum"/"subtract"
3
+ * sources: "multiply"/"percent" with any missing source → undefined; "sum"/"subtract"
4
4
  * treat missing as 0 only when at least one source is present — if every
5
5
  * source is missing, return undefined (do not invent 0). Money cells are
6
6
  * minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
@@ -15,6 +15,12 @@ export function computeDerivedCellValue(op, values) {
15
15
  }
16
16
  return product;
17
17
  }
18
+ if (op === "percent") {
19
+ const [base, rate] = values;
20
+ if (values.length !== 2 || base === undefined || rate === undefined)
21
+ return undefined;
22
+ return (base * rate) / 100;
23
+ }
18
24
  if (values.every((value) => value === undefined))
19
25
  return undefined;
20
26
  const numeric = values.map((value) => value ?? 0);
@@ -1,6 +1,7 @@
1
1
  import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_ROUTE_PATH_PREFIX, } from "@cosmicdrift/kumiko-types/websocket-route";
4
+ import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
4
5
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
5
6
  import { LifecycleHookTypes } from "./constants.js";
6
7
  import { resolveName } from "./handler-helpers.js";
@@ -298,6 +299,9 @@ export function buildUiExtensionsMethods(state, name) {
298
299
  throw new Error(`[Feature ${name}] Projection "${definition.name}" already registered. ` +
299
300
  `Projection names must be unique per feature.`);
300
301
  }
302
+ if (typeof definition.table === "object" && definition.table !== null) {
303
+ markExecutorManagedTable(definition.table);
304
+ }
301
305
  state.projections[definition.name] = definition;
302
306
  },
303
307
  multiStreamProjection(definition) {
@@ -0,0 +1,21 @@
1
+ import type { FormFieldDefinition } from "@cosmicdrift/kumiko-types/fields";
2
+ import type { FormFieldDerivation } from "./types/screen.ts";
3
+ export type FormDerivationScreen = {
4
+ readonly fields: Readonly<Record<string, FormFieldDefinition>>;
5
+ readonly derive?: Readonly<Record<string, FormFieldDerivation>>;
6
+ };
7
+ /** Targets ordered so every derived source is computed before its reader. */
8
+ export declare function orderFormDerivations(derive: Readonly<Record<string, FormFieldDerivation>>): readonly string[];
9
+ /** True when `field` is a `listSum` without `whenEmpty` whose list has no
10
+ * rows: the sum has nothing to add up, so the user types the value. */
11
+ export declare function isFormDerivationInputMode(screen: FormDerivationScreen, values: unknown, field: string): boolean;
12
+ /** True when `field` is only read as a `whenEmpty` operand of `listSum`
13
+ * derivations whose lists all have rows: nothing reads it, so it is locked
14
+ * rather than inviting an input that is silently dropped. */
15
+ export declare function isUnusedFallbackOperand(screen: FormDerivationScreen, values: unknown, field: string): boolean;
16
+ /** Recomputes every derived head field of an action form from its inputs.
17
+ * Pure and client-safe: the renderer runs it live, the server runs it
18
+ * again (`withFormDerivations`) and overwrites whatever the client sent.
19
+ * Money operands are converted to minor units for the arithmetic and money
20
+ * targets are written back as `{ amount, currency }` in major units. */
21
+ export declare function applyFormDerivations(values: unknown, screen: FormDerivationScreen): unknown;
@@ -0,0 +1,175 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "./embedded-derived.js";
3
+ import { MINOR_UNIT_SCALE, toMinorUnits } from "./minor-units.js";
4
+ function readMoneyOperand(raw) {
5
+ if (typeof raw === "number")
6
+ return { minor: toMinorUnits(raw), currency: undefined };
7
+ if (!isPlainObject(raw) || typeof raw["amount"] !== "number")
8
+ return undefined;
9
+ const currency = raw["currency"];
10
+ return {
11
+ minor: toMinorUnits(raw["amount"]),
12
+ currency: typeof currency === "string" && currency !== "" ? currency : undefined,
13
+ };
14
+ }
15
+ function readOperand(values, fields, name) {
16
+ const raw = values[name];
17
+ if (fields[name]?.type === "money") {
18
+ const money = readMoneyOperand(raw);
19
+ return { value: money?.minor, currency: money?.currency };
20
+ }
21
+ return { value: typeof raw === "number" ? raw : undefined, currency: undefined };
22
+ }
23
+ function dependenciesOf(derivation) {
24
+ if (derivation.op === "listSum") {
25
+ return derivation.whenEmpty === undefined ? [] : derivation.whenEmpty.from;
26
+ }
27
+ return derivation.from;
28
+ }
29
+ /** Targets ordered so every derived source is computed before its reader. */
30
+ export function orderFormDerivations(derive) {
31
+ const ordered = [];
32
+ const state = new Map();
33
+ const visit = (name, path) => {
34
+ const derivation = derive[name];
35
+ // skip: plain input field, nothing to order
36
+ if (derivation === undefined)
37
+ return;
38
+ const status = state.get(name);
39
+ // skip: already ordered via another path
40
+ if (status === "done")
41
+ return;
42
+ if (status === "visiting") {
43
+ throw new Error(`Form derivation cycle: ${[...path, name].join(" -> ")}`);
44
+ }
45
+ state.set(name, "visiting");
46
+ for (const dependency of dependenciesOf(derivation))
47
+ visit(dependency, [...path, name]);
48
+ state.set(name, "done");
49
+ ordered.push(name);
50
+ };
51
+ for (const name of Object.keys(derive))
52
+ visit(name, []);
53
+ return ordered;
54
+ }
55
+ function isNonEmptyList(value) {
56
+ return Array.isArray(value) && value.length > 0;
57
+ }
58
+ /** True when `field` is a `listSum` without `whenEmpty` whose list has no
59
+ * rows: the sum has nothing to add up, so the user types the value. */
60
+ export function isFormDerivationInputMode(screen, values, field) {
61
+ const derivation = screen.derive?.[field];
62
+ if (derivation === undefined || derivation.op !== "listSum")
63
+ return false;
64
+ if (derivation.whenEmpty !== undefined)
65
+ return false;
66
+ return !isPlainObject(values) || !isNonEmptyList(values[derivation.list]);
67
+ }
68
+ /** True when `field` is only read as a `whenEmpty` operand of `listSum`
69
+ * derivations whose lists all have rows: nothing reads it, so it is locked
70
+ * rather than inviting an input that is silently dropped. */
71
+ export function isUnusedFallbackOperand(screen, values, field) {
72
+ const derive = screen.derive;
73
+ if (derive === undefined || derive[field] !== undefined)
74
+ return false;
75
+ let usedAsFallback = false;
76
+ for (const derivation of Object.values(derive)) {
77
+ if (derivation.op !== "listSum") {
78
+ if (derivation.from.includes(field))
79
+ return false;
80
+ continue;
81
+ }
82
+ if (derivation.whenEmpty?.from.includes(field) !== true)
83
+ continue;
84
+ if (!isPlainObject(values) || !isNonEmptyList(values[derivation.list]))
85
+ return false;
86
+ usedAsFallback = true;
87
+ }
88
+ return usedAsFallback;
89
+ }
90
+ function sumListCell(rows, listField, cell) {
91
+ const embedded = listField?.type === "embedded" ? listField : undefined;
92
+ let sum = 0;
93
+ for (const rawRow of rows) {
94
+ // Recompute the row's derived cells so a manipulated row value can't leak into the head.
95
+ const row = embedded?.derived === undefined
96
+ ? rawRow
97
+ : withDerivedCells(rawRow, embedded.derived, embedded.schema);
98
+ if (isPlainObject(row) && typeof row[cell] === "number")
99
+ sum += row[cell];
100
+ }
101
+ return sum;
102
+ }
103
+ function targetCurrency(field, current, sourceCurrency) {
104
+ if (isPlainObject(current) &&
105
+ typeof current["currency"] === "string" &&
106
+ current["currency"] !== "") {
107
+ return current["currency"];
108
+ }
109
+ if (sourceCurrency !== undefined)
110
+ return sourceCurrency;
111
+ if (field.type === "money" && field.currency.kind === "literal")
112
+ return field.currency.code;
113
+ return undefined;
114
+ }
115
+ /** Recomputes every derived head field of an action form from its inputs.
116
+ * Pure and client-safe: the renderer runs it live, the server runs it
117
+ * again (`withFormDerivations`) and overwrites whatever the client sent.
118
+ * Money operands are converted to minor units for the arithmetic and money
119
+ * targets are written back as `{ amount, currency }` in major units. */
120
+ export function applyFormDerivations(values, screen) {
121
+ if (!isPlainObject(values))
122
+ return values;
123
+ const derive = screen.derive;
124
+ if (derive === undefined)
125
+ return values;
126
+ const result = { ...values };
127
+ for (const target of orderFormDerivations(derive)) {
128
+ const derivation = derive[target];
129
+ const targetField = screen.fields[target];
130
+ if (derivation === undefined || targetField === undefined)
131
+ continue;
132
+ let computed;
133
+ let sourceCurrency;
134
+ let ordinary;
135
+ if (derivation.op === "listSum") {
136
+ const rows = result[derivation.list];
137
+ if (isNonEmptyList(rows)) {
138
+ computed = sumListCell(rows, screen.fields[derivation.list], derivation.cell);
139
+ }
140
+ else if (derivation.whenEmpty === undefined) {
141
+ continue;
142
+ }
143
+ else {
144
+ ordinary = derivation.whenEmpty;
145
+ }
146
+ }
147
+ else {
148
+ ordinary = derivation;
149
+ }
150
+ if (ordinary !== undefined) {
151
+ const operands = ordinary.from.map((name) => readOperand(result, screen.fields, name));
152
+ sourceCurrency = operands.find((operand) => operand.currency !== undefined)?.currency;
153
+ computed = computeDerivedCellValue(ordinary.op, operands.map((operand) => operand.value));
154
+ }
155
+ if (computed === undefined) {
156
+ delete result[target];
157
+ continue;
158
+ }
159
+ if (targetField.type === "money") {
160
+ const minor = roundDerivedCellValue(computed, { type: "money" });
161
+ const currency = targetCurrency(targetField, result[target], sourceCurrency);
162
+ result[target] =
163
+ currency === undefined
164
+ ? { amount: minor / MINOR_UNIT_SCALE }
165
+ : { amount: minor / MINOR_UNIT_SCALE, currency };
166
+ }
167
+ else if (targetField.type === "decimal") {
168
+ result[target] = roundDerivedCellValue(computed, targetField);
169
+ }
170
+ else {
171
+ result[target] = computed;
172
+ }
173
+ }
174
+ return result;
175
+ }
@@ -45,6 +45,8 @@ export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, group
45
45
  export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.ts";
46
46
  export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.ts";
47
47
  export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.ts";
48
+ export type { FormDerivationScreen } from "./form-derivations.ts";
49
+ export { applyFormDerivations, isFormDerivationInputMode } from "./form-derivations.ts";
48
50
  export { HANDLER_FIELD_REFERENCES_META_KEY, type HandlerFieldPathSegment, type HandlerFieldReference, type HandlerFieldReferenceAtPath, type HandlerFieldReferenceMeta, handlerFieldReference, handlerFieldReferences, readHandlerFieldReferences, } from "./handler-field-references.ts";
49
51
  export { resolveName, withResponseData } from "./handler-helpers.ts";
50
52
  export { i18nKey } from "./i18n-key.ts";
@@ -85,7 +87,8 @@ export { assertNotificationVariables, templateVariable, templateVariablesToZod,
85
87
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, requireTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.ts";
86
88
  export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.ts";
87
89
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.ts";
88
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, BootOptionName, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityObjectDefinition, 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, HookDefinition, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MaxPerTenantOptions, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, ProviderPluginContext, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StackBootRequirement, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.ts";
90
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, BootOptionName, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityObjectDefinition, 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, HookDefinition, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MaxPerTenantOptions, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, ProviderPluginContext, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StackBootRequirement, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.ts";
89
91
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.ts";
90
92
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.ts";
91
93
  export { runValidation } from "./validation.ts";
94
+ export { formDerivationsOfSchema, withFormDerivations } from "./with-form-derivations.ts";
@@ -40,6 +40,7 @@ export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, group
40
40
  export { compareVersions, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
41
41
  export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
42
42
  export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
43
+ export { applyFormDerivations, isFormDerivationInputMode } from "./form-derivations.js";
43
44
  export { HANDLER_FIELD_REFERENCES_META_KEY, handlerFieldReference, handlerFieldReferences, readHandlerFieldReferences, } from "./handler-field-references.js";
44
45
  export { resolveName, withResponseData } from "./handler-helpers.js";
45
46
  export { i18nKey } from "./i18n-key.js";
@@ -80,3 +81,4 @@ export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTra
80
81
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
81
82
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
82
83
  export { runValidation } from "./validation.js";
84
+ export { formDerivationsOfSchema, withFormDerivations } from "./with-form-derivations.js";
@@ -0,0 +1,2 @@
1
+ export declare const MINOR_UNIT_SCALE = 100;
2
+ export declare function toMinorUnits(amount: number): number;
@@ -0,0 +1,8 @@
1
+ // 2 decimal places (cents) — covers every currently-supported currency
2
+ // (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
3
+ // upgrade path once a currency needing a different scale actually lands.
4
+ // Client-safe on purpose: db/money.ts pulls in engine runtime values.
5
+ export const MINOR_UNIT_SCALE = 100;
6
+ export function toMinorUnits(amount) {
7
+ return Math.round(amount * MINOR_UNIT_SCALE);
8
+ }
@@ -1,5 +1,6 @@
1
1
  import { applyEntityEvent } from "../db/apply-entity-event.js";
2
2
  import { assertBackingTableSuperset, deriveEntityTableMeta } from "../db/entity-table-meta.js";
3
+ import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
3
4
  import { asEntityTableMeta } from "../db/query.js";
4
5
  import { buildEntityTable } from "../db/table-builder.js";
5
6
  import { qualifyEntityName } from "./qualified-name.js";
@@ -20,6 +21,7 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
20
21
  const drizzleTable = backingTable !== undefined
21
22
  ? resolveBackingTable(entityName, entity, backingTable)
22
23
  : buildEntityTable(entityName, entity);
24
+ markExecutorManagedTable(drizzleTable);
23
25
  // applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
24
26
  // Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
25
27
  const handler = async (event, tx) => {
@@ -476,8 +476,11 @@ export function validateProjectionApplyKeys(state) {
476
476
  // or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
477
477
  // A "Shape-Anchor"-entity is no longer needed for this case.
478
478
  const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
479
+ // An extension source may be a stream whose events the executor writes under a
480
+ // name that is no registered entity (document-extract); its auto-verbs count.
481
+ const extensionSources = projDef.extraSources ?? [];
479
482
  for (const src of rebuildSources) {
480
- if (state.entityMap.has(src)) {
483
+ if (state.entityMap.has(src) || extensionSources.includes(src)) {
481
484
  for (const verb of ENTITY_LIFECYCLE_VERBS)
482
485
  validEventTypes.add(`${src}.${verb}`);
483
486
  }
@@ -236,12 +236,26 @@ export function fieldToZod(field, currencies, opts = {}) {
236
236
  // whether the key may be omitted at all is decided by buildInsertSchema
237
237
  // off the same flag. `minItems` overrides that default when set.
238
238
  let list = z.array(row);
239
- const min = field.minItems ?? (field.required === true ? 1 : undefined);
240
- if (min !== undefined)
241
- list = list.min(min);
242
239
  if (field.maxItems !== undefined)
243
240
  list = list.max(field.maxItems);
244
- return list;
241
+ const min = field.minItems ?? (field.required === true ? 1 : undefined);
242
+ if (min === undefined)
243
+ return list;
244
+ // A custom issue rather than min(): other arrays (multiSelect) share zod's
245
+ // too_small and must not read "rows"; the bridge honours params.i18nKey.
246
+ return list.superRefine((rows, ctx) => {
247
+ // skip: the minimum is met, nothing to report
248
+ if (rows.length >= min)
249
+ return;
250
+ ctx.addIssue({
251
+ code: "custom",
252
+ input: rows,
253
+ params: {
254
+ i18nKey: `errors.validation.list_min_rows.${min === 1 ? "one" : "other"}`,
255
+ minimum: min,
256
+ },
257
+ });
258
+ });
245
259
  }
246
260
  case "jsonb": {
247
261
  // Free-form jsonb — keys sind tenant-/runtime-defined. Validation
@@ -1,5 +1,6 @@
1
1
  import type { FieldDefinition, SelectFieldDef } from "./types/fields.ts";
2
2
  import type { ActionFormScreenDefinition, ConfigEditScreenDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, FieldCondition, FormatSpec, ListColumnSpec, ProjectionDetailScreenDefinition, ScreenDefinition, SecretMintScreenDefinition } from "./types/screen.ts";
3
+ export declare const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
3
4
  export declare function explicitListScreenId(screen: ScreenDefinition): string | undefined;
4
5
  export declare function resolveNavParentScreen(screens: readonly ScreenDefinition[], detail: ScreenDefinition, getId: (screen: ScreenDefinition) => string): ScreenDefinition | undefined;
5
6
  export type EditLayoutScreen = ProjectionDetailScreenDefinition | EntityEditScreenDefinition | ActionFormScreenDefinition | ConfigEditScreenDefinition | SecretMintScreenDefinition;
@@ -1,3 +1,6 @@
1
+ // Entity name the renderer gives the synthesized entity of an actionForm, secretMint or confirm
2
+ // step. It is not registered, so nothing server-side may treat it as a real entity.
3
+ export const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
1
4
  // Every screen type carrying `listScreenId` declares it identically; centralizing
2
5
  // here keeps the switch the only place that grows for a new screen type.
3
6
  export function explicitListScreenId(screen) {