@cosmicdrift/kumiko-framework 0.399.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.
Files changed (144) hide show
  1. package/AGENTS.md +4 -3
  2. package/agents/api/framework-engine.md +16 -8
  3. package/agents/api/framework-ui-types.md +10 -5
  4. package/agents/api/renderer-web.md +2 -1
  5. package/agents/api/testing-stack.md +1 -0
  6. package/agents/api/types.md +256 -80
  7. package/agents/api.md +3 -3
  8. package/agents/examples/derived-fields-sql.md +251 -0
  9. package/agents/examples/documents.md +161 -0
  10. package/agents/examples/inspection-checklist.md +807 -0
  11. package/agents/examples/relations.md +13 -3
  12. package/agents/examples.md +3 -0
  13. package/agents/features-index.md +2 -0
  14. package/agents/features.md +8 -0
  15. package/dist/api/entity-read-access.d.ts +2 -0
  16. package/dist/api/entity-read-access.js +46 -2
  17. package/dist/api/server-boot-checks.js +2 -2
  18. package/dist/api/server-routes.js +3 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/db/derived-field-sql.d.ts +23 -0
  21. package/dist/db/derived-field-sql.js +115 -0
  22. package/dist/db/derived-related-sql.d.ts +31 -0
  23. package/dist/db/derived-related-sql.js +137 -0
  24. package/dist/db/driver-row-coercion.d.ts +1 -0
  25. package/dist/db/driver-row-coercion.js +13 -0
  26. package/dist/db/eagerload.d.ts +3 -1
  27. package/dist/db/eagerload.js +4 -6
  28. package/dist/db/event-store-executor-context.js +1 -1
  29. package/dist/db/event-store-executor-read-paging.js +1 -1
  30. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  31. package/dist/db/event-store-executor-read-search.js +17 -9
  32. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  33. package/dist/db/event-store-executor-read-sort.js +27 -14
  34. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  35. package/dist/db/event-store-executor-read-where.js +3 -9
  36. package/dist/db/event-store-executor-read.js +23 -4
  37. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  38. package/dist/db/event-store-executor-write-steps.js +20 -1
  39. package/dist/db/event-store-executor-write.js +8 -8
  40. package/dist/db/money.d.ts +4 -0
  41. package/dist/db/money.js +34 -29
  42. package/dist/db/parent-ref-clause.d.ts +1 -0
  43. package/dist/db/parent-ref-clause.js +18 -8
  44. package/dist/db/read-gate-clause.d.ts +7 -0
  45. package/dist/db/read-gate-clause.js +201 -0
  46. package/dist/db/read-ownership.d.ts +9 -0
  47. package/dist/db/read-ownership.js +12 -0
  48. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  49. package/dist/engine/boot-validator/boot-check.js +7 -11
  50. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  51. package/dist/engine/boot-validator/boot-context.js +57 -0
  52. package/dist/engine/boot-validator/boot-options.d.ts +38 -0
  53. package/dist/engine/boot-validator/boot-options.js +1 -0
  54. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  55. package/dist/engine/boot-validator/boot-phases-app.js +107 -0
  56. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  57. package/dist/engine/boot-validator/boot-phases.js +140 -0
  58. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  59. package/dist/engine/boot-validator/boot-report.js +47 -0
  60. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  61. package/dist/engine/boot-validator/boot-rule.js +14 -0
  62. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  63. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  64. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  65. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  66. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  67. package/dist/engine/boot-validator/entity-handler.js +6 -3
  68. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  69. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  70. package/dist/engine/boot-validator/field-conditions.js +196 -0
  71. package/dist/engine/boot-validator/i18n-keys.d.ts +3 -2
  72. package/dist/engine/boot-validator/i18n-keys.js +18 -4
  73. package/dist/engine/boot-validator/index.d.ts +5 -34
  74. package/dist/engine/boot-validator/index.js +24 -204
  75. package/dist/engine/boot-validator/pii-retention.js +15 -9
  76. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  77. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  78. package/dist/engine/boot-validator/read-gate.js +59 -0
  79. package/dist/engine/boot-validator/screens-forms.js +19 -6
  80. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  81. package/dist/engine/boot-validator/screens-lists.js +8 -0
  82. package/dist/engine/boot-validator/zod-shape.js +4 -2
  83. package/dist/engine/build-app-schema.js +11 -5
  84. package/dist/engine/embedded-derived.d.ts +8 -6
  85. package/dist/engine/embedded-derived.js +25 -7
  86. package/dist/engine/entity-handlers.d.ts +13 -6
  87. package/dist/engine/entity-handlers.js +17 -18
  88. package/dist/engine/entity-list-runtime-options.d.ts +2 -1
  89. package/dist/engine/entity-list-runtime-options.js +86 -5
  90. package/dist/engine/factories.d.ts +18 -6
  91. package/dist/engine/factories.js +21 -5
  92. package/dist/engine/field-access.d.ts +2 -0
  93. package/dist/engine/field-access.js +48 -1
  94. package/dist/engine/field-state.d.ts +12 -0
  95. package/dist/engine/field-state.js +67 -0
  96. package/dist/engine/handler-helpers.d.ts +3 -0
  97. package/dist/engine/handler-helpers.js +7 -1
  98. package/dist/engine/index.d.ts +3 -3
  99. package/dist/engine/index.js +3 -3
  100. package/dist/engine/ownership.d.ts +1 -1
  101. package/dist/engine/ownership.js +9 -5
  102. package/dist/engine/registry-state.d.ts +1 -0
  103. package/dist/engine/registry-state.js +1 -1
  104. package/dist/engine/schema-builder.js +3 -0
  105. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  106. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  107. package/dist/engine/user-entity-reference.d.ts +2 -0
  108. package/dist/engine/user-entity-reference.js +7 -0
  109. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  110. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  111. package/dist/files/bind-pending-file-refs.js +11 -12
  112. package/dist/files/bind-referenced-files.write.js +5 -5
  113. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  114. package/dist/files/create-file-ref-from-bytes.js +2 -0
  115. package/dist/files/create-file-ref.d.ts +2 -0
  116. package/dist/files/create-file-ref.js +39 -22
  117. package/dist/files/dispose-removed-file-refs.js +5 -8
  118. package/dist/files/file-field-slots.d.ts +10 -0
  119. package/dist/files/file-field-slots.js +53 -0
  120. package/dist/files/file-ref-entity.d.ts +1 -1
  121. package/dist/files/file-ref-table.d.ts +1 -1
  122. package/dist/files/file-routes.js +12 -3
  123. package/dist/files/index.d.ts +3 -0
  124. package/dist/files/index.js +2 -0
  125. package/dist/files/load-save-file-refs.js +3 -4
  126. package/dist/files/rebind-file-ref.d.ts +41 -0
  127. package/dist/files/rebind-file-ref.js +170 -0
  128. package/dist/files/record-read-check.js +2 -30
  129. package/dist/files/record-write-check.d.ts +2 -0
  130. package/dist/files/record-write-check.js +18 -3
  131. package/dist/files/upload-field-resolution.d.ts +2 -1
  132. package/dist/files/upload-field-resolution.js +33 -13
  133. package/dist/i18n/required-surface-keys.d.ts +6 -0
  134. package/dist/i18n/required-surface-keys.js +61 -0
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-boot-input.js +2 -0
  139. package/dist/schema-cli/validate-command.js +46 -12
  140. package/dist/schema-cli.js +1 -1
  141. package/dist/ui-types/index.d.ts +2 -1
  142. package/dist/ui-types/index.js +2 -1
  143. package/package.json +3 -3
  144. package/src/changes.json +68 -0
@@ -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) {
@@ -1,7 +1,6 @@
1
- import { isFileField } from "../engine/field-helpers.js";
2
1
  import { reraiseAsKumikoError } from "../errors/index.js";
3
- import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
2
  import { getFileRefExecutor } from "./create-file-ref.js";
3
+ import { fileRefIdsInField, holdsFileRefs } from "./file-field-slots.js";
5
4
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
5
  // The bound fieldName may differ from the field the id was removed from: an id
7
6
  // moved A -> B stays bound to A (binding never re-targets).
@@ -10,15 +9,13 @@ function isBoundToRecord(row, entityName, entityId, fileFields) {
10
9
  return (row["entityType"] === entityName &&
11
10
  row["entityId"] === entityId &&
12
11
  typeof boundField === "string" &&
13
- isFileField(fileFields[boundField]));
12
+ holdsFileRefs(fileFields[boundField]));
14
13
  }
15
14
  // Across all file fields: an id moved from one field to another is still in use.
16
15
  function fileRefIdsStoredInAnyField(fields, data) {
17
16
  const ids = new Set();
18
17
  for (const [fieldName, fieldDef] of Object.entries(fields)) {
19
- if (!isFileField(fieldDef))
20
- continue;
21
- for (const fileRefId of fileRefIdsOf(data[fieldName]))
18
+ for (const fileRefId of fileRefIdsInField(fieldDef, data[fieldName]))
22
19
  ids.add(fileRefId);
23
20
  }
24
21
  return ids;
@@ -50,9 +47,9 @@ export async function disposeRemovedFileRefs(args) {
50
47
  const executor = getFileRefExecutor();
51
48
  const keptAnywhere = fileRefIdsStoredInAnyField(entity.fields, result.data);
52
49
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
53
- if (!isFileField(fieldDef) || !(fieldName in result.changes))
50
+ if (!holdsFileRefs(fieldDef) || !(fieldName in result.changes))
54
51
  continue;
55
- for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
52
+ for (const fileRefId of fileRefIdsInField(fieldDef, result.previous[fieldName])) {
56
53
  if (keptAnywhere.has(fileRefId))
57
54
  continue;
58
55
  const row = await executor.detail({ id: fileRefId }, user, db);
@@ -0,0 +1,10 @@
1
+ import type { EmbeddedFileSubFieldDef, EmbeddedSubFieldDef, FieldDefinition } from "../engine/types/fields.ts";
2
+ export declare function fileRefIdsOf(value: unknown): readonly string[];
3
+ export declare function isFileSubField(sub: EmbeddedSubFieldDef): sub is EmbeddedFileSubFieldDef;
4
+ /** A field whose value carries FileRef ids: a file field itself, or an embedded field with file sub-fields. */
5
+ export declare function holdsFileRefs(def: FieldDefinition | undefined): boolean;
6
+ /** FileRef ids a field value names. `file_refs.fieldName` stays the top-level field, never the sub-field. */
7
+ export declare function fileRefIdsInField(def: FieldDefinition | undefined, value: unknown): string[];
8
+ /** FileRef ids per file sub-field of an embedded field value, pooled over all rows. */
9
+ export declare function fileRefIdsBySubField(def: FieldDefinition | undefined, value: unknown): ReadonlyMap<string, readonly string[]>;
10
+ export declare function resolveFileSubField(def: FieldDefinition | undefined, subField: string): EmbeddedFileSubFieldDef | undefined;
@@ -0,0 +1,53 @@
1
+ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ import { isFileField } from "../engine/field-helpers.js";
4
+ export function fileRefIdsOf(value) {
5
+ if (typeof value === "string")
6
+ return isUuid(value) ? [value] : [];
7
+ if (!Array.isArray(value))
8
+ return [];
9
+ return value.filter((item) => typeof item === "string" && isUuid(item));
10
+ }
11
+ export function isFileSubField(sub) {
12
+ return sub.type === "images" || sub.type === "files";
13
+ }
14
+ function fileSubFieldNames(def) {
15
+ return Object.entries(def.schema).flatMap(([name, sub]) => (isFileSubField(sub) ? [name] : []));
16
+ }
17
+ /** A field whose value carries FileRef ids: a file field itself, or an embedded field with file sub-fields. */
18
+ export function holdsFileRefs(def) {
19
+ if (def === undefined)
20
+ return false;
21
+ if (isFileField(def))
22
+ return true;
23
+ return def.type === "embedded" && Object.values(def.schema).some(isFileSubField);
24
+ }
25
+ /** FileRef ids a field value names. `file_refs.fieldName` stays the top-level field, never the sub-field. */
26
+ export function fileRefIdsInField(def, value) {
27
+ if (def === undefined)
28
+ return [];
29
+ if (isFileField(def))
30
+ return [...fileRefIdsOf(value)];
31
+ if (def.type !== "embedded")
32
+ return [];
33
+ const subNames = fileSubFieldNames(def);
34
+ const rows = Array.isArray(value) ? value : [value];
35
+ return rows.flatMap((row) => isPlainObject(row) ? subNames.flatMap((name) => fileRefIdsOf(row[name])) : []);
36
+ }
37
+ /** FileRef ids per file sub-field of an embedded field value, pooled over all rows. */
38
+ export function fileRefIdsBySubField(def, value) {
39
+ const bySubField = new Map();
40
+ if (def?.type !== "embedded")
41
+ return bySubField;
42
+ const rows = Array.isArray(value) ? value : [value];
43
+ for (const name of fileSubFieldNames(def)) {
44
+ bySubField.set(name, rows.flatMap((row) => (isPlainObject(row) ? fileRefIdsOf(row[name]) : [])));
45
+ }
46
+ return bySubField;
47
+ }
48
+ export function resolveFileSubField(def, subField) {
49
+ if (def?.type !== "embedded")
50
+ return undefined;
51
+ const sub = def.schema[subField];
52
+ return sub !== undefined && isFileSubField(sub) ? sub : undefined;
53
+ }
@@ -19,7 +19,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
19
19
  readonly sensitive?: boolean;
20
20
  readonly default?: number;
21
21
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
22
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
22
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
23
23
  required: true;
24
24
  };
25
25
  readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
@@ -18,7 +18,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
18
18
  readonly sensitive?: boolean;
19
19
  readonly default?: number;
20
20
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
21
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
21
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
22
22
  required: true;
23
23
  };
24
24
  readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
@@ -17,6 +17,7 @@ import { listActionFormFileFields, mayUseActionForm } from "./action-form-upload
17
17
  import { buildContentDispositionHeader } from "./content-disposition.js";
18
18
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
19
19
  import { createDefaultGuard, DEFAULT_PRIVILEGED_ROLES } from "./default-file-guard.js";
20
+ import { isFileSubField } from "./file-field-slots.js";
20
21
  import { createFileContext, createTenantFileContext } from "./file-handle.js";
21
22
  import { fileRefEntity } from "./file-ref-entity.js";
22
23
  import { fileRefsTable } from "./file-ref-table.js";
@@ -48,6 +49,12 @@ const BATCH_META_CONCURRENCY = 10;
48
49
  // Multipart framing plus the small entityType/entityId/fieldName parts
49
50
  // riding alongside the file.
50
51
  const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
52
+ function fileFieldMaxSizes(field) {
53
+ if (field.type === "embedded") {
54
+ return Object.values(field.schema).flatMap((sub) => isFileSubField(sub) && sub.maxSize ? [sub.maxSize] : []);
55
+ }
56
+ return isFileField(field) && field.maxSize ? [field.maxSize] : [];
57
+ }
51
58
  // entityType/fieldName (and therefore the field's own maxSize) are only
52
59
  // known after the body is parsed, so this covers the largest configured
53
60
  // field, not just the global default.
@@ -55,8 +62,8 @@ export function resolveMaxUploadBodyBytes(options) {
55
62
  let maxBytes = parseMaxSize(options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE);
56
63
  for (const entity of options.registry?.getAllEntities().values() ?? []) {
57
64
  for (const field of Object.values(entity.fields)) {
58
- if (isFileField(field) && field.maxSize) {
59
- maxBytes = Math.max(maxBytes, parseMaxSize(field.maxSize));
65
+ for (const maxSize of fileFieldMaxSizes(field)) {
66
+ maxBytes = Math.max(maxBytes, parseMaxSize(maxSize));
60
67
  }
61
68
  }
62
69
  }
@@ -162,6 +169,7 @@ export function createFileRoutes(options) {
162
169
  // store it in the text entityId column.
163
170
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
164
171
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
172
+ const subField = typeof body["subField"] === "string" ? body["subField"] : undefined;
165
173
  const actionForm = typeof body["actionForm"] === "string" ? body["actionForm"] : undefined;
166
174
  if (isAnonymous &&
167
175
  !isAnonymousPendingUploadAllowed(options.registry, {
@@ -197,7 +205,7 @@ export function createFileRoutes(options) {
197
205
  const decision = options.registry !== undefined && options.registry.getEntity(entityType) === undefined
198
206
  ? RecordWriteDecisions.writable
199
207
  : options.canWriteRecord
200
- ? await options.canWriteRecord({ entityType, entityId, fieldName, user })
208
+ ? await options.canWriteRecord({ entityType, entityId, fieldName, subField, user })
201
209
  : RecordWriteDecisions.forbidden;
202
210
  switch (decision) {
203
211
  case RecordWriteDecisions.writable:
@@ -235,6 +243,7 @@ export function createFileRoutes(options) {
235
243
  entityType,
236
244
  entityId,
237
245
  fieldName,
246
+ subField,
238
247
  actionForm,
239
248
  });
240
249
  if (outcome.kind === "rejected")
@@ -8,6 +8,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
8
8
  export { createDefaultGuard } from "./default-file-guard.ts";
9
9
  export { deleteFileRefsBoundToRecord } from "./delete-record-file-refs.ts";
10
10
  export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
11
+ export { holdsFileRefs } from "./file-field-slots.ts";
11
12
  export type { FileContext } from "./file-handle.ts";
12
13
  export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
13
14
  export { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.ts";
@@ -19,6 +20,8 @@ export { createInMemoryFileProvider } from "./in-memory-provider.ts";
19
20
  export { createLocalProvider } from "./local-provider.ts";
20
21
  export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, } from "./provider-resolver.ts";
21
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";
22
25
  export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
23
26
  export type { FileStorageProvider, SignedUrlOptions, } from "./types.ts";
24
27
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.ts";
@@ -5,6 +5,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
5
5
  export { createDefaultGuard } from "./default-file-guard.js";
6
6
  export { deleteFileRefsBoundToRecord } from "./delete-record-file-refs.js";
7
7
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
8
+ export { holdsFileRefs } from "./file-field-slots.js";
8
9
  // `createFileHandle` is an implementation detail — construct handles via
9
10
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
10
11
  export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
@@ -14,6 +15,7 @@ export { configuredMaxUploadBytes, readFilesRouteOptions, resolveMaxUploadBodyBy
14
15
  export { createInMemoryFileProvider } from "./in-memory-provider.js";
15
16
  export { createLocalProvider } from "./local-provider.js";
16
17
  export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.js";
18
+ export { rebindFileRef } from "./rebind-file-ref.js";
17
19
  export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
18
20
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.js";
19
21
  export { fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
@@ -1,8 +1,7 @@
1
1
  import { selectMany } from "../db/query.js";
2
2
  import { tenantDbRunner } from "../db/tenant-db-runner.js";
3
- import { isFileField } from "../engine/field-helpers.js";
4
3
  import { getStreamVersions } from "../event-store/event-store.js";
5
- import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
+ import { fileRefIdsInField, holdsFileRefs } from "./file-field-slots.js";
6
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
7
6
  import { fileRefsTable } from "./file-ref-table.js";
8
7
  // One query for every fileRef a save names, restricted to the writer's own tenant and live
@@ -19,9 +18,9 @@ export async function loadSaveFileRefs(args) {
19
18
  return new Map();
20
19
  const ids = new Set();
21
20
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
22
- if (!isFileField(fieldDef))
21
+ if (!holdsFileRefs(fieldDef))
23
22
  continue;
24
- for (const id of fileRefIdsOf(result.changes[fieldName]))
23
+ for (const id of fileRefIdsInField(fieldDef, result.changes[fieldName]))
25
24
  ids.add(id);
26
25
  }
27
26
  return loadFileRefRows(db, [...ids], user.tenantId);
@@ -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
+ }