@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
@@ -5,16 +5,18 @@
5
5
  // true buys an entity restore + trash (ctx.includeDeleted) + this cleanup, with
6
6
  // no feature to wire.
7
7
  //
8
- // Hard-deleting the projection row leaves the event stream intact (source of
9
- // truth) — a full projection rebuild would replay created+deleted and
10
- // resurrect the row as isDeleted=true. That's acceptable: cleanup bounds LIVE
11
- // table growth; irreversible event-log purging is data-retention's job
12
- // (pruneEvents), a separate, consumer-lag-guarded path.
13
- import { asEntityTableMeta, deleteMany } from "../db/query.js";
8
+ // Each expired row is purged through the executor's forget verb: the `<entity>.forgotten`
9
+ // event replays on a projection rebuild, so the purged row stays gone. The earlier events stay
10
+ // in the log; irreversible event-log purging is data-retention's job (pruneEvents), a separate,
11
+ // consumer-lag-guarded path.
12
+ import { createEventStoreExecutor } from "../db/event-store-executor.js";
13
+ import { asEntityTableMeta, selectMany } from "../db/query.js";
14
14
  import { hasFieldProperty } from "../db/table-introspection.js";
15
+ import { reraiseAsKumikoError } from "../errors/index.js";
15
16
  import { purgeExpiredFileRefsWithBytes } from "../files/purge-expired-file-ref-bytes.js";
16
17
  import { FILE_REF_ENTITY_NAME } from "../files/types.js";
17
- import { SYSTEM_USER_ID } from "./system-user.js";
18
+ import { createSystemUser, SYSTEM_USER_ID } from "./system-user.js";
19
+ import { SYSTEM_TENANT_ID } from "./types/identifiers.js";
18
20
  // qualifyEntityName convention (feature:type:kebab-name) with a reserved
19
21
  // "soft-delete" owner — no real feature owns these; the framework synthesizes
20
22
  // them. The job-runner keys cron scheduling off the name, the config-resolver
@@ -46,6 +48,10 @@ export function isSystemScopedSoftDeleteTable(table, entity) {
46
48
  function isGlobalTenancyTable(table) {
47
49
  return asEntityTableMeta(table)?.tenancy === "global";
48
50
  }
51
+ async function resolveGraceDays(ctx, tenantId, db) {
52
+ const resolved = await ctx.configResolver?.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db);
53
+ return typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
54
+ }
49
55
  export const softDeleteCleanupJob = async (_payload, ctx) => {
50
56
  const { db, registry } = ctx;
51
57
  if (!db || !registry) {
@@ -61,10 +67,7 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
61
67
  // skip: cron fired without a perTenant fan-out tenant — nothing scoped to purge
62
68
  return;
63
69
  }
64
- const resolved = ctx.configResolver
65
- ? await ctx.configResolver.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db)
66
- : undefined;
67
- const graceDays = typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
70
+ const graceDays = await resolveGraceDays(ctx, tenantId, db);
68
71
  const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
69
72
  for (const proj of registry.getAllProjections().values()) {
70
73
  if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
@@ -80,23 +83,49 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
80
83
  if (isSystemScopedSoftDeleteTable(proj.table, entity))
81
84
  continue;
82
85
  const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
83
- await purgeExpiredRows(db, proj.source, proj.table, where, cutoff, ctx);
86
+ const executor = createEventStoreExecutor(proj.table, entity, { entityName: proj.source });
87
+ await purgeExpiredRows({ db, executor, user: ctx.systemUser ?? createSystemUser(tenantId) }, proj.source, proj.table, where, cutoff, ctx);
84
88
  }
85
89
  };
86
- async function purgeExpiredRows(db, source, table, where, cutoff, ctx) {
90
+ const PURGE_PAGE_SIZE = 500;
91
+ async function forgetRow(writer, id) {
92
+ // ponytail: one append per expired row, batch when purge volume needs it
93
+ const result = await writer.executor.forget({ id }, writer.user, writer.db);
94
+ if (!result.isSuccess)
95
+ throw reraiseAsKumikoError(result.error);
96
+ }
97
+ async function purgeExpiredRows(writer, source, table, where, cutoff, ctx, readDb = writer.db) {
87
98
  if (source === FILE_REF_ENTITY_NAME) {
88
99
  // The row is the only record of the storageKey, so its bytes go first.
89
- await purgeExpiredFileRefsWithBytes({
90
- db,
100
+ return await purgeExpiredFileRefsWithBytes({
101
+ db: writer.db,
91
102
  table,
92
103
  where,
93
104
  expiredBefore: cutoff,
94
105
  files: ctx.files,
95
106
  log: ctx.log,
107
+ forgetRow: (id) => forgetRow(writer, id),
96
108
  });
97
109
  }
98
- else {
99
- await deleteMany(db, table, where);
110
+ await forgetExpiredPages(writer, source, table, where, ctx, readDb);
111
+ }
112
+ // Keyset on id: a row whose forget fails is logged and skipped, it must not hide the rows behind it.
113
+ async function forgetExpiredPages(writer, source, table, where, ctx, readDb) {
114
+ let afterId;
115
+ for (;;) {
116
+ const page = await selectMany(readDb, table, { ...where, ...(afterId === undefined ? {} : { id: { gt: afterId } }) }, { orderBy: { col: "id", direction: "asc" }, limit: PURGE_PAGE_SIZE });
117
+ const last = page[page.length - 1];
118
+ if (last === undefined)
119
+ break;
120
+ for (const row of page) {
121
+ try {
122
+ await forgetRow(writer, row.id);
123
+ }
124
+ catch (err) {
125
+ ctx.log?.warn(`[soft-delete-cleanup] ${source} ${row.id} not purged: ${err instanceof Error ? err.message : String(err)}`);
126
+ }
127
+ }
128
+ afterId = last.id;
100
129
  }
101
130
  }
102
131
  // System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
@@ -119,13 +148,13 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
119
148
  if (!isSystemScopedSoftDeleteTable(proj.table, entity))
120
149
  continue;
121
150
  const where = { isDeleted: true, deletedAt: { lt: cutoff } };
122
- if (isGlobalTenancyTable(proj.table)) {
123
- // db.global() rejects writes on executor-managed tables; a retention purge has no event to append.
124
- await deleteMany(db.unsafeRaw(), proj.table, where);
125
- }
126
- else {
127
- await deleteMany(db, proj.table, where);
128
- }
151
+ const writer = {
152
+ db,
153
+ executor: createEventStoreExecutor(proj.table, entity, { entityName: proj.source }),
154
+ user: ctx.systemUser ?? createSystemUser(SYSTEM_TENANT_ID),
155
+ };
156
+ // A global-tenancy table is invisible to a tenant-mode ctx.db, so its candidates are read unscoped.
157
+ await purgeExpiredRows(writer, proj.source, proj.table, where, cutoff, ctx, isGlobalTenancyTable(proj.table) ? db.unsafeRaw() : db);
129
158
  }
130
159
  };
131
160
  export function buildSoftDeleteCleanupJob() {
@@ -16,6 +16,7 @@
16
16
  // must commit in the same TX as the aggregate-mutation that triggered
17
17
  // it (stronger consistency than an async projection). Reviewer judges.
18
18
  import { deleteMany } from "../../db/query.js";
19
+ import { withExecutorManagedWrites } from "../../db/tenant-db.js";
19
20
  import { tenantDbWriteRunner } from "../../db/tenant-db-runner.js";
20
21
  import { defineStep } from "../define-step.js";
21
22
  import { resolveRequired } from "./_resolver-utils.js";
@@ -27,7 +28,7 @@ defineStep({
27
28
  // The `unsafe*`-step IS the sanctioned direct projection write — it exists
28
29
  // to bypass the executor deliberately (boot-validated allowlist + reviewer
29
30
  // gate). The brand-strip cast makes that explicit at the one blessed seam.
30
- await deleteMany(tenantDbWriteRunner(ctx.db, "unsafe projection delete"), args.table, where);
31
+ await deleteMany(withExecutorManagedWrites(tenantDbWriteRunner(ctx.db, "unsafe projection delete")), args.table, where);
31
32
  },
32
33
  });
33
34
  export function buildUnsafeProjectionDeleteStep(args) {
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import type { Registry, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariables } from "./types/index.ts";
2
+ import type { Registry, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariables } from "./types/index.ts";
3
3
  type CommonOptions = {
4
4
  readonly label?: string;
5
5
  readonly required?: boolean;
@@ -26,6 +26,7 @@ export declare const templateVariable: {
26
26
  readonly percent: (opts?: CommonOptions & {
27
27
  readonly example?: number;
28
28
  }) => TemplateScalarVariableDef;
29
+ readonly images: (opts?: CommonOptions) => TemplateImagesVariableDef;
29
30
  readonly list: (item: Readonly<Record<string, TemplateScalarVariableDef>>, opts?: CommonOptions & {
30
31
  readonly example?: readonly Readonly<Record<string, unknown>>[];
31
32
  }) => TemplateListVariableDef;
@@ -17,6 +17,7 @@ export const templateVariable = {
17
17
  ...opts,
18
18
  }),
19
19
  percent: (opts = {}) => ({ type: "percent", ...opts }),
20
+ images: (opts = {}) => ({ type: "images", ...opts }),
20
21
  list: (item, opts = {}) => ({ type: "list", item, ...opts }),
21
22
  };
22
23
  const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
@@ -53,7 +54,10 @@ function scalarToZod(def) {
53
54
  return z.number().finite();
54
55
  }
55
56
  }
57
+ const imagesSchema = z.union([z.string(), z.array(z.string())]);
56
58
  function variableToZod(def) {
59
+ if (def.type === "images")
60
+ return imagesSchema;
57
61
  if (def.type !== "list")
58
62
  return scalarToZod(def);
59
63
  return z.array(z.object(Object.fromEntries(Object.entries(def.item).map(([name, item]) => [name, withOptionality(item)]))));
@@ -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, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, 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, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, 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, ProjectionRowIdOf, 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, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListEmptyStateSpec, 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";
20
+ export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormFieldDerivation, FormWidth, ListColumnSpec, ListEmptyStateSpec, 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, relatedListGroupHeaderLabel, relatedListGroupKey, } 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";
@@ -0,0 +1,9 @@
1
+ import * as z from "zod";
2
+ import { type FormDerivationScreen } from "./form-derivations.ts";
3
+ import type { FormFieldDerivation } from "./types/screen.ts";
4
+ /** The `derive` a schema was wrapped with via `withFormDerivations`, so the
5
+ * boot validator can prove an actionForm's handler recomputes its totals. */
6
+ export declare function formDerivationsOfSchema(schema: unknown): Readonly<Record<string, FormFieldDerivation>> | undefined;
7
+ /** Server counterpart of an actionForm's `derive`: recomputes the derived head
8
+ * fields before the schema validates them, so client-sent totals never count. */
9
+ export declare function withFormDerivations<Schema extends z.ZodType>(schema: Schema, screen: FormDerivationScreen): z.ZodPreprocess<Schema, unknown>;
@@ -0,0 +1,16 @@
1
+ import * as z from "zod";
2
+ import { applyFormDerivations } from "./form-derivations.js";
3
+ const derivationsBySchema = new WeakMap();
4
+ /** The `derive` a schema was wrapped with via `withFormDerivations`, so the
5
+ * boot validator can prove an actionForm's handler recomputes its totals. */
6
+ export function formDerivationsOfSchema(schema) {
7
+ return schema instanceof z.ZodType ? derivationsBySchema.get(schema) : undefined;
8
+ }
9
+ /** Server counterpart of an actionForm's `derive`: recomputes the derived head
10
+ * fields before the schema validates them, so client-sent totals never count. */
11
+ export function withFormDerivations(schema, screen) {
12
+ const wrapped = z.preprocess((value) => applyFormDerivations(value, screen), schema);
13
+ if (screen.derive !== undefined)
14
+ derivationsBySchema.set(wrapped, screen.derive);
15
+ return wrapped;
16
+ }
@@ -7,7 +7,7 @@ import type { PgClient, PgListenClient } from "../db/connection.ts";
7
7
  import type { AppContext, Registry } from "../engine/types/index.ts";
8
8
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner.ts";
9
9
  import type { Lifecycle } from "../lifecycle/index.ts";
10
- import type { ObservabilityOptions, ObservabilityProvider } from "../observability/index.ts";
10
+ import type { ObservabilityProvider } from "../observability/index.ts";
11
11
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher.ts";
12
12
  import type { EventDedup, EventDispatcher } from "../pipeline/index.ts";
13
13
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline.ts";
@@ -17,8 +17,6 @@ export type BaseEntrypointOptions = {
17
17
  readonly jwtSecret: string | JwtKeyring;
18
18
  readonly jwtIssuer?: string;
19
19
  readonly observability?: ObservabilityProvider;
20
- /** @deprecated No effect: the HTTP route label carries no query string anymore, so there is nothing left to configure. */
21
- readonly observabilityOptions?: ObservabilityOptions;
22
20
  readonly dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
23
21
  readonly systemHooks?: SystemHooks;
24
22
  readonly eventDedup?: EventDedup;
@@ -16,6 +16,7 @@ export declare const FrameworkReasons: {
16
16
  readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
17
17
  readonly subjectErased: "subject_erased";
18
18
  readonly fileRejected: "file_rejected";
19
+ readonly fileViolatesBindTarget: "file_violates_bind_target";
19
20
  };
20
21
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
21
22
  export declare const AgentReasons: {
@@ -4,7 +4,7 @@
4
4
  // framework-level reason (e.g. "is this a stale-state race? retry once").
5
5
  //
6
6
  // Features add their own local Reasons objects (see `samples/errors/` or
7
- // `packages/bundled-features/src/tenant/constants.ts` → TenantErrors). The
7
+ // `packages/bundled-features/src/tenant/web/constants.ts` → TenantErrors). The
8
8
  // framework deliberately does NOT enforce uniqueness across features — two
9
9
  // features may use the same reason string if the semantics match. The
10
10
  // convention: snake_case, no spaces, no feature prefix for framework reasons.
@@ -71,6 +71,9 @@ export const FrameworkReasons = {
71
71
  // UnprocessableError: createFileRefFromBytes refused the file (size, type,
72
72
  // content or attach target). `details.error` carries the upload-route error string.
73
73
  fileRejected: "file_rejected",
74
+ // UnprocessableError: bindPendingFileRef({ bindTo }) found the pending upload outside the
75
+ // target field's accept/maxSize. `details.error` carries the validator's error string.
76
+ fileViolatesBindTarget: "file_violates_bind_target",
74
77
  };
75
78
  // Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
76
79
  // sister catalog to FrameworkReasons instead of extending it — see the
@@ -12,6 +12,7 @@ export type ErrorResponseBody = {
12
12
  readonly timestamp: string;
13
13
  };
14
14
  };
15
+ export declare const exposesCauseDetails: () => boolean;
15
16
  export declare function serializeError(err: KumikoError, requestId?: string, errorDocs?: ErrorDocsConfig): ErrorResponseBody;
16
17
  export type ErrorLogEntry = {
17
18
  readonly name: string;
@@ -7,10 +7,14 @@ import { resolveErrorDocsUrl } from "./docs-url.js";
7
7
  // possible — without this, a crash in a handler comes back as a bare
8
8
  // "internal error" and developers have to add try/catch + UnprocessableError
9
9
  // just to see what went wrong (real footgun, not theoretical). The exposure
10
- // is gated on NODE_ENV !== "production" so a misconfigured prod deploy
11
- // doesn't leak stack traces over the wire.
10
+ // is gated on NODE_ENV being explicitly "development" or "test": a missing or
11
+ // misspelled NODE_ENV (a misconfigured prod deploy) must not leak stack traces,
12
+ // paths or upstream URLs over the wire.
12
13
  const CODES_WITHOUT_CLIENT_DETAILS = new Set(["internal_error"]);
13
- const isProductionEnv = () => process.env["NODE_ENV"] === "production";
14
+ export const exposesCauseDetails = () => {
15
+ const env = process.env["NODE_ENV"];
16
+ return env === "development" || env === "test";
17
+ };
14
18
  function devCauseDetail(cause) {
15
19
  if (!(cause instanceof Error))
16
20
  return undefined;
@@ -21,7 +25,7 @@ function devCauseDetail(cause) {
21
25
  };
22
26
  }
23
27
  export function serializeError(err, requestId, errorDocs) {
24
- const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && isProductionEnv();
28
+ const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && !exposesCauseDetails();
25
29
  const exposeDeclaredDetails = err.details !== undefined && !stripped;
26
30
  // For internal_error in dev/test, surface the cause chain so the
27
31
  // response body has something useful instead of "internal error".
@@ -30,7 +34,7 @@ export function serializeError(err, requestId, errorDocs) {
30
34
  const detailsForResponse = (() => {
31
35
  if (exposeDeclaredDetails)
32
36
  return err.details;
33
- if (CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && !isProductionEnv()) {
37
+ if (CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && exposesCauseDetails()) {
34
38
  return devCauseDetail(err.cause);
35
39
  }
36
40
  return undefined;
@@ -1,6 +1,7 @@
1
1
  import { NotFoundError, UnprocessableError } from "./classes.js";
2
2
  import { KumikoError } from "./kumiko-error.js";
3
3
  import { FrameworkReasons } from "./reasons.js";
4
+ import { exposesCauseDetails } from "./serialize.js";
4
5
  import { buildInvalidTransitionDetails } from "./transition-details.js";
5
6
  // Convenience for call sites that return a failed WriteResult. Keeps the
6
7
  // pattern `return writeFailure(new NotFoundError(...))` compact so handlers
@@ -49,9 +50,9 @@ export function toWriteErrorInfo(err) {
49
50
  // back to the client as a bare `{ message: "internal error" }` —
50
51
  // identical user-experience to a misconfigured prod, just slower
51
52
  // because the dev has to add try/catch + console.log to find the
52
- // actual stack. Same gating as serializeError: NODE_ENV !== production.
53
+ // actual stack. Same gating as serializeError.
53
54
  const causeDetails = err.details === undefined && err.code === "internal_error" && err.cause instanceof Error
54
- ? process.env["NODE_ENV"] !== "production"
55
+ ? exposesCauseDetails()
55
56
  ? {
56
57
  causeName: err.cause.name,
57
58
  causeMessage: err.cause.message,
@@ -54,5 +54,12 @@ function extractIssueParams(issue) {
54
54
  if (bag[key] !== undefined)
55
55
  out[key] = bag[key];
56
56
  }
57
+ // A custom issue that overrides its i18n key also carries the values the text interpolates.
58
+ if (issue.code === "custom" && typeof issue.params?.["i18nKey"] === "string") {
59
+ for (const [key, value] of Object.entries(issue.params)) {
60
+ if (key !== "i18nKey" && value !== undefined)
61
+ out[key] = value;
62
+ }
63
+ }
57
64
  return Object.keys(out).length > 0 ? out : undefined;
58
65
  }
@@ -1,5 +1,6 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.ts";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.ts";
3
+ export { type DeleteRowsWithoutReplayableStreamArgs, deleteRowsWithoutReplayableStream, } from "../db/queries/event-store-orphan-rows.ts";
3
4
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.ts";
4
5
  export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.ts";
5
6
  export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.ts";
@@ -1,5 +1,6 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
3
+ export { deleteRowsWithoutReplayableStream, } from "../db/queries/event-store-orphan-rows.js";
3
4
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
5
  export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
6
  export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
@@ -0,0 +1,16 @@
1
+ import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.ts";
2
+ export declare function isActionFormScreenQn(value: string): boolean;
3
+ /** The files/images field `fieldName` of the registered actionForm screen `screenQn`, if any. */
4
+ export declare function resolveActionFormFileField(registry: Pick<Registry, "getScreen"> | undefined, screenQn: string, fieldName: string): AnyFileFieldDef | undefined;
5
+ export type ActionFormFileField = {
6
+ readonly screenQn: string;
7
+ readonly fieldName: string;
8
+ readonly fieldDef: AnyFileFieldDef;
9
+ };
10
+ export declare function listActionFormFileFields(registry: Pick<Registry, "getAllScreens"> | undefined): readonly ActionFormFileField[];
11
+ /**
12
+ * Whether `user` may submit the actionForm `screenQn`: the screen's own access (when set) and
13
+ * the access of its handler must both pass, so an upload is never accepted for a form the
14
+ * caller could not submit.
15
+ */
16
+ export declare function mayUseActionForm(registry: Pick<Registry, "getScreen" | "getWriteHandler">, screenQn: string, user: Pick<SessionUser, "roles">): boolean;
@@ -0,0 +1,45 @@
1
+ import { hasAccess } from "../engine/access.js";
2
+ import { isFileField, } from "../engine/types/index.js";
3
+ // Same character set as the other upload identifiers: the screen QN is stored as the pending
4
+ // upload's entityType and compared by exact match, never interpolated into a storage key.
5
+ const ACTION_FORM_SCREEN_QN = /^[A-Za-z0-9_-]+:screen:[A-Za-z0-9_-]+$/;
6
+ export function isActionFormScreenQn(value) {
7
+ return ACTION_FORM_SCREEN_QN.test(value);
8
+ }
9
+ function findActionFormScreen(registry, screenQn) {
10
+ if (!isActionFormScreenQn(screenQn))
11
+ return undefined;
12
+ const screen = registry.getScreen(screenQn);
13
+ return screen?.type === "actionForm" ? screen : undefined;
14
+ }
15
+ /** The files/images field `fieldName` of the registered actionForm screen `screenQn`, if any. */
16
+ export function resolveActionFormFileField(registry, screenQn, fieldName) {
17
+ const screen = registry && findActionFormScreen(registry, screenQn);
18
+ const field = screen?.fields[fieldName];
19
+ return field !== undefined && isFileField(field) ? field : undefined;
20
+ }
21
+ export function listActionFormFileFields(registry) {
22
+ const found = [];
23
+ for (const [screenQn, screen] of registry?.getAllScreens() ?? []) {
24
+ if (screen.type !== "actionForm")
25
+ continue;
26
+ for (const [fieldName, fieldDef] of Object.entries(screen.fields)) {
27
+ if (isFileField(fieldDef))
28
+ found.push({ screenQn, fieldName, fieldDef });
29
+ }
30
+ }
31
+ return found;
32
+ }
33
+ /**
34
+ * Whether `user` may submit the actionForm `screenQn`: the screen's own access (when set) and
35
+ * the access of its handler must both pass, so an upload is never accepted for a form the
36
+ * caller could not submit.
37
+ */
38
+ export function mayUseActionForm(registry, screenQn, user) {
39
+ const screen = findActionFormScreen(registry, screenQn);
40
+ if (screen === undefined)
41
+ return false;
42
+ if (screen.access !== undefined && !hasAccess(user, screen.access))
43
+ return false;
44
+ return hasAccess(user, registry.getWriteHandler(screen.handler)?.access);
45
+ }
@@ -2,6 +2,7 @@ import type { TenantDb } from "../db/tenant-db.ts";
2
2
  import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.ts";
3
3
  import { type FileRefRows } from "./load-save-file-refs.ts";
4
4
  export declare function fileRefIdsOf(value: unknown): readonly string[];
5
+ type FileRefRow = Readonly<Record<string, unknown>>;
5
6
  export declare function bindPendingFileRefs(args: {
6
7
  readonly registry: Registry;
7
8
  readonly result: SaveContext;
@@ -24,6 +25,11 @@ type PendingFileRefLookup = {
24
25
  readonly db: TenantDb;
25
26
  };
26
27
  export declare function loadPendingFileRef(lookup: PendingFileRefLookup): Promise<PendingFileRef | undefined>;
28
+ export type PendingFileRefBindTarget = {
29
+ readonly entityName: string;
30
+ readonly fieldName: string;
31
+ };
32
+ export declare function assertRowFitsBindTarget(registry: Registry | undefined, bindTo: PendingFileRefBindTarget, row: FileRefRow): void;
27
33
  export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
28
34
  /**
29
35
  * Binds one of the caller's own pending uploads to a record from a custom handler, with the
@@ -32,10 +38,18 @@ export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-confl
32
38
  * The helper checks the upload, not the target: the caller must already be authorized to write
33
39
  * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
34
40
  *
41
+ * `bindTo` moves the upload onto an entity file field of its own: a form upload is pending under
42
+ * the actionForm screen (entityName = the screen QN), and the handler decides which entity field
43
+ * keeps it. The row is re-tagged to `bindTo` and must fit that field's `accept`/`maxSize`
44
+ * (UnprocessableError otherwise); the field must be registered, which needs `registry`
45
+ * (the handler's `ctx.registry`), else InternalError.
46
+ *
35
47
  * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
36
48
  * winner has committed); anything else throws.
37
49
  */
38
50
  export declare function bindPendingFileRef(args: PendingFileRefLookup & {
39
51
  readonly entityId: string;
52
+ readonly bindTo?: PendingFileRefBindTarget;
53
+ readonly registry?: Registry;
40
54
  }): Promise<BindPendingFileRefOutcome>;
41
55
  export {};
@@ -1,9 +1,10 @@
1
1
  import { isAnonymousSessionUser } from "../engine/system-user.js";
2
2
  import { isFileField, isUuid, } from "../engine/types/index.js";
3
- import { reraiseAsKumikoError } from "../errors/index.js";
4
- import { getFileRefExecutor } from "./create-file-ref.js";
3
+ import { FrameworkReasons, InternalError, reraiseAsKumikoError, UnprocessableError, } from "../errors/index.js";
4
+ import { getFileRefExecutor, resolveAttachedField } from "./create-file-ref.js";
5
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
6
  import { loadFileRefRows } from "./load-save-file-refs.js";
7
+ import { validateFile } from "./types.js";
7
8
  export function fileRefIdsOf(value) {
8
9
  if (typeof value === "string")
9
10
  return isUuid(value) ? [value] : [];
@@ -21,10 +22,16 @@ function streamVersionOf(row) {
21
22
  return typeof row["version"] === "number" ? row["version"] : undefined;
22
23
  }
23
24
  async function bindPendingRow(args) {
24
- const { row, fileRefId, entityName, fieldName, entityId, user, db } = args;
25
+ const { row, fileRefId, entityName, fieldName, entityId, bindTo, user, db } = args;
25
26
  if (!isPendingUploadFor(row, entityName, fieldName, user))
26
27
  return { kind: "not-pending" };
27
- const bound = await getFileRefExecutor().update({ id: fileRefId, version: streamVersionOf(row), changes: { entityId } }, user, db);
28
+ const bound = await getFileRefExecutor().update({
29
+ id: fileRefId,
30
+ version: streamVersionOf(row),
31
+ changes: bindTo === undefined
32
+ ? { entityId }
33
+ : { entityId, entityType: bindTo.entityName, fieldName: bindTo.fieldName },
34
+ }, user, db);
28
35
  return bound.isSuccess ? { kind: "bound" } : { kind: "failed", error: bound.error };
29
36
  }
30
37
  // A form (or a server handler via createFileRefFromBytes) uploads before the
@@ -98,6 +105,31 @@ export async function loadPendingFileRef(lookup) {
98
105
  }
99
106
  return { id: lookup.fileRefId, storageKey, mimeType, size, version };
100
107
  }
108
+ // The pending upload was checked against the form field it came in through; a different target
109
+ // field may be stricter. Metadata only (bytes are not re-read): the extension comes from the
110
+ // storage key, whose suffix is the sniffed type, because fileName may be encrypted personal data.
111
+ export function assertRowFitsBindTarget(registry, bindTo, row) {
112
+ const target = resolveAttachedField(registry, bindTo.entityName, bindTo.fieldName);
113
+ if (target.kind !== "resolved") {
114
+ throw new InternalError({
115
+ message: `bindPendingFileRef: bindTo ${bindTo.entityName}.${bindTo.fieldName} is not a registered file field (pass the handler's ctx.registry)`,
116
+ });
117
+ }
118
+ const { storageKey, mimeType, size } = row;
119
+ if (typeof storageKey !== "string" || typeof mimeType !== "string" || typeof size !== "number") {
120
+ // A row without these can only come from corruption; letting it through would skip the check.
121
+ throw new InternalError({
122
+ message: `bindPendingFileRef: pending file_ref ${String(row["id"])} lacks storageKey, mimeType or size`,
123
+ });
124
+ }
125
+ const error = validateFile({ fileName: storageKey, mimeType, size }, { maxSize: target.fieldDef.maxSize, accept: target.fieldDef.accept });
126
+ if (error !== null) {
127
+ throw new UnprocessableError(FrameworkReasons.fileViolatesBindTarget, {
128
+ i18nKey: "errors.file.violatesBindTarget",
129
+ details: { error },
130
+ });
131
+ }
132
+ }
101
133
  const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
102
134
  /**
103
135
  * Binds one of the caller's own pending uploads to a record from a custom handler, with the
@@ -106,6 +138,12 @@ const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
106
138
  * The helper checks the upload, not the target: the caller must already be authorized to write
107
139
  * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
108
140
  *
141
+ * `bindTo` moves the upload onto an entity file field of its own: a form upload is pending under
142
+ * the actionForm screen (entityName = the screen QN), and the handler decides which entity field
143
+ * keeps it. The row is re-tagged to `bindTo` and must fit that field's `accept`/`maxSize`
144
+ * (UnprocessableError otherwise); the field must be registered, which needs `registry`
145
+ * (the handler's `ctx.registry`), else InternalError.
146
+ *
109
147
  * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
110
148
  * winner has committed); anything else throws.
111
149
  */
@@ -114,6 +152,8 @@ export async function bindPendingFileRef(args) {
114
152
  // Without a stream version the update would run unversioned and could overwrite a concurrent bind.
115
153
  if (row === undefined || streamVersionOf(row) === undefined)
116
154
  return "not-pending";
155
+ if (args.bindTo !== undefined)
156
+ assertRowFitsBindTarget(args.registry, args.bindTo, row);
117
157
  const outcome = await bindPendingRow({ ...args, row });
118
158
  switch (outcome.kind) {
119
159
  case "bound":
@@ -38,6 +38,9 @@ export type StoreFileInput = {
38
38
  readonly entityType?: string | undefined;
39
39
  readonly entityId?: string | undefined;
40
40
  readonly fieldName?: string | undefined;
41
+ /** Qualified name of a registered actionForm screen; `fieldName` then names one of its files
42
+ * fields and the upload is stored as pending (entityType = the screen QN, no entityId). */
43
+ readonly actionForm?: string | undefined;
41
44
  };
42
45
  export type StoreFileDeps = {
43
46
  readonly user: SessionUser;