@cosmicdrift/kumiko-framework 0.398.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.
- package/AGENTS.md +33 -19
- package/agents/api/dev-server-testing.md +23 -0
- package/agents/api/framework-db.md +277 -0
- package/agents/api/framework-engine.md +1810 -0
- package/agents/api/framework-errors.md +119 -0
- package/agents/api/framework-event-store.md +99 -0
- package/agents/api/framework-seeding.md +26 -0
- package/agents/api/framework-ui-types.md +542 -0
- package/agents/api/renderer-web.md +1450 -0
- package/agents/api/testing-helpers.md +189 -0
- package/agents/api/testing-stack.md +144 -0
- package/agents/api/types.md +5124 -0
- package/agents/api.md +20 -0
- 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.md +3 -0
- package/agents/features-index.md +178 -0
- package/agents/features.md +9 -1
- 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/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 +188 -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 +33 -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 +103 -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 +178 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
- package/dist/engine/boot-validator/i18n-keys.js +1 -3
- 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 +2 -7
- 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 +54 -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 +1 -1
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +5 -1
- package/dist/engine/entity-list-runtime-options.js +103 -4
- package/dist/engine/factories.d.ts +5 -5
- package/dist/engine/factories.js +3 -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 +2 -1
- package/dist/engine/index.js +2 -1
- 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/entrypoint/index.js +1 -0
- package/dist/entrypoint/internals.d.ts +2 -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 +30 -9
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -4
- 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 +4 -1
- package/dist/files/upload-field-resolution.js +57 -12
- 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-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 +67 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership.js";
|
|
1
|
+
import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow, } from "./ownership.js";
|
|
2
2
|
function writeOnlyFieldNames(entity) {
|
|
3
3
|
const names = [];
|
|
4
4
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
@@ -150,6 +150,11 @@ export function checkWriteFieldOwnership(entity, submittedChanges, user, oldRow,
|
|
|
150
150
|
const field = entity.fields[key];
|
|
151
151
|
if (!field)
|
|
152
152
|
continue;
|
|
153
|
+
if (field.type === "embedded") {
|
|
154
|
+
const deniedSubField = deniedEmbeddedSubField(field, submittedChanges[key], oldRow?.[key], user);
|
|
155
|
+
if (deniedSubField)
|
|
156
|
+
return `${key}.${deniedSubField}`;
|
|
157
|
+
}
|
|
153
158
|
const accessMap = normalizeAccessEntry(field.access?.write);
|
|
154
159
|
if (!accessMap)
|
|
155
160
|
continue;
|
|
@@ -168,3 +173,45 @@ export function checkWriteFieldOwnership(entity, submittedChanges, user, oldRow,
|
|
|
168
173
|
}
|
|
169
174
|
return null;
|
|
170
175
|
}
|
|
176
|
+
// Role gate without a row: the user holds at least one role the map grants.
|
|
177
|
+
export function userHasWriteRole(user, accessMap) {
|
|
178
|
+
return user.roles.some((role) => accessMap[role] !== undefined);
|
|
179
|
+
}
|
|
180
|
+
function isRecord(value) {
|
|
181
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
182
|
+
}
|
|
183
|
+
function embeddedRows(value) {
|
|
184
|
+
if (Array.isArray(value))
|
|
185
|
+
return value.map((row) => (isRecord(row) ? row : undefined));
|
|
186
|
+
return isRecord(value) ? [value] : [];
|
|
187
|
+
}
|
|
188
|
+
function stableJson(value) {
|
|
189
|
+
return JSON.stringify(value, (_key, nested) => {
|
|
190
|
+
if (!nested || typeof nested !== "object" || Array.isArray(nested))
|
|
191
|
+
return nested;
|
|
192
|
+
return Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a.localeCompare(b)));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Sub-field write rules of an embedded field, enforced on save. Rows are paired by index;
|
|
196
|
+
// a sub-field counts as written when its value differs between old and new row (an added or
|
|
197
|
+
// removed row that carries a value counts too). The rule is evaluated against the embedded
|
|
198
|
+
// row, as the read filter does. Returns the denied sub-field name or null.
|
|
199
|
+
function deniedEmbeddedSubField(field, newValue, oldValue, user) {
|
|
200
|
+
const newRows = embeddedRows(newValue);
|
|
201
|
+
const oldRows = embeddedRows(oldValue);
|
|
202
|
+
for (const [subKey, subField] of Object.entries(field.schema)) {
|
|
203
|
+
const accessMap = normalizeAccessEntry(subField.access?.write);
|
|
204
|
+
if (!accessMap)
|
|
205
|
+
continue;
|
|
206
|
+
for (let i = 0; i < Math.max(newRows.length, oldRows.length); i++) {
|
|
207
|
+
const newRow = newRows[i];
|
|
208
|
+
const oldRow = oldRows[i];
|
|
209
|
+
if (stableJson(newRow?.[subKey]) === stableJson(oldRow?.[subKey]))
|
|
210
|
+
continue;
|
|
211
|
+
const rowAfter = newRow ?? oldRow ?? {};
|
|
212
|
+
if (!userCanWriteFieldRow(user, accessMap, oldRow ?? rowAfter, rowAfter))
|
|
213
|
+
return subKey;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FieldDefinition, FieldStateConditions } from "./types/fields.ts";
|
|
2
|
+
type RequiredState = FieldStateConditions & {
|
|
3
|
+
readonly required?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function isMissingRequiredValue(value: unknown): boolean;
|
|
6
|
+
export declare function isFieldVisible(def: FieldStateConditions, values: Record<string, unknown>): boolean;
|
|
7
|
+
export declare function isFieldRequired(def: RequiredState, values: Record<string, unknown>): boolean;
|
|
8
|
+
/** Paths of fields (and embedded sub-fields) whose `requiredWhen` holds while the value is missing.
|
|
9
|
+
* With `previous`, a field is only checked when it or one of its condition fields changed, so
|
|
10
|
+
* legacy rows stay editable. */
|
|
11
|
+
export declare function findMissingConditionalRequirements(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): readonly string[];
|
|
12
|
+
export {};
|
|
@@ -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
|
|
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;
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -45,6 +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 { parentVisibilityFor, referenceGroupLabels } from "./entity-list-runtime-options.ts";
|
|
48
49
|
export { declareEscapeHatch } from "./escape-hatch-declaration.ts";
|
|
49
50
|
export { CODEMOD_PLACEHOLDER_REASON_MARKER } from "./escape-hatch-reason.ts";
|
|
50
51
|
export { emitEvent, typedPayload } from "./event-helpers.ts";
|
|
@@ -67,7 +68,7 @@ export { HookPhases } from "./hook-helpers.ts";
|
|
|
67
68
|
export { i18nKey } from "./i18n-key.ts";
|
|
68
69
|
export { buildSessionRoles, findForbiddenMembershipRole, } from "./membership-roles.ts";
|
|
69
70
|
export type { OwnershipMap } from "./ownership.ts";
|
|
70
|
-
export { from, normalizeAccessEntry, } from "./ownership.ts";
|
|
71
|
+
export { from, normalizeAccessEntry, userCanWriteFieldRow, } from "./ownership.ts";
|
|
71
72
|
export { pagedListSchema, pageRows, } from "./paged-list.ts";
|
|
72
73
|
export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target.ts";
|
|
73
74
|
export { buildPipelineSteps, stepsPipeline } from "./pipeline.ts";
|
package/dist/engine/index.js
CHANGED
|
@@ -27,6 +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 { parentVisibilityFor, referenceGroupLabels } from "./entity-list-runtime-options.js";
|
|
30
31
|
export { declareEscapeHatch } from "./escape-hatch-declaration.js";
|
|
31
32
|
export { CODEMOD_PLACEHOLDER_REASON_MARKER } from "./escape-hatch-reason.js";
|
|
32
33
|
export { emitEvent, typedPayload } from "./event-helpers.js";
|
|
@@ -51,7 +52,7 @@ export { i18nKey } from "./i18n-key.js";
|
|
|
51
52
|
// representation a semver promise; the predicate functions are the intended
|
|
52
53
|
// surface.
|
|
53
54
|
export { buildSessionRoles, findForbiddenMembershipRole, } from "./membership-roles.js";
|
|
54
|
-
export { from, normalizeAccessEntry, } from "./ownership.js";
|
|
55
|
+
export { from, normalizeAccessEntry, userCanWriteFieldRow, } from "./ownership.js";
|
|
55
56
|
export { pagedListSchema, pageRows, } from "./paged-list.js";
|
|
56
57
|
export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target.js";
|
|
57
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;
|
package/dist/engine/ownership.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 "${
|
|
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
|
|
@@ -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
|
+
}
|
package/dist/entrypoint/index.js
CHANGED
|
@@ -150,6 +150,7 @@ function buildWorkerServer(opts, lifecycle, jobRunner) {
|
|
|
150
150
|
lifecycle,
|
|
151
151
|
processLane: "worker",
|
|
152
152
|
instanceId: opts.instanceId,
|
|
153
|
+
sseBroker: opts.sseBroker,
|
|
153
154
|
observability: opts.observability,
|
|
154
155
|
dispatcherOptions,
|
|
155
156
|
systemHooks: opts.systemHooks,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JwtKeyring } from "../api/jwt.ts";
|
|
2
2
|
import type { ServerOptions } from "../api/server.ts";
|
|
3
|
+
import type { SseBroker } from "../api/sse-broker.ts";
|
|
3
4
|
import type { RowLevelSecurityMode } from "../db/render-rls-ddl.ts";
|
|
4
5
|
import type { AppContext, Registry } from "../engine/types/index.ts";
|
|
5
6
|
import type { JobRunnerOptions } from "../jobs/job-runner.ts";
|
|
@@ -36,5 +37,6 @@ export type JobsBlock = {
|
|
|
36
37
|
};
|
|
37
38
|
export type WorkerEntrypointOptions = BaseEntrypointOptions & JobsBlock & {
|
|
38
39
|
readonly eventDispatcher?: ServerOptions["eventDispatcher"];
|
|
40
|
+
readonly sseBroker?: SseBroker;
|
|
39
41
|
};
|
|
40
42
|
export type AllInOneEntrypointOptions = ApiEntrypointOptions & WorkerEntrypointOptions;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { isFileField } from "../engine/field-helpers.js";
|
|
2
1
|
import { ValidationError } from "../errors/index.js";
|
|
3
|
-
import {
|
|
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 (!
|
|
59
|
+
if (!holdsFileRefs(fieldDef))
|
|
42
60
|
continue;
|
|
43
|
-
for (const fileRefId of
|
|
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 (!
|
|
66
|
+
if (!holdsFileRefs(fieldDef))
|
|
48
67
|
continue;
|
|
49
|
-
for (const fileRefId of
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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: `
|
|
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:
|
|
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]) =>
|
|
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;
|