@cosmicdrift/kumiko-framework 0.399.0 → 0.400.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/agents/api/framework-engine.md +9 -3
  2. package/agents/api/framework-ui-types.md +10 -5
  3. package/agents/api/renderer-web.md +2 -1
  4. package/agents/api/types.md +242 -80
  5. package/agents/api.md +2 -2
  6. package/agents/examples/derived-fields-sql.md +251 -0
  7. package/agents/examples/documents.md +161 -0
  8. package/agents/examples/inspection-checklist.md +807 -0
  9. package/agents/examples.md +3 -0
  10. package/agents/features-index.md +2 -0
  11. package/agents/features.md +8 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +46 -2
  14. package/dist/api/server-boot-checks.js +2 -2
  15. package/dist/api/server-routes.js +3 -1
  16. package/dist/db/derived-field-sql.d.ts +23 -0
  17. package/dist/db/derived-field-sql.js +115 -0
  18. package/dist/db/derived-related-sql.d.ts +31 -0
  19. package/dist/db/derived-related-sql.js +137 -0
  20. package/dist/db/driver-row-coercion.d.ts +1 -0
  21. package/dist/db/driver-row-coercion.js +13 -0
  22. package/dist/db/eagerload.d.ts +3 -1
  23. package/dist/db/eagerload.js +4 -6
  24. package/dist/db/event-store-executor-context.js +1 -1
  25. package/dist/db/event-store-executor-read-paging.js +1 -1
  26. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  27. package/dist/db/event-store-executor-read-search.js +17 -9
  28. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  29. package/dist/db/event-store-executor-read-sort.js +27 -14
  30. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  31. package/dist/db/event-store-executor-read-where.js +3 -9
  32. package/dist/db/event-store-executor-read.js +23 -4
  33. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  34. package/dist/db/event-store-executor-write-steps.js +20 -1
  35. package/dist/db/event-store-executor-write.js +8 -8
  36. package/dist/db/money.d.ts +4 -0
  37. package/dist/db/money.js +34 -29
  38. package/dist/db/parent-ref-clause.d.ts +1 -0
  39. package/dist/db/parent-ref-clause.js +18 -8
  40. package/dist/db/read-gate-clause.d.ts +7 -0
  41. package/dist/db/read-gate-clause.js +188 -0
  42. package/dist/db/read-ownership.d.ts +9 -0
  43. package/dist/db/read-ownership.js +12 -0
  44. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  45. package/dist/engine/boot-validator/boot-check.js +7 -11
  46. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  47. package/dist/engine/boot-validator/boot-context.js +57 -0
  48. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  49. package/dist/engine/boot-validator/boot-options.js +1 -0
  50. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  51. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  52. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  53. package/dist/engine/boot-validator/boot-phases.js +140 -0
  54. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  55. package/dist/engine/boot-validator/boot-report.js +47 -0
  56. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  57. package/dist/engine/boot-validator/boot-rule.js +14 -0
  58. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  59. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  60. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  61. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  62. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  63. package/dist/engine/boot-validator/entity-handler.js +6 -3
  64. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  65. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  66. package/dist/engine/boot-validator/field-conditions.js +178 -0
  67. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  68. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  69. package/dist/engine/boot-validator/index.d.ts +5 -34
  70. package/dist/engine/boot-validator/index.js +24 -204
  71. package/dist/engine/boot-validator/pii-retention.js +2 -7
  72. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  73. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  74. package/dist/engine/boot-validator/read-gate.js +54 -0
  75. package/dist/engine/boot-validator/screens-forms.js +19 -6
  76. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  77. package/dist/engine/boot-validator/screens-lists.js +8 -0
  78. package/dist/engine/boot-validator/zod-shape.js +4 -2
  79. package/dist/engine/build-app-schema.js +11 -5
  80. package/dist/engine/embedded-derived.d.ts +8 -6
  81. package/dist/engine/embedded-derived.js +25 -7
  82. package/dist/engine/entity-handlers.d.ts +1 -1
  83. package/dist/engine/entity-handlers.js +17 -18
  84. package/dist/engine/entity-list-runtime-options.d.ts +2 -1
  85. package/dist/engine/entity-list-runtime-options.js +86 -5
  86. package/dist/engine/factories.d.ts +5 -5
  87. package/dist/engine/factories.js +3 -5
  88. package/dist/engine/field-access.d.ts +2 -0
  89. package/dist/engine/field-access.js +48 -1
  90. package/dist/engine/field-state.d.ts +12 -0
  91. package/dist/engine/field-state.js +67 -0
  92. package/dist/engine/handler-helpers.d.ts +3 -0
  93. package/dist/engine/handler-helpers.js +7 -1
  94. package/dist/engine/index.d.ts +2 -2
  95. package/dist/engine/index.js +2 -2
  96. package/dist/engine/ownership.d.ts +1 -1
  97. package/dist/engine/ownership.js +9 -5
  98. package/dist/engine/registry-state.d.ts +1 -0
  99. package/dist/engine/registry-state.js +1 -1
  100. package/dist/engine/schema-builder.js +3 -0
  101. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  102. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  103. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  104. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  105. package/dist/files/bind-pending-file-refs.js +11 -12
  106. package/dist/files/bind-referenced-files.write.js +5 -5
  107. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  108. package/dist/files/create-file-ref-from-bytes.js +2 -0
  109. package/dist/files/create-file-ref.d.ts +2 -0
  110. package/dist/files/create-file-ref.js +39 -22
  111. package/dist/files/dispose-removed-file-refs.js +5 -8
  112. package/dist/files/file-field-slots.d.ts +10 -0
  113. package/dist/files/file-field-slots.js +53 -0
  114. package/dist/files/file-ref-entity.d.ts +1 -1
  115. package/dist/files/file-ref-table.d.ts +1 -1
  116. package/dist/files/file-routes.js +12 -3
  117. package/dist/files/index.d.ts +1 -0
  118. package/dist/files/index.js +1 -0
  119. package/dist/files/load-save-file-refs.js +3 -4
  120. package/dist/files/record-read-check.js +2 -30
  121. package/dist/files/record-write-check.d.ts +2 -0
  122. package/dist/files/record-write-check.js +18 -3
  123. package/dist/files/upload-field-resolution.d.ts +2 -1
  124. package/dist/files/upload-field-resolution.js +33 -13
  125. package/dist/pipeline/dispatch-batch.js +6 -5
  126. package/dist/pipeline/dispatch-write.js +2 -1
  127. package/dist/pipeline/write-result-masking.js +2 -1
  128. package/dist/schema-cli/validate-command.js +46 -12
  129. package/dist/schema-cli.js +1 -1
  130. package/dist/ui-types/index.d.ts +2 -1
  131. package/dist/ui-types/index.js +2 -1
  132. package/package.json +3 -3
  133. package/src/changes.json +55 -0
@@ -0,0 +1,67 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { evalFieldCondition, isUnchangedValue } from "./screen-helpers.js";
3
+ export function isMissingRequiredValue(value) {
4
+ return (value === undefined ||
5
+ value === null ||
6
+ value === "" ||
7
+ (Array.isArray(value) && value.length === 0));
8
+ }
9
+ export function isFieldVisible(def, values) {
10
+ return def.visibleWhen === undefined || evalFieldCondition(def.visibleWhen, values);
11
+ }
12
+ export function isFieldRequired(def, values) {
13
+ if (!isFieldVisible(def, values))
14
+ return false;
15
+ return (def.required === true ||
16
+ (def.requiredWhen !== undefined && evalFieldCondition(def.requiredWhen, values)));
17
+ }
18
+ function watchedKeys(name, def) {
19
+ return [
20
+ name,
21
+ ...(def.requiredWhen ? [def.requiredWhen.field] : []),
22
+ ...(def.visibleWhen ? [def.visibleWhen.field] : []),
23
+ ];
24
+ }
25
+ function embeddedRowsOf(def, value) {
26
+ if (def.multiple === true)
27
+ return Array.isArray(value) ? value.map((row, index) => ({ row, index })) : [];
28
+ return isPlainObject(value) ? [{ row: value, index: undefined }] : [];
29
+ }
30
+ function findMissingEmbeddedRequirements(name, def, value) {
31
+ const missing = [];
32
+ for (const { row, index } of embeddedRowsOf(def, value)) {
33
+ if (!isPlainObject(row))
34
+ continue;
35
+ for (const [subName, sub] of Object.entries(def.schema)) {
36
+ if (sub.requiredWhen === undefined)
37
+ continue;
38
+ if (!isFieldRequired(sub, row) || !isMissingRequiredValue(row[subName]))
39
+ continue;
40
+ missing.push(index === undefined ? `${name}.${subName}` : `${name}.${index}.${subName}`);
41
+ }
42
+ }
43
+ return missing;
44
+ }
45
+ /** Paths of fields (and embedded sub-fields) whose `requiredWhen` holds while the value is missing.
46
+ * With `previous`, a field is only checked when it or one of its condition fields changed, so
47
+ * legacy rows stay editable. */
48
+ export function findMissingConditionalRequirements(fields, values, previous) {
49
+ const missing = [];
50
+ for (const [name, def] of Object.entries(fields)) {
51
+ if (!isFieldVisible(def, values))
52
+ continue;
53
+ if (def.requiredWhen !== undefined) {
54
+ const unchanged = previous !== undefined &&
55
+ watchedKeys(name, def).every((key) => isUnchangedValue(values[key], previous[key]));
56
+ if (!unchanged && isFieldRequired(def, values) && isMissingRequiredValue(values[name])) {
57
+ missing.push(name);
58
+ }
59
+ }
60
+ if (def.type !== "embedded")
61
+ continue;
62
+ if (previous !== undefined && isUnchangedValue(values[name], previous[name]))
63
+ continue;
64
+ missing.push(...findMissingEmbeddedRequirements(name, def, values[name]));
65
+ }
66
+ return missing;
67
+ }
@@ -11,6 +11,9 @@ import type { NameOrRef, WriteResult } from "./types/handlers.ts";
11
11
  * return withResponseData(result, { userId, tenantId });
12
12
  * ```
13
13
  *
14
+ * When the executor result is a save/delete, it rides along as `lifecycle`, so postSave/postDelete
15
+ * hooks and projections still run; the dispatcher keeps it out of the response.
16
+ *
14
17
  * On failure the same WriteFailure instance is returned — the error
15
18
  * object round-trips without any wrapping, so the dispatcher / HTTP layer
16
19
  * still read the original error code + httpStatus + i18nKey.
@@ -1,3 +1,4 @@
1
+ import { isLifecycleResult } from "../pipeline/dispatcher-utils.js";
1
2
  /**
2
3
  * Override the success-side `data` of a WriteResult while forwarding the
3
4
  * failure half untouched. Useful for handlers that delegate to the
@@ -10,6 +11,9 @@
10
11
  * return withResponseData(result, { userId, tenantId });
11
12
  * ```
12
13
  *
14
+ * When the executor result is a save/delete, it rides along as `lifecycle`, so postSave/postDelete
15
+ * hooks and projections still run; the dispatcher keeps it out of the response.
16
+ *
13
17
  * On failure the same WriteFailure instance is returned — the error
14
18
  * object round-trips without any wrapping, so the dispatcher / HTTP layer
15
19
  * still read the original error code + httpStatus + i18nKey.
@@ -17,7 +21,9 @@
17
21
  export function withResponseData(result, data) {
18
22
  if (!result.isSuccess)
19
23
  return result;
20
- return { isSuccess: true, data };
24
+ return isLifecycleResult(result.data)
25
+ ? { isSuccess: true, data, lifecycle: result.data }
26
+ : { isSuccess: true, data };
21
27
  }
22
28
  export function resolveName(ref) {
23
29
  return typeof ref === "string" ? ref : ref.name;
@@ -45,7 +45,7 @@ export { computeDefinitionFingerprint, defineWorkflow } from "./define-workflow.
45
45
  export type { ToggleReader } from "./effective-features.ts";
46
46
  export { computeEffectiveFeatures, isToggleableFeature } from "./effective-features.ts";
47
47
  export { createEntityExecutor, defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityQueryHandler, defineEntityRestoreHandler, defineEntityUpdateHandler, defineEntityWriteHandler, defineProjectionQueryHandler, entityListSchema, MAX_LIST_LIMIT, } from "./entity-handlers.ts";
48
- export { referenceGroupLabels } from "./entity-list-runtime-options.ts";
48
+ export { parentVisibilityFor, referenceGroupLabels } from "./entity-list-runtime-options.ts";
49
49
  export { declareEscapeHatch } from "./escape-hatch-declaration.ts";
50
50
  export { CODEMOD_PLACEHOLDER_REASON_MARKER } from "./escape-hatch-reason.ts";
51
51
  export { emitEvent, typedPayload } from "./event-helpers.ts";
@@ -68,7 +68,7 @@ export { HookPhases } from "./hook-helpers.ts";
68
68
  export { i18nKey } from "./i18n-key.ts";
69
69
  export { buildSessionRoles, findForbiddenMembershipRole, } from "./membership-roles.ts";
70
70
  export type { OwnershipMap } from "./ownership.ts";
71
- export { from, normalizeAccessEntry, } from "./ownership.ts";
71
+ export { from, normalizeAccessEntry, userCanWriteFieldRow, } from "./ownership.ts";
72
72
  export { pagedListSchema, pageRows, } from "./paged-list.ts";
73
73
  export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target.ts";
74
74
  export { buildPipelineSteps, stepsPipeline } from "./pipeline.ts";
@@ -27,7 +27,7 @@ export { createEntityExecutor, defineEntityCreateHandler, defineEntityDeleteHand
27
27
  // apps use the verb-specific wrappers above. Existing callers (integration
28
28
  // tests, old bundled-features) remain runnable without changes.
29
29
  defineEntityQueryHandler, defineEntityRestoreHandler, defineEntityUpdateHandler, defineEntityWriteHandler, defineProjectionQueryHandler, entityListSchema, MAX_LIST_LIMIT, } from "./entity-handlers.js";
30
- export { referenceGroupLabels } from "./entity-list-runtime-options.js";
30
+ export { parentVisibilityFor, referenceGroupLabels } from "./entity-list-runtime-options.js";
31
31
  export { declareEscapeHatch } from "./escape-hatch-declaration.js";
32
32
  export { CODEMOD_PLACEHOLDER_REASON_MARKER } from "./escape-hatch-reason.js";
33
33
  export { emitEvent, typedPayload } from "./event-helpers.js";
@@ -52,7 +52,7 @@ export { i18nKey } from "./i18n-key.js";
52
52
  // representation a semver promise; the predicate functions are the intended
53
53
  // surface.
54
54
  export { buildSessionRoles, findForbiddenMembershipRole, } from "./membership-roles.js";
55
- export { from, normalizeAccessEntry, } from "./ownership.js";
55
+ export { from, normalizeAccessEntry, userCanWriteFieldRow, } from "./ownership.js";
56
56
  export { pagedListSchema, pageRows, } from "./paged-list.js";
57
57
  export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target.js";
58
58
  export { buildPipelineSteps, stepsPipeline } from "./pipeline.js";
@@ -9,5 +9,5 @@ export declare function normalizeAccessEntry(entry: OwnershipMap | readonly stri
9
9
  export declare function userCanCreateFieldRow(user: SessionUser, accessMap: OwnershipMap | undefined, newRow: Readonly<Record<string, unknown>>): boolean;
10
10
  export declare function tableNameOf(table: unknown): string;
11
11
  export declare function shiftParams(fragment: SqlFragment, shift: number): SqlFragment;
12
- export declare function buildOwnershipClause(user: SessionUser, accessMap: OwnershipMap | undefined, table: unknown, paramStart?: number): OwnershipClause;
12
+ export declare function buildOwnershipClause(user: SessionUser, accessMap: OwnershipMap | undefined, table: unknown, paramStart?: number, tableNameOverride?: string): OwnershipClause;
13
13
  export declare function combineClauses(a: OwnershipClause, b: OwnershipClause): OwnershipClause;
@@ -229,7 +229,10 @@ export function shiftParams(fragment, shift) {
229
229
  // does this outside `buildOwnershipClause`) — this function does not.
230
230
  // 3. Treat `kind: "empty"` (see `OwnershipClause`) as a hard DENY, and
231
231
  // `kind: "pass"` as an explicit bypass — not as "no additional filter".
232
- export function buildOwnershipClause(user, accessMap, table, paramStart = 1) {
232
+ //
233
+ // `tableNameOverride` is the alias the queried table carries in the caller's SQL: where-rules
234
+ // qualify columns with it, and a physical name would bind to an outer row of the same table.
235
+ export function buildOwnershipClause(user, accessMap, table, paramStart = 1, tableNameOverride) {
233
236
  if (!accessMap || Object.keys(accessMap).length === 0)
234
237
  return PASS_CLAUSE;
235
238
  const clauses = [];
@@ -243,7 +246,7 @@ export function buildOwnershipClause(user, accessMap, table, paramStart = 1) {
243
246
  anyRoleMatched = true;
244
247
  if (rule === "all")
245
248
  return PASS_CLAUSE;
246
- const resolved = ruleToFragment(rule, user, table, nextParamIdx);
249
+ const resolved = ruleToFragment(rule, user, table, nextParamIdx, tableNameOverride);
247
250
  if (resolved.kind === "sql") {
248
251
  clauses.push({ sqlText: resolved.sqlText, params: resolved.params });
249
252
  nextParamIdx += resolved.params.length;
@@ -297,20 +300,21 @@ function cachedTableColumnSqlNames(table) {
297
300
  }
298
301
  return names;
299
302
  }
300
- function ruleToFragment(rule, user, table, paramStart) {
303
+ function ruleToFragment(rule, user, table, paramStart, tableNameOverride) {
301
304
  if (rule === "all") {
302
305
  return { kind: "sql", sqlText: "TRUE", params: [] };
303
306
  }
304
307
  if (rule.kind === "where") {
308
+ const tableName = tableNameOverride ?? tableNameOf(table);
305
309
  const frag = rule.where(user, {
306
310
  table,
307
- tableName: tableNameOf(table),
311
+ tableName,
308
312
  paramStart,
309
313
  });
310
314
  // Runtime backstop for the boot-time probe (boot-validator/ownership.ts):
311
315
  // an unqualified column in a correlated subquery silently turns into a
312
316
  // fail-open tautology — throw instead of shipping it.
313
- assertQualifiedWhereFragment(frag.sqlText, cachedTableColumnSqlNames(table), `ownership where-rule on "${tableNameOf(table)}"`);
317
+ assertQualifiedWhereFragment(frag.sqlText, cachedTableColumnSqlNames(table), `ownership where-rule on "${tableName}"`);
314
318
  return { kind: "sql", sqlText: frag.sqlText, params: frag.params };
315
319
  }
316
320
  // FromRule
@@ -22,6 +22,7 @@ export type SearchableReferenceField = {
22
22
  readonly labelField: string;
23
23
  };
24
24
  export type SortableReferenceField = SearchableReferenceField;
25
+ export declare const IMPLICIT_PROJECTION_SUFFIX: "-entity";
25
26
  export declare function buildImplicitProjection(featureName: string, entityName: string, entity: EntityDefinition, qualify: typeof qualifyEntityName, backingTable?: unknown, extensions?: readonly EntityProjectionExtension[]): ProjectionDefinition;
26
27
  export declare const qualify: typeof qualifyEntityName;
27
28
  export type RegistryState = {
@@ -4,7 +4,7 @@ import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
4
4
  import { asEntityTableMeta } from "../db/query.js";
5
5
  import { buildEntityTable } from "../db/table-builder.js";
6
6
  import { qualifyEntityName } from "./qualified-name.js";
7
- const IMPLICIT_PROJECTION_SUFFIX = "-entity";
7
+ export const IMPLICIT_PROJECTION_SUFFIX = "-entity";
8
8
  // Each `r.entity` registration gets an implicit projection with generated
9
9
  // apply handlers for the four automatic verbs. The live path writes directly
10
10
  // to the table through EventStoreExecutor, while rebuildProjection replays
@@ -73,6 +73,9 @@ function embeddedSubFieldToZod(subField) {
73
73
  }
74
74
  case "reference":
75
75
  return z.uuid();
76
+ case "images":
77
+ case "files":
78
+ return fileListFieldToZod(subField);
76
79
  default:
77
80
  assertUnreachable(subField, "embedded sub-field type");
78
81
  }
@@ -0,0 +1,4 @@
1
+ import type { AccessRule } from "@cosmicdrift/kumiko-types/handlers";
2
+ import type { AppSchema } from "../ui-types/app-schema.ts";
3
+ export type QueryAccessLookup = (queryQn: string) => AccessRule | undefined;
4
+ export declare function stripUnreadableRelatedLists(schema: AppSchema, roles: readonly string[], queryAccess: QueryAccessLookup): AppSchema;
@@ -0,0 +1,80 @@
1
+ import { hasAccess } from "./access.js";
2
+ // A relatedList section runs its own query; a caller whose roles cannot execute that query would
3
+ // see a tab that only ever errors, so the section is removed from the role's schema.
4
+ export function stripUnreadableRelatedLists(schema, roles, queryAccess) {
5
+ const canRun = (queryQn) => hasAccess({ roles }, queryAccess(queryQn));
6
+ let changed = false;
7
+ const features = schema.features.map((feature) => {
8
+ const next = stripFeature(feature, canRun);
9
+ if (next !== feature)
10
+ changed = true;
11
+ return next;
12
+ });
13
+ return changed ? { ...schema, features } : schema;
14
+ }
15
+ function stripFeature(feature, canRun) {
16
+ let changed = false;
17
+ const screens = feature.screens.map((screen) => {
18
+ const next = stripScreen(screen, canRun);
19
+ if (next !== screen)
20
+ changed = true;
21
+ return next;
22
+ });
23
+ return changed ? { ...feature, screens } : feature;
24
+ }
25
+ function stripScreen(screen, canRun) {
26
+ switch (screen.type) {
27
+ case "projectionDetail":
28
+ return stripProjectionDetail(screen, canRun);
29
+ case "entityEdit":
30
+ return stripEntityEdit(screen, canRun);
31
+ case "entityList":
32
+ return stripExpandableRow(screen, canRun);
33
+ default:
34
+ return screen;
35
+ }
36
+ }
37
+ function stripExpandableRow(screen, canRun) {
38
+ if (screen.expandableRow === undefined || canRun(screen.expandableRow.query))
39
+ return screen;
40
+ const { expandableRow: _removed, ...rest } = screen;
41
+ return rest;
42
+ }
43
+ function stripEntityEdit(screen, canRun) {
44
+ const { layout } = stripLayout(screen.layout, canRun);
45
+ return layout === screen.layout ? screen : { ...screen, layout };
46
+ }
47
+ function stripProjectionDetail(screen, canRun) {
48
+ const { layout, removedIds } = stripLayout(screen.layout, canRun);
49
+ if (layout === screen.layout)
50
+ return screen;
51
+ const metrics = screen.metrics?.map((metric) => stripDeadTabNavigate(metric, removedIds));
52
+ return { ...screen, layout, ...(metrics !== undefined && { metrics }) };
53
+ }
54
+ function stripLayout(layout, canRun) {
55
+ const removedIds = [];
56
+ const sections = layout.sections.filter((section) => {
57
+ if (!isUnrunnableRelatedList(section, canRun))
58
+ return true;
59
+ if (section.id !== undefined)
60
+ removedIds.push(section.id);
61
+ return false;
62
+ });
63
+ if (sections.length === layout.sections.length)
64
+ return { layout, removedIds };
65
+ return { layout: { ...layout, sections }, removedIds };
66
+ }
67
+ function isUnrunnableRelatedList(section, canRun) {
68
+ return section.kind === "relatedList" && !canRun(section.query);
69
+ }
70
+ function stripDeadTabNavigate(metric, removedTabIds) {
71
+ const navigate = metric.navigate;
72
+ const pointsAtRemovedTab = navigate?.tab !== undefined &&
73
+ navigate.screen === undefined &&
74
+ navigate.entity === undefined &&
75
+ removedTabIds.includes(navigate.tab);
76
+ if (!pointsAtRemovedTab)
77
+ return metric;
78
+ const { navigate: _removed, ...rest } = metric;
79
+ return rest;
80
+ }
@@ -1,6 +1,6 @@
1
- import { isFileField } from "../engine/field-helpers.js";
2
1
  import { ValidationError } from "../errors/index.js";
3
- import { fileRefIdsOf } from "./bind-pending-file-refs.js";
2
+ import { assertRowFitsFileField } from "./bind-pending-file-refs.js";
3
+ import { fileRefIdsBySubField, fileRefIdsInField, holdsFileRefs, resolveFileSubField, } from "./file-field-slots.js";
4
4
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
5
5
  // A file field only stores an id, and the file routes authorize by tenant at
6
6
  // read time: without this check a writer could name another tenant's fileRef
@@ -23,6 +23,24 @@ function isBoundToOtherRecord(row, entityName, recordId) {
23
23
  return false;
24
24
  return row["entityType"] !== entityName || boundId !== recordId;
25
25
  }
26
+ // A file sub-field may be stricter than the sibling the upload came in through (`images` next
27
+ // to `files`); the upload was only checked against the sub-field it named. Ids a sub-field
28
+ // already held are not re-checked, a move from another sub-field counts as new.
29
+ function assertNewIdsFitSubFields(fieldDef, previous, changes, fileRefRows) {
30
+ const previousBySubField = fileRefIdsBySubField(fieldDef, previous);
31
+ for (const [subField, ids] of fileRefIdsBySubField(fieldDef, changes)) {
32
+ const subFieldDef = resolveFileSubField(fieldDef, subField);
33
+ if (subFieldDef === undefined)
34
+ continue;
35
+ for (const fileRefId of ids) {
36
+ if (previousBySubField.get(subField)?.includes(fileRefId))
37
+ continue;
38
+ const row = fileRefRows.get(fileRefId);
39
+ if (row !== undefined)
40
+ assertRowFitsFileField(row, subFieldDef);
41
+ }
42
+ }
43
+ }
26
44
  export function assertFileRefsBelongToTenant(args) {
27
45
  const { registry, result, fileRefRows } = args;
28
46
  // skip: saves of unregistered aggregates (custom executors) declare no file fields
@@ -38,15 +56,16 @@ export function assertFileRefsBelongToTenant(args) {
38
56
  const recordId = String(result.id);
39
57
  const alreadyHeld = new Set();
40
58
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
41
- if (!isFileField(fieldDef))
59
+ if (!holdsFileRefs(fieldDef))
42
60
  continue;
43
- for (const fileRefId of fileRefIdsOf(result.previous[fieldName]))
61
+ for (const fileRefId of fileRefIdsInField(fieldDef, result.previous[fieldName])) {
44
62
  alreadyHeld.add(fileRefId);
63
+ }
45
64
  }
46
65
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
47
- if (!isFileField(fieldDef))
66
+ if (!holdsFileRefs(fieldDef))
48
67
  continue;
49
- for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
68
+ for (const fileRefId of fileRefIdsInField(fieldDef, result.changes[fieldName])) {
50
69
  // A value the record already holds is no new binding; its file_refs row may be gone
51
70
  // (forget purged the file), and sending the unchanged field back must still save.
52
71
  if (alreadyHeld.has(fileRefId))
@@ -64,5 +83,6 @@ export function assertFileRefsBelongToTenant(args) {
64
83
  ],
65
84
  });
66
85
  }
86
+ assertNewIdsFitSubFields(fieldDef, result.previous[fieldName], result.changes[fieldName], fileRefRows);
67
87
  }
68
88
  }
@@ -1,8 +1,9 @@
1
+ import type { AnyFileFieldDef } from "@cosmicdrift/kumiko-types/fields";
1
2
  import type { SaveContext } from "@cosmicdrift/kumiko-types/hooks";
2
3
  import type { TenantDb } from "../db/tenant-db.ts";
3
4
  import type { Registry, SessionUser } from "../engine/types/index.ts";
4
5
  import { type FileRefRows } from "./load-save-file-refs.ts";
5
- export declare function fileRefIdsOf(value: unknown): readonly string[];
6
+ export { fileRefIdsOf } from "./file-field-slots.ts";
6
7
  type FileRefRow = Readonly<Record<string, unknown>>;
7
8
  export declare function bindPendingFileRefs(args: {
8
9
  readonly registry: Registry;
@@ -31,6 +32,7 @@ export type PendingFileRefBindTarget = {
31
32
  readonly fieldName: string;
32
33
  };
33
34
  export declare function assertRowFitsBindTarget(registry: Registry | undefined, bindTo: PendingFileRefBindTarget, row: FileRefRow): void;
35
+ export declare function assertRowFitsFileField(row: FileRefRow, fieldDef: AnyFileFieldDef): void;
34
36
  export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
35
37
  /**
36
38
  * Binds one of the caller's own pending uploads to a record from a custom handler, with the
@@ -53,4 +55,3 @@ export declare function bindPendingFileRef(args: PendingFileRefLookup & {
53
55
  readonly bindTo?: PendingFileRefBindTarget;
54
56
  readonly registry?: Registry;
55
57
  }): Promise<BindPendingFileRefOutcome>;
56
- export {};
@@ -1,19 +1,13 @@
1
1
  import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
2
- import { isFileField } from "../engine/field-helpers.js";
3
2
  import { isAnonymousSessionUser } from "../engine/system-user.js";
4
3
  import { FrameworkReasons, InternalError, reraiseAsKumikoError, UnprocessableError, } from "../errors/index.js";
5
4
  import { getFileRefExecutor } from "./create-file-ref.js";
5
+ import { fileRefIdsInField, holdsFileRefs } from "./file-field-slots.js";
6
6
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
7
7
  import { loadFileRefRows } from "./load-save-file-refs.js";
8
8
  import { validateFile } from "./types.js";
9
9
  import { resolveAttachedField } from "./upload-field-resolution.js";
10
- export function fileRefIdsOf(value) {
11
- if (typeof value === "string")
12
- return isUuid(value) ? [value] : [];
13
- if (!Array.isArray(value))
14
- return [];
15
- return value.filter((item) => typeof item === "string" && isUuid(item));
16
- }
10
+ export { fileRefIdsOf } from "./file-field-slots.js";
17
11
  function isPendingUploadFor(row, entityName, fieldName, user) {
18
12
  // Every anonymous visitor shares one user id, so insertedById cannot tell their uploads apart.
19
13
  if (isAnonymousSessionUser(user))
@@ -60,11 +54,11 @@ export async function bindPendingFileRefs(args) {
60
54
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
61
55
  return;
62
56
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
63
- if (!isFileField(fieldDef))
57
+ if (!holdsFileRefs(fieldDef))
64
58
  continue;
65
59
  // changes, not data: an update's row carries every stored file id, including
66
60
  // ones bound earlier; only the ids this save names can be pending uploads.
67
- for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
61
+ for (const fileRefId of fileRefIdsInField(fieldDef, result.changes[fieldName])) {
68
62
  const row = fileRefRows.get(fileRefId);
69
63
  if (row === undefined)
70
64
  continue;
@@ -117,14 +111,19 @@ export function assertRowFitsBindTarget(registry, bindTo, row) {
117
111
  message: `bindPendingFileRef: bindTo ${bindTo.entityName}.${bindTo.fieldName} is not a registered file field (pass the handler's ctx.registry)`,
118
112
  });
119
113
  }
114
+ assertRowFitsFileField(row, target.fieldDef);
115
+ }
116
+ // Same metadata check for any file slot a stored row lands in (a bind target, or an embedded
117
+ // file sub-field a save names).
118
+ export function assertRowFitsFileField(row, fieldDef) {
120
119
  const { storageKey, mimeType, size } = row;
121
120
  if (typeof storageKey !== "string" || typeof mimeType !== "string" || typeof size !== "number") {
122
121
  // A row without these can only come from corruption; letting it through would skip the check.
123
122
  throw new InternalError({
124
- message: `bindPendingFileRef: pending file_ref ${String(row["id"])} lacks storageKey, mimeType or size`,
123
+ message: `file_ref ${String(row["id"])} lacks storageKey, mimeType or size`,
125
124
  });
126
125
  }
127
- const error = validateFile({ fileName: storageKey, mimeType, size }, { maxSize: target.fieldDef.maxSize, accept: target.fieldDef.accept });
126
+ const error = validateFile({ fileName: storageKey, mimeType, size }, { maxSize: fieldDef.maxSize, accept: fieldDef.accept });
128
127
  if (error !== null) {
129
128
  throw new UnprocessableError(FrameworkReasons.fileViolatesBindTarget, {
130
129
  i18nKey: "errors.file.violatesBindTarget",
@@ -1,9 +1,8 @@
1
1
  import * as z from "zod";
2
- import { isFileField } from "../engine/field-helpers.js";
3
2
  import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/index.js";
4
3
  import { ValidationError } from "../errors/index.js";
5
- import { fileRefIdsOf } from "./bind-pending-file-refs.js";
6
4
  import { getFileRefExecutor } from "./create-file-ref.js";
5
+ import { fileRefIdsInField, holdsFileRefs } from "./file-field-slots.js";
7
6
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
8
7
  import { loadFileRefRows } from "./load-save-file-refs.js";
9
8
  import { findImplicitEntityTable } from "./record-write-check.js";
@@ -36,7 +35,7 @@ function resolveFileBearingEntity(registry, entityType) {
36
35
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
37
36
  throw invalidEntityType();
38
37
  const fileFieldNames = Object.entries(entity.fields)
39
- .filter(([, fieldDef]) => isFileField(fieldDef))
38
+ .filter(([, fieldDef]) => holdsFileRefs(fieldDef))
40
39
  .map(([fieldName]) => fieldName);
41
40
  if (fileFieldNames.length === 0)
42
41
  throw invalidEntityType();
@@ -83,14 +82,15 @@ export const bindReferencedFilesWrite = defineWriteHandler({
83
82
  const { entityType, entityIds, limit } = event.payload;
84
83
  const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
85
84
  const records = await loadRecordPage(ctx.db, source, event.payload);
86
- const referencedIds = records.flatMap((record) => fileFieldNames.flatMap((fieldName) => fileRefIdsOf(record[fieldName])));
85
+ const fileRefIdsOfRecordField = (record, fieldName) => fileRefIdsInField(source.entity.fields[fieldName], record[fieldName]);
86
+ const referencedIds = records.flatMap((record) => fileFieldNames.flatMap((fieldName) => fileRefIdsOfRecordField(record, fieldName)));
87
87
  const fileRefRows = await loadFileRefRows(ctx.db, referencedIds, event.user.tenantId);
88
88
  const executor = getFileRefExecutor();
89
89
  let bound = 0;
90
90
  let skipped = 0;
91
91
  for (const record of records) {
92
92
  for (const fieldName of fileFieldNames) {
93
- for (const fileRefId of fileRefIdsOf(record[fieldName])) {
93
+ for (const fileRefId of fileRefIdsOfRecordField(record, fieldName)) {
94
94
  const row = fileRefRows.get(fileRefId);
95
95
  if (row === undefined ||
96
96
  !isBindableToRecord(row, {
@@ -19,6 +19,8 @@ export type CreateFileRefInput = {
19
19
  readonly attachTo?: {
20
20
  readonly entityType: string;
21
21
  readonly fieldName: string;
22
+ /** File sub-field of the embedded `fieldName`, like POST /files' `subField` part. */
23
+ readonly subField?: string;
22
24
  readonly entityId?: string;
23
25
  };
24
26
  /** Byte limit replacing the configured default upload limit; a file field's own maxSize still applies first. */
@@ -42,6 +42,7 @@ export async function createFileRefFromBytes(ctx, input) {
42
42
  entityType: input.attachTo?.entityType,
43
43
  entityId: input.attachTo?.entityId,
44
44
  fieldName: input.attachTo?.fieldName,
45
+ subField: input.attachTo?.subField,
45
46
  });
46
47
  switch (outcome.kind) {
47
48
  case "created":
@@ -82,6 +83,7 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
82
83
  entityType: input.attachTo?.entityType,
83
84
  entityId: input.attachTo?.entityId,
84
85
  fieldName: input.attachTo?.fieldName,
86
+ subField: input.attachTo?.subField,
85
87
  });
86
88
  if (outcome.kind === "rejected")
87
89
  return fileRejectedFailure(outcome.error);
@@ -30,6 +30,8 @@ export type StoreFileInput = {
30
30
  readonly entityType?: string | undefined;
31
31
  readonly entityId?: string | undefined;
32
32
  readonly fieldName?: string | undefined;
33
+ /** File sub-field of the embedded `fieldName`; the stored row keeps the top-level `fieldName`. */
34
+ readonly subField?: string | undefined;
33
35
  /** Qualified name of a registered actionForm screen; `fieldName` then names one of its files
34
36
  * fields and the upload is stored as pending (entityType = the screen QN, no entityId). */
35
37
  readonly actionForm?: string | undefined;
@@ -27,36 +27,32 @@ const ACTION_FORM_STORAGE_SEGMENT = "action-form";
27
27
  const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
28
28
  class AttachTargetLockedError extends Error {
29
29
  }
30
- function acceptFile(deps, input) {
31
- const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
32
- const rejected = (error) => ({ kind: "rejected", error });
33
- // These interpolate directly into the storage key (buildStorageKey) —
34
- // reject anything but a safe identifier so a caller can't path-traverse
35
- // out of its tenant's storage prefix (../.. segments, absolute paths).
30
+ // These interpolate directly into the storage key (buildStorageKey) —
31
+ // reject anything but a safe identifier so a caller can't path-traverse
32
+ // out of its tenant's storage prefix (../.. segments, absolute paths).
33
+ function invalidIdentifierError(input) {
34
+ const { entityType, entityId, fieldName, subField } = input;
36
35
  for (const [field, value] of [
37
36
  ["entityType", entityType],
38
37
  ["entityId", entityId],
39
38
  ["fieldName", fieldName],
39
+ ["subField", subField],
40
40
  ]) {
41
41
  if (value !== undefined && !SAFE_IDENTIFIER.test(value)) {
42
- return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
42
+ return `invalid_${field}: must match /^[A-Za-z0-9_-]+$/`;
43
43
  }
44
44
  }
45
- const field = resolveUploadField(deps.registry, input, deps.attachTargets);
46
- if (field.kind === "rejected")
47
- return field;
48
- if (field.kind === "unresolvable") {
49
- return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
45
+ if (subField !== undefined && (entityType === undefined || fieldName === undefined)) {
46
+ return "invalid_sub_field: subField requires entityType and fieldName";
50
47
  }
51
- const fieldDef = field.kind === "resolved" ? field.fieldDef : undefined;
52
- const maxSize = fieldDef?.maxSize || deps.defaultMaxUploadSize;
53
- const accept = fieldDef?.accept;
54
- const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
55
- if (validationError)
56
- return rejected(validationError);
48
+ return undefined;
49
+ }
50
+ function acceptFileContent(input, accept) {
51
+ const { fileName, declaredMimeType, data } = input;
57
52
  const contentValidation = validateFileContent(fileName, data, accept);
58
- if (contentValidation.kind === "rejected")
59
- return rejected(contentValidation.error);
53
+ if (contentValidation.kind === "rejected") {
54
+ return { kind: "rejected", error: contentValidation.error };
55
+ }
60
56
  // A normalized upload's real type is already known from the sniffed
61
57
  // bytes — resolveUploadMimeType's alias/rewrite step only applies to the
62
58
  // declared mimeType of an unnormalized upload.
@@ -68,8 +64,9 @@ function acceptFile(deps, input) {
68
64
  };
69
65
  }
70
66
  const mimeTypeResolution = resolveUploadMimeType(fileName, declaredMimeType, data);
71
- if (mimeTypeResolution.kind === "rejected")
72
- return rejected(mimeTypeResolution.error);
67
+ if (mimeTypeResolution.kind === "rejected") {
68
+ return { kind: "rejected", error: mimeTypeResolution.error };
69
+ }
73
70
  return {
74
71
  kind: "accepted",
75
72
  mimeType: resolveContentType({
@@ -80,6 +77,26 @@ function acceptFile(deps, input) {
80
77
  extension: undefined,
81
78
  };
82
79
  }
80
+ function acceptFile(deps, input) {
81
+ const { fileName, declaredMimeType, data } = input;
82
+ const rejected = (error) => ({ kind: "rejected", error });
83
+ const identifierError = invalidIdentifierError(input);
84
+ if (identifierError !== undefined)
85
+ return rejected(identifierError);
86
+ const field = resolveUploadField(deps.registry, input, deps.attachTargets);
87
+ if (field.kind === "rejected")
88
+ return field;
89
+ if (field.kind === "unresolvable") {
90
+ return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
91
+ }
92
+ const fieldDef = field.kind === "resolved" ? field.fieldDef : undefined;
93
+ const maxSize = fieldDef?.maxSize || deps.defaultMaxUploadSize;
94
+ const accept = fieldDef?.accept;
95
+ const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
96
+ if (validationError)
97
+ return rejected(validationError);
98
+ return acceptFileContent(input, accept);
99
+ }
83
100
  // Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
84
101
  // I/O doesn't belong inside a PG connection's tx window.
85
102
  export async function writeAcceptedFileBytes(deps, input) {