@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 +4 -3
- package/agents/api/framework-engine.md +7 -5
- package/agents/api/testing-stack.md +1 -0
- package/agents/api/types.md +15 -1
- package/agents/api.md +2 -2
- package/agents/examples/relations.md +13 -3
- package/dist/config/index.d.ts +1 -1
- package/dist/db/read-gate-clause.js +23 -10
- package/dist/engine/boot-validator/boot-options.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +5 -1
- package/dist/engine/boot-validator/field-conditions.js +19 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
- package/dist/engine/boot-validator/i18n-keys.js +17 -1
- package/dist/engine/boot-validator/pii-retention.js +13 -2
- package/dist/engine/boot-validator/read-gate.js +12 -7
- package/dist/engine/entity-handlers.d.ts +12 -5
- package/dist/engine/factories.d.ts +13 -1
- package/dist/engine/factories.js +18 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/user-entity-reference.d.ts +2 -0
- package/dist/engine/user-entity-reference.js +7 -0
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/rebind-file-ref.d.ts +41 -0
- package/dist/files/rebind-file-ref.js +170 -0
- package/dist/i18n/required-surface-keys.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +61 -0
- package/dist/schema-cli/validate-boot-input.js +2 -0
- package/package.json +3 -3
- package/src/changes.json +13 -0
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
|
|
117
|
-
is `
|
|
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):
|
|
64
|
-
function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
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):
|
|
69
|
-
function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
70
|
-
function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
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>;
|
package/agents/api/types.md
CHANGED
|
@@ -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` |
|
|
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` |
|
|
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 {
|
|
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:
|
|
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:
|
|
252
|
+
memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
|
|
243
253
|
},
|
|
244
254
|
});
|
|
245
255
|
|
package/dist/config/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
const link = findEntity(check.featureMap,
|
|
7
|
+
function validateLink(check, key, gateLink) {
|
|
8
|
+
const link = findEntity(check.featureMap, gateLink.entity);
|
|
9
9
|
if (link === undefined) {
|
|
10
|
-
fail(check, `readGate.
|
|
10
|
+
fail(check, `readGate.${key}.entity "${gateLink.entity}" is not registered.`);
|
|
11
11
|
}
|
|
12
12
|
if (link.parentRef === undefined) {
|
|
13
|
-
fail(check, `readGate.
|
|
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[
|
|
16
|
-
fail(check, `readGate.
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
65
|
-
export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
66
|
-
export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
67
|
-
export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
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
|
*
|
package/dist/engine/factories.js
CHANGED
|
@@ -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
|
*
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/engine/index.js
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/files/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/files/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|