@cosmicdrift/kumiko-framework 0.399.0 → 0.400.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/api/framework-engine.md +9 -3
- package/agents/api/framework-ui-types.md +10 -5
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/types.md +242 -80
- package/agents/api.md +2 -2
- 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 +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/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 +2 -1
- package/dist/engine/entity-list-runtime-options.js +86 -5
- 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 -2
- package/dist/engine/index.js +2 -2
- 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/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 +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 +2 -1
- package/dist/files/upload-field-resolution.js +33 -13
- 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 +55 -0
|
@@ -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";
|
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";
|
|
@@ -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);
|
|
@@ -1,35 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { collectEntityDetailQueries } from "../api/entity-read-access.js";
|
|
2
2
|
import { isKumikoError } from "../errors/index.js";
|
|
3
3
|
import { createFallbackLogger } from "../logging/index.js";
|
|
4
4
|
const log = createFallbackLogger("files");
|
|
5
|
-
// Only the "<entity>:detail" handler counts: other "<entity>:...:detail"
|
|
6
|
-
// queries map to the same entity but may return a different shape or scope.
|
|
7
|
-
// Entity names are globally unique and only the owning feature maps handlers
|
|
8
|
-
// to its entity, so at most one QN matches per entity.
|
|
9
|
-
function isEntityDetailQuery(qn, entityName) {
|
|
10
|
-
return qn.endsWith(`:query:${toKebab(entityName)}:detail`);
|
|
11
|
-
}
|
|
12
|
-
function buildDetailQueryIndex(registry) {
|
|
13
|
-
const detailQueryByEntity = new Map();
|
|
14
|
-
for (const qn of registry.getAllQueryHandlers().keys()) {
|
|
15
|
-
const entityName = registry.getHandlerEntity(qn);
|
|
16
|
-
if (entityName !== undefined && isEntityDetailQuery(qn, entityName)) {
|
|
17
|
-
detailQueryByEntity.set(entityName, qn);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
for (const projection of registry.getAllProjections().values()) {
|
|
21
|
-
const entityName = projection.source;
|
|
22
|
-
if (typeof entityName !== "string")
|
|
23
|
-
continue;
|
|
24
|
-
const owner = registry.getReadModelEntityOwner(entityName);
|
|
25
|
-
if (owner === undefined)
|
|
26
|
-
continue;
|
|
27
|
-
const qn = `${owner}:query:${toKebab(entityName)}:detail`;
|
|
28
|
-
if (registry.getAllQueryHandlers().has(qn))
|
|
29
|
-
detailQueryByEntity.set(entityName, qn);
|
|
30
|
-
}
|
|
31
|
-
return detailQueryByEntity;
|
|
32
|
-
}
|
|
33
5
|
// "May this user read the record?" is answered by the entity's own detail
|
|
34
6
|
// query run as that user: handler access, row ownership and parent visibility
|
|
35
7
|
// all apply, exactly as when the record is opened. null (not found or not
|
|
@@ -38,7 +10,7 @@ function buildDetailQueryIndex(registry) {
|
|
|
38
10
|
export function createRecordReadCheck(registry, query) {
|
|
39
11
|
let detailQueryByEntity;
|
|
40
12
|
return async ({ entityType, entityId, user }) => {
|
|
41
|
-
detailQueryByEntity ??=
|
|
13
|
+
detailQueryByEntity ??= collectEntityDetailQueries(registry);
|
|
42
14
|
const detailQuery = detailQueryByEntity.get(entityType);
|
|
43
15
|
if (detailQuery === undefined)
|
|
44
16
|
return false;
|
|
@@ -12,6 +12,8 @@ export type RecordWriteCheck = (args: {
|
|
|
12
12
|
readonly entityType: string;
|
|
13
13
|
readonly entityId: string;
|
|
14
14
|
readonly fieldName: string | undefined;
|
|
15
|
+
/** Embedded file sub-field of `fieldName` the upload is meant for. */
|
|
16
|
+
readonly subField?: string | undefined;
|
|
15
17
|
readonly user: SessionUser;
|
|
16
18
|
}) => Promise<RecordWriteDecision>;
|
|
17
19
|
export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { userHasWriteRole } from "../engine/field-access.js";
|
|
2
3
|
import { normalizeAccessEntry, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
3
4
|
import { toKebab } from "../engine/qualified-name.js";
|
|
4
5
|
import { isRecordLocked } from "../engine/screen-helpers.js";
|
|
6
|
+
import { resolveFileSubField } from "./file-field-slots.js";
|
|
5
7
|
export const RecordWriteDecisions = {
|
|
6
8
|
writable: "writable",
|
|
7
9
|
notFound: "not_found",
|
|
@@ -19,6 +21,20 @@ export function findImplicitEntityTable(registry, entityType) {
|
|
|
19
21
|
function isEntityUpdateWrite(qn, entityName) {
|
|
20
22
|
return qn.endsWith(`:write:${toKebab(entityName)}:update`);
|
|
21
23
|
}
|
|
24
|
+
// The named field's own write rules against the stored row. The upload names no embedded row,
|
|
25
|
+
// so a sub-field only gets the role gate; its row-level rules run when the record is saved.
|
|
26
|
+
function mayWriteNamedField(args) {
|
|
27
|
+
const { entity, fieldName, subField, user, row } = args;
|
|
28
|
+
if (fieldName === undefined)
|
|
29
|
+
return true;
|
|
30
|
+
const fieldRules = normalizeAccessEntry(entity.fields[fieldName]?.access?.write);
|
|
31
|
+
if (!userCanWriteFieldRow(user, fieldRules, row, row))
|
|
32
|
+
return false;
|
|
33
|
+
const subFieldRules = subField === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: normalizeAccessEntry(resolveFileSubField(entity.fields[fieldName], subField)?.access?.write);
|
|
36
|
+
return subFieldRules === undefined || userHasWriteRole(user, subFieldRules);
|
|
37
|
+
}
|
|
22
38
|
// "May this user attach a file to the record?" asks what saving the record would ask: the
|
|
23
39
|
// entity's update handler, the row-level and field-level write rules against the stored row,
|
|
24
40
|
// and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
|
|
@@ -33,7 +49,7 @@ export function createRecordWriteCheck(args) {
|
|
|
33
49
|
}
|
|
34
50
|
return undefined;
|
|
35
51
|
}
|
|
36
|
-
return async ({ entityType, entityId, fieldName, user }) => {
|
|
52
|
+
return async ({ entityType, entityId, fieldName, subField, user }) => {
|
|
37
53
|
if (!(await canReadRecord({ entityType, entityId, user }))) {
|
|
38
54
|
return RecordWriteDecisions.notFound;
|
|
39
55
|
}
|
|
@@ -53,8 +69,7 @@ export function createRecordWriteCheck(args) {
|
|
|
53
69
|
if (!userCanWriteFieldRow(user, entity.access?.write, row, row)) {
|
|
54
70
|
return RecordWriteDecisions.forbidden;
|
|
55
71
|
}
|
|
56
|
-
|
|
57
|
-
if (!userCanWriteFieldRow(user, normalizeAccessEntry(fieldWriteRules), row, row)) {
|
|
72
|
+
if (!mayWriteNamedField({ entity, fieldName, subField, user, row })) {
|
|
58
73
|
return RecordWriteDecisions.forbidden;
|
|
59
74
|
}
|
|
60
75
|
if (isRecordLocked(entity.lockedWhen, row))
|
|
@@ -4,6 +4,7 @@ type UploadTarget = {
|
|
|
4
4
|
readonly entityType?: string | undefined;
|
|
5
5
|
readonly entityId?: string | undefined;
|
|
6
6
|
readonly fieldName?: string | undefined;
|
|
7
|
+
readonly subField?: string | undefined;
|
|
7
8
|
readonly actionForm?: string | undefined;
|
|
8
9
|
};
|
|
9
10
|
export type AttachTargets = "entities" | "entities-and-read-models";
|
|
@@ -15,7 +16,7 @@ type AttachedFieldResolution = {
|
|
|
15
16
|
} | {
|
|
16
17
|
readonly kind: "unresolvable";
|
|
17
18
|
};
|
|
18
|
-
export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets): AttachedFieldResolution;
|
|
19
|
+
export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets, subField?: string): AttachedFieldResolution;
|
|
19
20
|
type UploadFieldResolution = AttachedFieldResolution | {
|
|
20
21
|
readonly kind: "rejected";
|
|
21
22
|
readonly error: string;
|
|
@@ -1,30 +1,48 @@
|
|
|
1
1
|
import { isFileField } from "../engine/field-helpers.js";
|
|
2
2
|
import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
|
|
3
|
+
import { resolveFileSubField } from "./file-field-slots.js";
|
|
4
|
+
function findAttachEntity(registry, entityType, attachTargets) {
|
|
5
|
+
return (registry?.getEntity(entityType) ??
|
|
6
|
+
(attachTargets === "entities-and-read-models"
|
|
7
|
+
? registry?.getReadModelEntity(entityType)
|
|
8
|
+
: undefined));
|
|
9
|
+
}
|
|
3
10
|
// entityType/fieldName drive the GDPR-forget decision downstream
|
|
4
11
|
// (fileRefDeleteHook resolves the field's PII annotation from exactly this
|
|
5
12
|
// pairing) — attaching to a field nobody can resolve would leave that
|
|
6
|
-
// decision hanging on an unverified client string.
|
|
13
|
+
// decision hanging on an unverified client string. `subField` names a file sub-field of an
|
|
14
|
+
// embedded `fieldName`; the binding stays on the top-level field. Either both are omitted
|
|
7
15
|
// (unattached upload, allowed) or both must resolve to a real registered
|
|
8
16
|
// field. "entities-and-read-models" is for server-side callers only: a client upload must
|
|
9
17
|
// never name a projection entity, whose rows nobody can write-check.
|
|
10
|
-
export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities") {
|
|
18
|
+
export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities", subField) {
|
|
11
19
|
if (entityType === undefined && fieldName === undefined)
|
|
12
20
|
return { kind: "unattached" };
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
if (!entityType || !fieldName)
|
|
22
|
+
return { kind: "unresolvable" };
|
|
23
|
+
const fieldDef = findAttachEntity(registry, entityType, attachTargets)?.fields[fieldName];
|
|
24
|
+
if (!fieldDef)
|
|
25
|
+
return { kind: "unresolvable" };
|
|
26
|
+
if (subField !== undefined) {
|
|
27
|
+
const subFieldDef = resolveFileSubField(fieldDef, subField);
|
|
28
|
+
return subFieldDef === undefined
|
|
29
|
+
? { kind: "unresolvable" }
|
|
30
|
+
: { kind: "resolved", fieldDef: subFieldDef };
|
|
31
|
+
}
|
|
32
|
+
if (!isFileField(fieldDef))
|
|
21
33
|
return { kind: "unresolvable" };
|
|
22
34
|
return { kind: "resolved", fieldDef };
|
|
23
35
|
}
|
|
24
36
|
export function resolveUploadField(registry, input, attachTargets) {
|
|
25
|
-
const { entityType, entityId, fieldName, actionForm } = input;
|
|
37
|
+
const { entityType, entityId, fieldName, subField, actionForm } = input;
|
|
26
38
|
if (actionForm === undefined) {
|
|
27
|
-
return resolveAttachedField(registry, entityType, fieldName, attachTargets);
|
|
39
|
+
return resolveAttachedField(registry, entityType, fieldName, attachTargets, subField);
|
|
40
|
+
}
|
|
41
|
+
if (subField !== undefined) {
|
|
42
|
+
return {
|
|
43
|
+
kind: "rejected",
|
|
44
|
+
error: "invalid_action_form: actionForm cannot be combined with subField",
|
|
45
|
+
};
|
|
28
46
|
}
|
|
29
47
|
if (entityType !== undefined || entityId !== undefined) {
|
|
30
48
|
return {
|
|
@@ -72,5 +90,7 @@ export function isAnonymousPendingUploadAllowed(registry, target) {
|
|
|
72
90
|
if (target.entityId !== undefined || target.actionForm !== undefined)
|
|
73
91
|
return false;
|
|
74
92
|
const resolution = resolveAttachedField(registry, target.entityType, target.fieldName);
|
|
75
|
-
return resolution.kind === "resolved" &&
|
|
93
|
+
return (resolution.kind === "resolved" &&
|
|
94
|
+
isFileField(resolution.fieldDef) &&
|
|
95
|
+
resolution.fieldDef.anonymousUpload === true);
|
|
76
96
|
}
|
|
@@ -176,12 +176,13 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
176
176
|
for (const r of results) {
|
|
177
177
|
if (!r.isSuccess)
|
|
178
178
|
continue;
|
|
179
|
-
|
|
179
|
+
const lifecycleResult = r.lifecycle ?? r.data;
|
|
180
|
+
if (!isLifecycleResult(lifecycleResult))
|
|
180
181
|
continue;
|
|
181
|
-
if (
|
|
182
|
-
saves.push(
|
|
183
|
-
else if (
|
|
184
|
-
deletes.push(
|
|
182
|
+
if (lifecycleResult.kind === "save")
|
|
183
|
+
saves.push(lifecycleResult);
|
|
184
|
+
else if (lifecycleResult.kind === "delete")
|
|
185
|
+
deletes.push(lifecycleResult);
|
|
185
186
|
}
|
|
186
187
|
// skip: nothing to flush when there are no lifecycle hooks or no successful save/delete
|
|
187
188
|
if (!lifecycle || (saves.length === 0 && deletes.length === 0))
|
|
@@ -508,7 +508,7 @@ async function invokeWriteHandler(handler, type, gated, user, handlerContext) {
|
|
|
508
508
|
async function runWriteLifecycle(ctx, args) {
|
|
509
509
|
try {
|
|
510
510
|
const runner = resolveDbSource(ctx, args.tx);
|
|
511
|
-
await runLifecycle(ctx, args.type, args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
|
|
511
|
+
await runLifecycle(ctx, args.type, args.result.lifecycle ?? args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
|
|
512
512
|
}
|
|
513
513
|
catch (e) {
|
|
514
514
|
return writeFailure(wrapToKumiko(e));
|
|
@@ -584,5 +584,6 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
584
584
|
// data / previous / changes). Feature code that fed a plaintext through
|
|
585
585
|
// to the return payload fails here instead of hitting the client.
|
|
586
586
|
assertNoSecretLeak(result.data);
|
|
587
|
+
assertNoSecretLeak(result.lifecycle);
|
|
587
588
|
return result;
|
|
588
589
|
}
|
|
@@ -25,10 +25,11 @@ function maskLifecycleOrRow(entity, data) {
|
|
|
25
25
|
export function maskWriteResultForClient(registry, handlerType, result) {
|
|
26
26
|
if (!result.isSuccess)
|
|
27
27
|
return result;
|
|
28
|
+
const { lifecycle, ...clientResult } = result;
|
|
28
29
|
const entityName = registry.getHandlerEntity(handlerType);
|
|
29
30
|
const entity = entityName ? registry.getEntity(entityName) : undefined;
|
|
30
31
|
const data = maskLifecycleOrRow(entity, result.data);
|
|
31
|
-
return data === result.data ? result : { ...
|
|
32
|
+
return data === result.data && lifecycle === undefined ? result : { ...clientResult, data };
|
|
32
33
|
}
|
|
33
34
|
export function maskBatchResultForClient(registry, commands, batch) {
|
|
34
35
|
const results = batch.results.map((result, index) => {
|
|
@@ -4,6 +4,7 @@ import { assertRowLevelSecurityTableState, createDbConnection, generateMigration
|
|
|
4
4
|
import { isLiveFrameworkTable } from "../db/known-table-names.js";
|
|
5
5
|
import { readMigrationSqlTexts } from "../db/migrate-runner.js";
|
|
6
6
|
import { diffReplayAgainstSnapshot, replayMigrationsDir } from "../db/replay-migration-sql.js";
|
|
7
|
+
import { BootValidationError } from "../engine/boot-validator/boot-rule.js";
|
|
7
8
|
import { validateBoot } from "../engine/boot-validator.js";
|
|
8
9
|
import { describeThrown, loadPreviousSnapshot, loadSchemaModOrReport, nextSequenceNumber, SNAPSHOT_FILENAME, } from "./shared.js";
|
|
9
10
|
import { featureDefinitionsOf, parseValidateBootOptions, withFileStoragePresence, } from "./validate-boot-input.js";
|
|
@@ -35,6 +36,16 @@ function checkSchemaDrift(ctx, schemaMod, prevSnapshot) {
|
|
|
35
36
|
out.err(" Fix: `kumiko-schema generate <name>`, then commit the migration.");
|
|
36
37
|
return false;
|
|
37
38
|
}
|
|
39
|
+
function unclassifiedViolation(check, e) {
|
|
40
|
+
return { ruleId: `unclassified/${check}`, check, message: describeThrown(e) };
|
|
41
|
+
}
|
|
42
|
+
function reportBootViolations(out, violations) {
|
|
43
|
+
for (const v of violations) {
|
|
44
|
+
out.err(` ✗ boot [${v.ruleId}] ${v.message}`);
|
|
45
|
+
if (v.fix !== undefined)
|
|
46
|
+
out.err(` Fix: ${v.fix}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
38
49
|
// 2. Boot validity — needs the composed feature set.
|
|
39
50
|
function checkBootValidity(out, schemaMod) {
|
|
40
51
|
let bootFeatures;
|
|
@@ -43,11 +54,11 @@ function checkBootValidity(out, schemaMod) {
|
|
|
43
54
|
}
|
|
44
55
|
catch (e) {
|
|
45
56
|
out.err(` ✗ boot: ${describeThrown(e)}`);
|
|
46
|
-
return false;
|
|
57
|
+
return { ok: false, violations: [unclassifiedViolation("featureDefinitionsOf", e)] };
|
|
47
58
|
}
|
|
48
59
|
if (bootFeatures === undefined) {
|
|
49
60
|
out.log(" · boot: skipped — add `export const FEATURES = composeFeatures(APP_FEATURES, { includeBundled: true })` to kumiko/schema.ts to enable validateBoot.");
|
|
50
|
-
return true;
|
|
61
|
+
return { ok: true, skipped: true, violations: [] };
|
|
51
62
|
}
|
|
52
63
|
try {
|
|
53
64
|
validateBoot(bootFeatures, {
|
|
@@ -55,11 +66,15 @@ function checkBootValidity(out, schemaMod) {
|
|
|
55
66
|
env: withFileStoragePresence(process.env),
|
|
56
67
|
});
|
|
57
68
|
out.log(" ✓ boot: feature configuration is valid");
|
|
58
|
-
return true;
|
|
69
|
+
return { ok: true, violations: [] };
|
|
59
70
|
}
|
|
60
71
|
catch (e) {
|
|
72
|
+
if (e instanceof BootValidationError) {
|
|
73
|
+
reportBootViolations(out, e.violations);
|
|
74
|
+
return { ok: false, violations: e.violations };
|
|
75
|
+
}
|
|
61
76
|
out.err(` ✗ boot: ${describeThrown(e)}`);
|
|
62
|
-
return false;
|
|
77
|
+
return { ok: false, violations: [unclassifiedViolation("validateBoot", e)] };
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
function declaredTablesOf(schemaMod) {
|
|
@@ -150,7 +165,8 @@ function checkMigrationContentDrift(ctx, prevSnapshot) {
|
|
|
150
165
|
// added/changed but never generated → missing table → prod 500)
|
|
151
166
|
// 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
|
|
152
167
|
// nav/role refs). Runs only if kumiko/schema.ts exports FEATURES;
|
|
153
|
-
// honours its optional VALIDATE_BOOT_OPTIONS export.
|
|
168
|
+
// honours its optional VALIDATE_BOOT_OPTIONS export. `--json` prints
|
|
169
|
+
// this result as one JSON document on stdout.
|
|
154
170
|
// 3. migration-content drift: replay every committed *.sql file and
|
|
155
171
|
// diff the result against .snapshot.json — catches a migration
|
|
156
172
|
// file whose SQL body doesn't match what its own snapshot entry
|
|
@@ -161,14 +177,32 @@ function checkMigrationContentDrift(ctx, prevSnapshot) {
|
|
|
161
177
|
// 4. live row-level security (only with DATABASE_URL): the boot's
|
|
162
178
|
// table-state check against the real database (not the role: that is a boot-time property).
|
|
163
179
|
// The DB-level gate (assertKumikoSchemaCurrent) stays at boot/deploy.
|
|
180
|
+
function printBootJson(ctx, boot) {
|
|
181
|
+
const { ok, skipped, violations } = boot;
|
|
182
|
+
ctx.out.log(JSON.stringify({ boot: { ok, ...(skipped ? { skipped } : {}), violations } }));
|
|
183
|
+
}
|
|
164
184
|
export async function runValidateCommand(ctx) {
|
|
165
|
-
const
|
|
166
|
-
|
|
185
|
+
const jsonMode = ctx.argv.includes("--json");
|
|
186
|
+
// stdout carries exactly one JSON document in --json mode.
|
|
187
|
+
const out = jsonMode ? { log: ctx.out.err, err: ctx.out.err } : ctx.out;
|
|
188
|
+
const humanCtx = { ...ctx, out };
|
|
189
|
+
const schemaMod = await loadSchemaModOrReport(humanCtx);
|
|
190
|
+
if (schemaMod === undefined) {
|
|
191
|
+
if (jsonMode) {
|
|
192
|
+
const message = "kumiko/schema.ts could not be loaded";
|
|
193
|
+
printBootJson(ctx, {
|
|
194
|
+
ok: false,
|
|
195
|
+
violations: [{ ruleId: "unclassified/loadSchemaMod", check: "loadSchemaMod", message }],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
167
198
|
return 1;
|
|
199
|
+
}
|
|
168
200
|
const prevSnapshot = loadPreviousSnapshot(join(ctx.migrationsDir, SNAPSHOT_FILENAME));
|
|
169
|
-
const schemaOk = checkSchemaDrift(
|
|
170
|
-
const
|
|
171
|
-
const migrationsOk = checkMigrationContentDrift(
|
|
172
|
-
const rowLevelSecurityOk = await checkLiveRowLevelSecurity(
|
|
173
|
-
|
|
201
|
+
const schemaOk = checkSchemaDrift(humanCtx, schemaMod, prevSnapshot);
|
|
202
|
+
const boot = checkBootValidity(out, schemaMod);
|
|
203
|
+
const migrationsOk = checkMigrationContentDrift(humanCtx, prevSnapshot);
|
|
204
|
+
const rowLevelSecurityOk = await checkLiveRowLevelSecurity(out, schemaMod);
|
|
205
|
+
if (jsonMode)
|
|
206
|
+
printBootJson(ctx, boot);
|
|
207
|
+
return schemaOk && boot.ok && migrationsOk && rowLevelSecurityOk ? 0 : 1;
|
|
174
208
|
}
|
package/dist/schema-cli.js
CHANGED
|
@@ -28,7 +28,7 @@ function printSchemaCliUsage(out) {
|
|
|
28
28
|
out.log("");
|
|
29
29
|
out.log(" Subcommands:");
|
|
30
30
|
out.log(" generate <name> Write a new migration from the EntityTableMeta diff");
|
|
31
|
-
out.log(" validate
|
|
31
|
+
out.log(" validate [--json] Static CI gate (no DB): schema drift + validateBoot");
|
|
32
32
|
out.log(" apply Apply pending checked-in SQL files");
|
|
33
33
|
out.log(" baseline Mark checked-in migrations as applied (no SQL run)");
|
|
34
34
|
out.log(" status List applied vs pending");
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
export type { DerivedCellRoundingTarget } from "../engine/embedded-derived.ts";
|
|
3
|
-
export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.ts";
|
|
3
|
+
export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "../engine/embedded-derived.ts";
|
|
4
|
+
export { findMissingConditionalRequirements, isFieldRequired, isFieldVisible, isMissingRequiredValue, } from "../engine/field-state.ts";
|
|
4
5
|
export type { FormDerivationScreen } from "../engine/form-derivations.ts";
|
|
5
6
|
export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.ts";
|
|
6
7
|
export type { ParsedRefTarget } from "../engine/parse-ref-target.ts";
|
package/dist/ui-types/index.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// When adding a symbol here, verify it's either a type or a pure
|
|
21
21
|
// helper with no cross-module side-effects.
|
|
22
22
|
export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
23
|
-
export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.js";
|
|
23
|
+
export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "../engine/embedded-derived.js";
|
|
24
|
+
export { findMissingConditionalRequirements, isFieldRequired, isFieldVisible, isMissingRequiredValue, } from "../engine/field-state.js";
|
|
24
25
|
export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.js";
|
|
25
26
|
export { parseRefTarget } from "../engine/parse-ref-target.js";
|
|
26
27
|
export { ACTION_FORM_PSEUDO_ENTITY, availableSelectOptions, evalFieldCondition, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.400.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.400.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"hono": "^4.13.14",
|
|
209
209
|
"i18next": "^26.1.0",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.400.0",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
230
|
"fflate": "^0.8.3",
|
|
231
231
|
"meilisearch": "^0.58.0",
|