@cosmicdrift/kumiko-framework 0.371.1 → 0.373.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.
Files changed (79) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/custom-handlers.md +3 -5
  3. package/agents/examples/embedded-entity-form.md +1 -1
  4. package/agents/examples/pdf-audit-export.md +1 -1
  5. package/agents/examples/rate-limiting.md +65 -1
  6. package/agents/examples/user-data-rights.md +176 -0
  7. package/dist/api/jwt.js +2 -1
  8. package/dist/api/observability-middleware.d.ts +1 -2
  9. package/dist/api/observability-middleware.js +9 -8
  10. package/dist/api/pii-leak-guard.js +2 -1
  11. package/dist/api/route-label.d.ts +3 -0
  12. package/dist/api/route-label.js +17 -0
  13. package/dist/api/server.d.ts +1 -2
  14. package/dist/api/server.js +3 -5
  15. package/dist/config/index.d.ts +5 -0
  16. package/dist/db/event-store-executor.d.ts +2 -1
  17. package/dist/db/index.d.ts +2 -2
  18. package/dist/db/index.js +1 -1
  19. package/dist/db/located-timestamp.d.ts +7 -0
  20. package/dist/db/query.d.ts +1 -1
  21. package/dist/db/query.js +8 -2
  22. package/dist/db/table-builder.d.ts +45 -11
  23. package/dist/db/tenant-db.js +3 -1
  24. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  25. package/dist/engine/boot-validator/entity-handler.js +47 -16
  26. package/dist/engine/entity-handlers.d.ts +3 -3
  27. package/dist/engine/factories.d.ts +2 -1
  28. package/dist/engine/factories.js +2 -0
  29. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  30. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  31. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  32. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  33. package/dist/engine/membership-roles.d.ts +1 -0
  34. package/dist/engine/membership-roles.js +8 -0
  35. package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
  36. package/dist/engine/types/index.d.ts +2 -2
  37. package/dist/engine/types/index.js +1 -1
  38. package/dist/entrypoint/index.d.ts +1 -0
  39. package/dist/entrypoint/index.js +0 -2
  40. package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
  41. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  42. package/dist/files/bind-pending-file-refs.js +80 -6
  43. package/dist/files/create-file-ref-from-bytes.js +12 -6
  44. package/dist/files/create-file-ref.d.ts +20 -2
  45. package/dist/files/create-file-ref.js +10 -1
  46. package/dist/files/feature.d.ts +1 -0
  47. package/dist/files/feature.js +25 -0
  48. package/dist/files/file-handle.d.ts +4 -1
  49. package/dist/files/file-handle.js +30 -4
  50. package/dist/files/file-ref-entity.d.ts +7 -7
  51. package/dist/files/file-ref-table.d.ts +7 -7
  52. package/dist/files/file-routes.js +6 -1
  53. package/dist/files/index.d.ts +5 -1
  54. package/dist/files/index.js +4 -1
  55. package/dist/files/load-save-file-refs.js +3 -1
  56. package/dist/files/provider-resolver.js +3 -1
  57. package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
  58. package/dist/files/sweep-orphaned-uploads.js +53 -0
  59. package/dist/files/upload-ledger.d.ts +10 -0
  60. package/dist/files/upload-ledger.js +47 -0
  61. package/dist/i18n/index.d.ts +1 -1
  62. package/dist/i18n/index.js +1 -1
  63. package/dist/i18n/request-locale.d.ts +15 -4
  64. package/dist/i18n/request-locale.js +26 -9
  65. package/dist/jobs/job-runner.js +2 -4
  66. package/dist/pipeline/dispatch-shared.js +57 -39
  67. package/dist/pipeline/index.d.ts +1 -0
  68. package/dist/pipeline/index.js +1 -0
  69. package/dist/rate-limit/index.d.ts +2 -1
  70. package/dist/rate-limit/index.js +1 -0
  71. package/dist/rate-limit/resolver.d.ts +1 -1
  72. package/dist/rate-limit/resolver.js +104 -2
  73. package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
  74. package/dist/rate-limit/unlimited-resolver.js +22 -0
  75. package/dist/schema-cli.js +46 -5
  76. package/package.json +5 -5
  77. package/src/changes.json +55 -0
  78. package/src/scripts/codemod/README.md +22 -0
  79. package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
@@ -7,8 +7,8 @@ export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsM
7
7
  export type { BootCheckContext, BootCheckFn, BootOptionName, EntityObjectDefinition, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, HookDefinition, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StackBootRequirement, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
9
  export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
- export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
- export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
10
+ export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitOptions, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
+ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, declaredRateLimitOptions, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isRateLimitOption, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
13
13
  export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
14
14
  export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
@@ -1,7 +1,7 @@
1
1
  // Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
2
2
  // runtime helpers below that stay framework-side.
3
3
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
4
- export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
4
+ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, declaredRateLimitOptions, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isRateLimitOption, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
5
5
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-types/identifiers";
6
6
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
7
7
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, isFileField } from "../field-helpers.js";
@@ -17,6 +17,7 @@ export type BaseEntrypointOptions = {
17
17
  readonly jwtSecret: string | JwtKeyring;
18
18
  readonly jwtIssuer?: string;
19
19
  readonly observability?: ObservabilityProvider;
20
+ /** @deprecated No effect: the HTTP route label carries no query string anymore, so there is nothing left to configure. */
20
21
  readonly observabilityOptions?: ObservabilityOptions;
21
22
  readonly dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
22
23
  readonly systemHooks?: SystemHooks;
@@ -127,7 +127,6 @@ function buildApiServer(opts, lifecycle, dispatcherOverride, jobRunner, processL
127
127
  metrics: opts.metrics,
128
128
  extraRoutes: opts.extraRoutes,
129
129
  observability: opts.observability,
130
- observabilityOptions: opts.observabilityOptions,
131
130
  dispatcherOptions,
132
131
  systemHooks: opts.systemHooks,
133
132
  eventDedup: opts.eventDedup,
@@ -147,7 +146,6 @@ function buildWorkerServer(opts, lifecycle, jobRunner) {
147
146
  lifecycle,
148
147
  processLane: "worker",
149
148
  observability: opts.observability,
150
- observabilityOptions: opts.observabilityOptions,
151
149
  dispatcherOptions,
152
150
  systemHooks: opts.systemHooks,
153
151
  eventDedup: opts.eventDedup,
@@ -10,6 +10,19 @@ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
10
10
  // cannot be probed across tenants. `fileRefRows` holds only the writer's own
11
11
  // tenant's live rows, so a missing entry is exactly those two cases. Values
12
12
  // that are not UUIDs are skipped by fileRefIdsOf and never reach this check.
13
+ //
14
+ // Within the tenant, a newly named id must not be bound to a different record:
15
+ // the file routes authorize through the record a file is bound to, so naming
16
+ // record A's file in record B would hand B's readers A's file (and a later
17
+ // removal from B would not touch it). Pending uploads (no entityId), ids the
18
+ // record already holds in any file field (checked first, even if their row is gone)
19
+ // and moves between its own fields stay allowed. The error is the same as above so the owning record is not revealed.
20
+ function isBoundToOtherRecord(row, entityName, recordId) {
21
+ const boundId = row["entityId"];
22
+ if (boundId === null || boundId === undefined)
23
+ return false;
24
+ return row["entityType"] !== entityName || boundId !== recordId;
25
+ }
13
26
  export function assertFileRefsBelongToTenant(args) {
14
27
  const { registry, result, fileRefRows } = args;
15
28
  // skip: saves of unregistered aggregates (custom executors) declare no file fields
@@ -22,11 +35,24 @@ export function assertFileRefsBelongToTenant(args) {
22
35
  // skip: without the files feature the file_refs table is not part of the schema
23
36
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
24
37
  return;
38
+ const recordId = String(result.id);
39
+ const alreadyHeld = new Set();
40
+ for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
41
+ if (!isFileField(fieldDef))
42
+ continue;
43
+ for (const fileRefId of fileRefIdsOf(result.previous[fieldName]))
44
+ alreadyHeld.add(fileRefId);
45
+ }
25
46
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
26
47
  if (!isFileField(fieldDef))
27
48
  continue;
28
49
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
29
- if (fileRefRows.has(fileRefId))
50
+ // A value the record already holds is no new binding; its file_refs row may be gone
51
+ // (forget purged the file), and sending the unchanged field back must still save.
52
+ if (alreadyHeld.has(fileRefId))
53
+ continue;
54
+ const row = fileRefRows.get(fileRefId);
55
+ if (row !== undefined && !isBoundToOtherRecord(row, result.entityName, recordId))
30
56
  continue;
31
57
  throw new ValidationError({
32
58
  fields: [
@@ -1,6 +1,6 @@
1
1
  import type { TenantDb } from "../db/tenant-db.ts";
2
2
  import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.ts";
3
- import type { FileRefRows } from "./load-save-file-refs.ts";
3
+ import { type FileRefRows } from "./load-save-file-refs.ts";
4
4
  export declare function fileRefIdsOf(value: unknown): readonly string[];
5
5
  export declare function bindPendingFileRefs(args: {
6
6
  readonly registry: Registry;
@@ -9,3 +9,33 @@ export declare function bindPendingFileRefs(args: {
9
9
  readonly db: TenantDb;
10
10
  readonly fileRefRows: FileRefRows;
11
11
  }): Promise<void>;
12
+ export type PendingFileRef = {
13
+ readonly id: string;
14
+ readonly storageKey: string;
15
+ readonly mimeType: string;
16
+ readonly size: number;
17
+ readonly version: number;
18
+ };
19
+ type PendingFileRefLookup = {
20
+ readonly fileRefId: string;
21
+ readonly entityName: string;
22
+ readonly fieldName: string;
23
+ readonly user: SessionUser;
24
+ readonly db: TenantDb;
25
+ };
26
+ export declare function loadPendingFileRef(lookup: PendingFileRefLookup): Promise<PendingFileRef | undefined>;
27
+ export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
28
+ /**
29
+ * Binds one of the caller's own pending uploads to a record from a custom handler, with the
30
+ * same ownership predicate as the save pipeline. Anonymous callers never own an upload.
31
+ *
32
+ * The helper checks the upload, not the target: the caller must already be authorized to write
33
+ * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
34
+ *
35
+ * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
36
+ * winner has committed); anything else throws.
37
+ */
38
+ export declare function bindPendingFileRef(args: PendingFileRefLookup & {
39
+ readonly entityId: string;
40
+ }): Promise<BindPendingFileRefOutcome>;
41
+ export {};
@@ -1,7 +1,9 @@
1
+ import { isAnonymousSessionUser } from "../engine/system-user.js";
1
2
  import { isFileField, isUuid, } from "../engine/types/index.js";
2
3
  import { reraiseAsKumikoError } from "../errors/index.js";
3
4
  import { getFileRefExecutor } from "./create-file-ref.js";
4
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
+ import { loadFileRefRows } from "./load-save-file-refs.js";
5
7
  export function fileRefIdsOf(value) {
6
8
  if (typeof value === "string")
7
9
  return isUuid(value) ? [value] : [];
@@ -15,6 +17,16 @@ function isPendingUploadFor(row, entityName, fieldName, user) {
15
17
  row["fieldName"] === fieldName &&
16
18
  row["insertedById"] === user.id);
17
19
  }
20
+ function streamVersionOf(row) {
21
+ return typeof row["version"] === "number" ? row["version"] : undefined;
22
+ }
23
+ async function bindPendingRow(args) {
24
+ const { row, fileRefId, entityName, fieldName, entityId, user, db } = args;
25
+ if (!isPendingUploadFor(row, entityName, fieldName, user))
26
+ return { kind: "not-pending" };
27
+ const bound = await getFileRefExecutor().update({ id: fileRefId, version: streamVersionOf(row), changes: { entityId } }, user, db);
28
+ return bound.isSuccess ? { kind: "bound" } : { kind: "failed", error: bound.error };
29
+ }
18
30
  // A form (or a server handler via createFileRefFromBytes) uploads before the
19
31
  // record references the file, so its fileRefs carry entityType + fieldName but
20
32
  // no entityId. Binding them in the save's own transaction makes record-bound
@@ -35,7 +47,6 @@ export async function bindPendingFileRefs(args) {
35
47
  // skip: without the files feature the file_refs table is not part of the schema
36
48
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
37
49
  return;
38
- const executor = getFileRefExecutor();
39
50
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
40
51
  if (!isFileField(fieldDef))
41
52
  continue;
@@ -43,12 +54,75 @@ export async function bindPendingFileRefs(args) {
43
54
  // ones bound earlier; only the ids this save names can be pending uploads.
44
55
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
45
56
  const row = fileRefRows.get(fileRefId);
46
- if (row === undefined || !isPendingUploadFor(row, entityName, fieldName, user))
57
+ if (row === undefined)
47
58
  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);
59
+ const outcome = await bindPendingRow({
60
+ row,
61
+ fileRefId,
62
+ entityName,
63
+ fieldName,
64
+ entityId: String(result.id),
65
+ user,
66
+ db,
67
+ });
68
+ if (outcome.kind === "failed")
69
+ throw reraiseAsKumikoError(outcome.error);
52
70
  }
53
71
  }
54
72
  }
73
+ async function loadPendingRow(lookup) {
74
+ const { fileRefId, entityName, fieldName, user, db } = lookup;
75
+ // Every anonymous visitor shares one user id, so insertedById cannot tell their uploads apart.
76
+ if (isAnonymousSessionUser(user))
77
+ return undefined;
78
+ if (!isUuid(fileRefId))
79
+ return undefined;
80
+ const row = (await loadFileRefRows(db, [fileRefId], user.tenantId)).get(fileRefId);
81
+ return row !== undefined && isPendingUploadFor(row, entityName, fieldName, user)
82
+ ? row
83
+ : undefined;
84
+ }
85
+ // fileName is left out on purpose: it is personal data and may be encrypted; read it through
86
+ // the fileRef executor's detail when it is needed.
87
+ export async function loadPendingFileRef(lookup) {
88
+ const row = await loadPendingRow(lookup);
89
+ if (row === undefined)
90
+ return undefined;
91
+ const { storageKey, mimeType, size } = row;
92
+ const version = streamVersionOf(row);
93
+ if (typeof storageKey !== "string" ||
94
+ typeof mimeType !== "string" ||
95
+ typeof size !== "number" ||
96
+ version === undefined) {
97
+ return undefined;
98
+ }
99
+ return { id: lookup.fileRefId, storageKey, mimeType, size, version };
100
+ }
101
+ const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
102
+ /**
103
+ * Binds one of the caller's own pending uploads to a record from a custom handler, with the
104
+ * same ownership predicate as the save pipeline. Anonymous callers never own an upload.
105
+ *
106
+ * The helper checks the upload, not the target: the caller must already be authorized to write
107
+ * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
108
+ *
109
+ * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
110
+ * winner has committed); anything else throws.
111
+ */
112
+ export async function bindPendingFileRef(args) {
113
+ const row = await loadPendingRow(args);
114
+ // Without a stream version the update would run unversioned and could overwrite a concurrent bind.
115
+ if (row === undefined || streamVersionOf(row) === undefined)
116
+ return "not-pending";
117
+ const outcome = await bindPendingRow({ ...args, row });
118
+ switch (outcome.kind) {
119
+ case "bound":
120
+ return "bound";
121
+ case "not-pending":
122
+ return "not-pending";
123
+ case "failed":
124
+ if (CONCURRENT_BIND_ERROR_CODES.includes(outcome.error.code))
125
+ return "version-conflict";
126
+ throw reraiseAsKumikoError(outcome.error);
127
+ }
128
+ }
@@ -1,4 +1,5 @@
1
- import { fetchOne } from "../db/query.js";
1
+ import { fetchOne, runnerCanBeginTransaction } from "../db/query.js";
2
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
2
3
  import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
3
4
  import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcceptedFileBytes, } from "./create-file-ref.js";
4
5
  import { fileRefsTable } from "./file-ref-table.js";
@@ -22,9 +23,10 @@ export async function createFileRefFromBytes(ctx, input) {
22
23
  const outcome = await storeFileAsFileRef({
23
24
  user: ctx.user,
24
25
  tenantDb: ctx.db,
26
+ rowInOwnTransaction: runnerCanBeginTransaction(tenantDbRunner(ctx.db)),
25
27
  registry: ctx.registry,
26
28
  defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
27
- writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
29
+ writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
28
30
  deleteBytes: (key) => files.ref(key).delete(),
29
31
  }, {
30
32
  fileName: input.fileName,
@@ -48,7 +50,8 @@ function fileRejectedFailure(error) {
48
50
  }
49
51
  // Pre-transaction half of createFileRefFromBytes: validates and writes the binary while no
50
52
  // transaction holds a connection. Pair it with createFileRefFromUploadedBytes inside the handler.
51
- // A crash between this phase and the commit leaves the bytes in storage; nothing sweeps them.
53
+ // A crash between this phase and the commit leaves the bytes in storage; the upload ledger sweep
54
+ // removes them after its grace period.
52
55
  export async function uploadFileBytesBeforeTransaction(ctx, input) {
53
56
  const { files } = ctx;
54
57
  if (!files) {
@@ -60,7 +63,7 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
60
63
  user: ctx.user,
61
64
  registry: ctx.registry,
62
65
  defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
63
- writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
66
+ writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
64
67
  }, {
65
68
  fileName: input.fileName,
66
69
  declaredMimeType: input.mimeType,
@@ -80,8 +83,11 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
80
83
  const committedRow = await fetchOne(ctx.db, fileRefsTable, {
81
84
  storageKey: uploaded.storageKey,
82
85
  });
83
- if (!committedRow)
84
- await files.ref(uploaded.storageKey).delete();
86
+ // skip: a committed row owns the bytes, the commit already took the ledger entry
87
+ if (committedRow)
88
+ return;
89
+ await files.ref(uploaded.storageKey).delete();
90
+ await files.forgetUploadBytes(uploaded.storageKey);
85
91
  },
86
92
  };
87
93
  }
@@ -1,8 +1,26 @@
1
1
  import { type TenantDb } from "../db/tenant-db.ts";
2
2
  import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.ts";
3
- import type { WriteFailure } from "../errors/index.ts";
3
+ import { type WriteFailure } from "../errors/index.ts";
4
4
  export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
5
- declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
5
+ declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<{
6
+ readonly id: string;
7
+ readonly tenantId: string;
8
+ readonly version: number;
9
+ readonly insertedAt: Temporal.Instant;
10
+ readonly modifiedAt: Temporal.Instant | null;
11
+ readonly insertedById: string | null;
12
+ readonly modifiedById: string | null;
13
+ readonly isDeleted?: boolean | undefined;
14
+ readonly deletedAt?: Temporal.Instant | null | undefined;
15
+ readonly deletedById?: string | null | undefined;
16
+ readonly entityId: string | null;
17
+ readonly entityType: string | null;
18
+ readonly fieldName: string | null;
19
+ readonly fileName: string;
20
+ readonly mimeType: string;
21
+ readonly size: unknown;
22
+ readonly storageKey: string;
23
+ }>;
6
24
  export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
7
25
  type AttachedFieldResolution = {
8
26
  readonly kind: "unattached";
@@ -1,11 +1,13 @@
1
1
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
2
2
  import { runInOwnTransaction } from "../db/tenant-db.js";
3
3
  import { isFileField, } from "../engine/types/index.js";
4
+ import { InternalError } from "../errors/index.js";
4
5
  import { generateId } from "../utils/index.js";
5
6
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
6
7
  import { fileRefsTable } from "./file-ref-table.js";
7
8
  import { resolveContentType } from "./resolve-content-type.js";
8
9
  import { buildStorageKey, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
10
+ import { takeUploadLedgerEntry } from "./upload-ledger.js";
9
11
  export const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
10
12
  // Standard entity executor for fileRef: create/delete emit fileRef.created /
11
13
  // fileRef.deleted and write file_refs via applyEntityEvent in one tx
@@ -132,6 +134,13 @@ export async function createFileRefRow(deps, uploaded) {
132
134
  }, deps.user, deps.tenantDb);
133
135
  if (!result.isSuccess)
134
136
  return { failure: result };
137
+ // Throws instead of returning a failure: a returned failure would still commit in the
138
+ // own-transaction path, leaving a fileRef row whose bytes the sweep already deleted.
139
+ if (!(await takeUploadLedgerEntry(deps.tenantDb, uploaded.storageKey))) {
140
+ throw new InternalError({
141
+ message: "upload expired: the bytes were swept before the fileRef row was created, upload again",
142
+ });
143
+ }
135
144
  return {
136
145
  fileRef: {
137
146
  id: fileRefId,
@@ -148,7 +157,7 @@ export async function storeFileAsFileRef(deps, input) {
148
157
  if (upload.kind === "rejected")
149
158
  return upload;
150
159
  // A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
151
- // crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
160
+ // crash cannot, so the upload ledger sweep removes those bytes after its grace period.
152
161
  // Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
153
162
  // A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
154
163
  const deleteUploadedBytes = async () => {
@@ -6,6 +6,7 @@ export type FilesFeatureOptions = {
6
6
  readonly privilegedRoles?: readonly string[];
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
+ readonly uploadSweepGraceHours?: number;
9
10
  };
10
11
  export declare const FILES_FEATURE: "files";
11
12
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -1,6 +1,8 @@
1
1
  import { defineFeature } from "../engine/index.js";
2
2
  import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
3
3
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
4
+ import { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
5
+ import { fileUploadLedgerTable } from "./upload-ledger.js";
4
6
  export { fileRefEntity } from "./file-ref-entity.js";
5
7
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
6
8
  // den Standard-Entity-Executor (file-routes.ts: executor.create/delete →
@@ -14,7 +16,14 @@ export { fileRefEntity } from "./file-ref-entity.js";
14
16
  // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
15
17
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
16
18
  export const FILES_FEATURE = "files";
19
+ function assertValidSweepGraceHours(hours) {
20
+ if (!Number.isInteger(hours) || hours < MIN_UPLOAD_SWEEP_GRACE_HOURS) {
21
+ throw new Error(`createFilesFeature: uploadSweepGraceHours must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
22
+ }
23
+ }
17
24
  export function createFilesFeature(options = {}) {
25
+ const uploadSweepGraceHours = options.uploadSweepGraceHours ?? DEFAULT_UPLOAD_SWEEP_GRACE_HOURS;
26
+ assertValidSweepGraceHours(uploadSweepGraceHours);
18
27
  return defineFeature(FILES_FEATURE, (r) => {
19
28
  r.describe("Exposes the `fileRef` entity from the framework core so that uploaded files — tracked in the `file_refs` table by `createFileRoutes` — participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.");
20
29
  r.uiHints({
@@ -24,6 +33,22 @@ export function createFilesFeature(options = {}) {
24
33
  });
25
34
  r.entity({ name: FILE_REF_ENTITY_NAME, ...fileRefEntity });
26
35
  r.writeHandler(bindReferencedFilesWrite);
36
+ r.storeTable(fileUploadLedgerTable, { reason: "coordination.file_upload_ledger" });
37
+ r.job({
38
+ name: "sweep-orphaned-uploads",
39
+ trigger: { cron: "0 * * * *" },
40
+ perTenant: true,
41
+ concurrency: "skip",
42
+ handler: async (_payload, ctx) => {
43
+ await sweepOrphanedUploads({
44
+ db: ctx.db,
45
+ files: ctx.files,
46
+ now: ctx.tz.now(),
47
+ graceHours: uploadSweepGraceHours,
48
+ log: ctx.log,
49
+ });
50
+ },
51
+ });
27
52
  return { routeOptions: options };
28
53
  });
29
54
  }
@@ -1,7 +1,10 @@
1
1
  import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
2
+ import type { FileProviderResolver, FileUploadLedger } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
3
+ import type { TenantId } from "../engine/types/identifiers.ts";
2
4
  import { type FileStorageProvider } from "./types.ts";
3
5
  export type { FileContext, FileHandle };
4
6
  export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
5
- export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
7
+ export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string, uploadLedger?: FileUploadLedger): FileContext;
8
+ export declare function createTenantFileContext(resolver: FileProviderResolver, tenantId: TenantId): FileContext;
6
9
  export declare function deriveKey(key: string, suffix: string): string;
7
10
  export declare function storageKeyStemPrefix(key: string): string;
@@ -11,6 +11,7 @@
11
11
  // `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
12
12
  // the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
13
13
  import { isDerivativeSuffix } from "../derivatives/variant-key.js";
14
+ import { InternalError } from "../errors/index.js";
14
15
  import { assertSafeStorageKey } from "./types.js";
15
16
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
16
17
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
@@ -42,19 +43,39 @@ export function createFileHandle(key, getProvider) {
42
43
  // With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
43
44
  // the key space is the isolation boundary in a store shared by several tenants, and
44
45
  // a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
45
- export function createFileContext(resolve, tenantId) {
46
+ export function createFileContext(resolve, tenantId, uploadLedger) {
46
47
  let cached;
47
48
  const getProvider = () => {
48
49
  cached ??= resolve();
49
50
  return cached;
50
51
  };
52
+ const requireUploadLedger = () => {
53
+ if (uploadLedger === undefined) {
54
+ throw new InternalError({
55
+ message: "FileContext: no upload ledger is wired, so upload bytes would be unrecoverable after a crash. Build the context from a resolver made by withFileProviderResolver (or withUploadLedger)",
56
+ });
57
+ }
58
+ return uploadLedger;
59
+ };
60
+ const assertOwnKey = (method, key) => {
61
+ if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
62
+ throw new Error(`FileContext.${method}: key must start with "${tenantId}/"`);
63
+ }
64
+ };
51
65
  return {
52
66
  ref: (key) => {
53
- if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
54
- throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
55
- }
67
+ assertOwnKey("ref", key);
56
68
  return createFileHandle(key, getProvider);
57
69
  },
70
+ writeUploadBytes: async (key, data, mimeType) => {
71
+ assertOwnKey("writeUploadBytes", key);
72
+ await requireUploadLedger().record(key);
73
+ await (await getProvider()).write(key, data, mimeType);
74
+ },
75
+ forgetUploadBytes: async (key) => {
76
+ assertOwnKey("forgetUploadBytes", key);
77
+ await requireUploadLedger().forget(key);
78
+ },
58
79
  /** Validates only the storage key shape (assertSafeStorageKey), no tenant prefix: keys
59
80
  * may outlive a tenant handover. The caller must ensure the fileRef row came from a
60
81
  * tenant-filtered read. */
@@ -74,6 +95,11 @@ export function createFileContext(resolve, tenantId) {
74
95
  },
75
96
  };
76
97
  }
98
+ // The one way to build a tenant's FileContext from a resolver: carries the resolver's upload
99
+ // ledger, so every context agrees on where upload bytes are tracked.
100
+ export function createTenantFileContext(resolver, tenantId) {
101
+ return createFileContext(() => resolver(tenantId), tenantId, resolver.uploadLedgerFor?.(tenantId));
102
+ }
77
103
  // Splits a key into the part before the last dot of its last path segment
78
104
  // (the "stem") and the extension including the dot. No dot in the last
79
105
  // segment: the whole key is the stem, extension is empty.
@@ -1,6 +1,6 @@
1
1
  export { FILE_REF_ENTITY_NAME } from "./types.ts";
2
2
  export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
3
- storageKey: {
3
+ readonly storageKey: {
4
4
  readonly type: "text";
5
5
  readonly description?: string;
6
6
  readonly maxLength?: number;
@@ -23,7 +23,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
23
23
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
24
24
  required: true;
25
25
  };
26
- fileName: {
26
+ readonly fileName: {
27
27
  readonly type: "text";
28
28
  readonly description?: string;
29
29
  readonly maxLength?: number;
@@ -46,7 +46,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
46
46
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
47
47
  required: true;
48
48
  };
49
- mimeType: {
49
+ readonly mimeType: {
50
50
  readonly type: "text";
51
51
  readonly description?: string;
52
52
  readonly maxLength?: number;
@@ -69,7 +69,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
69
69
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
70
70
  required: true;
71
71
  };
72
- size: {
72
+ readonly size: {
73
73
  readonly type: "bigInt";
74
74
  readonly description?: string;
75
75
  readonly required?: boolean;
@@ -81,7 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
81
81
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
82
82
  required: true;
83
83
  };
84
- entityType: {
84
+ readonly entityType: {
85
85
  readonly type: "text";
86
86
  readonly description?: string;
87
87
  readonly maxLength?: number;
@@ -104,7 +104,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
104
104
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
105
105
  required: false;
106
106
  };
107
- entityId: {
107
+ readonly entityId: {
108
108
  readonly type: "text";
109
109
  readonly description?: string;
110
110
  readonly maxLength?: number;
@@ -127,7 +127,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
127
127
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
128
128
  required: false;
129
129
  };
130
- fieldName: {
130
+ readonly fieldName: {
131
131
  readonly type: "text";
132
132
  readonly description?: string;
133
133
  readonly maxLength?: number;
@@ -1,5 +1,5 @@
1
1
  export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable<import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
- storageKey: {
2
+ readonly storageKey: {
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
@@ -22,7 +22,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
22
22
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
23
23
  required: true;
24
24
  };
25
- fileName: {
25
+ readonly fileName: {
26
26
  readonly type: "text";
27
27
  readonly description?: string;
28
28
  readonly maxLength?: number;
@@ -45,7 +45,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
45
45
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
46
46
  required: true;
47
47
  };
48
- mimeType: {
48
+ readonly mimeType: {
49
49
  readonly type: "text";
50
50
  readonly description?: string;
51
51
  readonly maxLength?: number;
@@ -68,7 +68,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
68
68
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
69
69
  required: true;
70
70
  };
71
- size: {
71
+ readonly size: {
72
72
  readonly type: "bigInt";
73
73
  readonly description?: string;
74
74
  readonly required?: boolean;
@@ -80,7 +80,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
80
80
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
81
81
  required: true;
82
82
  };
83
- entityType: {
83
+ readonly entityType: {
84
84
  readonly type: "text";
85
85
  readonly description?: string;
86
86
  readonly maxLength?: number;
@@ -103,7 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
103
103
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
104
104
  required: false;
105
105
  };
106
- entityId: {
106
+ readonly entityId: {
107
107
  readonly type: "text";
108
108
  readonly description?: string;
109
109
  readonly maxLength?: number;
@@ -126,7 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
126
126
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
127
127
  required: false;
128
128
  };
129
- fieldName: {
129
+ readonly fieldName: {
130
130
  readonly type: "text";
131
131
  readonly description?: string;
132
132
  readonly maxLength?: number;