@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.
- package/AGENTS.md +4 -3
- package/agents/api/framework-engine.md +16 -8
- package/agents/api/framework-ui-types.md +10 -5
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/testing-stack.md +1 -0
- package/agents/api/types.md +256 -80
- package/agents/api.md +3 -3
- package/agents/examples/derived-fields-sql.md +251 -0
- package/agents/examples/documents.md +161 -0
- package/agents/examples/inspection-checklist.md +807 -0
- package/agents/examples/relations.md +13 -3
- package/agents/examples.md +3 -0
- package/agents/features-index.md +2 -0
- package/agents/features.md +8 -0
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +46 -2
- package/dist/api/server-boot-checks.js +2 -2
- package/dist/api/server-routes.js +3 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/db/derived-field-sql.d.ts +23 -0
- package/dist/db/derived-field-sql.js +115 -0
- package/dist/db/derived-related-sql.d.ts +31 -0
- package/dist/db/derived-related-sql.js +137 -0
- package/dist/db/driver-row-coercion.d.ts +1 -0
- package/dist/db/driver-row-coercion.js +13 -0
- package/dist/db/eagerload.d.ts +3 -1
- package/dist/db/eagerload.js +4 -6
- package/dist/db/event-store-executor-context.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +1 -1
- package/dist/db/event-store-executor-read-search.d.ts +3 -0
- package/dist/db/event-store-executor-read-search.js +17 -9
- package/dist/db/event-store-executor-read-sort.d.ts +9 -2
- package/dist/db/event-store-executor-read-sort.js +27 -14
- package/dist/db/event-store-executor-read-where.d.ts +0 -5
- package/dist/db/event-store-executor-read-where.js +3 -9
- package/dist/db/event-store-executor-read.js +23 -4
- package/dist/db/event-store-executor-write-steps.d.ts +6 -1
- package/dist/db/event-store-executor-write-steps.js +20 -1
- package/dist/db/event-store-executor-write.js +8 -8
- package/dist/db/money.d.ts +4 -0
- package/dist/db/money.js +34 -29
- package/dist/db/parent-ref-clause.d.ts +1 -0
- package/dist/db/parent-ref-clause.js +18 -8
- package/dist/db/read-gate-clause.d.ts +7 -0
- package/dist/db/read-gate-clause.js +201 -0
- package/dist/db/read-ownership.d.ts +9 -0
- package/dist/db/read-ownership.js +12 -0
- package/dist/engine/boot-validator/boot-check.d.ts +3 -1
- package/dist/engine/boot-validator/boot-check.js +7 -11
- package/dist/engine/boot-validator/boot-context.d.ts +26 -0
- package/dist/engine/boot-validator/boot-context.js +57 -0
- package/dist/engine/boot-validator/boot-options.d.ts +38 -0
- package/dist/engine/boot-validator/boot-options.js +1 -0
- package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +107 -0
- package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
- package/dist/engine/boot-validator/boot-phases.js +140 -0
- package/dist/engine/boot-validator/boot-report.d.ts +6 -0
- package/dist/engine/boot-validator/boot-report.js +47 -0
- package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
- package/dist/engine/boot-validator/boot-rule.js +14 -0
- package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
- package/dist/engine/boot-validator/boot-warnings.js +20 -0
- package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
- package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
- package/dist/engine/boot-validator/entity-handler.js +6 -3
- package/dist/engine/boot-validator/entity-list-screens.js +3 -1
- package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
- package/dist/engine/boot-validator/field-conditions.js +196 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +3 -2
- package/dist/engine/boot-validator/i18n-keys.js +18 -4
- package/dist/engine/boot-validator/index.d.ts +5 -34
- package/dist/engine/boot-validator/index.js +24 -204
- package/dist/engine/boot-validator/pii-retention.js +15 -9
- package/dist/engine/boot-validator/projection-list-screens.js +19 -1
- package/dist/engine/boot-validator/read-gate.d.ts +2 -0
- package/dist/engine/boot-validator/read-gate.js +59 -0
- package/dist/engine/boot-validator/screens-forms.js +19 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
- package/dist/engine/boot-validator/screens-lists.js +8 -0
- package/dist/engine/boot-validator/zod-shape.js +4 -2
- package/dist/engine/build-app-schema.js +11 -5
- package/dist/engine/embedded-derived.d.ts +8 -6
- package/dist/engine/embedded-derived.js +25 -7
- package/dist/engine/entity-handlers.d.ts +13 -6
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +2 -1
- package/dist/engine/entity-list-runtime-options.js +86 -5
- package/dist/engine/factories.d.ts +18 -6
- package/dist/engine/factories.js +21 -5
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +48 -1
- package/dist/engine/field-state.d.ts +12 -0
- package/dist/engine/field-state.js +67 -0
- package/dist/engine/handler-helpers.d.ts +3 -0
- package/dist/engine/handler-helpers.js +7 -1
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +3 -3
- package/dist/engine/ownership.d.ts +1 -1
- package/dist/engine/ownership.js +9 -5
- package/dist/engine/registry-state.d.ts +1 -0
- package/dist/engine/registry-state.js +1 -1
- package/dist/engine/schema-builder.js +3 -0
- package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
- package/dist/engine/strip-unreadable-related-lists.js +80 -0
- package/dist/engine/user-entity-reference.d.ts +2 -0
- package/dist/engine/user-entity-reference.js +7 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
- package/dist/files/bind-pending-file-refs.d.ts +3 -2
- package/dist/files/bind-pending-file-refs.js +11 -12
- package/dist/files/bind-referenced-files.write.js +5 -5
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +2 -0
- package/dist/files/create-file-ref.d.ts +2 -0
- package/dist/files/create-file-ref.js +39 -22
- package/dist/files/dispose-removed-file-refs.js +5 -8
- package/dist/files/file-field-slots.d.ts +10 -0
- package/dist/files/file-field-slots.js +53 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-table.d.ts +1 -1
- package/dist/files/file-routes.js +12 -3
- package/dist/files/index.d.ts +3 -0
- package/dist/files/index.js +2 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/rebind-file-ref.d.ts +41 -0
- package/dist/files/rebind-file-ref.js +170 -0
- package/dist/files/record-read-check.js +2 -30
- package/dist/files/record-write-check.d.ts +2 -0
- package/dist/files/record-write-check.js +18 -3
- package/dist/files/upload-field-resolution.d.ts +2 -1
- package/dist/files/upload-field-resolution.js +33 -13
- package/dist/i18n/required-surface-keys.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +61 -0
- package/dist/pipeline/dispatch-batch.js +6 -5
- package/dist/pipeline/dispatch-write.js +2 -1
- package/dist/pipeline/write-result-masking.js +2 -1
- package/dist/schema-cli/validate-boot-input.js +2 -0
- package/dist/schema-cli/validate-command.js +46 -12
- package/dist/schema-cli.js +1 -1
- package/dist/ui-types/index.d.ts +2 -1
- package/dist/ui-types/index.js +2 -1
- package/package.json +3 -3
- 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]) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
42
|
+
return `invalid_${field}: must match /^[A-Za-z0-9_-]+$/`;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
50
|
+
if (!holdsFileRefs(fieldDef) || !(fieldName in result.changes))
|
|
54
51
|
continue;
|
|
55
|
-
for (const fileRefId of
|
|
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
|
-
|
|
59
|
-
maxBytes = Math.max(maxBytes, parseMaxSize(
|
|
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")
|
package/dist/files/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/files/index.js
CHANGED
|
@@ -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 {
|
|
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 (!
|
|
21
|
+
if (!holdsFileRefs(fieldDef))
|
|
23
22
|
continue;
|
|
24
|
-
for (const id of
|
|
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
|
+
}
|