@cosmicdrift/kumiko-framework 0.359.0 → 0.361.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/dist/api/anonymous-cookie.js +9 -12
- package/dist/api/api-constants.d.ts +0 -1
- package/dist/api/api-constants.js +0 -1
- package/dist/api/auth-middleware.d.ts +0 -2
- package/dist/api/auth-middleware.js +16 -9
- package/dist/api/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +28 -40
- package/dist/api/csrf-middleware.js +3 -2
- package/dist/api/index.d.ts +4 -1
- package/dist/api/index.js +3 -1
- package/dist/api/origin-middleware.d.ts +11 -1
- package/dist/api/origin-middleware.js +40 -7
- package/dist/api/server.js +30 -4
- package/dist/api/session-cookies.d.ts +23 -0
- package/dist/api/session-cookies.js +85 -0
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.d.ts +2 -0
- package/dist/crypto/owner-transition-rekey.js +30 -0
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +5 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +14 -1
- package/dist/db/apply-entity-event.js +0 -5
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-field-encryption.js +3 -2
- package/dist/db/entity-table-meta.js +3 -3
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +9 -2
- package/dist/db/event-store-executor-write.js +31 -5
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/event-store-executor.js +1 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/migrate-generator.d.ts +1 -0
- package/dist/db/migrate-generator.js +12 -0
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +120 -15
- package/dist/db/table-builder.d.ts +3 -1
- package/dist/db/table-builder.js +3 -6
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +38 -1
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -0
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +89 -14
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/define-handler.js +1 -3
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +8 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/schema-builder.js +2 -2
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +9 -0
- package/dist/engine/soft-delete-cleanup.js +12 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/classes.d.ts +7 -0
- package/dist/errors/classes.js +12 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
- package/dist/files/bind-pending-file-refs.d.ts +9 -0
- package/dist/files/bind-pending-file-refs.js +54 -0
- package/dist/files/create-file-ref-from-bytes.d.ts +22 -0
- package/dist/files/create-file-ref-from-bytes.js +39 -0
- package/dist/files/create-file-ref.d.ts +50 -0
- package/dist/files/create-file-ref.js +135 -0
- package/dist/files/dispose-removed-file-refs.d.ts +8 -0
- package/dist/files/dispose-removed-file-refs.js +51 -0
- package/dist/files/feature.js +2 -2
- package/dist/files/file-ref-entity.d.ts +1 -0
- package/dist/files/file-ref-entity.js +1 -0
- package/dist/files/file-ref-table.js +2 -2
- package/dist/files/file-routes.d.ts +4 -0
- package/dist/files/file-routes.js +60 -126
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
- package/dist/files/purge-expired-file-ref-bytes.js +34 -0
- package/dist/files/record-read-check.d.ts +9 -0
- package/dist/files/record-read-check.js +49 -0
- package/dist/files/record-write-check.d.ts +21 -0
- package/dist/files/record-write-check.js +64 -0
- package/dist/files/types.d.ts +1 -0
- package/dist/files/types.js +3 -0
- package/dist/i18n/required-surface-keys.js +10 -0
- package/dist/jobs/cron-repeat-options.d.ts +9 -0
- package/dist/jobs/cron-repeat-options.js +5 -0
- package/dist/jobs/job-runner.js +2 -1
- package/dist/migrations/pii-event-backfill.d.ts +1 -0
- package/dist/migrations/pii-event-backfill.js +16 -2
- package/dist/pipeline/cascade-handler.js +17 -2
- package/dist/pipeline/dispatch-write.js +7 -0
- package/dist/schema-cli.js +12 -6
- package/dist/search/reindex-entity.js +3 -4
- package/dist/stack/test-stack.d.ts +3 -1
- package/dist/stack/test-stack.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +135 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem, normalizeEventPiiSubject, } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { ZodObject } from "zod";
|
|
3
|
+
import { isValidIanaTimeZone } from "../time/iana.js";
|
|
3
4
|
import { resolveName } from "./handler-helpers.js";
|
|
4
5
|
import { splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
|
|
5
6
|
import { QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
@@ -168,6 +169,13 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
168
169
|
if (jobGrantsProblem) {
|
|
169
170
|
throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
|
|
170
171
|
}
|
|
172
|
+
if ("cron" in jobOptions.trigger && jobOptions.trigger.tz !== undefined) {
|
|
173
|
+
const tz = jobOptions.trigger.tz;
|
|
174
|
+
if (!isValidIanaTimeZone(tz)) {
|
|
175
|
+
throw new Error(`[Feature ${name}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(tz)} } — ` +
|
|
176
|
+
'tz must be an IANA time zone name such as "Europe/Berlin".');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
171
179
|
// Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
|
|
172
180
|
// wird zu Array von resolved strings, Single bleibt single string —
|
|
173
181
|
// job-runner unterscheidet anhand Array.isArray.
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -80,7 +80,7 @@ export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUse
|
|
|
80
80
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
81
81
|
export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
|
|
82
82
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
83
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
83
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
84
84
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
85
85
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
|
|
86
86
|
export { runValidation } from "./validation.js";
|
|
@@ -409,8 +409,8 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
|
|
|
409
409
|
// Field types whose column is nullable and whose projection write accepts a
|
|
410
410
|
// plain `null`. Excluded: select/writeOnly text (fieldToZod is already
|
|
411
411
|
// nullable there), and types where null would not clear cleanly — NOT NULL
|
|
412
|
-
// jsonb columns (multiSelect, embedded, jsonb, multi reference
|
|
413
|
-
//
|
|
412
|
+
// jsonb columns (multiSelect, embedded, jsonb, multi reference, files/images),
|
|
413
|
+
// locatedTimestamp (split into _utc/_tz
|
|
414
414
|
// columns, a bare null targets a column that does not exist) and money (the
|
|
415
415
|
// currency column would keep its stale value).
|
|
416
416
|
const NULL_CLEARABLE_FIELD_TYPES = new Set([
|
|
@@ -25,6 +25,7 @@ export declare function normalizeListColumn(c: ListColumnSpec): Exclude<ListColu
|
|
|
25
25
|
* (form-condition closures) reuse it; three hand-rolled copies had
|
|
26
26
|
* already drifted in shape. */
|
|
27
27
|
export declare function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean;
|
|
28
|
+
export declare function isRecordLocked(lockedWhen: FieldCondition | undefined, row: Readonly<Record<string, unknown>>): boolean;
|
|
28
29
|
type SelectOptionRules = Pick<SelectFieldDef, "options" | "conditionalOptions">;
|
|
29
30
|
export type UnavailableSelectOption = {
|
|
30
31
|
readonly field: string;
|
|
@@ -110,6 +110,15 @@ export function evalFieldCondition(cond, values) {
|
|
|
110
110
|
return !cond.notIn.includes(val);
|
|
111
111
|
return val !== cond.ne;
|
|
112
112
|
}
|
|
113
|
+
// A row that lacks the condition's field counts as locked: an unreadable lock state
|
|
114
|
+
// must not open the record.
|
|
115
|
+
export function isRecordLocked(lockedWhen, row) {
|
|
116
|
+
if (lockedWhen === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
if (typeof lockedWhen === "object" && !(lockedWhen.field in row))
|
|
119
|
+
return true;
|
|
120
|
+
return evalFieldCondition(lockedWhen, { ...row });
|
|
121
|
+
}
|
|
113
122
|
/** Options of a select field that are offered for the given row values, in declaration order. */
|
|
114
123
|
export function availableSelectOptions(field, values) {
|
|
115
124
|
const rules = field.conditionalOptions;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// table growth; irreversible event-log purging is data-retention's job
|
|
12
12
|
// (pruneEvents), a separate, consumer-lag-guarded path.
|
|
13
13
|
import { asEntityTableMeta, deleteMany } from "../db/query.js";
|
|
14
|
+
import { purgeExpiredFileRefsWithBytes } from "../files/purge-expired-file-ref-bytes.js";
|
|
15
|
+
import { FILE_REF_ENTITY_NAME } from "../files/types.js";
|
|
14
16
|
import { SYSTEM_USER_ID } from "./system-user.js";
|
|
15
17
|
// qualifyEntityName convention (feature:type:kebab-name) with a reserved
|
|
16
18
|
// "soft-delete" owner — no real feature owns these; the framework synthesizes
|
|
@@ -77,9 +79,18 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
77
79
|
if (isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
78
80
|
continue;
|
|
79
81
|
const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
|
|
80
|
-
await
|
|
82
|
+
await purgeExpiredRows(db, proj.source, proj.table, where, ctx);
|
|
81
83
|
}
|
|
82
84
|
};
|
|
85
|
+
async function purgeExpiredRows(db, source, table, where, ctx) {
|
|
86
|
+
if (source === FILE_REF_ENTITY_NAME) {
|
|
87
|
+
// The row is the only record of the storageKey, so its bytes go first.
|
|
88
|
+
await purgeExpiredFileRefsWithBytes({ db, table, where, files: ctx.files, log: ctx.log });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await deleteMany(db, table, where);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
83
94
|
// System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
|
|
84
95
|
// per-tenant grace period — there's no "this tenant's view" of a row that
|
|
85
96
|
// isn't scoped to any tenant. Runs once (not perTenant) with a fixed grace
|
|
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
|
|
|
17
17
|
export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
|
|
18
18
|
export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionRowIdOf, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
|
|
19
19
|
export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
|
|
20
|
-
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
20
|
+
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
21
21
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
22
22
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
23
23
|
export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
|
package/dist/errors/classes.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export declare class PreconditionFailedError extends ConflictError {
|
|
|
60
60
|
readonly code: string;
|
|
61
61
|
constructor(details: PreconditionFailedDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">);
|
|
62
62
|
}
|
|
63
|
+
export declare class RecordLockedError extends ConflictError {
|
|
64
|
+
readonly code: string;
|
|
65
|
+
constructor(details: {
|
|
66
|
+
readonly entityName: string;
|
|
67
|
+
readonly entityId: number | string;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
63
70
|
export declare class IdempotentReplayError extends ConflictError {
|
|
64
71
|
readonly code: string;
|
|
65
72
|
constructor(details: {
|
package/dist/errors/classes.js
CHANGED
|
@@ -119,6 +119,18 @@ export class PreconditionFailedError extends ConflictError {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
// The entity's `lockedWhen` condition holds for the stored row: no write handler
|
|
123
|
+
// may change or delete it. Same 409 family as the other state conflicts.
|
|
124
|
+
export class RecordLockedError extends ConflictError {
|
|
125
|
+
code = "record_locked";
|
|
126
|
+
constructor(details) {
|
|
127
|
+
super({
|
|
128
|
+
message: `${details.entityName} ${details.entityId} is locked and cannot be changed`,
|
|
129
|
+
i18nKey: "errors.recordLocked",
|
|
130
|
+
details,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
122
134
|
// The caller retried a write with an idempotencyKey it had already used —
|
|
123
135
|
// the event-store's partial unique index on metadata.idempotencyKey caught
|
|
124
136
|
// the duplicate. Distinct from unique_violation/version_conflict: this is
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
|
|
2
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, RecordLockedError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
3
3
|
export type { ErrorDocsConfig } from "./docs-url.js";
|
|
4
4
|
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
5
5
|
export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
|
package/dist/errors/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
1
|
+
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, RecordLockedError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
2
|
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
3
3
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
4
4
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const FrameworkReasons: {
|
|
|
14
14
|
readonly irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk";
|
|
15
15
|
readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
|
|
16
16
|
readonly subjectErased: "subject_erased";
|
|
17
|
+
readonly fileRejected: "file_rejected";
|
|
17
18
|
};
|
|
18
19
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
|
19
20
|
export declare const AgentReasons: {
|
package/dist/errors/reasons.js
CHANGED
|
@@ -66,6 +66,9 @@ export const FrameworkReasons = {
|
|
|
66
66
|
// ConflictError: a write touched a personal-data field of a data subject whose
|
|
67
67
|
// key was erased (crypto-shredding); the subject can no longer receive data.
|
|
68
68
|
subjectErased: "subject_erased",
|
|
69
|
+
// UnprocessableError: createFileRefFromBytes refused the file (size, type,
|
|
70
|
+
// content or attach target). `details.error` carries the upload-route error string.
|
|
71
|
+
fileRejected: "file_rejected",
|
|
69
72
|
};
|
|
70
73
|
// Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
|
|
71
74
|
// sister catalog to FrameworkReasons instead of extending it — see the
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function assertFileRefsBelongToTenant(args: {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly result: SaveContext;
|
|
6
|
+
readonly user: SessionUser;
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
2
|
+
import { ValidationError } from "../errors/index.js";
|
|
3
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
4
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
5
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
|
+
// A file field only stores an id, and the file routes authorize by tenant at
|
|
7
|
+
// read time: without this check a writer could name another tenant's fileRef
|
|
8
|
+
// and make the record point at it. Only newly written values are checked (all
|
|
9
|
+
// of a create, `changes` of an update), so existing data is left alone. The
|
|
10
|
+
// error is the same for "missing" and "belongs to another tenant" so the id
|
|
11
|
+
// cannot be probed across tenants.
|
|
12
|
+
export async function assertFileRefsBelongToTenant(args) {
|
|
13
|
+
const { registry, result, user, db } = args;
|
|
14
|
+
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
15
|
+
if (result.entityName === undefined)
|
|
16
|
+
return;
|
|
17
|
+
const entity = registry.getEntity(result.entityName);
|
|
18
|
+
// skip: an aggregate without an entity definition has no file fields
|
|
19
|
+
if (entity === undefined)
|
|
20
|
+
return;
|
|
21
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
22
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
23
|
+
return;
|
|
24
|
+
const executor = getFileRefExecutor();
|
|
25
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
26
|
+
if (!isFileField(fieldDef))
|
|
27
|
+
continue;
|
|
28
|
+
for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
|
|
29
|
+
if (await isFileRefOfTenant(executor, fileRefId, user, db))
|
|
30
|
+
continue;
|
|
31
|
+
throw new ValidationError({
|
|
32
|
+
fields: [
|
|
33
|
+
{
|
|
34
|
+
path: fieldName,
|
|
35
|
+
code: "invalid_element",
|
|
36
|
+
i18nKey: "errors.validation.invalid_element",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function isFileRefOfTenant(executor, fileRefId, user, db) {
|
|
44
|
+
if (!isUuid(fileRefId))
|
|
45
|
+
return false;
|
|
46
|
+
// detail() widens its tenant filter to (self, SYSTEM); only the writer's own
|
|
47
|
+
// tenant counts here, same as GET /files/:id/meta.
|
|
48
|
+
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
49
|
+
return row !== null && row["tenantId"] === user.tenantId;
|
|
50
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function fileRefIdsOf(value: unknown): readonly string[];
|
|
4
|
+
export declare function bindPendingFileRefs(args: {
|
|
5
|
+
readonly registry: Registry;
|
|
6
|
+
readonly result: SaveContext;
|
|
7
|
+
readonly user: SessionUser;
|
|
8
|
+
readonly db: TenantDb;
|
|
9
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
2
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
3
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
4
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
5
|
+
export function fileRefIdsOf(value) {
|
|
6
|
+
if (typeof value === "string")
|
|
7
|
+
return isUuid(value) ? [value] : [];
|
|
8
|
+
if (!Array.isArray(value))
|
|
9
|
+
return [];
|
|
10
|
+
return value.filter((item) => typeof item === "string" && isUuid(item));
|
|
11
|
+
}
|
|
12
|
+
function isPendingUploadFor(row, entityName, fieldName, user) {
|
|
13
|
+
return (row["entityId"] === null &&
|
|
14
|
+
row["entityType"] === entityName &&
|
|
15
|
+
row["fieldName"] === fieldName &&
|
|
16
|
+
row["insertedById"] === user.id);
|
|
17
|
+
}
|
|
18
|
+
// A form (or a server handler via createFileRefFromBytes) uploads before the
|
|
19
|
+
// record references the file, so its fileRefs carry entityType + fieldName but
|
|
20
|
+
// no entityId. Binding them in the save's own transaction makes record-bound
|
|
21
|
+
// variants (qr overlay) and record-read file access work for them. Only the
|
|
22
|
+
// caller's own pending uploads for exactly this entity field are bound: naming
|
|
23
|
+
// someone else's upload id in a save payload must not pull that file into a
|
|
24
|
+
// record the caller controls.
|
|
25
|
+
export async function bindPendingFileRefs(args) {
|
|
26
|
+
const { registry, result, user, db } = args;
|
|
27
|
+
// skip: saves of unnamed aggregates declare no file fields
|
|
28
|
+
if (result.entityName === undefined)
|
|
29
|
+
return;
|
|
30
|
+
const entityName = result.entityName;
|
|
31
|
+
const entity = registry.getEntity(entityName);
|
|
32
|
+
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
33
|
+
if (entity === undefined)
|
|
34
|
+
return;
|
|
35
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
36
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const executor = getFileRefExecutor();
|
|
39
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
40
|
+
if (!isFileField(fieldDef))
|
|
41
|
+
continue;
|
|
42
|
+
// changes, not data: an update's row carries every stored file id, including
|
|
43
|
+
// ones bound earlier; only the ids this save names can be pending uploads.
|
|
44
|
+
for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
|
|
45
|
+
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
46
|
+
if (row === null || !isPendingUploadFor(row, entityName, fieldName, user))
|
|
47
|
+
continue;
|
|
48
|
+
const version = typeof row["version"] === "number" ? row["version"] : undefined;
|
|
49
|
+
const bound = await executor.update({ id: fileRefId, version, changes: { entityId: String(result.id) } }, user, db);
|
|
50
|
+
if (!bound.isSuccess)
|
|
51
|
+
throw reraiseAsKumikoError(bound.error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import type { Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
3
|
+
import { type CreatedFileRef } from "./create-file-ref.js";
|
|
4
|
+
import type { FileContext } from "./file-handle.js";
|
|
5
|
+
export type { CreatedFileRef };
|
|
6
|
+
export type CreateFileRefContext = {
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
readonly files?: FileContext | undefined;
|
|
9
|
+
readonly registry: Registry;
|
|
10
|
+
readonly user: SessionUser;
|
|
11
|
+
};
|
|
12
|
+
export type CreateFileRefInput = {
|
|
13
|
+
readonly fileName: string;
|
|
14
|
+
readonly mimeType: string;
|
|
15
|
+
readonly data: Uint8Array;
|
|
16
|
+
readonly attachTo?: {
|
|
17
|
+
readonly entityType: string;
|
|
18
|
+
readonly fieldName: string;
|
|
19
|
+
readonly entityId?: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
2
|
+
import { DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, } from "./create-file-ref.js";
|
|
3
|
+
import { readFilesRouteOptions } from "./file-routes.js";
|
|
4
|
+
// Server-side counterpart of POST /files for handlers/jobs that produce bytes
|
|
5
|
+
// themselves (generated PDFs, exports). Same validation, storage layout and
|
|
6
|
+
// fileRef.created event as an upload; the tenant is always ctx.user.tenantId.
|
|
7
|
+
export async function createFileRefFromBytes(ctx, input) {
|
|
8
|
+
const { files } = ctx;
|
|
9
|
+
if (!files) {
|
|
10
|
+
return writeFailure(new InternalError({
|
|
11
|
+
message: "createFileRefFromBytes: ctx.files is unavailable — mount a file-provider feature to store files",
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
const outcome = await storeFileAsFileRef({
|
|
15
|
+
user: ctx.user,
|
|
16
|
+
tenantDb: ctx.db,
|
|
17
|
+
registry: ctx.registry,
|
|
18
|
+
defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
19
|
+
writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
|
|
20
|
+
deleteBytes: (key) => files.ref(key).delete(),
|
|
21
|
+
}, {
|
|
22
|
+
fileName: input.fileName,
|
|
23
|
+
declaredMimeType: input.mimeType,
|
|
24
|
+
data: input.data,
|
|
25
|
+
entityType: input.attachTo?.entityType,
|
|
26
|
+
entityId: input.attachTo?.entityId,
|
|
27
|
+
fieldName: input.attachTo?.fieldName,
|
|
28
|
+
});
|
|
29
|
+
switch (outcome.kind) {
|
|
30
|
+
case "created":
|
|
31
|
+
return { isSuccess: true, data: outcome.fileRef };
|
|
32
|
+
case "rejected":
|
|
33
|
+
return writeFailure(new UnprocessableError(FrameworkReasons.fileRejected, {
|
|
34
|
+
details: { error: outcome.error },
|
|
35
|
+
}));
|
|
36
|
+
case "failed":
|
|
37
|
+
return outcome.failure;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
import type { WriteFailure } from "../errors/index.js";
|
|
4
|
+
export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
5
|
+
declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
|
|
6
|
+
export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
|
|
7
|
+
type AttachedFieldResolution = {
|
|
8
|
+
readonly kind: "unattached";
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "resolved";
|
|
11
|
+
readonly fieldDef: AnyFileFieldDef;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "unresolvable";
|
|
14
|
+
};
|
|
15
|
+
export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined): AttachedFieldResolution;
|
|
16
|
+
export type StoreFileInput = {
|
|
17
|
+
readonly fileName: string;
|
|
18
|
+
readonly declaredMimeType: string;
|
|
19
|
+
readonly data: Uint8Array;
|
|
20
|
+
readonly entityType?: string | undefined;
|
|
21
|
+
readonly entityId?: string | undefined;
|
|
22
|
+
readonly fieldName?: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
export type StoreFileDeps = {
|
|
25
|
+
readonly user: SessionUser;
|
|
26
|
+
readonly tenantDb: TenantDb;
|
|
27
|
+
readonly registry: Registry | undefined;
|
|
28
|
+
readonly defaultMaxUploadSize: string;
|
|
29
|
+
readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
|
|
30
|
+
readonly deleteBytes: (storageKey: string) => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
export type CreatedFileRef = {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly fileName: string;
|
|
35
|
+
readonly mimeType: string;
|
|
36
|
+
readonly size: number;
|
|
37
|
+
readonly storageKey: string;
|
|
38
|
+
};
|
|
39
|
+
export type StoreFileOutcome = {
|
|
40
|
+
readonly kind: "created";
|
|
41
|
+
readonly fileRef: CreatedFileRef;
|
|
42
|
+
} | {
|
|
43
|
+
readonly kind: "rejected";
|
|
44
|
+
readonly error: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: "failed";
|
|
47
|
+
readonly failure: WriteFailure;
|
|
48
|
+
};
|
|
49
|
+
export declare function storeFileAsFileRef(deps: StoreFileDeps, input: StoreFileInput): Promise<StoreFileOutcome>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
2
|
+
import { isFileField, } from "../engine/types/index.js";
|
|
3
|
+
import { generateId } from "../utils/index.js";
|
|
4
|
+
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
5
|
+
import { fileRefsTable } from "./file-ref-table.js";
|
|
6
|
+
import { resolveContentType } from "./resolve-content-type.js";
|
|
7
|
+
import { buildStorageKey, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
|
|
8
|
+
export const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
9
|
+
// Standard entity executor for fileRef: create/delete emit fileRef.created /
|
|
10
|
+
// fileRef.deleted and write file_refs via applyEntityEvent in one tx
|
|
11
|
+
// (read-your-own-write), exactly like any other entity's lifecycle. Built on
|
|
12
|
+
// first use: the table builder's import graph reaches back into this module.
|
|
13
|
+
let fileRefExecutor;
|
|
14
|
+
function buildFileRefExecutor() {
|
|
15
|
+
return createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
16
|
+
entityName: FILE_REF_ENTITY_NAME,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function getFileRefExecutor() {
|
|
20
|
+
fileRefExecutor ??= buildFileRefExecutor();
|
|
21
|
+
return fileRefExecutor;
|
|
22
|
+
}
|
|
23
|
+
const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
|
|
24
|
+
// entityType/fieldName drive the GDPR-forget decision downstream
|
|
25
|
+
// (fileRefDeleteHook resolves the field's PII annotation from exactly this
|
|
26
|
+
// pairing) — attaching to a field nobody can resolve would leave that
|
|
27
|
+
// decision hanging on an unverified client string. Either both are omitted
|
|
28
|
+
// (unattached upload, allowed) or both must resolve to a real registered
|
|
29
|
+
// field.
|
|
30
|
+
export function resolveAttachedField(registry, entityType, fieldName) {
|
|
31
|
+
if (entityType === undefined && fieldName === undefined)
|
|
32
|
+
return { kind: "unattached" };
|
|
33
|
+
const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
|
|
34
|
+
const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
|
|
35
|
+
if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
|
|
36
|
+
return { kind: "unresolvable" };
|
|
37
|
+
return { kind: "resolved", fieldDef };
|
|
38
|
+
}
|
|
39
|
+
function acceptFile(deps, input) {
|
|
40
|
+
const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
|
|
41
|
+
const rejected = (error) => ({ kind: "rejected", error });
|
|
42
|
+
// These interpolate directly into the storage key (buildStorageKey) —
|
|
43
|
+
// reject anything but a safe identifier so a caller can't path-traverse
|
|
44
|
+
// out of its tenant's storage prefix (../.. segments, absolute paths).
|
|
45
|
+
for (const [field, value] of [
|
|
46
|
+
["entityType", entityType],
|
|
47
|
+
["entityId", entityId],
|
|
48
|
+
["fieldName", fieldName],
|
|
49
|
+
]) {
|
|
50
|
+
if (value !== undefined && !SAFE_IDENTIFIER.test(value)) {
|
|
51
|
+
return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let maxSize = deps.defaultMaxUploadSize;
|
|
55
|
+
let accept;
|
|
56
|
+
const attachedField = resolveAttachedField(deps.registry, entityType, fieldName);
|
|
57
|
+
if (attachedField.kind === "unresolvable") {
|
|
58
|
+
return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
|
|
59
|
+
}
|
|
60
|
+
if (attachedField.kind === "resolved") {
|
|
61
|
+
if (attachedField.fieldDef.maxSize)
|
|
62
|
+
maxSize = attachedField.fieldDef.maxSize;
|
|
63
|
+
if (attachedField.fieldDef.accept)
|
|
64
|
+
accept = attachedField.fieldDef.accept;
|
|
65
|
+
}
|
|
66
|
+
const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
|
|
67
|
+
if (validationError)
|
|
68
|
+
return rejected(validationError);
|
|
69
|
+
const contentValidation = validateFileContent(fileName, data, accept);
|
|
70
|
+
if (contentValidation.kind === "rejected")
|
|
71
|
+
return rejected(contentValidation.error);
|
|
72
|
+
// A normalized upload's real type is already known from the sniffed
|
|
73
|
+
// bytes — resolveUploadMimeType's alias/rewrite step only applies to the
|
|
74
|
+
// declared mimeType of an unnormalized upload.
|
|
75
|
+
if (contentValidation.kind === "normalized") {
|
|
76
|
+
return {
|
|
77
|
+
kind: "accepted",
|
|
78
|
+
mimeType: contentValidation.mimeType,
|
|
79
|
+
extension: contentValidation.extension,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const mimeTypeResolution = resolveUploadMimeType(fileName, declaredMimeType, data);
|
|
83
|
+
if (mimeTypeResolution.kind === "rejected")
|
|
84
|
+
return rejected(mimeTypeResolution.error);
|
|
85
|
+
return {
|
|
86
|
+
kind: "accepted",
|
|
87
|
+
mimeType: resolveContentType({
|
|
88
|
+
declared: mimeTypeResolution.mimeType,
|
|
89
|
+
bytes: data,
|
|
90
|
+
filename: fileName,
|
|
91
|
+
}),
|
|
92
|
+
extension: undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// The one upload pipeline behind POST /files and createFileRefFromBytes.
|
|
96
|
+
export async function storeFileAsFileRef(deps, input) {
|
|
97
|
+
const { fileName, data, entityType, entityId, fieldName } = input;
|
|
98
|
+
const accepted = acceptFile(deps, input);
|
|
99
|
+
if (accepted.kind === "rejected")
|
|
100
|
+
return accepted;
|
|
101
|
+
const { mimeType, extension } = accepted;
|
|
102
|
+
const fileRefId = generateId();
|
|
103
|
+
const storageKey = buildStorageKey(deps.user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", fileName, generateId(), extension);
|
|
104
|
+
// Write binary FIRST (outside the tx — network/disk I/O doesn't belong
|
|
105
|
+
// inside a PG connection's tx window). A failed create deletes the bytes
|
|
106
|
+
// again below; a later rollback of an enclosing transaction cannot, so
|
|
107
|
+
// those are orphaned for the cleanup-jobs to sweep. Losing a row on
|
|
108
|
+
// append-failure is acceptable; a committed row with a missing binary is not.
|
|
109
|
+
await deps.writeBytes(storageKey, data, mimeType);
|
|
110
|
+
// id is explicit so the response + storageKey stay consistent; tenantId,
|
|
111
|
+
// insertedAt and insertedById are set by applyEntityEvent from the event metadata.
|
|
112
|
+
const result = await getFileRefExecutor().create({
|
|
113
|
+
id: fileRefId,
|
|
114
|
+
storageKey,
|
|
115
|
+
fileName,
|
|
116
|
+
mimeType,
|
|
117
|
+
size: data.byteLength,
|
|
118
|
+
entityType: entityType ?? null,
|
|
119
|
+
entityId: entityId ?? null,
|
|
120
|
+
fieldName: fieldName ?? null,
|
|
121
|
+
}, deps.user, deps.tenantDb);
|
|
122
|
+
if (!result.isSuccess) {
|
|
123
|
+
try {
|
|
124
|
+
await deps.deleteBytes(storageKey);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// skip: the create failure is the error worth reporting; leftover bytes are swept by cleanup-jobs.
|
|
128
|
+
}
|
|
129
|
+
return { kind: "failed", failure: result };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
kind: "created",
|
|
133
|
+
fileRef: { id: fileRefId, fileName, mimeType, size: data.byteLength, storageKey },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function disposeRemovedFileRefs(args: {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly result: SaveContext;
|
|
6
|
+
readonly user: SessionUser;
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
}): Promise<void>;
|