@cosmicdrift/kumiko-framework 0.400.0 → 0.401.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -113,8 +113,9 @@ the field types reject a text field without that stance.
113
113
  `personal: false, reason: "is_business_data"` (a snake_case reason) for
114
114
  business data. For personal data name the subject, `personal: "self"`,
115
115
  `personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
116
- `find: "exact" | "fuzzy" | "none" | "secret"`. A plain foreign key to a user
117
- is `personal: "ref"`. `personal: "self"` is for the row that is the user.
116
+ `find: "exact" | "fuzzy" | "none" | "secret"`. A foreign key to a user
117
+ is `createReferenceField({ entity: "user:user" })`, which defaults to
118
+ `personal: "ref"`; `personal: false` on it fails at boot. `personal: "self"` is for the row that is the user.
118
119
  When the record itself is the person, such as a contact or a customer that
119
120
  is not a user, use `personal: { of: "id" }`; the entity then needs a uuid
120
121
  `id`. The framework encrypts personal fields per subject and
@@ -306,7 +307,7 @@ as bundled features.
306
307
  | Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
307
308
  | Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
308
309
  | Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
309
- | Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
310
+ | Relations between entities | `createReferenceField({ entity })` for the foreign key (user ids: `entity: "user:user"`), `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
310
311
  | Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
311
312
  | List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
312
313
  | A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
@@ -45,6 +45,7 @@ function createLongTextField<R extends true | false = false>(overrides: Partial<
45
45
  function createMoneyField<R extends true | false = false>(overrides?: Partial<Omit<MoneyFieldDef, "type" | "required">> & { required?: R; }): MoneyFieldDef & { required: R; };
46
46
  function createMultiSelectField<const TOptions extends string[]>(opts: { options: TOptions; } & Partial<Omit<MultiSelectFieldDef<TOptions>, "type" | "options" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): MultiSelectFieldDef<TOptions>; // Multi-select field — N values from a fixed option list.
47
47
  function createNumberField<R extends true | false = false>(overrides?: Partial<Omit<NumberFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): NumberFieldDef & { required: R; }; // Numeric field — `double precision` column by default (fractional values allowed end to end).
48
+ function createReferenceField<R extends true | false = false>(opts: { entity: string; } & Partial<Omit<ReferenceFieldDef, "type" | "entity" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): ReferenceFieldDef & { required: R; }; // Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
48
49
  function createRegistry(rawFeatures: FeatureDefinition[]): Registry;
49
50
  function createSeed(opts: CreateSeedOptions): ConfigSeedDef;
50
51
  function createSelectField<const TOptions extends string[], R extends true | false = false>(opts: { options: TOptions; } & Partial<Omit<SelectFieldDef<NoInfer<TOptions>>, "type" | "options" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): SelectFieldDef<TOptions> & { required: R; };
@@ -60,14 +61,14 @@ function createUserConfig<T extends ConfigKeyType>(type: T, opts?: ConfigKeyOpti
60
61
  function declareEscapeHatch(declaration: EscapeHatchDeclaration): void;
61
62
  function dedupeFeatures(features: FeatureDefinition[]): FeatureDefinition[];
62
63
  function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, table: ProjectionTable) => Promise<void>): SingleStreamApplyFn;
63
- function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
64
- function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
64
+ function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
65
+ function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
65
66
  function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
66
67
  function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
67
68
  function defineEntityQueryHandler(name: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
68
- function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
69
- function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
70
- function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
69
+ function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
70
+ function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
71
+ function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
71
72
  function defineFeature<const TName extends string, TExports = undefined>(name: TName, setup: (r: FeatureRegistrar<TName>) => TExports, settings?: DefineFeatureSettings): FeatureDefinition & { exports: TExports; };
72
73
  function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, ctx: MultiStreamApplyContext) => Promise<void>): MultiStreamApplyFn;
73
74
  function definePagedQueryHandler<const TName extends string, TSchema extends ZodType, TRow = unknown, TMap extends object = KumikoEventTypeMap>(def: QueryHandlerDefinition<TName, TSchema, CursorResult<TRow>, TMap>, ..._noSecretInResponse: true extends ContainsSecret<CursorResult<TRow>> ? [ secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field." ] : [ ]): PagedQueryHandlerDefinition<TName, TSchema, TRow, TMap>;
@@ -1713,6 +1714,7 @@ type ValidateBootOptions = {
1713
1714
  env?: Readonly<Record<string, string | undefined>>; // Env the encrypted-field master-key probe reads.
1714
1715
  entityFieldCipher?: EnvelopeCipher; // The cipher the runner injects after validation (built from the app's `masterKey` or the env keyring).
1715
1716
  requiredLocales?: string[]; // Locales every translation key must carry across all mounted features (locale packages count).
1717
+ requireFieldValueTranslations?: boolean; // Require translations for the values that entityList columns and entityEdit fields display: select/multiSelect option labels and the column headers and select options of embedded lists.
1716
1718
  };
1717
1719
  type ValidationError = {
1718
1720
  field: string;
@@ -13,6 +13,7 @@ function createRequestHelper(app: Hono, jwt: JwtHelper, options?: RequestHelperO
13
13
  function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb>;
14
14
  function createTestRedis(opts?: CreateTestRedisOptions): Promise<TestRedis>;
15
15
  function drainEventConsumers(stack: Pick<TestStack, "db" | "eventDispatcher">, consumerNames: [ string, ...string[] ], options?: { maxPasses?: number; }): Promise<void>;
16
+ function dropTestDb(baseUrl: string, dbName: string): Promise<void>;
16
17
  function pushEntityProjectionTables(stack: DbHolder, registry: Registry): Promise<void>; // Push all implicit-projection tables — one per `r.entity()` — that the registry knows about.
17
18
  function resetEventStore(stack: { db: unknown; eventDispatcher?: EventDispatcher; }, extraTables?: (unknown | string)[]): Promise<void>; // Wipes event store + framework-state + the given feature read-models in one TRUNCATE, then re-registers the event-consumer state rows.
18
19
  function setupTestStack(options: TestStackOptions): Promise<TestStack>;
@@ -903,6 +903,16 @@ type WorkflowTrigger = // Trigger configuration for a workflow.
903
903
  type NumericDerivedOp = Exclude<EmbeddedDerivedCellDef["op"], "inRange">;
904
904
  ```
905
905
 
906
+ ## packages/framework/src/engine/entity-handlers.ts
907
+
908
+ ```ts
909
+ type EntityWriteHandlerDef = WriteHandlerDef & { // The phase slots are always unset on entity handlers, so a consumer can spread one into its own handler and add typed phases without inheriting the slot types.
910
+ reserveBeforeTransaction?: undefined;
911
+ beforeTransaction?: undefined;
912
+ beforeTransactionWhenNested?: undefined;
913
+ };
914
+ ```
915
+
906
916
  ## packages/framework/src/engine/event-helpers.ts
907
917
 
908
918
  ```ts
@@ -3162,10 +3172,14 @@ type ReadGateDef = {
3162
3172
  entity: string;
3163
3173
  field: string;
3164
3174
  }; // Row readable only if at least one live row of `entity` with `field` = row.id is readable through that entity's own parentRef gate (host visibility).
3175
+ allLinks?: {
3176
+ entity: string;
3177
+ field: string;
3178
+ }; // Like `anyLink`, but every live link row must be readable, and there must be at least one.
3165
3179
  fileOwner?: {
3166
3180
  field: string;
3167
3181
  }; // Row readable only if the file_ref in `field` is live and readable through its owner: owner row readable (ownership, parentRef, handler gate) and the field read access on the file_ref's fieldName passes.
3168
- linkExemptRoles?: string[]; // Roles that skip `anyLink` (they also see unlinked rows).
3182
+ linkExemptRoles?: string[]; // Roles that skip `anyLink` / `allLinks` (they also see unlinked rows).
3169
3183
  };
3170
3184
  ```
3171
3185
 
package/agents/api.md CHANGED
@@ -8,13 +8,13 @@ Format: one line per function or constant, object and options types as property
8
8
 
9
9
  | Import path | File | Exports |
10
10
  | --- | --- | --- |
11
- | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 411 |
11
+ | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 412 |
12
12
  | `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
13
13
  | `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
14
14
  | `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
15
15
  | `@cosmicdrift/kumiko-framework/event-store` | `api/framework-event-store.md` | 38 |
16
16
  | `@cosmicdrift/kumiko-framework/seeding` | `api/framework-seeding.md` | 6 |
17
17
  | `@cosmicdrift/kumiko-renderer/web` | `api/renderer-web.md` | 298 |
18
- | `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` | 21 |
18
+ | `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` | 22 |
19
19
  | `@cosmicdrift/kumiko-testing/helpers` | `api/testing-helpers.md` | 71 |
20
20
  | `@cosmicdrift/kumiko-dev-server/testing` | `api/dev-server-testing.md` | 5 |
@@ -217,7 +217,12 @@ describe("cascade: deleting member removes tasks", () => {
217
217
 
218
218
  ```ts
219
219
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
220
- import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
220
+ import {
221
+ createEntity,
222
+ createReferenceField,
223
+ createTextField,
224
+ defineFeature,
225
+ } from "@cosmicdrift/kumiko-framework/engine";
221
226
 
222
227
  export const teamEntity = createEntity({
223
228
  table: "read_sample_teams",
@@ -230,7 +235,12 @@ export const memberEntity = createEntity({
230
235
  table: "read_sample_members",
231
236
  fields: {
232
237
  name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
233
- teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
238
+ teamId: createReferenceField({
239
+ entity: "team",
240
+ personal: false,
241
+ reason: "is_system_identifier",
242
+ required: true,
243
+ }),
234
244
  role: createTextField({ personal: false, reason: "is_business_data" }),
235
245
  },
236
246
  });
@@ -239,7 +249,7 @@ export const taskEntity = createEntity({
239
249
  table: "read_sample_member_tasks",
240
250
  fields: {
241
251
  title: createTextField({ personal: false, reason: "is_business_data", required: true }),
242
- memberId: createTextField({ personal: "ref", required: true }),
252
+ memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
243
253
  },
244
254
  });
245
255
 
@@ -20,7 +20,7 @@ export type AppPorts = {
20
20
  readonly infra?: AppInfraPorts;
21
21
  };
22
22
  /** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
23
- export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "warnOnUniqueAccessRoles">;
23
+ export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "requireFieldValueTranslations" | "warnOnUniqueAccessRoles">;
24
24
  export type AppConfig = {
25
25
  /** Features mounted in every environment: dev, tests, prod. */
26
26
  readonly features: readonly FeatureDefinition[];
@@ -42,7 +42,9 @@ function liveRowSql(entity, table, alias) {
42
42
  function isRoleExempt(gate, user) {
43
43
  return user.roles.some((role) => gate.linkExemptRoles?.includes(role) === true);
44
44
  }
45
- function anyLinkClause(input, link) {
45
+ // "any": one readable live link suffices. "all": additionally no live link may be unreadable;
46
+ // COALESCE keeps a NULL from the ownership SQL from counting as readable.
47
+ function linkClause(input, link, mode) {
46
48
  const { option, user, db, tableName } = input;
47
49
  const linkEntity = option.entities.get(link.entity);
48
50
  if (linkEntity?.parentRef === undefined)
@@ -56,18 +58,24 @@ function anyLinkClause(input, link) {
56
58
  if (linkOwnership.kind === "empty")
57
59
  return EMPTY;
58
60
  const sql = new GateSql();
59
- const conditions = [
61
+ const linkRowsOfThisRow = () => [
60
62
  `"${LINK_ALIAS}"."${physicalColumnName(linkTable, link.field)}"::text = "${tableName}"."${physicalColumnName(input.table, "id")}"::text`,
61
63
  ...tenantScopeSql(sql, db, linkTable, LINK_ALIAS),
62
64
  ...liveRowSql(linkEntity, linkTable, LINK_ALIAS),
63
65
  ];
66
+ const existsLink = (conditions) => `EXISTS (SELECT 1 FROM "${linkTableName}" AS "${LINK_ALIAS}" WHERE ${conditions.join(" AND ")})`;
67
+ const readable = linkRowsOfThisRow();
64
68
  if (linkOwnership.kind === "sql")
65
- conditions.push(sql.embed(linkOwnership));
66
- return {
67
- kind: "sql",
68
- sqlText: `EXISTS (SELECT 1 FROM "${linkTableName}" AS "${LINK_ALIAS}" WHERE ${conditions.join(" AND ")})`,
69
- params: sql.params,
70
- };
69
+ readable.push(sql.embed(linkOwnership));
70
+ let sqlText = existsLink(readable);
71
+ if (mode === "all" && linkOwnership.kind === "sql") {
72
+ const unreadable = [
73
+ ...linkRowsOfThisRow(),
74
+ `NOT COALESCE((${sql.embed(linkOwnership)}), FALSE)`,
75
+ ];
76
+ sqlText = `(${sqlText} AND NOT ${existsLink(unreadable)})`;
77
+ }
78
+ return { kind: "sql", sqlText, params: sql.params };
71
79
  }
72
80
  // Every registered entity with a file field can own a file. Join rows and gated entities
73
81
  // are left out: their readability is not resolvable without recursion.
@@ -178,8 +186,13 @@ export function buildReadGateClause(entity, table, tableName, user, db, option)
178
186
  return EMPTY;
179
187
  const input = { entity, table, tableName, user, db, option };
180
188
  let clause = PASS;
181
- if (gate.anyLink !== undefined && !isRoleExempt(gate, user)) {
182
- clause = combineClauses(clause, anyLinkClause(input, gate.anyLink));
189
+ if (!isRoleExempt(gate, user)) {
190
+ if (gate.anyLink !== undefined) {
191
+ clause = combineClauses(clause, linkClause(input, gate.anyLink, "any"));
192
+ }
193
+ if (gate.allLinks !== undefined) {
194
+ clause = combineClauses(clause, linkClause(input, gate.allLinks, "all"));
195
+ }
183
196
  }
184
197
  if (gate.fileOwner !== undefined) {
185
198
  clause = combineClauses(clause, fileOwnerClause(input, gate.fileOwner.field));
@@ -30,4 +30,9 @@ export type ValidateBootOptions = {
30
30
  * (locale packages count). Without it a key can ship in one language and
31
31
  * silently fall back at runtime for users of the others. */
32
32
  readonly requiredLocales?: readonly string[];
33
+ /** Require translations for the values that entityList columns and entityEdit
34
+ * fields display: select/multiSelect option labels and the column headers
35
+ * and select options of embedded lists. Without them the UI shows raw
36
+ * option values and sub-field names. Opt-in (default false). */
37
+ readonly requireFieldValueTranslations?: boolean;
33
38
  };
@@ -9,7 +9,7 @@ import { validateExtensionPreSaveWiring } from "./entity-handler.js";
9
9
  import { validateEntityListScreens } from "./entity-list-screens.js";
10
10
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
11
11
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
12
- import { validateI18nSurfaceKeys, validateRequiredLocales, validateTranslationLocaleConsistency, } from "./i18n-keys.js";
12
+ import { validateFieldValueTranslations, validateI18nSurfaceKeys, validateRequiredLocales, validateTranslationLocaleConsistency, } from "./i18n-keys.js";
13
13
  import { validateNavCycles } from "./nav.js";
14
14
  import { validateProjectionListScreens, validateRelatedListSectionQueries, } from "./projection-list-screens.js";
15
15
  import { validateQueryOutputColumns } from "./query-output-columns.js";
@@ -25,6 +25,10 @@ const APP_CHECKS_AFTER_I18N = [
25
25
  check: "validateRequiredLocales",
26
26
  run: (c) => validateRequiredLocales(c.features, c.options?.requiredLocales),
27
27
  },
28
+ {
29
+ check: "validateFieldValueTranslations",
30
+ run: (c) => validateFieldValueTranslations(c.features, c.options?.requireFieldValueTranslations),
31
+ },
28
32
  { check: "validateEntityListScreens", run: (c) => validateEntityListScreens(c.features) },
29
33
  {
30
34
  check: "validateSubmitPrefilledHiddenFields",
@@ -77,9 +77,19 @@ function assertFileSubField(where, sub) {
77
77
  throw new Error(`${where} has invalid maxSize "${sub.maxSize}". Use e.g. "10mb", "500kb".`);
78
78
  }
79
79
  }
80
- if (sub.accept?.some((ext) => ext.length === 0)) {
80
+ assertAcceptEntries(where, sub.accept);
81
+ }
82
+ // validateFile compares accept entries with the lower-cased last file-name segment, so only
83
+ // bare lower-case extensions can ever match.
84
+ const BARE_EXTENSION = /^[a-z0-9]+$/;
85
+ function assertAcceptEntries(where, accept) {
86
+ if (accept?.some((ext) => ext.length === 0)) {
81
87
  throw new Error(`${where} has an empty entry in accept. List file extensions like "pdf".`);
82
88
  }
89
+ const offender = accept?.find((entry) => !BARE_EXTENSION.test(entry));
90
+ if (offender !== undefined) {
91
+ throw new Error(`${where} has accept entry "${offender}", which can never match an uploaded file. accept takes bare lower-case file extensions without dot, MIME type or wildcard: use "pdf" instead of ".pdf" or "application/pdf".`);
92
+ }
83
93
  }
84
94
  function assertSelectSubFieldTones(where, sub) {
85
95
  // skip: only select sub-fields with declared tones have tones to check
@@ -168,6 +178,14 @@ function assertField(feature, entityName, entity, fieldName, field) {
168
178
  }
169
179
  if (field.type === "embedded")
170
180
  assertEmbeddedField(where, field);
181
+ if (isFileAcceptField(field))
182
+ assertAcceptEntries(where, field.accept);
183
+ }
184
+ function isFileAcceptField(field) {
185
+ return (field.type === "file" ||
186
+ field.type === "files" ||
187
+ field.type === "image" ||
188
+ field.type === "images");
171
189
  }
172
190
  export function validateFieldConditions(feature) {
173
191
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
@@ -2,3 +2,4 @@ import type { FeatureDefinition, Registry } from "../types/index.ts";
2
2
  export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[], registry: Registry): void;
3
3
  export declare function validateTranslationLocaleConsistency(features: readonly FeatureDefinition[]): void;
4
4
  export declare function validateRequiredLocales(features: readonly FeatureDefinition[], requiredLocales: readonly string[] | undefined): void;
5
+ export declare function validateFieldValueTranslations(features: readonly FeatureDefinition[], enabled: boolean | undefined): void;
@@ -1,4 +1,4 @@
1
- import { buildEffectiveTranslationKeys, featureHasI18nSurface, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
1
+ import { buildEffectiveTranslationKeys, featureHasI18nSurface, fieldValueTranslationKeysFromFeature, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
2
2
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "../build-config-feature-schema.js";
3
3
  import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins.js";
4
4
  function isDormantSecretsScreen(screen) {
@@ -100,3 +100,19 @@ export function validateRequiredLocales(features, requiredLocales) {
100
100
  throw new Error(`${lines.join("\n")}\nThe app requires locales [${requiredLocales.join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
101
101
  }
102
102
  }
103
+ export function validateFieldValueTranslations(features, enabled) {
104
+ // skip: opt-in rule, off unless the app enables requireFieldValueTranslations
105
+ if (enabled !== true)
106
+ return;
107
+ const defined = buildEffectiveTranslationKeys(features);
108
+ const missing = [];
109
+ for (const feature of features) {
110
+ for (const key of fieldValueTranslationKeysFromFeature(feature)) {
111
+ if (!hasDefinedTranslation(defined, key))
112
+ missing.push(` - ${feature.name}: ${key}`);
113
+ }
114
+ }
115
+ if (missing.length > 0) {
116
+ throw new Error(`[i18n] ${missing.length} select option / embedded-cell translation key(s) missing (requireFieldValueTranslations):\n${missing.join("\n")}\nAdd them via r.translations(...) in the owning feature.`);
117
+ }
118
+ }
@@ -1,5 +1,6 @@
1
1
  import { holdsFileRefs } from "../../files/file-field-slots.js";
2
2
  import { createFallbackLogger } from "../../logging/utils.js";
3
+ import { isUserEntityReference } from "../user-entity-reference.js";
3
4
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
4
5
  const log = createFallbackLogger("kumiko:boot");
5
6
  // Framework-managed timestamp fields may be used as retention.reference
@@ -134,6 +135,7 @@ export function validatePiiAndRetention(feature) {
134
135
  const FIELD_RULES = [
135
136
  rejectMultipleSubjectAnnotations,
136
137
  rejectTextWithoutPersonalStance,
138
+ rejectPlaintextUserReference,
137
139
  rejectInvalidLookupable,
138
140
  rejectSensitiveWithoutCiphertextAtRest,
139
141
  validateWriteOnlyField,
@@ -180,6 +182,16 @@ function rejectTextWithoutPersonalStance(input) {
180
182
  throw new Error(`${fieldLocation(input)} (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
181
183
  }
182
184
  }
185
+ function rejectPlaintextUserReference(input) {
186
+ const { field, flags } = input;
187
+ // skip: only a plaintext reference to the user entity is a violation
188
+ if (field.type !== "reference" || !flags.allowPlaintext)
189
+ return;
190
+ // skip: references to other entities may stay plaintext
191
+ if (!isUserEntityReference(field.entity))
192
+ return;
193
+ throw new Error(`${fieldLocation(input)} references the user entity "${field.entity}" with { personal: false } — a user id is a pseudonym that falls under Art.17 forget. Use createReferenceField({ entity: "${field.entity}" }) or { personal: "ref" }.`);
194
+ }
183
195
  // lookupable: only text fields with a subject annotation are supported. Without
184
196
  // a subject, the value remains plaintext, so a blind index would be a
185
197
  // second deterministic copy without any benefit.
@@ -263,8 +275,7 @@ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
263
275
  if (ownerField.type !== "reference")
264
276
  return;
265
277
  const refTarget = ownerField.entity;
266
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
267
- if (targetEntity !== "user") {
278
+ if (!isUserEntityReference(refTarget)) {
268
279
  log.warn(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
269
280
  }
270
281
  }
@@ -4,16 +4,16 @@ import { findEntity } from "./parent-ref.js";
4
4
  function fail(check, message) {
5
5
  throw new Error(`[Kumiko ReadGate] entity "${check.entityName}" (feature: "${check.feature.name}"): ${message}`);
6
6
  }
7
- function validateAnyLink(check, anyLink) {
8
- const link = findEntity(check.featureMap, anyLink.entity);
7
+ function validateLink(check, key, gateLink) {
8
+ const link = findEntity(check.featureMap, gateLink.entity);
9
9
  if (link === undefined) {
10
- fail(check, `readGate.anyLink.entity "${anyLink.entity}" is not registered.`);
10
+ fail(check, `readGate.${key}.entity "${gateLink.entity}" is not registered.`);
11
11
  }
12
12
  if (link.parentRef === undefined) {
13
- fail(check, `readGate.anyLink.entity "${anyLink.entity}" must declare a parentRef; its host visibility is what the gate checks.`);
13
+ fail(check, `readGate.${key}.entity "${gateLink.entity}" must declare a parentRef; its host visibility is what the gate checks.`);
14
14
  }
15
- if (link.fields[anyLink.field] === undefined) {
16
- fail(check, `readGate.anyLink.field "${anyLink.field}" is not a field of "${anyLink.entity}".`);
15
+ if (link.fields[gateLink.field] === undefined) {
16
+ fail(check, `readGate.${key}.field "${gateLink.field}" is not a field of "${gateLink.entity}".`);
17
17
  }
18
18
  }
19
19
  function validateFileOwner(check, fileOwner) {
@@ -45,8 +45,13 @@ export function validateReadGates(feature, featureMap) {
45
45
  if (entity.parentRef !== undefined) {
46
46
  fail(check, "readGate and parentRef cannot be combined; a gated entity cannot be a join row.");
47
47
  }
48
+ if (gate.anyLink !== undefined && gate.allLinks !== undefined) {
49
+ fail(check, "readGate declares both anyLink and allLinks; pick one.");
50
+ }
48
51
  if (gate.anyLink !== undefined)
49
- validateAnyLink(check, gate.anyLink);
52
+ validateLink(check, "anyLink", gate.anyLink);
53
+ if (gate.allLinks !== undefined)
54
+ validateLink(check, "allLinks", gate.allLinks);
50
55
  if (gate.fileOwner !== undefined)
51
56
  validateFileOwner(check, gate.fileOwner);
52
57
  validateNoHandWrittenReaders(check);
@@ -59,12 +59,19 @@ export declare const entityListSchema: z.ZodObject<{
59
59
  }, z.core.$strip>>>;
60
60
  }, z.core.$strip>>;
61
61
  }, z.core.$strip>;
62
- export declare function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
62
+ /** The phase slots are always unset on entity handlers, so a consumer can spread one into its own
63
+ * handler and add typed phases without inheriting the slot types. */
64
+ export type EntityWriteHandlerDef = WriteHandlerDef & {
65
+ readonly reserveBeforeTransaction?: undefined;
66
+ readonly beforeTransaction?: undefined;
67
+ readonly beforeTransactionWhenNested?: undefined;
68
+ };
69
+ export declare function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
63
70
  export declare function defineEntityQueryHandler(name: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
64
- export declare function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
65
- export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
66
- export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
67
- export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
71
+ export declare function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
72
+ export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
73
+ export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
74
+ export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
68
75
  export declare function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
69
76
  export declare function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
70
77
  export declare function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): {
@@ -1,4 +1,4 @@
1
- import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TimestampFieldDef, TzFieldDef } from "@cosmicdrift/kumiko-types/fields";
1
+ import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TimestampFieldDef, TzFieldDef } from "@cosmicdrift/kumiko-types/fields";
2
2
  import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
3
3
  import type { TextFieldDef } from "./types/index.ts";
4
4
  export declare function createTextField<R extends true | false = false>(overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotations & {
@@ -40,6 +40,18 @@ export declare function createSelectField<const TOptions extends readonly string
40
40
  }): SelectFieldDef<TOptions> & {
41
41
  required: R;
42
42
  };
43
+ /**
44
+ * Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
45
+ * A reference to the user entity defaults to `personal: "ref"`; `personal: false` is
46
+ * rejected there because a user id is personal data.
47
+ */
48
+ export declare function createReferenceField<R extends true | false = false>(opts: {
49
+ entity: string;
50
+ } & Partial<Omit<ReferenceFieldDef, "type" | "entity" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
51
+ required?: R;
52
+ }): ReferenceFieldDef & {
53
+ required: R;
54
+ };
43
55
  /**
44
56
  * Multi-select field — N values from a fixed option list.
45
57
  *
@@ -1,4 +1,5 @@
1
1
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { isUserEntityReference } from "./user-entity-reference.js";
2
3
  // `personal: { of: "id" }` means the row itself is the subject, not a
3
4
  // referenced user — resolves to `recordOwned` instead of `userOwned`.
4
5
  const RECORD_OWNER_FIELD = "id";
@@ -167,6 +168,23 @@ export function createSelectField(opts) {
167
168
  ...expandPersonalAnnotations(opts),
168
169
  }; // @cast-boundary engine-payload
169
170
  }
171
+ /**
172
+ * Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
173
+ * A reference to the user entity defaults to `personal: "ref"`; `personal: false` is
174
+ * rejected there because a user id is personal data.
175
+ */
176
+ export function createReferenceField(opts) {
177
+ const targetsUser = isUserEntityReference(opts.entity);
178
+ if (targetsUser && opts.personal === false) {
179
+ throw new Error(`createReferenceField({ entity: "${opts.entity}" }): personal: false is not allowed for a reference to the user entity — a user id is personal data. Drop the annotation or use { personal: "ref" }.`);
180
+ }
181
+ const withUserDefault = targetsUser && opts.personal === undefined ? { ...opts, personal: "ref" } : opts;
182
+ return {
183
+ type: "reference",
184
+ required: false,
185
+ ...expandPersonalAnnotations(withUserDefault),
186
+ }; // @cast-boundary engine-payload
187
+ }
170
188
  /**
171
189
  * Multi-select field — N values from a fixed option list.
172
190
  *
@@ -57,7 +57,7 @@ export type { StorageProviderDestroyTenantHook, StorageProviderHookCtx, } from "
57
57
  export { isTenantDataExtensionHooks, type TenantDataDestroyHook, type TenantDataHookCtx, } from "./extensions/tenant-data.ts";
58
58
  export { isTenantResourceExtensionHooks, type TenantDestroyHookResult, type TenantResourceDestroyHook, type TenantResourceHookCtx, } from "./extensions/tenant-resource.ts";
59
59
  export type { TenantUserModel, UserDataDeleteHook, UserDataDeleteStrategy, UserDataExportHook, UserDataExportSnippet, UserDataHookCtx, UserDataStorageProvider, } from "./extensions/user-data.ts";
60
- export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.ts";
60
+ export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createReferenceField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.ts";
61
61
  export { type ChangelogEntry, compareVersions, filterEntriesAfter, parseFeatureChangelog, } from "./feature-changelog.ts";
62
62
  export { buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.ts";
63
63
  export { filterReadFields, maskWriteOnlyFields, } from "./field-access.ts";
@@ -36,7 +36,7 @@ export { extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginId
36
36
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
37
37
  export { isTenantDataExtensionHooks, } from "./extensions/tenant-data.js";
38
38
  export { isTenantResourceExtensionHooks, } from "./extensions/tenant-resource.js";
39
- export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
39
+ export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createReferenceField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
40
40
  export { compareVersions, filterEntriesAfter, parseFeatureChangelog, } from "./feature-changelog.js";
41
41
  export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
42
42
  export { filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
@@ -0,0 +1,2 @@
1
+ /** True when a reference field's `entity` ("user" or "<feature>:user") targets the user entity. */
2
+ export declare function isUserEntityReference(referenceTarget: string): boolean;
@@ -0,0 +1,7 @@
1
+ /** True when a reference field's `entity` ("user" or "<feature>:user") targets the user entity. */
2
+ export function isUserEntityReference(referenceTarget) {
3
+ const entityName = referenceTarget.includes(":")
4
+ ? referenceTarget.split(":")[1]
5
+ : referenceTarget;
6
+ return entityName === "user";
7
+ }
@@ -20,6 +20,8 @@ export { createInMemoryFileProvider } from "./in-memory-provider.ts";
20
20
  export { createLocalProvider } from "./local-provider.ts";
21
21
  export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, } from "./provider-resolver.ts";
22
22
  export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.ts";
23
+ export type { FileRefOwner, RebindFileRefContext, RebindFileRefOutcome, } from "./rebind-file-ref.ts";
24
+ export { rebindFileRef } from "./rebind-file-ref.ts";
23
25
  export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
24
26
  export type { FileStorageProvider, SignedUrlOptions, } from "./types.ts";
25
27
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.ts";
@@ -15,6 +15,7 @@ export { configuredMaxUploadBytes, readFilesRouteOptions, resolveMaxUploadBodyBy
15
15
  export { createInMemoryFileProvider } from "./in-memory-provider.js";
16
16
  export { createLocalProvider } from "./local-provider.js";
17
17
  export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.js";
18
+ export { rebindFileRef } from "./rebind-file-ref.js";
18
19
  export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
19
20
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.js";
20
21
  export { fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
@@ -0,0 +1,41 @@
1
+ import type { TenantDb } from "../db/tenant-db.ts";
2
+ import type { Registry, SessionUser } from "../engine/types/index.ts";
3
+ import { type CanReadFileRefContext } from "./can-caller-read-file-ref.ts";
4
+ export type RebindFileRefContext = {
5
+ readonly db: TenantDb;
6
+ readonly registry: Registry;
7
+ readonly user: SessionUser;
8
+ readonly dispatch?: CanReadFileRefContext["dispatch"];
9
+ };
10
+ export type FileRefOwner = {
11
+ readonly entityType: string;
12
+ readonly entityId: string;
13
+ readonly fieldName: string;
14
+ };
15
+ export type RebindFileRefOutcome = "rebound" | "not-found" | "moved" | "version-conflict";
16
+ /**
17
+ * Moves a bound file from one record's file field to another's, for a record that takes over a
18
+ * file uploaded elsewhere (an upload record, a mail attachment). Server code only; pending uploads
19
+ * go through bindPendingFileRef.
20
+ *
21
+ * Only files of the caller's tenant are found ("not-found" otherwise, as for unknown ids). The
22
+ * file must still sit at `from`; "moved" means it does not (any more), for example because a
23
+ * parallel rebind took it first. A file already at `to` answers "rebound" without a write. The
24
+ * target must be a registered file field whose accept/maxSize the file fits (InternalError /
25
+ * UnprocessableError). Unless the caller is the framework system user, the caller must be able
26
+ * to read the file and to write both the source record and field (it must not be locked, and an
27
+ * entity without update handler leaves the move to the system user) and the target record and
28
+ * field (AccessDeniedError, RecordLockedError); the system user skips these checks, and only the
29
+ * target record has to exist (NotFoundError). The update is checked against the file's stream
30
+ * version read here, so a bind committing in between returns "version-conflict"; the source row
31
+ * stays locked until the surrounding transaction commits, so a lock set on `from` in between is
32
+ * either seen or waits. Rebind first, then save the id into
33
+ * the target field: the save accepts a file already bound to that record, and the source record
34
+ * no longer disposes of it when its field is cleared or the record is deleted.
35
+ * A handler passes its ctx; a job passes `{ db, registry, user: ctx.systemUser }`.
36
+ */
37
+ export declare function rebindFileRef(ctx: RebindFileRefContext, args: {
38
+ readonly fileRefId: string;
39
+ readonly from: FileRefOwner;
40
+ readonly to: FileRefOwner;
41
+ }): Promise<RebindFileRefOutcome>;
@@ -0,0 +1,170 @@
1
+ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { isFrameworkSystemUser } from "../db/lock-guard.js";
3
+ import { selectRowForUpdateById } from "../db/queries/entity-read.js";
4
+ import { asEntityTableMeta } from "../db/table-introspection.js";
5
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
6
+ import { RecordLockedError } from "../errors/classes.js";
7
+ import { AccessDeniedError, InternalError, NotFoundError, reraiseAsKumikoError, } from "../errors/index.js";
8
+ import { assertRowFitsBindTarget } from "./bind-pending-file-refs.js";
9
+ import { canCallerReadFileRef } from "./can-caller-read-file-ref.js";
10
+ import { getFileRefExecutor } from "./create-file-ref.js";
11
+ import { loadFileRefRows } from "./load-save-file-refs.js";
12
+ import { createRecordReadCheck } from "./record-read-check.js";
13
+ import { createRecordWriteCheck, findImplicitEntityTable, RecordWriteDecisions, } from "./record-write-check.js";
14
+ const CONCURRENT_WRITE_ERROR_CODES = ["version_conflict", "unique_violation"];
15
+ function nullableString(value) {
16
+ return value === null || typeof value === "string";
17
+ }
18
+ // The file guard (and an app's accessGuard) reads these columns; a row missing one is corrupt.
19
+ async function loadRow(ctx, fileRefId) {
20
+ if (!isUuid(fileRefId))
21
+ return undefined;
22
+ const row = (await loadFileRefRows(ctx.db, [fileRefId], ctx.user.tenantId)).get(fileRefId);
23
+ if (row === undefined)
24
+ return undefined;
25
+ const { tenantId, storageKey, fileName, mimeType, size, entityType, entityId, fieldName } = row;
26
+ const { insertedById, version } = row;
27
+ if (typeof tenantId !== "string" ||
28
+ typeof storageKey !== "string" ||
29
+ typeof fileName !== "string" ||
30
+ typeof mimeType !== "string" ||
31
+ typeof size !== "number" ||
32
+ typeof version !== "number" ||
33
+ !nullableString(entityType) ||
34
+ !nullableString(entityId) ||
35
+ !nullableString(fieldName) ||
36
+ !nullableString(insertedById)) {
37
+ return undefined;
38
+ }
39
+ return {
40
+ fileRef: {
41
+ id: fileRefId,
42
+ tenantId,
43
+ storageKey,
44
+ fileName,
45
+ mimeType,
46
+ size,
47
+ entityType,
48
+ entityId,
49
+ fieldName,
50
+ insertedById,
51
+ },
52
+ version,
53
+ };
54
+ }
55
+ function isOwnedBy(fileRef, owner) {
56
+ return (fileRef.entityType === owner.entityType &&
57
+ fileRef.entityId === owner.entityId &&
58
+ fileRef.fieldName === owner.fieldName);
59
+ }
60
+ // Holds the source row until commit, so a lock or ownership change committed by another
61
+ // transaction is either seen by the write check below or waits for this rebind to finish.
62
+ async function lockSourceRow(ctx, from) {
63
+ const table = findImplicitEntityTable(ctx.registry, from.entityType);
64
+ const tableName = asEntityTableMeta(table)?.tableName;
65
+ // skip: no stored row to lock; the write check below answers not_found for it
66
+ if (tableName === undefined)
67
+ return;
68
+ await selectRowForUpdateById(tenantDbRunner(ctx.db), tableName, from.entityId);
69
+ }
70
+ // A caller other than the system job must be able to read the file today and must be able to
71
+ // write both records as a save of them would: the source it takes the file from (unlocked, and
72
+ // with an update handler the caller passes; without one only the system job may move the file),
73
+ // and the target field it puts the file into.
74
+ async function assertCallerMayMove(ctx, fileRef, from, to) {
75
+ if (!(await canCallerReadFileRef(ctx, fileRef))) {
76
+ throw new AccessDeniedError({ message: `rebindFileRef: file ${fileRef.id} is not readable` });
77
+ }
78
+ const canWriteRecord = createRecordWriteCheck({
79
+ registry: ctx.registry,
80
+ canReadRecord: createRecordReadCheck(ctx.registry, (qn, payload) => ctx.dispatch.query(qn, payload)),
81
+ tenantDbFor: () => ctx.db,
82
+ });
83
+ await lockSourceRow(ctx, from);
84
+ const sourceDecision = await canWriteRecord({ ...from, user: ctx.user });
85
+ if (sourceDecision === RecordWriteDecisions.locked) {
86
+ throw new RecordLockedError({ entityName: from.entityType, entityId: from.entityId });
87
+ }
88
+ if (sourceDecision !== RecordWriteDecisions.writable) {
89
+ throw new AccessDeniedError({
90
+ message: `rebindFileRef: source ${from.entityType}.${from.fieldName} of ${from.entityId} is not writable`,
91
+ details: { decision: sourceDecision },
92
+ });
93
+ }
94
+ const decision = await canWriteRecord({ ...to, user: ctx.user });
95
+ if (decision !== RecordWriteDecisions.writable) {
96
+ throw new AccessDeniedError({
97
+ message: `rebindFileRef: ${to.entityType}.${to.fieldName} of ${to.entityId} is not writable`,
98
+ details: { decision },
99
+ });
100
+ }
101
+ }
102
+ // The caller checks find the target as a side effect; the system path needs its own lookup so a
103
+ // job cannot point a file at a record that does not exist in its tenant.
104
+ async function assertTargetRecordExists(ctx, to) {
105
+ const entity = ctx.registry.getEntity(to.entityType);
106
+ const table = findImplicitEntityTable(ctx.registry, to.entityType);
107
+ const row = entity === undefined || table === undefined
108
+ ? undefined
109
+ : await ctx.db.fetchOne(table, {
110
+ id: to.entityId,
111
+ ...(entity.softDelete === true && { isDeleted: false }),
112
+ });
113
+ if (row === undefined)
114
+ throw new NotFoundError(to.entityType, to.entityId);
115
+ }
116
+ /**
117
+ * Moves a bound file from one record's file field to another's, for a record that takes over a
118
+ * file uploaded elsewhere (an upload record, a mail attachment). Server code only; pending uploads
119
+ * go through bindPendingFileRef.
120
+ *
121
+ * Only files of the caller's tenant are found ("not-found" otherwise, as for unknown ids). The
122
+ * file must still sit at `from`; "moved" means it does not (any more), for example because a
123
+ * parallel rebind took it first. A file already at `to` answers "rebound" without a write. The
124
+ * target must be a registered file field whose accept/maxSize the file fits (InternalError /
125
+ * UnprocessableError). Unless the caller is the framework system user, the caller must be able
126
+ * to read the file and to write both the source record and field (it must not be locked, and an
127
+ * entity without update handler leaves the move to the system user) and the target record and
128
+ * field (AccessDeniedError, RecordLockedError); the system user skips these checks, and only the
129
+ * target record has to exist (NotFoundError). The update is checked against the file's stream
130
+ * version read here, so a bind committing in between returns "version-conflict"; the source row
131
+ * stays locked until the surrounding transaction commits, so a lock set on `from` in between is
132
+ * either seen or waits. Rebind first, then save the id into
133
+ * the target field: the save accepts a file already bound to that record, and the source record
134
+ * no longer disposes of it when its field is cleared or the record is deleted.
135
+ * A handler passes its ctx; a job passes `{ db, registry, user: ctx.systemUser }`.
136
+ */
137
+ export async function rebindFileRef(ctx, args) {
138
+ const { fileRefId, from, to } = args;
139
+ const loaded = await loadRow(ctx, fileRefId);
140
+ if (loaded === undefined)
141
+ return "not-found";
142
+ const { fileRef, version } = loaded;
143
+ if (isOwnedBy(fileRef, to))
144
+ return "rebound";
145
+ if (!isOwnedBy(fileRef, from))
146
+ return "moved";
147
+ assertRowFitsBindTarget(ctx.registry, { entityName: to.entityType, fieldName: to.fieldName }, fileRef);
148
+ if (isFrameworkSystemUser(ctx.user)) {
149
+ await assertTargetRecordExists(ctx, to);
150
+ }
151
+ else {
152
+ const { dispatch } = ctx;
153
+ if (dispatch === undefined) {
154
+ throw new InternalError({
155
+ message: "rebindFileRef: a caller other than the system user needs ctx.dispatch.query",
156
+ });
157
+ }
158
+ await assertCallerMayMove({ ...ctx, dispatch }, fileRef, from, to);
159
+ }
160
+ const moved = await getFileRefExecutor().update({
161
+ id: fileRefId,
162
+ version,
163
+ changes: { entityType: to.entityType, entityId: to.entityId, fieldName: to.fieldName },
164
+ }, ctx.user, ctx.db);
165
+ if (moved.isSuccess)
166
+ return "rebound";
167
+ if (CONCURRENT_WRITE_ERROR_CODES.includes(moved.error.code))
168
+ return "version-conflict";
169
+ throw reraiseAsKumikoError(moved.error);
170
+ }
@@ -56,3 +56,9 @@ export type RequiredLocaleGap = {
56
56
  * reported as gap owners.
57
57
  */
58
58
  export declare function findRequiredLocaleGaps(features: readonly FeatureDefinition[], requiredLocales: readonly string[]): readonly RequiredLocaleGap[];
59
+ export declare function embeddedCellLabelKey(featureName: string, entityName: string, fieldName: string, subFieldName: string): string;
60
+ export declare function embeddedCellOptionLabelKey(featureName: string, entityName: string, fieldName: string, subFieldName: string, value: string): string;
61
+ /** Keys for select option labels and embedded-list cells that the screens of one feature
62
+ * display. Opt-in via `requireFieldValueTranslations`: without them the renderer falls back
63
+ * to the raw option value or sub-field name. */
64
+ export declare function fieldValueTranslationKeysFromFeature(feature: FeatureDefinition): readonly string[];
@@ -596,3 +596,64 @@ export function findRequiredLocaleGaps(features, requiredLocales) {
596
596
  }
597
597
  return gaps;
598
598
  }
599
+ export function embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName) {
600
+ return `${fieldLabelKey(featureName, entityName, fieldName)}:cell:${subFieldName}`;
601
+ }
602
+ export function embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value) {
603
+ return `${embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName)}:option:${value}`;
604
+ }
605
+ function pushFieldValueTranslationKeys(out, featureName, entityName, fieldName, def, includeEmbeddedCells) {
606
+ if (def.type === "select" || def.type === "multiSelect") {
607
+ for (const value of def.options) {
608
+ out.add(selectFacetOptionKey(featureName, entityName, fieldName, value));
609
+ }
610
+ // skip: option keys are complete, nothing else to collect for select fields
611
+ return;
612
+ }
613
+ // skip: only multi-row embedded fields render cell labels
614
+ if (!includeEmbeddedCells || def.type !== "embedded" || def.multiple !== true)
615
+ return;
616
+ for (const [subName, sub] of Object.entries(def.schema)) {
617
+ out.add(embeddedCellLabelKey(featureName, entityName, fieldName, subName));
618
+ if (sub.type !== "select")
619
+ continue;
620
+ for (const value of sub.options) {
621
+ out.add(embeddedCellOptionLabelKey(featureName, entityName, fieldName, subName, value));
622
+ }
623
+ }
624
+ }
625
+ /** Keys for select option labels and embedded-list cells that the screens of one feature
626
+ * display. Opt-in via `requireFieldValueTranslations`: without them the renderer falls back
627
+ * to the raw option value or sub-field name. */
628
+ export function fieldValueTranslationKeysFromFeature(feature) {
629
+ const out = new Set();
630
+ for (const screen of Object.values(feature.screens)) {
631
+ if (screen.type === "entityList") {
632
+ const entity = feature.entities?.[screen.entity];
633
+ if (!entity)
634
+ continue;
635
+ for (const col of screen.columns) {
636
+ const { field } = normalizeListColumn(col);
637
+ const def = entity.fields[field];
638
+ if (def)
639
+ pushFieldValueTranslationKeys(out, feature.name, screen.entity, field, def, false);
640
+ }
641
+ }
642
+ else if (screen.type === "entityEdit") {
643
+ const entity = feature.entities?.[screen.entity];
644
+ if (!entity)
645
+ continue;
646
+ for (const section of screen.layout.sections) {
647
+ if (isExtensionEditSection(section) || section.kind === "relatedList")
648
+ continue;
649
+ for (const spec of sectionFieldSpecs(section)) {
650
+ const field = editFieldName(spec);
651
+ const def = entity.fields[field];
652
+ if (def)
653
+ pushFieldValueTranslationKeys(out, feature.name, screen.entity, field, def, true);
654
+ }
655
+ }
656
+ }
657
+ }
658
+ return [...out];
659
+ }
@@ -41,8 +41,10 @@ export function parseValidateBootOptions(raw) {
41
41
  const navAllowlistExempt = readOptionalOption(raw, "navAllowlistExempt", isStringSet, "a Set<string>");
42
42
  const warnOnUniqueAccessRoles = readOptionalOption(raw, "warnOnUniqueAccessRoles", isBoolean, "a boolean");
43
43
  const requiredLocales = readOptionalOption(raw, "requiredLocales", isNonEmptyStringArray, "an array of non-empty strings");
44
+ const requireFieldValueTranslations = readOptionalOption(raw, "requireFieldValueTranslations", isBoolean, "a boolean");
44
45
  return {
45
46
  ...(requiredLocales !== undefined && { requiredLocales }),
47
+ ...(requireFieldValueTranslations !== undefined && { requireFieldValueTranslations }),
46
48
  ...(navAllowlist !== undefined && { navAllowlist }),
47
49
  ...(navAllowlistExempt !== undefined && { navAllowlistExempt }),
48
50
  ...(warnOnUniqueAccessRoles !== undefined && { warnOnUniqueAccessRoles }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.400.0",
3
+ "version": "0.401.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,7 +203,7 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.400.0",
206
+ "@cosmicdrift/kumiko-types": "0.401.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "hono": "^4.13.14",
209
209
  "i18next": "^26.1.0",
@@ -225,7 +225,7 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.400.0",
228
+ "@cosmicdrift/kumiko-testing": "0.401.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.401.0",
4
+ "type": "improvement",
5
+ "title": "rebindFileRef, readGate allLinks with documents linkReadMode, documents tab entity fix",
6
+ "detail": "`rebindFileRef(ctx, { fileRefId, from, to })` from `@cosmicdrift/kumiko-framework/files` moves a file that is already bound (for example to an upload record or an inbound mail) onto another record's file field, for server code where a record takes over that file; pending uploads keep using `bindPendingFileRef`. It finds only files of the caller's tenant (\"not-found\" otherwise), answers \"moved\" when the file no longer sits at `from`, \"rebound\" when it was moved or already sits at `to`, and \"version-conflict\" when a parallel bind committed first, because the update is checked against the stream version. The target must be a registered file field whose accept and maxSize the file fits. A caller other than the framework system user must be able to read the file and must be able to write both the source record and field (not locked; an entity without update handler leaves the move to the system user) and the target record and field; the source row stays locked until the transaction commits, so a lock set in between is either seen or waits; a system job (`{ db, registry, user: ctx.systemUser }`) skips these checks, but the target record must exist. Rebind first, then save the id into the target field; clearing or deleting the source record afterwards leaves the file alone. An entity `readGate` takes `allLinks` as an alternative to `anyLink`: a row with links is visible only when the viewer can read every live link, and the boot validator rejects a gate that declares both. `createDocumentsFeature({ linkReadMode: \"allLinks\" })` switches the documents gate to it; the default stays `anyLink`, and the file-owner part is unchanged. `documentsTab()` now references `documents:filed-document`; before, a projection screen using it failed boot after the entity rename. Linking a document to a target entity without an update handler is now allowed for the roles in that entity's `access.write` (plus its row-level write rule); a target with neither stays unlinkable. `documents:write:link` and `documents:write:unlink` have title and field-label translations (de, en), and the documents write handlers carry an explicit `agent.risk` (link, unlink, create, update \"mid\"; import \"high\"). `createDocumentsFeature({ categoryLabels })` takes a de/en label per category (an unlabelled one shows its raw value); a key that is not in `categories` throws."
7
+ },
8
+ {
9
+ "version": "0.401.0",
10
+ "type": "breaking",
11
+ "title": "Boot rejects a user reference with personal: false and file accept entries that never match",
12
+ "detail": "`createReferenceField({ entity, personal?, reason?, required? })` builds a foreign key; a target ending in `:user` defaults to `personal: \"ref\"` and throws on `personal: false`. The boot validator now rejects a reference field to the user entity that carries `personal: false`, and a file, files, image or images field (also inside embedded schemas) whose `accept` has an entry that is not a bare lower-case extension, because the upload check compares the file's extension only. The new boot option `requireFieldValueTranslations: true` (also readable from the schema CLI input) lists every missing translation of select/multiSelect options and embedded cell labels shown by entityList/entityEdit screens in one error. `defineEntityWriteHandler` and the create/update/delete/restore wrappers return a type whose `reserveBeforeTransaction`, `beforeTransaction` and `beforeTransactionWhenNested` are `undefined`, so a handler spread into `defineWriteHandler` can add its own. user-data-rights has the export-job status option labels (de, en, es, fr, it); managed-pages branding accepts `png, jpg, jpeg, webp` instead of MIME types.",
13
+ "migration": "Replace `personal: false` on a reference to the user entity with `createReferenceField({ entity: \"user:user\" })` or `personal: \"ref\"`. Write `accept` entries as bare extensions (`\"pdf\"`, not `\".pdf\"` or `\"application/pdf\"`)."
14
+ },
2
15
  {
3
16
  "version": "0.400.0",
4
17
  "type": "improvement",