@cosmicdrift/kumiko-framework 0.393.0 → 0.395.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 +1 -1
- package/agents/examples/cross-feature-events.md +87 -0
- package/agents/examples/dashboard-stats.md +141 -2
- package/agents/examples/env-composition.md +7 -7
- package/agents/examples/inbound-mail-triage.md +12 -0
- package/agents/examples/pdf-audit-export.md +103 -50
- package/agents/features.md +17 -15
- package/agents/widgets.md +1 -1
- package/dist/api/entity-read-access.d.ts +1 -0
- package/dist/api/entity-read-access.js +20 -5
- package/dist/api/server-event-dispatcher.js +23 -2
- package/dist/compliance/profiles.js +4 -4
- package/dist/crypto/index.d.ts +1 -0
- package/dist/crypto/index.js +1 -0
- package/dist/crypto/kms-outage.d.ts +8 -0
- package/dist/crypto/kms-outage.js +33 -0
- package/dist/crypto/pii-field-encryption.js +4 -3
- package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
- package/dist/engine/boot-validator/entity-handler.js +2 -16
- package/dist/engine/boot-validator/query-output-columns.js +7 -0
- package/dist/engine/boot-validator/query-refs.js +9 -0
- package/dist/engine/boot-validator/screens-actions.js +19 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
- package/dist/engine/boot-validator/screens-dashboard.js +13 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
- package/dist/engine/boot-validator/screens-lists.js +3 -0
- package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
- package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
- package/dist/engine/boot-validator/transaction-phases.js +28 -0
- package/dist/engine/define-handler.d.ts +3 -2
- package/dist/engine/define-handler.js +3 -0
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +1 -17
- package/dist/engine/handler-definition-slots.d.ts +17 -0
- package/dist/engine/handler-definition-slots.js +20 -0
- package/dist/engine/qualified-name.d.ts +2 -1
- package/dist/engine/qualified-name.js +2 -14
- package/dist/engine/registry-facade.js +6 -0
- package/dist/engine/registry-read-models.d.ts +2 -0
- package/dist/engine/registry-read-models.js +24 -0
- package/dist/engine/registry-state.d.ts +4 -0
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/registry-validate.js +12 -1
- package/dist/engine/registry.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
- package/dist/events-cli.d.ts +19 -0
- package/dist/events-cli.js +121 -0
- package/dist/files/bind-pending-file-refs.js +2 -1
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/can-caller-read-file-ref.d.ts +10 -0
- package/dist/files/can-caller-read-file-ref.js +12 -0
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +7 -2
- package/dist/files/create-file-ref.d.ts +4 -12
- package/dist/files/create-file-ref.js +2 -45
- package/dist/files/default-file-guard.d.ts +4 -0
- package/dist/files/default-file-guard.js +41 -0
- package/dist/files/delete-record-file-refs.d.ts +8 -0
- package/dist/files/delete-record-file-refs.js +20 -0
- package/dist/files/file-routes.d.ts +1 -0
- package/dist/files/file-routes.js +19 -38
- package/dist/files/index.d.ts +6 -2
- package/dist/files/index.js +4 -1
- package/dist/files/record-only-file-entity-types.d.ts +2 -0
- package/dist/files/record-only-file-entity-types.js +22 -0
- package/dist/files/record-read-check.js +11 -0
- package/dist/files/types.d.ts +2 -1
- package/dist/files/types.js +1 -1
- package/dist/files/upload-field-resolution.d.ts +24 -0
- package/dist/files/upload-field-resolution.js +51 -0
- package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
- package/dist/jobs/job-runner-event-triggers.js +64 -0
- package/dist/jobs/job-runner-helpers.d.ts +5 -0
- package/dist/jobs/job-runner-helpers.js +10 -0
- package/dist/jobs/job-runner-types.d.ts +1 -0
- package/dist/jobs/job-runner.js +3 -51
- package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
- package/dist/pipeline/aggregate-type-removal.js +146 -0
- package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
- package/dist/pipeline/catch-up-job-consumer.js +38 -0
- package/dist/pipeline/dispatch-write.js +4 -9
- package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
- package/dist/pipeline/job-trigger-consumer.js +48 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
- package/dist/pipeline/pre-transaction-reservations.js +16 -0
- package/dist/pipeline/system-hooks.d.ts +0 -3
- package/dist/pipeline/system-hooks.js +0 -48
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +11 -3
- package/src/changes.json +48 -0
|
@@ -2,6 +2,7 @@ import { normalizeListColumn } from "../screen-helpers.js";
|
|
|
2
2
|
import { validateListBulkActions } from "./list-bulk-actions.js";
|
|
3
3
|
import { validateListTableOptions } from "./list-table-options.js";
|
|
4
4
|
import { assertWriteHandlerRegistered, resolveRowActionNavigateTarget, validateAtMostOneRowClick, validateDrawerTargetAction, } from "./screens-actions.js";
|
|
5
|
+
import { validateDashboardPanelVisibleWhen } from "./screens-dashboard.js";
|
|
5
6
|
import { listActionContext, rejectEmptyColumns, rejectMissingQuery, rejectNonArrayInFilter, validateListColumnRendering, } from "./screens-kind-shared.js";
|
|
6
7
|
import { validateListActionTargets, validateListFacets } from "./screens-lists.js";
|
|
7
8
|
function validateProjectionListRowActions(ctx, screenId, rowActions) {
|
|
@@ -15,6 +16,7 @@ function validateProjectionListRowActions(ctx, screenId, rowActions) {
|
|
|
15
16
|
function validateProjectionListToolbarActions(ctx, screenId, toolbarActions) {
|
|
16
17
|
const featureName = ctx.feature.name;
|
|
17
18
|
for (const action of toolbarActions) {
|
|
19
|
+
validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (projectionList) toolbarAction "${action.id}"`, action.visibleWhen);
|
|
18
20
|
if (action.kind === "drawer") {
|
|
19
21
|
validateDrawerTargetAction(featureName, screenId, "projectionList", "toolbarAction", action, ctx.feature.screens);
|
|
20
22
|
}
|
|
@@ -201,6 +201,9 @@ function validateRelatedListDefaultSort(args, defaultSort) {
|
|
|
201
201
|
function validateRelatedListToolbarActions(args, toolbarActions) {
|
|
202
202
|
const { featureName, screenId, screenType, screens, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
203
203
|
for (const action of toolbarActions) {
|
|
204
|
+
if (action.visibleWhen !== undefined) {
|
|
205
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList toolbarAction "${action.id}" sets visibleWhen, which only list-screen toolbars support. Use visible (a condition on the enclosing record) instead.`);
|
|
206
|
+
}
|
|
204
207
|
if (action.kind === "drawer") {
|
|
205
208
|
validateDrawerTargetAction(featureName, screenId, screenType, "toolbarAction", action, screens);
|
|
206
209
|
}
|
|
@@ -15,7 +15,7 @@ export function validateExtensionSelectorPanels(features) {
|
|
|
15
15
|
validateDashboardCustomPanel(feature.name, where, panel);
|
|
16
16
|
continue;
|
|
17
17
|
}
|
|
18
|
-
validateDashboardScreenPanel(feature.name, where, panel, featureMap);
|
|
18
|
+
validateDashboardScreenPanel(feature.name, where, panel, featureMap, undefined);
|
|
19
19
|
if (panel.visibleWhen !== undefined) {
|
|
20
20
|
checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `${context} visibleWhen`);
|
|
21
21
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types/index.ts";
|
|
2
|
+
export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
3
|
+
export declare function validateBeforeTransactionWhenNested(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function perCommandConflict(handler) {
|
|
2
|
+
if (handler.transaction !== "perCommand")
|
|
3
|
+
return undefined;
|
|
4
|
+
if (handler.beforeTransaction)
|
|
5
|
+
return "beforeTransaction";
|
|
6
|
+
if (handler.reserveBeforeTransaction)
|
|
7
|
+
return "reserveBeforeTransaction";
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
export function validatePerCommandTransaction(featureName, handlerName, handler) {
|
|
11
|
+
const conflicting = perCommandConflict(handler);
|
|
12
|
+
if (conflicting !== undefined) {
|
|
13
|
+
throw new Error(`write handler "${featureName}:write:${handlerName}" declares transaction: "perCommand" together with ${conflicting} — ` +
|
|
14
|
+
"a perCommand handler holds no transaction, so move the phase into the commands it issues via ctx.dispatch.write.");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function validateBeforeTransactionWhenNested(featureName, handlerName, handler) {
|
|
18
|
+
// skip: handler does not opt into running nested without the phase
|
|
19
|
+
if (handler.beforeTransactionWhenNested === undefined)
|
|
20
|
+
return;
|
|
21
|
+
const where = `write handler "${featureName}:write:${handlerName}" declares beforeTransactionWhenNested`;
|
|
22
|
+
if (!handler.beforeTransaction) {
|
|
23
|
+
throw new Error(`${where} without beforeTransaction — there is no phase to skip.`);
|
|
24
|
+
}
|
|
25
|
+
if (handler.reserveBeforeTransaction) {
|
|
26
|
+
throw new Error(`${where} together with reserveBeforeTransaction — a nested run cannot skip the reservation, it would run unreserved.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
|
|
2
2
|
import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-types/event-type-map";
|
|
3
|
+
import type { BeforeTransactionWhenNested } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
4
|
import type { ZodType } from "zod";
|
|
4
5
|
import type { ContainsSecret } from "../secrets/types.ts";
|
|
5
6
|
import type { QueryHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput } from "./types/define-handler.ts";
|
|
6
7
|
export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "./types/define-handler.ts";
|
|
7
|
-
export declare function defineWriteHandler<const TName extends string, TSchema extends ZodType, TData = unknown, TMap extends object = KumikoEventTypeMap, TPrepared = undefined>(def: WriteHandlerInput<TName, TSchema, TData, TMap, TPrepared>, ..._noSecretInResponse: true extends ContainsSecret<TData> ? [
|
|
8
|
+
export declare function defineWriteHandler<const TName extends string, TSchema extends ZodType, TData = unknown, TMap extends object = KumikoEventTypeMap, TPrepared = undefined, TWhenNested extends BeforeTransactionWhenNested | undefined = undefined>(def: WriteHandlerInput<TName, TSchema, TData, TMap, TPrepared, TWhenNested>, ..._noSecretInResponse: true extends ContainsSecret<TData> ? [
|
|
8
9
|
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field."
|
|
9
|
-
] : []): WriteHandlerDefinition<TName, TSchema, TData, TMap, TPrepared>;
|
|
10
|
+
] : []): WriteHandlerDefinition<TName, TSchema, TData, TMap, TPrepared, TWhenNested>;
|
|
10
11
|
export declare function defineQueryHandler<const TName extends string, TSchema extends ZodType, TResult = unknown, TMap extends object = KumikoEventTypeMap>(def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>, ..._noSecretInResponse: true extends ContainsSecret<TResult> ? [
|
|
11
12
|
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field."
|
|
12
13
|
] : []): QueryHandlerDefinition<TName, TSchema, TResult, TMap>;
|
|
@@ -5,6 +5,9 @@ function pickPreTransactionPhases(def) {
|
|
|
5
5
|
return {
|
|
6
6
|
...(def.reserveBeforeTransaction && { reserveBeforeTransaction: def.reserveBeforeTransaction }),
|
|
7
7
|
...(def.beforeTransaction && { beforeTransaction: def.beforeTransaction }),
|
|
8
|
+
...(def.beforeTransactionWhenNested && {
|
|
9
|
+
beforeTransactionWhenNested: def.beforeTransactionWhenNested,
|
|
10
|
+
}),
|
|
8
11
|
};
|
|
9
12
|
}
|
|
10
13
|
// `in` keeps both branches of the discriminated union readable regardless of narrowing.
|
|
@@ -199,4 +199,6 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
|
|
|
199
199
|
readonly tenancy?: T;
|
|
200
200
|
/** See EntityDefinition.transferable. */
|
|
201
201
|
readonly transferable?: boolean;
|
|
202
|
+
/** See EntityDefinition.attachedFileAccess. */
|
|
203
|
+
readonly attachedFileAccess?: EntityDefinition["attachedFileAccess"];
|
|
202
204
|
}): F extends FieldsMap ? EntityDefinition<F, T> : never;
|
|
@@ -108,6 +108,9 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
108
108
|
...(jobOptions.trigger.where !== undefined
|
|
109
109
|
? { where: jobOptions.trigger.where }
|
|
110
110
|
: {}),
|
|
111
|
+
...(jobOptions.trigger.catchUp !== undefined
|
|
112
|
+
? { catchUp: jobOptions.trigger.catchUp }
|
|
113
|
+
: {}),
|
|
111
114
|
}
|
|
112
115
|
: jobOptions.trigger;
|
|
113
116
|
state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-types/event-type-map";
|
|
2
2
|
import type { EntityObjectDefinition } from "@cosmicdrift/kumiko-types/feature-registrar";
|
|
3
3
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
4
|
-
import type { AccessRule, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, RateLimitDeclaration, StreamHandlerFn } from "@cosmicdrift/kumiko-types/handlers";
|
|
4
|
+
import type { AccessRule, BeforeTransactionWhenNested, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, RateLimitDeclaration, StreamHandlerFn } from "@cosmicdrift/kumiko-types/handlers";
|
|
5
5
|
import type { RelationDefinition } from "@cosmicdrift/kumiko-types/relations";
|
|
6
6
|
import type * as z from "zod";
|
|
7
7
|
import type { ZodType } from "zod";
|
|
@@ -14,7 +14,7 @@ export declare function buildEntityHandlerMethods<TName extends string>(state: F
|
|
|
14
14
|
readonly entity: NameOrRef;
|
|
15
15
|
readonly name: string;
|
|
16
16
|
} & RelationDefinition), relationName?: string, definition?: RelationDefinition): void;
|
|
17
|
-
writeHandler<TName_1 extends string, TSchema extends ZodType, TPrepared = undefined>(def: WriteHandlerDefinition<TName_1, TSchema, unknown, KumikoEventTypeMap, TPrepared>): HandlerRef;
|
|
17
|
+
writeHandler<TName_1 extends string, TSchema extends ZodType, TPrepared = undefined, TWhenNested extends BeforeTransactionWhenNested | undefined = undefined>(def: WriteHandlerDefinition<TName_1, TSchema, unknown, KumikoEventTypeMap, TPrepared, TWhenNested>): HandlerRef;
|
|
18
18
|
queryHandler<TName_1 extends string, TSchema extends ZodType>(def: QueryHandlerDefinition<TName_1, TSchema>): HandlerRef;
|
|
19
19
|
streamHandler<TName_1 extends string, TSchema extends ZodType>(nameOrDef: string | StreamHandlerDefinition<TName_1, TSchema>, schema?: TSchema, handler?: StreamHandlerFn<z.infer<TSchema>>, options?: {
|
|
20
20
|
access: AccessRule;
|
|
@@ -2,6 +2,7 @@ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handler
|
|
|
2
2
|
import { toTableName } from "../db/table-builder.js";
|
|
3
3
|
import { isEntityConventionQueryHandler, isPagedQueryHandler, PAGED_QUERY_HANDLER_BRAND, } from "./define-handler.js";
|
|
4
4
|
import { assertEscapeHatchAccessIsSystemAdminOnly, registerCrudHandlers, } from "./entity-handlers.js";
|
|
5
|
+
import { agentSlots, transactionPhaseSlots } from "./handler-definition-slots.js";
|
|
5
6
|
import { resolveName } from "./handler-helpers.js";
|
|
6
7
|
import { isObjectArgument, requireArgument, throwObjectArgumentRequired } from "./object-form.js";
|
|
7
8
|
const CRUD_VERBS = new Set(["create", "update", "delete"]);
|
|
@@ -48,23 +49,6 @@ function tryMapEntity(state, name, handlerName) {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
|
-
function agentSlots(source) {
|
|
52
|
-
return {
|
|
53
|
-
...(source.description !== undefined && { description: source.description }),
|
|
54
|
-
...(source.agent && { agent: source.agent }),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
function transactionPhaseSlots(def) {
|
|
58
|
-
return {
|
|
59
|
-
...(def.reserveBeforeTransaction && {
|
|
60
|
-
reserveBeforeTransaction: def.reserveBeforeTransaction,
|
|
61
|
-
}),
|
|
62
|
-
...(def.beforeTransaction && {
|
|
63
|
-
beforeTransaction: def.beforeTransaction, // @cast-boundary engine-bridge
|
|
64
|
-
}),
|
|
65
|
-
...(def.transaction && { transaction: def.transaction }),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
52
|
// Typed callers cannot omit `access`; untyped ones (JS, designer output) must not get a
|
|
69
53
|
// handler that silently falls through to the boot-time default-deny.
|
|
70
54
|
function assertHandlerHasAccess(featureName, method, handlerName, access) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-types/event-type-map";
|
|
2
|
+
import type { AgentHandlerHints, BeforeTransactionPhase, BeforeTransactionWhenNested } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import type { ZodType } from "zod";
|
|
4
|
+
import type { WriteHandlerDefinition } from "./define-handler.ts";
|
|
5
|
+
export declare function agentSlots(source: {
|
|
6
|
+
readonly description?: string;
|
|
7
|
+
readonly agent?: AgentHandlerHints;
|
|
8
|
+
}): {
|
|
9
|
+
description?: string;
|
|
10
|
+
agent?: AgentHandlerHints;
|
|
11
|
+
};
|
|
12
|
+
export declare function transactionPhaseSlots<TSchema extends ZodType, TPrepared, TWhenNested extends BeforeTransactionWhenNested | undefined>(def: Pick<WriteHandlerDefinition<string, TSchema, unknown, KumikoEventTypeMap, TPrepared, TWhenNested>, "reserveBeforeTransaction" | "beforeTransaction" | "beforeTransactionWhenNested" | "transaction">): {
|
|
13
|
+
reserveBeforeTransaction?: import("@cosmicdrift/kumiko-types/handlers").PreTransactionReservation | undefined;
|
|
14
|
+
beforeTransaction?: BeforeTransactionPhase | undefined;
|
|
15
|
+
beforeTransactionWhenNested?: NonNullable<TWhenNested> | undefined;
|
|
16
|
+
transaction?: "perCommand" | undefined;
|
|
17
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function agentSlots(source) {
|
|
2
|
+
return {
|
|
3
|
+
...(source.description !== undefined && { description: source.description }),
|
|
4
|
+
...(source.agent && { agent: source.agent }),
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function transactionPhaseSlots(def) {
|
|
8
|
+
return {
|
|
9
|
+
...(def.reserveBeforeTransaction && {
|
|
10
|
+
reserveBeforeTransaction: def.reserveBeforeTransaction,
|
|
11
|
+
}),
|
|
12
|
+
...(def.beforeTransaction && {
|
|
13
|
+
beforeTransaction: def.beforeTransaction, // @cast-boundary engine-bridge
|
|
14
|
+
}),
|
|
15
|
+
...(def.beforeTransactionWhenNested && {
|
|
16
|
+
beforeTransactionWhenNested: def.beforeTransactionWhenNested,
|
|
17
|
+
}),
|
|
18
|
+
...(def.transaction && { transaction: def.transaction }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
|
|
2
|
+
export { toKebab };
|
|
1
3
|
export declare const QnTypes: {
|
|
2
4
|
readonly write: "write";
|
|
3
5
|
readonly query: "query";
|
|
@@ -24,4 +26,3 @@ export declare function isValidQn(value: string): boolean;
|
|
|
24
26
|
export declare function isKebabSegment(name: string): boolean;
|
|
25
27
|
export declare function qnScope(qualifiedName: string): string | undefined;
|
|
26
28
|
export declare function qualifyEntityName(featureName: string, type: QnType, shortName: string): string;
|
|
27
|
-
export declare function toKebab(input: string): string;
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// "tasks:write:task:create" → scope=tasks, type=write, name=task:create
|
|
9
9
|
// "system:hook:search-index" → scope=system, type=hook, name=search-index
|
|
10
10
|
// "billing:notify:invoice-sent" → scope=billing, type=notify, name=invoice-sent
|
|
11
|
+
import { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
|
|
12
|
+
export { toKebab };
|
|
11
13
|
// Built-in QN types used by the framework.
|
|
12
14
|
// Features can define additional types — the type segment is validated for format only, not membership.
|
|
13
15
|
export const QnTypes = {
|
|
@@ -122,17 +124,3 @@ export function qnScope(qualifiedName) {
|
|
|
122
124
|
export function qualifyEntityName(featureName, type, shortName) {
|
|
123
125
|
return qn(toKebab(featureName), type, toKebab(shortName));
|
|
124
126
|
}
|
|
125
|
-
// Convert camelCase or dot.separated strings to kebab-case.
|
|
126
|
-
// "task.create" → "task-create"
|
|
127
|
-
// "ticketAssigned" → "ticket-assigned"
|
|
128
|
-
// "billing-period.create" → "billing-period-create"
|
|
129
|
-
// "monthlyReport" → "monthly-report"
|
|
130
|
-
// Already kebab-case → unchanged
|
|
131
|
-
// Colons are preserved: "task:create" → "task:create"
|
|
132
|
-
export function toKebab(input) {
|
|
133
|
-
return input
|
|
134
|
-
.replace(/\./g, "-") // dots → dashes
|
|
135
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") // consecutive uppercase: SSEBroadcast → SSE-Broadcast
|
|
136
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase boundaries: ticketAssigned → ticket-Assigned
|
|
137
|
-
.toLowerCase();
|
|
138
|
-
}
|
|
@@ -15,6 +15,12 @@ export function buildRegistryFacade(state) {
|
|
|
15
15
|
getEntity(name) {
|
|
16
16
|
return state.entityMap.get(name);
|
|
17
17
|
},
|
|
18
|
+
getReadModelEntity(name) {
|
|
19
|
+
return state.readModelEntityMap.get(name)?.entity;
|
|
20
|
+
},
|
|
21
|
+
getReadModelEntityOwner(name) {
|
|
22
|
+
return state.readModelEntityMap.get(name)?.featureName;
|
|
23
|
+
},
|
|
18
24
|
getAllEntities() {
|
|
19
25
|
return state.entityMap;
|
|
20
26
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Explicit projections that carry an entity make that entity addressable by file
|
|
2
|
+
// bindings and the file read check, keyed by the source aggregate type. They stay out of
|
|
3
|
+
// entityMap on purpose: CRUD, preSave, write checks and soft-delete cleanup hang off it.
|
|
4
|
+
export function populateReadModelEntities(state) {
|
|
5
|
+
const registeredBy = new Map();
|
|
6
|
+
for (const [qualifiedName, projection] of state.projectionMap) {
|
|
7
|
+
const { entity, source } = projection;
|
|
8
|
+
if (projection.isImplicit || entity === undefined || typeof source !== "string")
|
|
9
|
+
continue;
|
|
10
|
+
if (state.entityMap.has(source))
|
|
11
|
+
continue;
|
|
12
|
+
const previous = registeredBy.get(source);
|
|
13
|
+
if (previous !== undefined) {
|
|
14
|
+
throw new Error(`Read-model entity "${source}" is declared by two projections ("${previous}" and ` +
|
|
15
|
+
`"${qualifiedName}"). File bindings address the entity by its source, so each ` +
|
|
16
|
+
"source may carry an entity in one projection only.");
|
|
17
|
+
}
|
|
18
|
+
registeredBy.set(source, qualifiedName);
|
|
19
|
+
state.readModelEntityMap.set(source, {
|
|
20
|
+
entity,
|
|
21
|
+
featureName: qualifiedName.split(":")[0] ?? qualifiedName,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -27,6 +27,10 @@ export declare const qualify: typeof qualifyEntityName;
|
|
|
27
27
|
export type RegistryState = {
|
|
28
28
|
featureMap: Map<string, FeatureDefinition>;
|
|
29
29
|
entityMap: Map<string, EntityDefinition>;
|
|
30
|
+
readModelEntityMap: Map<string, {
|
|
31
|
+
readonly entity: EntityDefinition;
|
|
32
|
+
readonly featureName: string;
|
|
33
|
+
}>;
|
|
30
34
|
relationMap: Map<string, Record<string, RelationDefinition>>;
|
|
31
35
|
writeHandlerMap: Map<string, WriteHandlerDef>;
|
|
32
36
|
queryHandlerMap: Map<string, QueryHandlerDef>;
|
|
@@ -682,7 +682,7 @@ export function resolveJobTriggers(state) {
|
|
|
682
682
|
// existing r.defineEvent registration. The latter is delivered async via the
|
|
683
683
|
// job-trigger event-consumer (server.ts), not the synchronous write-handler
|
|
684
684
|
// dispatch path — see createJobTriggerEventConsumer in
|
|
685
|
-
// pipeline/
|
|
685
|
+
// pipeline/job-trigger-consumer.ts. Short names are qualified against the job's own
|
|
686
686
|
// feature and written back, because the job runner matches by exact QN.
|
|
687
687
|
const allHandlers = allHandlerQns(state);
|
|
688
688
|
for (const [jobName, jobDef] of state.jobMap) {
|
|
@@ -694,6 +694,14 @@ export function resolveJobTriggers(state) {
|
|
|
694
694
|
const resolvedOn = isNameOrRefList(triggerOn)
|
|
695
695
|
? triggerOn.map(resolveTrigger)
|
|
696
696
|
: resolveTrigger(triggerOn);
|
|
697
|
+
if (jobDef.trigger.catchUp) {
|
|
698
|
+
const targets = isNameOrRefList(resolvedOn) ? resolvedOn : [resolvedOn];
|
|
699
|
+
const notEvents = targets.filter((qn) => !state.eventMap.has(qn) || allHandlers.has(qn));
|
|
700
|
+
if (notEvents.length > 0) {
|
|
701
|
+
throw new Error(`Job "${jobName}" sets trigger.catchUp but ${notEvents.map((qn) => `"${qn}"`).join(", ")} ` +
|
|
702
|
+
`is not an r.defineEvent event. catchUp replays stored events, so every "on" entry must be an event.`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
697
705
|
state.jobMap.set(jobName, { ...jobDef, trigger: { ...jobDef.trigger, on: resolvedOn } });
|
|
698
706
|
}
|
|
699
707
|
}
|
|
@@ -783,6 +791,9 @@ export function validateMaxPerTenant(state) {
|
|
|
783
791
|
if (!Number.isInteger(resolved.limit) || resolved.limit <= 0) {
|
|
784
792
|
throw new Error(`Job "${jobName}" maxPerTenant limit must be a positive integer (got ${resolved.limit})`);
|
|
785
793
|
}
|
|
794
|
+
if (resolved.onLimit === "skip" && "on" in jobDef.trigger && jobDef.trigger.catchUp) {
|
|
795
|
+
throw new Error(`Job "${jobName}" cannot combine trigger.catchUp with maxPerTenant onLimit "skip" (a plain number is skip): a replay must not discard history. Use onLimit "defer".`);
|
|
796
|
+
}
|
|
786
797
|
if (typeof jobDef.maxPerTenant !== "object")
|
|
787
798
|
continue;
|
|
788
799
|
const { deferMs } = jobDef.maxPerTenant;
|
package/dist/engine/registry.js
CHANGED
|
@@ -2,6 +2,7 @@ import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler.
|
|
|
2
2
|
import { dedupeFeatures } from "./dedupe-features.js";
|
|
3
3
|
import { buildRegistryFacade } from "./registry-facade.js";
|
|
4
4
|
import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateSystemEvents, populateTranslations, } from "./registry-ingest.js";
|
|
5
|
+
import { populateReadModelEntities } from "./registry-read-models.js";
|
|
5
6
|
import { createInitialState } from "./registry-state.js";
|
|
6
7
|
import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateMaxDefers, validateMaxPerTenant, validateNotificationRecordMappings, validateNotificationRenamedFrom, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
|
|
7
8
|
// This is where the magic happens. By "magic" I mean: precomputed maps.
|
|
@@ -31,6 +32,7 @@ export function createRegistry(rawFeatures) {
|
|
|
31
32
|
applyExtensionUsages(state);
|
|
32
33
|
buildSearchableSortableCaches(state);
|
|
33
34
|
buildImplicitProjections(state, features);
|
|
35
|
+
populateReadModelEntities(state);
|
|
34
36
|
buildIncomingRelations(state);
|
|
35
37
|
validateFieldAccessHandlersAreEntityMapped(state, features);
|
|
36
38
|
validateExtensionPreSaveWiring(features);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// gone it also drops every action, redirect, nav and workspace member that
|
|
12
12
|
// pointed at them. The one reference it does not resolve is a nav `parent`
|
|
13
13
|
// pointing at a missing nav; that is pruned here.
|
|
14
|
+
import { fallbackTargets } from "@cosmicdrift/kumiko-types/screen";
|
|
14
15
|
import { createFallbackLogger, scrubLogText } from "../logging/index.js";
|
|
15
16
|
import { resolveScreenTargetQn } from "./boot-validator/screens.js";
|
|
16
17
|
import { workspaceOwnerOf } from "./build-app-schema.js";
|
|
@@ -107,12 +108,14 @@ function screensKeptForFallback(feature) {
|
|
|
107
108
|
const fallback = pending.pop()?.fallback;
|
|
108
109
|
if (fallback === undefined)
|
|
109
110
|
continue;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
for (const fallbackTarget of fallbackTargets(fallback)) {
|
|
112
|
+
const targetQn = resolveScreenTargetQn(feature.featureName, fallbackTarget);
|
|
113
|
+
const target = byQn.get(targetQn);
|
|
114
|
+
if (target === undefined || kept.has(targetQn))
|
|
115
|
+
continue;
|
|
116
|
+
kept.add(targetQn);
|
|
117
|
+
pending.push(target);
|
|
118
|
+
}
|
|
116
119
|
}
|
|
117
120
|
return feature.screens.filter((screen) => kept.has(qnOf(screen.id)));
|
|
118
121
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ConsumerCliOut } from "./consumer-cli-internals.ts";
|
|
2
|
+
import { type RowLevelSecurityMode } from "./db/render-rls-ddl.ts";
|
|
3
|
+
import type { FeatureDefinition } from "./engine/types/feature.ts";
|
|
4
|
+
export type RunEventsCliOptions = {
|
|
5
|
+
/** The composed app features: needed to validate types and to find the projections they feed. */
|
|
6
|
+
readonly features: readonly FeatureDefinition[];
|
|
7
|
+
/** The mode the app runs with (`AppConfig.rowLevelSecurity`); the removal runs in system scope. */
|
|
8
|
+
readonly rowLevelSecurity?: RowLevelSecurityMode;
|
|
9
|
+
};
|
|
10
|
+
export type ParsedEventsPruneArgs = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly types: readonly string[];
|
|
13
|
+
readonly apply: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
readonly ok: false;
|
|
16
|
+
readonly error: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function parseEventsPruneArgs(argv: readonly string[]): ParsedEventsPruneArgs;
|
|
19
|
+
export declare function runEventsCli(argv: readonly string[], out: ConsumerCliOut, options: RunEventsCliOptions): Promise<number>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Standalone event-log ops CLI (`kumiko events prune`) for the production bundle, same shape as
|
|
2
|
+
// consumer-cli.ts: one runEventsCli(argv, out, options) entry point with its own DB connection.
|
|
3
|
+
// Exists because old events of a retired aggregate type can fail the rebuild that `schema apply`
|
|
4
|
+
// runs, and no other surface removes a type from the log.
|
|
5
|
+
import { createDbConnection, dbConnectionOptionsFromEnv } from "./db/connection.js";
|
|
6
|
+
import { DEFAULT_ROW_LEVEL_SECURITY_MODE } from "./db/render-rls-ddl.js";
|
|
7
|
+
import { createRegistry } from "./engine/registry.js";
|
|
8
|
+
import { removeAggregateTypes, } from "./pipeline/aggregate-type-removal.js";
|
|
9
|
+
import { ensureTemporalPolyfill } from "./time/index.js";
|
|
10
|
+
const AGGREGATE_TYPE_FLAG = "--aggregate-type";
|
|
11
|
+
// Accepts "--aggregate-type <t>" and "--aggregate-type=<t>", repeatable. An unknown flag is an
|
|
12
|
+
// error rather than ignored: a typo such as "--aggregate-types" must not turn into "delete nothing"
|
|
13
|
+
// or, worse, into a partial selection.
|
|
14
|
+
export function parseEventsPruneArgs(argv) {
|
|
15
|
+
const types = [];
|
|
16
|
+
let apply = false;
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
if (arg === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
if (arg === "--apply") {
|
|
22
|
+
apply = true;
|
|
23
|
+
}
|
|
24
|
+
else if (arg === AGGREGATE_TYPE_FLAG) {
|
|
25
|
+
const value = argv[i + 1];
|
|
26
|
+
if (value === undefined || value.startsWith("--")) {
|
|
27
|
+
return { ok: false, error: `${AGGREGATE_TYPE_FLAG} needs a value.` };
|
|
28
|
+
}
|
|
29
|
+
types.push(value);
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
else if (arg.startsWith(`${AGGREGATE_TYPE_FLAG}=`)) {
|
|
33
|
+
types.push(arg.slice(AGGREGATE_TYPE_FLAG.length + 1));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (types.length === 0) {
|
|
40
|
+
return { ok: false, error: `At least one ${AGGREGATE_TYPE_FLAG} is required.` };
|
|
41
|
+
}
|
|
42
|
+
if (types.some((type) => type.trim().length === 0)) {
|
|
43
|
+
return { ok: false, error: `${AGGREGATE_TYPE_FLAG} must not be empty.` };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, types: [...new Set(types)], apply };
|
|
46
|
+
}
|
|
47
|
+
function printHelp(write) {
|
|
48
|
+
write("");
|
|
49
|
+
write(" Subcommands:");
|
|
50
|
+
write(" prune --aggregate-type <type> [--aggregate-type <type> ...] [--apply]");
|
|
51
|
+
write(" Removes every event, snapshot, archive marker and dead letter of the given");
|
|
52
|
+
write(" aggregate types, and empties the projections fed only by them.");
|
|
53
|
+
write(" Without --apply it only reports counts.");
|
|
54
|
+
write("");
|
|
55
|
+
}
|
|
56
|
+
function report(out, result) {
|
|
57
|
+
const { plan, deleted } = result;
|
|
58
|
+
out.log("");
|
|
59
|
+
for (const s of plan.stats) {
|
|
60
|
+
out.log(` ${s.aggregateType}: ${s.events} events, ${s.aggregates} aggregates, ${s.tenants} tenants`);
|
|
61
|
+
}
|
|
62
|
+
for (const wiped of plan.wipedProjections) {
|
|
63
|
+
const rows = deleted?.projectionRows[wiped.projection];
|
|
64
|
+
out.log(` projection ${wiped.projection} (${wiped.table.tableName}): ${rows === undefined ? "all rows would be deleted" : `${rows} rows deleted`}`);
|
|
65
|
+
}
|
|
66
|
+
if (deleted === undefined) {
|
|
67
|
+
out.log("");
|
|
68
|
+
out.log(" Dry run, nothing deleted. Re-run with --apply to remove the above.");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
out.log("");
|
|
72
|
+
out.log(` Deleted ${deleted.events} events, ${deleted.snapshots} snapshots, ${deleted.archivedStreams} archive markers, ` +
|
|
73
|
+
`${deleted.upcasterDeadLetters + deleted.rebuildDeadLetters} dead letters.`);
|
|
74
|
+
}
|
|
75
|
+
out.log("");
|
|
76
|
+
}
|
|
77
|
+
export async function runEventsCli(argv, out, options) {
|
|
78
|
+
// The standalone bundle never runs the app boot that installs Temporal; event rows carry
|
|
79
|
+
// Temporal.Instant values.
|
|
80
|
+
await ensureTemporalPolyfill();
|
|
81
|
+
const [sub, ...rest] = argv;
|
|
82
|
+
if (sub !== "prune") {
|
|
83
|
+
if (sub !== undefined)
|
|
84
|
+
out.err(` Unknown subcommand: ${sub}`);
|
|
85
|
+
printHelp(sub === undefined ? out.log : out.err);
|
|
86
|
+
return sub === undefined ? 0 : 1;
|
|
87
|
+
}
|
|
88
|
+
const parsed = parseEventsPruneArgs(rest);
|
|
89
|
+
if (!parsed.ok) {
|
|
90
|
+
out.err(` ${parsed.error}`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
94
|
+
if (!dbUrl) {
|
|
95
|
+
out.err(" DATABASE_URL not set.");
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const { db, close } = createDbConnection({
|
|
99
|
+
url: dbUrl,
|
|
100
|
+
rowLevelSecurity: options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE,
|
|
101
|
+
...dbConnectionOptionsFromEnv(),
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
const result = await removeAggregateTypes(db, createRegistry(options.features), {
|
|
105
|
+
types: parsed.types,
|
|
106
|
+
apply: parsed.apply,
|
|
107
|
+
});
|
|
108
|
+
report(out, result);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
out.err("");
|
|
113
|
+
// Message only: a driver error's stack can carry connection details.
|
|
114
|
+
out.err(` ${e instanceof Error ? e.message : String(e)}`);
|
|
115
|
+
out.err("");
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -2,10 +2,11 @@ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
|
|
|
2
2
|
import { isFileField } from "../engine/field-helpers.js";
|
|
3
3
|
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
4
4
|
import { FrameworkReasons, InternalError, reraiseAsKumikoError, UnprocessableError, } from "../errors/index.js";
|
|
5
|
-
import { getFileRefExecutor
|
|
5
|
+
import { getFileRefExecutor } from "./create-file-ref.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
|
+
import { resolveAttachedField } from "./upload-field-resolution.js";
|
|
9
10
|
export function fileRefIdsOf(value) {
|
|
10
11
|
if (typeof value === "string")
|
|
11
12
|
return isUuid(value) ? [value] : [];
|
|
@@ -13,4 +13,4 @@ export declare const bindReferencedFilesWrite: import("@cosmicdrift/kumiko-types
|
|
|
13
13
|
bound: number;
|
|
14
14
|
skipped: number;
|
|
15
15
|
nextAfterId: string | null;
|
|
16
|
-
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
|
|
16
|
+
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined, undefined>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Registry, SessionUser } from "../engine/types/index.ts";
|
|
2
|
+
import { type FileRef } from "./file-routes.ts";
|
|
3
|
+
export type CanReadFileRefContext = {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly user: SessionUser;
|
|
6
|
+
readonly dispatch: {
|
|
7
|
+
readonly query: (qn: string, payload: unknown) => Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export declare function canCallerReadFileRef(ctx: CanReadFileRefContext, fileRef: FileRef): Promise<boolean>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createDefaultGuard, DEFAULT_PRIVILEGED_ROLES } from "./default-file-guard.js";
|
|
2
|
+
import { readFilesRouteOptions } from "./file-routes.js";
|
|
3
|
+
import { collectRecordOnlyFileEntityTypes } from "./record-only-file-entity-types.js";
|
|
4
|
+
import { createRecordReadCheck } from "./record-read-check.js";
|
|
5
|
+
// Same decision as GET /files/:id: the app's accessGuard when configured, otherwise the
|
|
6
|
+
// default guard with the configured privileged roles and record-read check.
|
|
7
|
+
export async function canCallerReadFileRef(ctx, fileRef) {
|
|
8
|
+
const options = readFilesRouteOptions(ctx.registry);
|
|
9
|
+
const guard = options.accessGuard ??
|
|
10
|
+
createDefaultGuard(options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES, createRecordReadCheck(ctx.registry, (qn, payload) => ctx.dispatch.query(qn, payload)), options.unclaimedUploadReaders ?? [], collectRecordOnlyFileEntityTypes(ctx.registry));
|
|
11
|
+
return (await guard({ fileRef, user: ctx.user, operation: "read" })) === "allow";
|
|
12
|
+
}
|
|
@@ -21,6 +21,8 @@ export type CreateFileRefInput = {
|
|
|
21
21
|
readonly fieldName: string;
|
|
22
22
|
readonly entityId?: string;
|
|
23
23
|
};
|
|
24
|
+
/** Byte limit replacing the configured default upload limit; a file field's own maxSize still applies first. */
|
|
25
|
+
readonly maxSize?: number;
|
|
24
26
|
};
|
|
25
27
|
export declare function filesUnavailableMessage(caller: string): string;
|
|
26
28
|
export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
|
|
@@ -5,6 +5,9 @@ import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcc
|
|
|
5
5
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
6
6
|
import { readFilesRouteOptions } from "./file-routes.js";
|
|
7
7
|
import { assertSafeStorageKey } from "./types.js";
|
|
8
|
+
function defaultUploadLimit(registry, input) {
|
|
9
|
+
return input.maxSize ?? readFilesRouteOptions(registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
|
|
10
|
+
}
|
|
8
11
|
// The handler context has no HTTP/job/seed marker to name, so the message states the actual
|
|
9
12
|
// cause: the dispatcher's AppContext carries no file-provider resolver.
|
|
10
13
|
export function filesUnavailableMessage(caller) {
|
|
@@ -25,7 +28,8 @@ export async function createFileRefFromBytes(ctx, input) {
|
|
|
25
28
|
tenantDb: ctx.db,
|
|
26
29
|
rowInOwnTransaction: runnerCanBeginTransaction(tenantDbRunner(ctx.db)),
|
|
27
30
|
registry: ctx.registry,
|
|
28
|
-
|
|
31
|
+
attachTargets: "entities-and-read-models",
|
|
32
|
+
defaultMaxUploadSize: defaultUploadLimit(ctx.registry, input),
|
|
29
33
|
writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
|
|
30
34
|
deleteBytes: async (key) => {
|
|
31
35
|
await files.ref(key).delete();
|
|
@@ -68,7 +72,8 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
|
|
|
68
72
|
const outcome = await writeAcceptedFileBytes({
|
|
69
73
|
user: ctx.user,
|
|
70
74
|
registry: ctx.registry,
|
|
71
|
-
|
|
75
|
+
attachTargets: "entities-and-read-models",
|
|
76
|
+
defaultMaxUploadSize: defaultUploadLimit(ctx.registry, input),
|
|
72
77
|
writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
|
|
73
78
|
}, {
|
|
74
79
|
fileName: input.fileName,
|