@cosmicdrift/kumiko-framework 0.400.0 → 0.402.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 +8 -5
- package/agents/api/framework-engine.md +31 -5
- package/agents/api/testing-stack.md +1 -0
- package/agents/api/types.md +16 -1
- package/agents/api.md +2 -2
- package/agents/boot-rules.md +98 -0
- package/agents/examples/relations.md +13 -3
- package/dist/config/index.d.ts +1 -1
- package/dist/db/read-gate-clause.js +23 -10
- package/dist/db/replay-migration-sql.js +1 -1
- package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
- package/dist/engine/boot-validator/api-ext-rules.js +27 -0
- package/dist/engine/boot-validator/api-ext.js +25 -12
- package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
- package/dist/engine/boot-validator/boot-check-rules.js +7 -0
- package/dist/engine/boot-validator/boot-check.js +13 -1
- package/dist/engine/boot-validator/boot-options.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +5 -1
- package/dist/engine/boot-validator/boot-report.d.ts +1 -0
- package/dist/engine/boot-validator/boot-report.js +58 -7
- package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
- package/dist/engine/boot-validator/boot-rule.js +33 -2
- package/dist/engine/boot-validator/boot-rules.d.ts +210 -0
- package/dist/engine/boot-validator/boot-rules.js +17 -0
- package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
- package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
- package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +32 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +32 -0
- package/dist/engine/boot-validator/entity-list-screens.js +74 -41
- package/dist/engine/boot-validator/field-conditions.js +19 -1
- package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
- package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
- package/dist/engine/boot-validator/i18n-keys.js +43 -16
- package/dist/engine/boot-validator/pii-retention-rules.d.ts +107 -0
- package/dist/engine/boot-validator/pii-retention-rules.js +107 -0
- package/dist/engine/boot-validator/pii-retention.js +88 -34
- package/dist/engine/boot-validator/read-gate.js +12 -7
- package/dist/engine/boot-validator/screens-actions-rules.d.ts +7 -0
- package/dist/engine/boot-validator/screens-actions-rules.js +7 -0
- package/dist/engine/boot-validator/screens-actions.js +7 -3
- package/dist/engine/entity-handlers.d.ts +12 -5
- package/dist/engine/factories.d.ts +13 -1
- package/dist/engine/factories.js +18 -0
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.js +3 -1
- package/dist/engine/user-entity-reference.d.ts +2 -0
- package/dist/engine/user-entity-reference.js +7 -0
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/rebind-file-ref.d.ts +41 -0
- package/dist/files/rebind-file-ref.js +170 -0
- package/dist/i18n/required-surface-keys.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +64 -0
- package/dist/schema-cli/validate-boot-input.js +2 -0
- package/dist/schema-cli/validate-command.js +4 -1
- package/package.json +3 -3
- package/src/changes.json +25 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { holdsFileRefs } from "../../files/file-field-slots.js";
|
|
2
2
|
import { createFallbackLogger } from "../../logging/utils.js";
|
|
3
|
+
import { isUserEntityReference } from "../user-entity-reference.js";
|
|
4
|
+
import { collectBootRuleViolations } from "./boot-report.js";
|
|
5
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
3
6
|
import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
|
|
7
|
+
import { PII_RULES } from "./pii-retention-rules.js";
|
|
4
8
|
const log = createFallbackLogger("kumiko:boot");
|
|
5
9
|
// Framework-managed timestamp fields may be used as retention.reference
|
|
6
10
|
// even when they are not declared in entity.fields.
|
|
@@ -45,7 +49,9 @@ function validateWriteOnlyField(input) {
|
|
|
45
49
|
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
46
50
|
if ("writeOnly" in subField && subField.writeOnly === true) {
|
|
47
51
|
// @cast-boundary schema-walk
|
|
48
|
-
throw
|
|
52
|
+
throw bootRuleError(PII_RULES.writeOnlyEmbeddedSubField, {
|
|
53
|
+
message: `${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields. Remove writeOnly from the sub-field or declare it on a top-level text field.`,
|
|
54
|
+
});
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
// skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
|
|
@@ -56,15 +62,21 @@ function validateWriteOnlyField(input) {
|
|
|
56
62
|
if (flags.writeOnly !== true)
|
|
57
63
|
return;
|
|
58
64
|
if (field.type !== "text") {
|
|
59
|
-
throw
|
|
65
|
+
throw bootRuleError(PII_RULES.writeOnlyNonText, {
|
|
66
|
+
message: `${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields. Use a text field or remove writeOnly.`,
|
|
67
|
+
});
|
|
60
68
|
}
|
|
61
69
|
if (field.sensitive !== true) {
|
|
62
|
-
throw
|
|
70
|
+
throw bootRuleError(PII_RULES.writeOnlyWithoutSensitive, {
|
|
71
|
+
message: `${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`,
|
|
72
|
+
});
|
|
63
73
|
}
|
|
64
74
|
for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
|
|
65
75
|
const value = flags[flag];
|
|
66
76
|
if (value !== undefined && value !== false) {
|
|
67
|
-
throw
|
|
77
|
+
throw bootRuleError(PII_RULES.writeOnlyConflictingFlag, {
|
|
78
|
+
message: `${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up. Remove either writeOnly or the conflicting flag.`,
|
|
79
|
+
});
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
}
|
|
@@ -88,15 +100,21 @@ function validatePersonalJsonbField(input) {
|
|
|
88
100
|
const flags = field; // @cast-boundary schema-walk
|
|
89
101
|
for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
|
|
90
102
|
if (flags[flag] !== undefined && flags[flag] !== false) {
|
|
91
|
-
throw
|
|
103
|
+
throw bootRuleError(PII_RULES.personalJsonbFlag, {
|
|
104
|
+
message: `${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}. Remove the ${flag} flag or the personal annotation.`,
|
|
105
|
+
});
|
|
92
106
|
}
|
|
93
107
|
}
|
|
94
108
|
const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
|
|
95
109
|
if (index) {
|
|
96
|
-
throw
|
|
110
|
+
throw bootRuleError(PII_RULES.personalJsonbIndex, {
|
|
111
|
+
message: `${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`,
|
|
112
|
+
});
|
|
97
113
|
}
|
|
98
114
|
if (fieldName === CUSTOM_FIELDS_COLUMN) {
|
|
99
|
-
throw
|
|
115
|
+
throw bootRuleError(PII_RULES.personalJsonbCustomFields, {
|
|
116
|
+
message: `${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`,
|
|
117
|
+
});
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
120
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
@@ -124,16 +142,17 @@ function validatePersonalJsonbField(input) {
|
|
|
124
142
|
// Encryption and decryption are handled elsewhere; this validation runs early
|
|
125
143
|
// so schema drift is detected during boot.
|
|
126
144
|
export function validatePiiAndRetention(feature) {
|
|
127
|
-
|
|
128
|
-
validateFieldAnnotations
|
|
129
|
-
|
|
130
|
-
}
|
|
145
|
+
collectBootRuleViolations(Object.entries(feature.entities ?? {}), ([entityName, entity]) => {
|
|
146
|
+
const entityChecks = [validateFieldAnnotations, validateEntityRetention];
|
|
147
|
+
collectBootRuleViolations(entityChecks, (check) => check(feature.name, entityName, entity));
|
|
148
|
+
});
|
|
131
149
|
}
|
|
132
|
-
//
|
|
133
|
-
//
|
|
150
|
+
// Every rule runs on every field; order only decides the order of the reported
|
|
151
|
+
// violations and warnings.
|
|
134
152
|
const FIELD_RULES = [
|
|
135
153
|
rejectMultipleSubjectAnnotations,
|
|
136
154
|
rejectTextWithoutPersonalStance,
|
|
155
|
+
rejectPlaintextUserReference,
|
|
137
156
|
rejectInvalidLookupable,
|
|
138
157
|
rejectSensitiveWithoutCiphertextAtRest,
|
|
139
158
|
validateWriteOnlyField,
|
|
@@ -144,7 +163,7 @@ const FIELD_RULES = [
|
|
|
144
163
|
];
|
|
145
164
|
function validateFieldAnnotations(featureName, entityName, entity) {
|
|
146
165
|
const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(entity.fields);
|
|
147
|
-
|
|
166
|
+
collectBootRuleViolations(Object.entries(entity.fields), ([fieldName, field]) => {
|
|
148
167
|
const flags = field; // @cast-boundary schema-walk
|
|
149
168
|
const input = {
|
|
150
169
|
featureName,
|
|
@@ -156,9 +175,8 @@ function validateFieldAnnotations(featureName, entityName, entity) {
|
|
|
156
175
|
subjectAnnotationCount: countSubjectAnnotations(flags),
|
|
157
176
|
personalOfOwnerFieldNames,
|
|
158
177
|
};
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
178
|
+
collectBootRuleViolations(FIELD_RULES, (rule) => rule(input));
|
|
179
|
+
});
|
|
162
180
|
}
|
|
163
181
|
function countSubjectAnnotations(flags) {
|
|
164
182
|
return [flags.pii, flags.userOwned, flags.tenantOwned, flags.recordOwned].filter(Boolean).length;
|
|
@@ -168,7 +186,9 @@ function fieldLocation(input) {
|
|
|
168
186
|
}
|
|
169
187
|
function rejectMultipleSubjectAnnotations(input) {
|
|
170
188
|
if (input.subjectAnnotationCount > 1) {
|
|
171
|
-
throw
|
|
189
|
+
throw bootRuleError(PII_RULES.multipleSubjectAnnotations, {
|
|
190
|
+
message: `${fieldLocation(input)} has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`,
|
|
191
|
+
});
|
|
172
192
|
}
|
|
173
193
|
}
|
|
174
194
|
function rejectTextWithoutPersonalStance(input) {
|
|
@@ -177,9 +197,23 @@ function rejectTextWithoutPersonalStance(input) {
|
|
|
177
197
|
input.subjectAnnotationCount === 0 &&
|
|
178
198
|
!flags.allowPlaintext &&
|
|
179
199
|
!flags.subjectRef) {
|
|
180
|
-
throw
|
|
200
|
+
throw bootRuleError(PII_RULES.textWithoutPersonalStance, {
|
|
201
|
+
message: `${fieldLocation(input)} (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`,
|
|
202
|
+
});
|
|
181
203
|
}
|
|
182
204
|
}
|
|
205
|
+
function rejectPlaintextUserReference(input) {
|
|
206
|
+
const { field, flags } = input;
|
|
207
|
+
// skip: only a plaintext reference to the user entity is a violation
|
|
208
|
+
if (field.type !== "reference" || !flags.allowPlaintext)
|
|
209
|
+
return;
|
|
210
|
+
// skip: references to other entities may stay plaintext
|
|
211
|
+
if (!isUserEntityReference(field.entity))
|
|
212
|
+
return;
|
|
213
|
+
throw bootRuleError(PII_RULES.plaintextUserReference, {
|
|
214
|
+
message: `${fieldLocation(input)} references the user entity "${field.entity}" with { personal: false } — a user id is a pseudonym that falls under Art.17 forget. Use createReferenceField({ entity: "${field.entity}" }) or { personal: "ref" }.`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
183
217
|
// lookupable: only text fields with a subject annotation are supported. Without
|
|
184
218
|
// a subject, the value remains plaintext, so a blind index would be a
|
|
185
219
|
// second deterministic copy without any benefit.
|
|
@@ -188,10 +222,14 @@ function rejectInvalidLookupable(input) {
|
|
|
188
222
|
if (input.flags.lookupable !== true)
|
|
189
223
|
return;
|
|
190
224
|
if (input.field.type !== "text") {
|
|
191
|
-
throw
|
|
225
|
+
throw bootRuleError(PII_RULES.lookupableNonText, {
|
|
226
|
+
message: `${fieldLocation(input)} declares find: "exact" (or "fuzzy") but has type "${input.field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`,
|
|
227
|
+
});
|
|
192
228
|
}
|
|
193
229
|
if (input.subjectAnnotationCount === 0) {
|
|
194
|
-
throw
|
|
230
|
+
throw bootRuleError(PII_RULES.lookupableWithoutSubject, {
|
|
231
|
+
message: `${fieldLocation(input)} declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`,
|
|
232
|
+
});
|
|
195
233
|
}
|
|
196
234
|
}
|
|
197
235
|
// Sensitive fields are stored as table ciphertext in the event log; without
|
|
@@ -200,7 +238,9 @@ function rejectInvalidLookupable(input) {
|
|
|
200
238
|
function rejectSensitiveWithoutCiphertextAtRest(input) {
|
|
201
239
|
const { flags } = input;
|
|
202
240
|
if (flags.sensitive === true && input.subjectAnnotationCount === 0 && flags.encrypted !== true) {
|
|
203
|
-
throw
|
|
241
|
+
throw bootRuleError(PII_RULES.sensitiveWithoutCiphertext, {
|
|
242
|
+
message: `${fieldLocation(input)} declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`,
|
|
243
|
+
});
|
|
204
244
|
}
|
|
205
245
|
}
|
|
206
246
|
// Sorting reads the projection column — that stays ciphertext, so
|
|
@@ -211,10 +251,14 @@ function rejectSensitiveWithoutCiphertextAtRest(input) {
|
|
|
211
251
|
function rejectCiphertextSortOrSensitiveSearch(input) {
|
|
212
252
|
const { flags } = input;
|
|
213
253
|
if (input.subjectAnnotationCount > 0 && flags.sortable === true) {
|
|
214
|
-
throw
|
|
254
|
+
throw bootRuleError(PII_RULES.subjectKeySortable, {
|
|
255
|
+
message: `${fieldLocation(input)} combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`,
|
|
256
|
+
});
|
|
215
257
|
}
|
|
216
258
|
if (flags.sensitive === true && flags.searchable === true) {
|
|
217
|
-
throw
|
|
259
|
+
throw bootRuleError(PII_RULES.sensitiveSearchable, {
|
|
260
|
+
message: `${fieldLocation(input)} combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`,
|
|
261
|
+
});
|
|
218
262
|
}
|
|
219
263
|
}
|
|
220
264
|
function validatePersonalOfOwner(input) {
|
|
@@ -224,7 +268,9 @@ function validatePersonalOfOwner(input) {
|
|
|
224
268
|
return;
|
|
225
269
|
const ownerName = personalOf.ownerField;
|
|
226
270
|
if (!ownerName || typeof ownerName !== "string") {
|
|
227
|
-
throw
|
|
271
|
+
throw bootRuleError(PII_RULES.personalOfMissingOwnerName, {
|
|
272
|
+
message: `${fieldLocation(input)} has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`,
|
|
273
|
+
});
|
|
228
274
|
}
|
|
229
275
|
const ownerField = requireOwnerField(input, ownerName);
|
|
230
276
|
rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf);
|
|
@@ -235,7 +281,9 @@ function requireOwnerField(input, ownerName) {
|
|
|
235
281
|
const ownerField = fieldsByName[ownerName];
|
|
236
282
|
if (!ownerField) {
|
|
237
283
|
const known = Object.keys(fieldsByName).sort().join(", ");
|
|
238
|
-
throw
|
|
284
|
+
throw bootRuleError(PII_RULES.personalOfUnknownOwner, {
|
|
285
|
+
message: `${fieldLocation(input)} references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`,
|
|
286
|
+
});
|
|
239
287
|
}
|
|
240
288
|
// Text is accepted alongside reference: the ES-framework carries
|
|
241
289
|
// user ids as plain text columns throughout the bundled features
|
|
@@ -243,17 +291,23 @@ function requireOwnerField(input, ownerName) {
|
|
|
243
291
|
// relational FK to point a reference at. Self-referencing
|
|
244
292
|
// ownerField (the field's own value IS the owner id) rides on this.
|
|
245
293
|
if (ownerField.type !== "reference" && ownerField.type !== "text") {
|
|
246
|
-
throw
|
|
294
|
+
throw bootRuleError(PII_RULES.personalOfOwnerType, {
|
|
295
|
+
message: `[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
|
|
296
|
+
});
|
|
247
297
|
}
|
|
248
298
|
return ownerField;
|
|
249
299
|
}
|
|
250
300
|
function rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf) {
|
|
251
301
|
const { whenAbsent } = personalOf;
|
|
252
302
|
if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
|
|
253
|
-
throw
|
|
303
|
+
throw bootRuleError(PII_RULES.personalOfInvalidWhenAbsent, {
|
|
304
|
+
message: `${fieldLocation(input)} declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`,
|
|
305
|
+
});
|
|
254
306
|
}
|
|
255
307
|
if (ownerField.required !== true && whenAbsent === undefined) {
|
|
256
|
-
throw
|
|
308
|
+
throw bootRuleError(PII_RULES.personalOfOptionalOwner, {
|
|
309
|
+
message: `${fieldLocation(input)} is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`,
|
|
310
|
+
});
|
|
257
311
|
}
|
|
258
312
|
}
|
|
259
313
|
// Custom subject entities (such as HR employees or patients) are allowed but
|
|
@@ -263,8 +317,7 @@ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
|
|
|
263
317
|
if (ownerField.type !== "reference")
|
|
264
318
|
return;
|
|
265
319
|
const refTarget = ownerField.entity;
|
|
266
|
-
|
|
267
|
-
if (targetEntity !== "user") {
|
|
320
|
+
if (!isUserEntityReference(refTarget)) {
|
|
268
321
|
log.warn(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
|
|
269
322
|
}
|
|
270
323
|
}
|
|
@@ -316,8 +369,7 @@ function validateEntityRetention(featureName, entityName, entity) {
|
|
|
316
369
|
fieldsByName: entity.fields,
|
|
317
370
|
retention,
|
|
318
371
|
};
|
|
319
|
-
|
|
320
|
-
rule(input);
|
|
372
|
+
collectBootRuleViolations(RETENTION_RULES, (rule) => rule(input));
|
|
321
373
|
}
|
|
322
374
|
function warnOnInvalidKeepFor({ featureName, entityName, retention }) {
|
|
323
375
|
if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
|
|
@@ -333,7 +385,9 @@ function rejectUnknownRetentionReference(input) {
|
|
|
333
385
|
if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
|
|
334
386
|
const known = Object.keys(fieldsByName).sort().join(", ");
|
|
335
387
|
const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
|
|
336
|
-
throw
|
|
388
|
+
throw bootRuleError(PII_RULES.retentionUnknownReference, {
|
|
389
|
+
message: `[Feature ${featureName}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`,
|
|
390
|
+
});
|
|
337
391
|
}
|
|
338
392
|
}
|
|
339
393
|
// blockDelete with no field that can carry `anonymize` is fine;
|
|
@@ -4,16 +4,16 @@ import { findEntity } from "./parent-ref.js";
|
|
|
4
4
|
function fail(check, message) {
|
|
5
5
|
throw new Error(`[Kumiko ReadGate] entity "${check.entityName}" (feature: "${check.feature.name}"): ${message}`);
|
|
6
6
|
}
|
|
7
|
-
function
|
|
8
|
-
const link = findEntity(check.featureMap,
|
|
7
|
+
function validateLink(check, key, gateLink) {
|
|
8
|
+
const link = findEntity(check.featureMap, gateLink.entity);
|
|
9
9
|
if (link === undefined) {
|
|
10
|
-
fail(check, `readGate.
|
|
10
|
+
fail(check, `readGate.${key}.entity "${gateLink.entity}" is not registered.`);
|
|
11
11
|
}
|
|
12
12
|
if (link.parentRef === undefined) {
|
|
13
|
-
fail(check, `readGate.
|
|
13
|
+
fail(check, `readGate.${key}.entity "${gateLink.entity}" must declare a parentRef; its host visibility is what the gate checks.`);
|
|
14
14
|
}
|
|
15
|
-
if (link.fields[
|
|
16
|
-
fail(check, `readGate.
|
|
15
|
+
if (link.fields[gateLink.field] === undefined) {
|
|
16
|
+
fail(check, `readGate.${key}.field "${gateLink.field}" is not a field of "${gateLink.entity}".`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
function validateFileOwner(check, fileOwner) {
|
|
@@ -45,8 +45,13 @@ export function validateReadGates(feature, featureMap) {
|
|
|
45
45
|
if (entity.parentRef !== undefined) {
|
|
46
46
|
fail(check, "readGate and parentRef cannot be combined; a gated entity cannot be a join row.");
|
|
47
47
|
}
|
|
48
|
+
if (gate.anyLink !== undefined && gate.allLinks !== undefined) {
|
|
49
|
+
fail(check, "readGate declares both anyLink and allLinks; pick one.");
|
|
50
|
+
}
|
|
48
51
|
if (gate.anyLink !== undefined)
|
|
49
|
-
|
|
52
|
+
validateLink(check, "anyLink", gate.anyLink);
|
|
53
|
+
if (gate.allLinks !== undefined)
|
|
54
|
+
validateLink(check, "allLinks", gate.allLinks);
|
|
50
55
|
if (gate.fileOwner !== undefined)
|
|
51
56
|
validateFileOwner(check, gate.fileOwner);
|
|
52
57
|
validateNoHandWrittenReaders(check);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const SCREEN_ACTION_RULES: {
|
|
2
|
+
readonly actionIcon: {
|
|
3
|
+
readonly id: "screen/action-icon";
|
|
4
|
+
readonly summary: "Every screen action resolves to a registered icon.";
|
|
5
|
+
readonly fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.";
|
|
6
|
+
};
|
|
7
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const SCREEN_ACTION_RULES = {
|
|
2
|
+
actionIcon: {
|
|
3
|
+
id: "screen/action-icon",
|
|
4
|
+
summary: "Every screen action resolves to a registered icon.",
|
|
5
|
+
fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.",
|
|
6
|
+
},
|
|
7
|
+
};
|
|
@@ -4,6 +4,8 @@ import { fallbackTargets, SCREEN_FALLBACK_REASONS, } from "@cosmicdrift/kumiko-t
|
|
|
4
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
5
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
6
6
|
import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
|
|
7
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
8
|
+
import { SCREEN_ACTION_RULES } from "./screens-actions-rules.js";
|
|
7
9
|
export function isRowClickAction(action) {
|
|
8
10
|
return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
|
|
9
11
|
}
|
|
@@ -117,9 +119,11 @@ function isRegisteredIcon(icon) {
|
|
|
117
119
|
export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
|
|
118
120
|
const resolved = resolveActionIcon(action.id, action.icon);
|
|
119
121
|
if (resolved === undefined || !isRegisteredIcon(resolved)) {
|
|
120
|
-
throw
|
|
121
|
-
`
|
|
122
|
-
|
|
122
|
+
throw bootRuleError(SCREEN_ACTION_RULES.actionIcon, {
|
|
123
|
+
message: `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
|
|
124
|
+
`resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
|
|
125
|
+
`its kebab segments via the default icon map (resolveActionIcon).`,
|
|
126
|
+
});
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
// Pin an action's declarative field references against the entity
|
|
@@ -59,12 +59,19 @@ export declare const entityListSchema: z.ZodObject<{
|
|
|
59
59
|
}, z.core.$strip>>>;
|
|
60
60
|
}, z.core.$strip>>;
|
|
61
61
|
}, z.core.$strip>;
|
|
62
|
-
|
|
62
|
+
/** The phase slots are always unset on entity handlers, so a consumer can spread one into its own
|
|
63
|
+
* handler and add typed phases without inheriting the slot types. */
|
|
64
|
+
export type EntityWriteHandlerDef = WriteHandlerDef & {
|
|
65
|
+
readonly reserveBeforeTransaction?: undefined;
|
|
66
|
+
readonly beforeTransaction?: undefined;
|
|
67
|
+
readonly beforeTransactionWhenNested?: undefined;
|
|
68
|
+
};
|
|
69
|
+
export declare function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
|
|
63
70
|
export declare function defineEntityQueryHandler(name: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
64
|
-
export declare function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
65
|
-
export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
66
|
-
export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
67
|
-
export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
71
|
+
export declare function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
|
|
72
|
+
export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
|
|
73
|
+
export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
|
|
74
|
+
export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
|
|
68
75
|
export declare function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
69
76
|
export declare function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
70
77
|
export declare function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TimestampFieldDef, TzFieldDef } from "@cosmicdrift/kumiko-types/fields";
|
|
1
|
+
import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TimestampFieldDef, TzFieldDef } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
3
3
|
import type { TextFieldDef } from "./types/index.ts";
|
|
4
4
|
export declare function createTextField<R extends true | false = false>(overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotations & {
|
|
@@ -40,6 +40,18 @@ export declare function createSelectField<const TOptions extends readonly string
|
|
|
40
40
|
}): SelectFieldDef<TOptions> & {
|
|
41
41
|
required: R;
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
|
|
45
|
+
* A reference to the user entity defaults to `personal: "ref"`; `personal: false` is
|
|
46
|
+
* rejected there because a user id is personal data.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createReferenceField<R extends true | false = false>(opts: {
|
|
49
|
+
entity: string;
|
|
50
|
+
} & Partial<Omit<ReferenceFieldDef, "type" | "entity" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
|
|
51
|
+
required?: R;
|
|
52
|
+
}): ReferenceFieldDef & {
|
|
53
|
+
required: R;
|
|
54
|
+
};
|
|
43
55
|
/**
|
|
44
56
|
* Multi-select field — N values from a fixed option list.
|
|
45
57
|
*
|
package/dist/engine/factories.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { isUserEntityReference } from "./user-entity-reference.js";
|
|
2
3
|
// `personal: { of: "id" }` means the row itself is the subject, not a
|
|
3
4
|
// referenced user — resolves to `recordOwned` instead of `userOwned`.
|
|
4
5
|
const RECORD_OWNER_FIELD = "id";
|
|
@@ -167,6 +168,23 @@ export function createSelectField(opts) {
|
|
|
167
168
|
...expandPersonalAnnotations(opts),
|
|
168
169
|
}; // @cast-boundary engine-payload
|
|
169
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
|
|
173
|
+
* A reference to the user entity defaults to `personal: "ref"`; `personal: false` is
|
|
174
|
+
* rejected there because a user id is personal data.
|
|
175
|
+
*/
|
|
176
|
+
export function createReferenceField(opts) {
|
|
177
|
+
const targetsUser = isUserEntityReference(opts.entity);
|
|
178
|
+
if (targetsUser && opts.personal === false) {
|
|
179
|
+
throw new Error(`createReferenceField({ entity: "${opts.entity}" }): personal: false is not allowed for a reference to the user entity — a user id is personal data. Drop the annotation or use { personal: "ref" }.`);
|
|
180
|
+
}
|
|
181
|
+
const withUserDefault = targetsUser && opts.personal === undefined ? { ...opts, personal: "ref" } : opts;
|
|
182
|
+
return {
|
|
183
|
+
type: "reference",
|
|
184
|
+
required: false,
|
|
185
|
+
...expandPersonalAnnotations(withUserDefault),
|
|
186
|
+
}; // @cast-boundary engine-payload
|
|
187
|
+
}
|
|
170
188
|
/**
|
|
171
189
|
* Multi-select field — N values from a fixed option list.
|
|
172
190
|
*
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-
|
|
|
22
22
|
export { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.ts";
|
|
23
23
|
export { hasAccess } from "./access.ts";
|
|
24
24
|
export type { PrincipalProfile, PrincipalStatus, PrincipalStatusPlugin, TenantLifecycleStatusPlugin, } from "./active-membership.ts";
|
|
25
|
+
export { collectBootRuleViolations } from "./boot-validator/boot-report.ts";
|
|
26
|
+
export { type BootRule, type BootRuleTable, BootRuleViolation, BootValidationError, type BootViolation, bootRuleError, } from "./boot-validator/boot-rule.ts";
|
|
25
27
|
export { validateBundledListsOptions } from "./boot-validator/bundled-lists-table.ts";
|
|
26
28
|
export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.ts";
|
|
27
29
|
export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.ts";
|
|
@@ -57,7 +59,7 @@ export type { StorageProviderDestroyTenantHook, StorageProviderHookCtx, } from "
|
|
|
57
59
|
export { isTenantDataExtensionHooks, type TenantDataDestroyHook, type TenantDataHookCtx, } from "./extensions/tenant-data.ts";
|
|
58
60
|
export { isTenantResourceExtensionHooks, type TenantDestroyHookResult, type TenantResourceDestroyHook, type TenantResourceHookCtx, } from "./extensions/tenant-resource.ts";
|
|
59
61
|
export type { TenantUserModel, UserDataDeleteHook, UserDataDeleteStrategy, UserDataExportHook, UserDataExportSnippet, UserDataHookCtx, UserDataStorageProvider, } from "./extensions/user-data.ts";
|
|
60
|
-
export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.ts";
|
|
62
|
+
export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createReferenceField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.ts";
|
|
61
63
|
export { type ChangelogEntry, compareVersions, filterEntriesAfter, parseFeatureChangelog, } from "./feature-changelog.ts";
|
|
62
64
|
export { buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.ts";
|
|
63
65
|
export { filterReadFields, maskWriteOnlyFields, } from "./field-access.ts";
|
package/dist/engine/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN,
|
|
|
4
4
|
export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-keys.js";
|
|
5
5
|
export { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.js";
|
|
6
6
|
export { hasAccess } from "./access.js";
|
|
7
|
+
export { collectBootRuleViolations } from "./boot-validator/boot-report.js";
|
|
8
|
+
export { BootRuleViolation, BootValidationError, bootRuleError, } from "./boot-validator/boot-rule.js";
|
|
7
9
|
export { validateBundledListsOptions } from "./boot-validator/bundled-lists-table.js";
|
|
8
10
|
export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
|
|
9
11
|
export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
|
|
@@ -36,7 +38,7 @@ export { extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginId
|
|
|
36
38
|
export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
|
|
37
39
|
export { isTenantDataExtensionHooks, } from "./extensions/tenant-data.js";
|
|
38
40
|
export { isTenantResourceExtensionHooks, } from "./extensions/tenant-resource.js";
|
|
39
|
-
export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
|
|
41
|
+
export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createReferenceField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
|
|
40
42
|
export { compareVersions, filterEntriesAfter, parseFeatureChangelog, } from "./feature-changelog.js";
|
|
41
43
|
export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
|
|
42
44
|
export { filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** True when a reference field's `entity` ("user" or "<feature>:user") targets the user entity. */
|
|
2
|
+
export function isUserEntityReference(referenceTarget) {
|
|
3
|
+
const entityName = referenceTarget.includes(":")
|
|
4
|
+
? referenceTarget.split(":")[1]
|
|
5
|
+
: referenceTarget;
|
|
6
|
+
return entityName === "user";
|
|
7
|
+
}
|
package/dist/files/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export { createInMemoryFileProvider } from "./in-memory-provider.ts";
|
|
|
20
20
|
export { createLocalProvider } from "./local-provider.ts";
|
|
21
21
|
export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, } from "./provider-resolver.ts";
|
|
22
22
|
export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.ts";
|
|
23
|
+
export type { FileRefOwner, RebindFileRefContext, RebindFileRefOutcome, } from "./rebind-file-ref.ts";
|
|
24
|
+
export { rebindFileRef } from "./rebind-file-ref.ts";
|
|
23
25
|
export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
|
|
24
26
|
export type { FileStorageProvider, SignedUrlOptions, } from "./types.ts";
|
|
25
27
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.ts";
|
package/dist/files/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { configuredMaxUploadBytes, readFilesRouteOptions, resolveMaxUploadBodyBy
|
|
|
15
15
|
export { createInMemoryFileProvider } from "./in-memory-provider.js";
|
|
16
16
|
export { createLocalProvider } from "./local-provider.js";
|
|
17
17
|
export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.js";
|
|
18
|
+
export { rebindFileRef } from "./rebind-file-ref.js";
|
|
18
19
|
export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
|
|
19
20
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.js";
|
|
20
21
|
export { fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.ts";
|
|
2
|
+
import type { Registry, SessionUser } from "../engine/types/index.ts";
|
|
3
|
+
import { type CanReadFileRefContext } from "./can-caller-read-file-ref.ts";
|
|
4
|
+
export type RebindFileRefContext = {
|
|
5
|
+
readonly db: TenantDb;
|
|
6
|
+
readonly registry: Registry;
|
|
7
|
+
readonly user: SessionUser;
|
|
8
|
+
readonly dispatch?: CanReadFileRefContext["dispatch"];
|
|
9
|
+
};
|
|
10
|
+
export type FileRefOwner = {
|
|
11
|
+
readonly entityType: string;
|
|
12
|
+
readonly entityId: string;
|
|
13
|
+
readonly fieldName: string;
|
|
14
|
+
};
|
|
15
|
+
export type RebindFileRefOutcome = "rebound" | "not-found" | "moved" | "version-conflict";
|
|
16
|
+
/**
|
|
17
|
+
* Moves a bound file from one record's file field to another's, for a record that takes over a
|
|
18
|
+
* file uploaded elsewhere (an upload record, a mail attachment). Server code only; pending uploads
|
|
19
|
+
* go through bindPendingFileRef.
|
|
20
|
+
*
|
|
21
|
+
* Only files of the caller's tenant are found ("not-found" otherwise, as for unknown ids). The
|
|
22
|
+
* file must still sit at `from`; "moved" means it does not (any more), for example because a
|
|
23
|
+
* parallel rebind took it first. A file already at `to` answers "rebound" without a write. The
|
|
24
|
+
* target must be a registered file field whose accept/maxSize the file fits (InternalError /
|
|
25
|
+
* UnprocessableError). Unless the caller is the framework system user, the caller must be able
|
|
26
|
+
* to read the file and to write both the source record and field (it must not be locked, and an
|
|
27
|
+
* entity without update handler leaves the move to the system user) and the target record and
|
|
28
|
+
* field (AccessDeniedError, RecordLockedError); the system user skips these checks, and only the
|
|
29
|
+
* target record has to exist (NotFoundError). The update is checked against the file's stream
|
|
30
|
+
* version read here, so a bind committing in between returns "version-conflict"; the source row
|
|
31
|
+
* stays locked until the surrounding transaction commits, so a lock set on `from` in between is
|
|
32
|
+
* either seen or waits. Rebind first, then save the id into
|
|
33
|
+
* the target field: the save accepts a file already bound to that record, and the source record
|
|
34
|
+
* no longer disposes of it when its field is cleared or the record is deleted.
|
|
35
|
+
* A handler passes its ctx; a job passes `{ db, registry, user: ctx.systemUser }`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function rebindFileRef(ctx: RebindFileRefContext, args: {
|
|
38
|
+
readonly fileRefId: string;
|
|
39
|
+
readonly from: FileRefOwner;
|
|
40
|
+
readonly to: FileRefOwner;
|
|
41
|
+
}): Promise<RebindFileRefOutcome>;
|