@cosmicdrift/kumiko-framework 0.378.0 → 0.380.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 (116) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/embedded.md +23 -37
  5. package/agents/examples/event-sourcing.md +6 -5
  6. package/agents/examples/legal-pages.md +5 -5
  7. package/agents/examples/reference-data.md +26 -21
  8. package/agents/examples/relations.md +2 -1
  9. package/agents/features.md +4 -4
  10. package/dist/api/entity-read-access.d.ts +2 -0
  11. package/dist/api/entity-read-access.js +27 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/server.js +10 -3
  15. package/dist/api/sse-route.d.ts +5 -0
  16. package/dist/api/sse-route.js +59 -9
  17. package/dist/db/apply-entity-event.d.ts +3 -2
  18. package/dist/db/apply-entity-event.js +1 -1
  19. package/dist/db/dialect.d.ts +2 -1
  20. package/dist/db/dialect.js +2 -2
  21. package/dist/db/entity-table-from-registry.js +0 -2
  22. package/dist/db/entity-table-meta.d.ts +5 -3
  23. package/dist/db/entity-table-meta.js +6 -15
  24. package/dist/db/event-store-executor-write.js +21 -16
  25. package/dist/db/executor-managed-tables.d.ts +8 -0
  26. package/dist/db/executor-managed-tables.js +48 -0
  27. package/dist/db/index.d.ts +6 -4
  28. package/dist/db/index.js +4 -2
  29. package/dist/db/money.d.ts +1 -1
  30. package/dist/db/queries/backfill-pii.js +22 -12
  31. package/dist/db/queries/database-now.d.ts +5 -0
  32. package/dist/db/queries/database-now.js +12 -0
  33. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  34. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  35. package/dist/db/query.d.ts +22 -11
  36. package/dist/db/query.js +41 -36
  37. package/dist/db/reference-data.d.ts +5 -4
  38. package/dist/db/reference-data.js +23 -21
  39. package/dist/db/table-builder.d.ts +2 -3
  40. package/dist/db/table-builder.js +2 -0
  41. package/dist/db/table-introspection.d.ts +2 -0
  42. package/dist/db/table-introspection.js +4 -0
  43. package/dist/db/tenant-db-runner.d.ts +2 -0
  44. package/dist/db/tenant-db-runner.js +7 -0
  45. package/dist/db/tenant-db.d.ts +9 -2
  46. package/dist/db/tenant-db.js +82 -25
  47. package/dist/engine/boot-validator/entity-handler.js +9 -4
  48. package/dist/engine/boot-validator/query-refs.js +8 -0
  49. package/dist/engine/boot-validator/screens.js +34 -9
  50. package/dist/engine/extensions/tenant-data.d.ts +2 -2
  51. package/dist/engine/feature-ui-extensions.js +4 -0
  52. package/dist/engine/projection-helpers.d.ts +3 -3
  53. package/dist/engine/registry-state.js +2 -0
  54. package/dist/engine/registry-validate.js +4 -1
  55. package/dist/engine/schema-builder.js +18 -4
  56. package/dist/engine/screen-helpers.d.ts +1 -0
  57. package/dist/engine/screen-helpers.js +3 -0
  58. package/dist/engine/soft-delete-cleanup.js +53 -24
  59. package/dist/engine/steps/unsafe-projection-delete.js +4 -2
  60. package/dist/entrypoint/index.d.ts +1 -3
  61. package/dist/errors/reasons.d.ts +1 -0
  62. package/dist/errors/reasons.js +4 -1
  63. package/dist/errors/serialize.d.ts +1 -0
  64. package/dist/errors/serialize.js +9 -5
  65. package/dist/errors/write-error-info.js +3 -2
  66. package/dist/errors/zod-bridge.js +7 -0
  67. package/dist/es-ops/operations-schema.d.ts +1 -1
  68. package/dist/event-store/archive.d.ts +1 -1
  69. package/dist/event-store/events-schema.d.ts +1 -1
  70. package/dist/event-store/index.d.ts +1 -0
  71. package/dist/event-store/index.js +1 -0
  72. package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
  73. package/dist/event-store/snapshot.d.ts +1 -1
  74. package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
  75. package/dist/files/action-form-upload.d.ts +16 -0
  76. package/dist/files/action-form-upload.js +45 -0
  77. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  78. package/dist/files/bind-pending-file-refs.js +44 -4
  79. package/dist/files/create-file-ref.d.ts +3 -0
  80. package/dist/files/create-file-ref.js +41 -13
  81. package/dist/files/file-routes.js +29 -2
  82. package/dist/files/index.d.ts +2 -1
  83. package/dist/files/index.js +1 -0
  84. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  85. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  86. package/dist/files/storage-tracking.d.ts +1 -1
  87. package/dist/files/upload-ledger.d.ts +2 -2
  88. package/dist/internal/testing.d.ts +7 -0
  89. package/dist/internal/testing.js +14 -1
  90. package/dist/migrations/pending-rebuilds.d.ts +1 -1
  91. package/dist/migrations/pii-event-backfill.d.ts +1 -1
  92. package/dist/migrations/pii-event-backfill.js +14 -7
  93. package/dist/observability/escape-hatch-report.d.ts +1 -1
  94. package/dist/observability/escape-hatch-report.js +14 -3
  95. package/dist/pipeline/cascade-handler.js +79 -10
  96. package/dist/pipeline/dispatch-shared.js +1 -9
  97. package/dist/pipeline/event-consumer-state.d.ts +1 -1
  98. package/dist/pipeline/msp-rebuild.js +5 -3
  99. package/dist/pipeline/projection-rebuild.js +4 -2
  100. package/dist/pipeline/projection-state.d.ts +1 -1
  101. package/dist/pipeline/projections-runner.js +2 -1
  102. package/dist/rate-limit/index.d.ts +1 -1
  103. package/dist/rate-limit/index.js +1 -1
  104. package/dist/rate-limit/middleware.d.ts +4 -0
  105. package/dist/rate-limit/middleware.js +5 -2
  106. package/dist/ui-types/index.d.ts +2 -2
  107. package/dist/ui-types/index.js +2 -2
  108. package/dist/ui-types/list-row-meta.d.ts +3 -0
  109. package/dist/ui-types/list-row-meta.js +12 -1
  110. package/kumiko-es-tables.json +6 -0
  111. package/package.json +4 -3
  112. package/src/changes.json +117 -0
  113. package/src/scripts/codemod/README.md +20 -0
  114. package/src/scripts/codemod/codemod-support.ts +1 -1
  115. package/src/scripts/codemod/feature-layout.ts +416 -0
  116. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -7,7 +7,7 @@ import type { PgClient, PgListenClient } from "../db/connection.ts";
7
7
  import type { AppContext, Registry } from "../engine/types/index.ts";
8
8
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner.ts";
9
9
  import type { Lifecycle } from "../lifecycle/index.ts";
10
- import type { ObservabilityOptions, ObservabilityProvider } from "../observability/index.ts";
10
+ import type { ObservabilityProvider } from "../observability/index.ts";
11
11
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher.ts";
12
12
  import type { EventDedup, EventDispatcher } from "../pipeline/index.ts";
13
13
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline.ts";
@@ -17,8 +17,6 @@ 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. */
21
- readonly observabilityOptions?: ObservabilityOptions;
22
20
  readonly dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
23
21
  readonly systemHooks?: SystemHooks;
24
22
  readonly eventDedup?: EventDedup;
@@ -16,6 +16,7 @@ export declare const FrameworkReasons: {
16
16
  readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
17
17
  readonly subjectErased: "subject_erased";
18
18
  readonly fileRejected: "file_rejected";
19
+ readonly fileViolatesBindTarget: "file_violates_bind_target";
19
20
  };
20
21
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
21
22
  export declare const AgentReasons: {
@@ -4,7 +4,7 @@
4
4
  // framework-level reason (e.g. "is this a stale-state race? retry once").
5
5
  //
6
6
  // Features add their own local Reasons objects (see `samples/errors/` or
7
- // `packages/bundled-features/src/tenant/constants.ts` → TenantErrors). The
7
+ // `packages/bundled-features/src/tenant/web/constants.ts` → TenantErrors). The
8
8
  // framework deliberately does NOT enforce uniqueness across features — two
9
9
  // features may use the same reason string if the semantics match. The
10
10
  // convention: snake_case, no spaces, no feature prefix for framework reasons.
@@ -71,6 +71,9 @@ export const FrameworkReasons = {
71
71
  // UnprocessableError: createFileRefFromBytes refused the file (size, type,
72
72
  // content or attach target). `details.error` carries the upload-route error string.
73
73
  fileRejected: "file_rejected",
74
+ // UnprocessableError: bindPendingFileRef({ bindTo }) found the pending upload outside the
75
+ // target field's accept/maxSize. `details.error` carries the validator's error string.
76
+ fileViolatesBindTarget: "file_violates_bind_target",
74
77
  };
75
78
  // Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
76
79
  // sister catalog to FrameworkReasons instead of extending it — see the
@@ -12,6 +12,7 @@ export type ErrorResponseBody = {
12
12
  readonly timestamp: string;
13
13
  };
14
14
  };
15
+ export declare const exposesCauseDetails: () => boolean;
15
16
  export declare function serializeError(err: KumikoError, requestId?: string, errorDocs?: ErrorDocsConfig): ErrorResponseBody;
16
17
  export type ErrorLogEntry = {
17
18
  readonly name: string;
@@ -7,10 +7,14 @@ import { resolveErrorDocsUrl } from "./docs-url.js";
7
7
  // possible — without this, a crash in a handler comes back as a bare
8
8
  // "internal error" and developers have to add try/catch + UnprocessableError
9
9
  // just to see what went wrong (real footgun, not theoretical). The exposure
10
- // is gated on NODE_ENV !== "production" so a misconfigured prod deploy
11
- // doesn't leak stack traces over the wire.
10
+ // is gated on NODE_ENV being explicitly "development" or "test": a missing or
11
+ // misspelled NODE_ENV (a misconfigured prod deploy) must not leak stack traces,
12
+ // paths or upstream URLs over the wire.
12
13
  const CODES_WITHOUT_CLIENT_DETAILS = new Set(["internal_error"]);
13
- const isProductionEnv = () => process.env["NODE_ENV"] === "production";
14
+ export const exposesCauseDetails = () => {
15
+ const env = process.env["NODE_ENV"];
16
+ return env === "development" || env === "test";
17
+ };
14
18
  function devCauseDetail(cause) {
15
19
  if (!(cause instanceof Error))
16
20
  return undefined;
@@ -21,7 +25,7 @@ function devCauseDetail(cause) {
21
25
  };
22
26
  }
23
27
  export function serializeError(err, requestId, errorDocs) {
24
- const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && isProductionEnv();
28
+ const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && !exposesCauseDetails();
25
29
  const exposeDeclaredDetails = err.details !== undefined && !stripped;
26
30
  // For internal_error in dev/test, surface the cause chain so the
27
31
  // response body has something useful instead of "internal error".
@@ -30,7 +34,7 @@ export function serializeError(err, requestId, errorDocs) {
30
34
  const detailsForResponse = (() => {
31
35
  if (exposeDeclaredDetails)
32
36
  return err.details;
33
- if (CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && !isProductionEnv()) {
37
+ if (CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && exposesCauseDetails()) {
34
38
  return devCauseDetail(err.cause);
35
39
  }
36
40
  return undefined;
@@ -1,6 +1,7 @@
1
1
  import { NotFoundError, UnprocessableError } from "./classes.js";
2
2
  import { KumikoError } from "./kumiko-error.js";
3
3
  import { FrameworkReasons } from "./reasons.js";
4
+ import { exposesCauseDetails } from "./serialize.js";
4
5
  import { buildInvalidTransitionDetails } from "./transition-details.js";
5
6
  // Convenience for call sites that return a failed WriteResult. Keeps the
6
7
  // pattern `return writeFailure(new NotFoundError(...))` compact so handlers
@@ -49,9 +50,9 @@ export function toWriteErrorInfo(err) {
49
50
  // back to the client as a bare `{ message: "internal error" }` —
50
51
  // identical user-experience to a misconfigured prod, just slower
51
52
  // because the dev has to add try/catch + console.log to find the
52
- // actual stack. Same gating as serializeError: NODE_ENV !== production.
53
+ // actual stack. Same gating as serializeError.
53
54
  const causeDetails = err.details === undefined && err.code === "internal_error" && err.cause instanceof Error
54
- ? process.env["NODE_ENV"] !== "production"
55
+ ? exposesCauseDetails()
55
56
  ? {
56
57
  causeName: err.cause.name,
57
58
  causeMessage: err.cause.message,
@@ -54,5 +54,12 @@ function extractIssueParams(issue) {
54
54
  if (bag[key] !== undefined)
55
55
  out[key] = bag[key];
56
56
  }
57
+ // A custom issue that overrides its i18n key also carries the values the text interpolates.
58
+ if (issue.code === "custom" && typeof issue.params?.["i18nKey"] === "string") {
59
+ for (const [key, value] of Object.entries(issue.params)) {
60
+ if (key !== "i18nKey" && value !== undefined)
61
+ out[key] = value;
62
+ }
63
+ }
57
64
  return Object.keys(out).length > 0 ? out : undefined;
58
65
  }
@@ -1,5 +1,5 @@
1
1
  import { type DbConnection } from "../db/index.ts";
2
2
  export type EsOperationType = "seed-migration";
3
3
  export type EsOperationAppliedBy = "boot" | "cli" | "ci-pipeline";
4
- export declare const esOperationsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
4
+ export declare const esOperationsTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
5
5
  export declare function createEsOperationsTable(db: DbConnection): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection, DbRunner } from "../db/connection.ts";
2
2
  import type { TenantId } from "../engine/types/index.ts";
3
- export declare const archivedStreamsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
3
+ export declare const archivedStreamsTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
4
4
  export declare function createArchivedStreamsTable(db: DbConnection): Promise<void>;
5
5
  export type ArchiveStreamArgs = {
6
6
  readonly tenantId: TenantId;
@@ -1,3 +1,3 @@
1
1
  import { type DbConnection } from "../db/index.ts";
2
- export declare const eventsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
2
+ export declare const eventsTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
3
3
  export declare function createEventsTable(db: DbConnection): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.ts";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.ts";
3
+ export { type DeleteRowsWithoutReplayableStreamArgs, deleteRowsWithoutReplayableStream, } from "../db/queries/event-store-orphan-rows.ts";
3
4
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.ts";
4
5
  export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.ts";
5
6
  export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.ts";
@@ -1,5 +1,6 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
3
+ export { deleteRowsWithoutReplayableStream, } from "../db/queries/event-store-orphan-rows.js";
3
4
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
5
  export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
6
  export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection, DbRunner } from "../db/connection.ts";
2
2
  import type { StoredEvent } from "./event-store.ts";
3
- export declare const rebuildDeadLetterTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
3
+ export declare const rebuildDeadLetterTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
4
4
  export declare function createRebuildDeadLetterTable(db: DbConnection): Promise<void>;
5
5
  export type SkippedApply = {
6
6
  readonly event: StoredEvent;
@@ -2,7 +2,7 @@
2
2
  import type { LoadAggregateWithSnapshotOptions, LoadAggregateWithSnapshotResult, SnapshotReducer } from "@cosmicdrift/kumiko-types/snapshot-types";
3
3
  import type { DbConnection, DbRunner } from "../db/connection.ts";
4
4
  import type { TenantId } from "../engine/types/index.ts";
5
- export declare const snapshotsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
5
+ export declare const snapshotsTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
6
6
  export declare function createSnapshotsTable(db: DbConnection): Promise<void>;
7
7
  export declare function effectiveSnapshotGeneration(callerVersion: number, entityEventVersion: number | undefined): number;
8
8
  export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection, DbRunner } from "../db/connection.ts";
2
2
  import type { StoredEvent } from "./event-store.ts";
3
- export declare const upcasterDeadLetterTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
3
+ export declare const upcasterDeadLetterTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
4
4
  export declare function createUpcasterDeadLetterTable(db: DbConnection): Promise<void>;
5
5
  export declare function recordUpcasterDeadLetter(db: DbRunner, args: {
6
6
  event: StoredEvent;
@@ -0,0 +1,16 @@
1
+ import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.ts";
2
+ export declare function isActionFormScreenQn(value: string): boolean;
3
+ /** The files/images field `fieldName` of the registered actionForm screen `screenQn`, if any. */
4
+ export declare function resolveActionFormFileField(registry: Pick<Registry, "getScreen"> | undefined, screenQn: string, fieldName: string): AnyFileFieldDef | undefined;
5
+ export type ActionFormFileField = {
6
+ readonly screenQn: string;
7
+ readonly fieldName: string;
8
+ readonly fieldDef: AnyFileFieldDef;
9
+ };
10
+ export declare function listActionFormFileFields(registry: Pick<Registry, "getAllScreens"> | undefined): readonly ActionFormFileField[];
11
+ /**
12
+ * Whether `user` may submit the actionForm `screenQn`: the screen's own access (when set) and
13
+ * the access of its handler must both pass, so an upload is never accepted for a form the
14
+ * caller could not submit.
15
+ */
16
+ export declare function mayUseActionForm(registry: Pick<Registry, "getScreen" | "getWriteHandler">, screenQn: string, user: Pick<SessionUser, "roles">): boolean;
@@ -0,0 +1,45 @@
1
+ import { hasAccess } from "../engine/access.js";
2
+ import { isFileField, } from "../engine/types/index.js";
3
+ // Same character set as the other upload identifiers: the screen QN is stored as the pending
4
+ // upload's entityType and compared by exact match, never interpolated into a storage key.
5
+ const ACTION_FORM_SCREEN_QN = /^[A-Za-z0-9_-]+:screen:[A-Za-z0-9_-]+$/;
6
+ export function isActionFormScreenQn(value) {
7
+ return ACTION_FORM_SCREEN_QN.test(value);
8
+ }
9
+ function findActionFormScreen(registry, screenQn) {
10
+ if (!isActionFormScreenQn(screenQn))
11
+ return undefined;
12
+ const screen = registry.getScreen(screenQn);
13
+ return screen?.type === "actionForm" ? screen : undefined;
14
+ }
15
+ /** The files/images field `fieldName` of the registered actionForm screen `screenQn`, if any. */
16
+ export function resolveActionFormFileField(registry, screenQn, fieldName) {
17
+ const screen = registry && findActionFormScreen(registry, screenQn);
18
+ const field = screen?.fields[fieldName];
19
+ return field !== undefined && isFileField(field) ? field : undefined;
20
+ }
21
+ export function listActionFormFileFields(registry) {
22
+ const found = [];
23
+ for (const [screenQn, screen] of registry?.getAllScreens() ?? []) {
24
+ if (screen.type !== "actionForm")
25
+ continue;
26
+ for (const [fieldName, fieldDef] of Object.entries(screen.fields)) {
27
+ if (isFileField(fieldDef))
28
+ found.push({ screenQn, fieldName, fieldDef });
29
+ }
30
+ }
31
+ return found;
32
+ }
33
+ /**
34
+ * Whether `user` may submit the actionForm `screenQn`: the screen's own access (when set) and
35
+ * the access of its handler must both pass, so an upload is never accepted for a form the
36
+ * caller could not submit.
37
+ */
38
+ export function mayUseActionForm(registry, screenQn, user) {
39
+ const screen = findActionFormScreen(registry, screenQn);
40
+ if (screen === undefined)
41
+ return false;
42
+ if (screen.access !== undefined && !hasAccess(user, screen.access))
43
+ return false;
44
+ return hasAccess(user, registry.getWriteHandler(screen.handler)?.access);
45
+ }
@@ -2,6 +2,7 @@ import type { TenantDb } from "../db/tenant-db.ts";
2
2
  import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.ts";
3
3
  import { type FileRefRows } from "./load-save-file-refs.ts";
4
4
  export declare function fileRefIdsOf(value: unknown): readonly string[];
5
+ type FileRefRow = Readonly<Record<string, unknown>>;
5
6
  export declare function bindPendingFileRefs(args: {
6
7
  readonly registry: Registry;
7
8
  readonly result: SaveContext;
@@ -24,6 +25,11 @@ type PendingFileRefLookup = {
24
25
  readonly db: TenantDb;
25
26
  };
26
27
  export declare function loadPendingFileRef(lookup: PendingFileRefLookup): Promise<PendingFileRef | undefined>;
28
+ export type PendingFileRefBindTarget = {
29
+ readonly entityName: string;
30
+ readonly fieldName: string;
31
+ };
32
+ export declare function assertRowFitsBindTarget(registry: Registry | undefined, bindTo: PendingFileRefBindTarget, row: FileRefRow): void;
27
33
  export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
28
34
  /**
29
35
  * Binds one of the caller's own pending uploads to a record from a custom handler, with the
@@ -32,10 +38,18 @@ export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-confl
32
38
  * The helper checks the upload, not the target: the caller must already be authorized to write
33
39
  * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
34
40
  *
41
+ * `bindTo` moves the upload onto an entity file field of its own: a form upload is pending under
42
+ * the actionForm screen (entityName = the screen QN), and the handler decides which entity field
43
+ * keeps it. The row is re-tagged to `bindTo` and must fit that field's `accept`/`maxSize`
44
+ * (UnprocessableError otherwise); the field must be registered, which needs `registry`
45
+ * (the handler's `ctx.registry`), else InternalError.
46
+ *
35
47
  * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
36
48
  * winner has committed); anything else throws.
37
49
  */
38
50
  export declare function bindPendingFileRef(args: PendingFileRefLookup & {
39
51
  readonly entityId: string;
52
+ readonly bindTo?: PendingFileRefBindTarget;
53
+ readonly registry?: Registry;
40
54
  }): Promise<BindPendingFileRefOutcome>;
41
55
  export {};
@@ -1,9 +1,10 @@
1
1
  import { isAnonymousSessionUser } from "../engine/system-user.js";
2
2
  import { isFileField, isUuid, } from "../engine/types/index.js";
3
- import { reraiseAsKumikoError } from "../errors/index.js";
4
- import { getFileRefExecutor } from "./create-file-ref.js";
3
+ import { FrameworkReasons, InternalError, reraiseAsKumikoError, UnprocessableError, } from "../errors/index.js";
4
+ import { getFileRefExecutor, resolveAttachedField } from "./create-file-ref.js";
5
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
6
  import { loadFileRefRows } from "./load-save-file-refs.js";
7
+ import { validateFile } from "./types.js";
7
8
  export function fileRefIdsOf(value) {
8
9
  if (typeof value === "string")
9
10
  return isUuid(value) ? [value] : [];
@@ -21,10 +22,16 @@ function streamVersionOf(row) {
21
22
  return typeof row["version"] === "number" ? row["version"] : undefined;
22
23
  }
23
24
  async function bindPendingRow(args) {
24
- const { row, fileRefId, entityName, fieldName, entityId, user, db } = args;
25
+ const { row, fileRefId, entityName, fieldName, entityId, bindTo, user, db } = args;
25
26
  if (!isPendingUploadFor(row, entityName, fieldName, user))
26
27
  return { kind: "not-pending" };
27
- const bound = await getFileRefExecutor().update({ id: fileRefId, version: streamVersionOf(row), changes: { entityId } }, user, db);
28
+ const bound = await getFileRefExecutor().update({
29
+ id: fileRefId,
30
+ version: streamVersionOf(row),
31
+ changes: bindTo === undefined
32
+ ? { entityId }
33
+ : { entityId, entityType: bindTo.entityName, fieldName: bindTo.fieldName },
34
+ }, user, db);
28
35
  return bound.isSuccess ? { kind: "bound" } : { kind: "failed", error: bound.error };
29
36
  }
30
37
  // A form (or a server handler via createFileRefFromBytes) uploads before the
@@ -98,6 +105,31 @@ export async function loadPendingFileRef(lookup) {
98
105
  }
99
106
  return { id: lookup.fileRefId, storageKey, mimeType, size, version };
100
107
  }
108
+ // The pending upload was checked against the form field it came in through; a different target
109
+ // field may be stricter. Metadata only (bytes are not re-read): the extension comes from the
110
+ // storage key, whose suffix is the sniffed type, because fileName may be encrypted personal data.
111
+ export function assertRowFitsBindTarget(registry, bindTo, row) {
112
+ const target = resolveAttachedField(registry, bindTo.entityName, bindTo.fieldName);
113
+ if (target.kind !== "resolved") {
114
+ throw new InternalError({
115
+ message: `bindPendingFileRef: bindTo ${bindTo.entityName}.${bindTo.fieldName} is not a registered file field (pass the handler's ctx.registry)`,
116
+ });
117
+ }
118
+ const { storageKey, mimeType, size } = row;
119
+ if (typeof storageKey !== "string" || typeof mimeType !== "string" || typeof size !== "number") {
120
+ // A row without these can only come from corruption; letting it through would skip the check.
121
+ throw new InternalError({
122
+ message: `bindPendingFileRef: pending file_ref ${String(row["id"])} lacks storageKey, mimeType or size`,
123
+ });
124
+ }
125
+ const error = validateFile({ fileName: storageKey, mimeType, size }, { maxSize: target.fieldDef.maxSize, accept: target.fieldDef.accept });
126
+ if (error !== null) {
127
+ throw new UnprocessableError(FrameworkReasons.fileViolatesBindTarget, {
128
+ i18nKey: "errors.file.violatesBindTarget",
129
+ details: { error },
130
+ });
131
+ }
132
+ }
101
133
  const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
102
134
  /**
103
135
  * Binds one of the caller's own pending uploads to a record from a custom handler, with the
@@ -106,6 +138,12 @@ const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
106
138
  * The helper checks the upload, not the target: the caller must already be authorized to write
107
139
  * `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
108
140
  *
141
+ * `bindTo` moves the upload onto an entity file field of its own: a form upload is pending under
142
+ * the actionForm screen (entityName = the screen QN), and the handler decides which entity field
143
+ * keeps it. The row is re-tagged to `bindTo` and must fit that field's `accept`/`maxSize`
144
+ * (UnprocessableError otherwise); the field must be registered, which needs `registry`
145
+ * (the handler's `ctx.registry`), else InternalError.
146
+ *
109
147
  * A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
110
148
  * winner has committed); anything else throws.
111
149
  */
@@ -114,6 +152,8 @@ export async function bindPendingFileRef(args) {
114
152
  // Without a stream version the update would run unversioned and could overwrite a concurrent bind.
115
153
  if (row === undefined || streamVersionOf(row) === undefined)
116
154
  return "not-pending";
155
+ if (args.bindTo !== undefined)
156
+ assertRowFitsBindTarget(args.registry, args.bindTo, row);
117
157
  const outcome = await bindPendingRow({ ...args, row });
118
158
  switch (outcome.kind) {
119
159
  case "bound":
@@ -38,6 +38,9 @@ export type StoreFileInput = {
38
38
  readonly entityType?: string | undefined;
39
39
  readonly entityId?: string | undefined;
40
40
  readonly fieldName?: string | undefined;
41
+ /** Qualified name of a registered actionForm screen; `fieldName` then names one of its files
42
+ * fields and the upload is stored as pending (entityType = the screen QN, no entityId). */
43
+ readonly actionForm?: string | undefined;
41
44
  };
42
45
  export type StoreFileDeps = {
43
46
  readonly user: SessionUser;
@@ -3,6 +3,7 @@ import { runInOwnTransaction } from "../db/tenant-db.js";
3
3
  import { isFileField, } from "../engine/types/index.js";
4
4
  import { InternalError } from "../errors/index.js";
5
5
  import { generateId } from "../utils/index.js";
6
+ import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
6
7
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
7
8
  import { fileRefsTable } from "./file-ref-table.js";
8
9
  import { resolveContentType } from "./resolve-content-type.js";
@@ -23,6 +24,7 @@ export function getFileRefExecutor() {
23
24
  fileRefExecutor ??= buildFileRefExecutor();
24
25
  return fileRefExecutor;
25
26
  }
27
+ const ACTION_FORM_STORAGE_SEGMENT = "action-form";
26
28
  const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
27
29
  // entityType/fieldName drive the GDPR-forget decision downstream
28
30
  // (fileRefDeleteHook resolves the field's PII annotation from exactly this
@@ -39,6 +41,33 @@ export function resolveAttachedField(registry, entityType, fieldName) {
39
41
  return { kind: "unresolvable" };
40
42
  return { kind: "resolved", fieldDef };
41
43
  }
44
+ function resolveUploadField(registry, input) {
45
+ const { entityType, entityId, fieldName, actionForm } = input;
46
+ if (actionForm === undefined)
47
+ return resolveAttachedField(registry, entityType, fieldName);
48
+ if (entityType !== undefined || entityId !== undefined) {
49
+ return {
50
+ kind: "rejected",
51
+ error: "invalid_action_form: actionForm cannot be combined with entityType/entityId",
52
+ };
53
+ }
54
+ if (!isActionFormScreenQn(actionForm)) {
55
+ return {
56
+ kind: "rejected",
57
+ error: "invalid_action_form: expected a screen QN like <feature>:screen:<id>",
58
+ };
59
+ }
60
+ const fieldDef = fieldName !== undefined
61
+ ? resolveActionFormFileField(registry, actionForm, fieldName)
62
+ : undefined;
63
+ if (fieldDef === undefined) {
64
+ return {
65
+ kind: "rejected",
66
+ error: "unresolvable_field: actionForm and fieldName must name a files/image field of a registered actionForm screen",
67
+ };
68
+ }
69
+ return { kind: "resolved", fieldDef };
70
+ }
42
71
  function acceptFile(deps, input) {
43
72
  const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
44
73
  const rejected = (error) => ({ kind: "rejected", error });
@@ -54,18 +83,15 @@ function acceptFile(deps, input) {
54
83
  return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
55
84
  }
56
85
  }
57
- let maxSize = deps.defaultMaxUploadSize;
58
- let accept;
59
- const attachedField = resolveAttachedField(deps.registry, entityType, fieldName);
60
- if (attachedField.kind === "unresolvable") {
86
+ const field = resolveUploadField(deps.registry, input);
87
+ if (field.kind === "rejected")
88
+ return field;
89
+ if (field.kind === "unresolvable") {
61
90
  return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
62
91
  }
63
- if (attachedField.kind === "resolved") {
64
- if (attachedField.fieldDef.maxSize)
65
- maxSize = attachedField.fieldDef.maxSize;
66
- if (attachedField.fieldDef.accept)
67
- accept = attachedField.fieldDef.accept;
68
- }
92
+ const fieldDef = field.kind === "resolved" ? field.fieldDef : undefined;
93
+ const maxSize = fieldDef?.maxSize || deps.defaultMaxUploadSize;
94
+ const accept = fieldDef?.accept;
69
95
  const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
70
96
  if (validationError)
71
97
  return rejected(validationError);
@@ -98,12 +124,14 @@ function acceptFile(deps, input) {
98
124
  // Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
99
125
  // I/O doesn't belong inside a PG connection's tx window.
100
126
  export async function writeAcceptedFileBytes(deps, input) {
101
- const { fileName, data, entityType, entityId, fieldName } = input;
127
+ const { fileName, data, entityType, entityId, fieldName, actionForm } = input;
102
128
  const accepted = acceptFile(deps, input);
103
129
  if (accepted.kind === "rejected")
104
130
  return accepted;
105
131
  const { mimeType, extension } = accepted;
106
- const storageKey = buildStorageKey(deps.user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", fileName, generateId(), extension);
132
+ const storageKey = buildStorageKey(deps.user.tenantId,
133
+ // The screen QN stays out of the key: a fixed segment keeps it a plain path part.
134
+ actionForm !== undefined ? ACTION_FORM_STORAGE_SEGMENT : (entityType ?? "unattached"), entityId, fieldName ?? "file", fileName, generateId(), extension);
107
135
  await deps.writeBytes(storageKey, data, mimeType);
108
136
  return {
109
137
  kind: "uploaded",
@@ -112,7 +140,7 @@ export async function writeAcceptedFileBytes(deps, input) {
112
140
  fileName,
113
141
  mimeType,
114
142
  size: data.byteLength,
115
- entityType,
143
+ entityType: actionForm ?? entityType,
116
144
  entityId,
117
145
  fieldName,
118
146
  },
@@ -6,8 +6,9 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
6
6
  import { selectMany } from "../db/query.js";
7
7
  import { createProjectingTenantDb } from "../db/tenant-db.js";
8
8
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
9
- import { SYSTEM_USER_ID } from "../engine/system-user.js";
9
+ import { isAnonymousSessionUser, SYSTEM_USER_ID } from "../engine/system-user.js";
10
10
  import { isFileField, isUuid, } from "../engine/types/index.js";
11
+ import { listActionFormFileFields, mayUseActionForm } from "./action-form-upload.js";
11
12
  import { buildContentDispositionHeader } from "./content-disposition.js";
12
13
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
13
14
  import { createFileContext } from "./file-handle.js";
@@ -70,6 +71,10 @@ export function resolveMaxUploadBodyBytes(options) {
70
71
  }
71
72
  }
72
73
  }
74
+ for (const { fieldDef } of listActionFormFileFields(options.registry)) {
75
+ if (fieldDef.maxSize)
76
+ maxBytes = Math.max(maxBytes, parseMaxSize(fieldDef.maxSize));
77
+ }
73
78
  return maxBytes + MULTIPART_OVERHEAD_MARGIN_BYTES;
74
79
  }
75
80
  // Upload-route policy carried by createFilesFeature(opts?) — read from the
@@ -148,6 +153,20 @@ export function createFileRoutes(options) {
148
153
  // store it in the text entityId column.
149
154
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
150
155
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
156
+ const actionForm = typeof body["actionForm"] === "string" ? body["actionForm"] : undefined;
157
+ // Ahead of the record check below, which would answer 404/403 for the entity half.
158
+ if (actionForm !== undefined && (entityType !== undefined || entityId !== undefined)) {
159
+ return c.json({ error: "invalid_action_form: actionForm cannot be combined with entityType or entityId" }, 400);
160
+ }
161
+ // Before any bytes are written, like the record check below. An unresolvable screen falls
162
+ // through to storeFileAsFileRef, which answers 400; a known one must be submittable by the
163
+ // caller. Anonymous callers cannot bind pending uploads, so they get no upload either.
164
+ if (actionForm !== undefined &&
165
+ options.registry !== undefined &&
166
+ options.registry.getScreen(actionForm)?.type === "actionForm" &&
167
+ (isAnonymousSessionUser(user) || !mayUseActionForm(options.registry, actionForm, user))) {
168
+ return c.json({ error: "forbidden: no access to this form" }, 403);
169
+ }
151
170
  if (entityId !== undefined) {
152
171
  if (entityType === undefined) {
153
172
  return c.json({ error: "missing_entity_type: entityId requires entityType" }, 400);
@@ -184,7 +203,15 @@ export function createFileRoutes(options) {
184
203
  await storageProvider.write(key, bytes, mimeType);
185
204
  },
186
205
  deleteBytes: (key) => storageProvider.delete(key),
187
- }, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
206
+ }, {
207
+ fileName: file.name,
208
+ declaredMimeType: file.type,
209
+ data,
210
+ entityType,
211
+ entityId,
212
+ fieldName,
213
+ actionForm,
214
+ });
188
215
  if (outcome.kind === "rejected")
189
216
  return c.json({ error: outcome.error }, 400);
190
217
  if (outcome.kind === "failed")
@@ -1,4 +1,5 @@
1
- export type { BindPendingFileRefOutcome, PendingFileRef } from "./bind-pending-file-refs.ts";
1
+ export { listActionFormFileFields, resolveActionFormFileField } from "./action-form-upload.ts";
2
+ export type { BindPendingFileRefOutcome, PendingFileRef, PendingFileRefBindTarget, } from "./bind-pending-file-refs.ts";
2
3
  export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
3
4
  export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.ts";
4
5
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
@@ -1,3 +1,4 @@
1
+ export { listActionFormFileFields, resolveActionFormFileField } from "./action-form-upload.js";
1
2
  export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
2
3
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
3
4
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
@@ -1,10 +1,10 @@
1
1
  import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
2
2
  import type { Logger } from "@cosmicdrift/kumiko-types/logger";
3
- import { deleteMany, type WhereObject } from "../db/query.ts";
3
+ import { selectMany, type WhereObject } from "../db/query.ts";
4
4
  import type { TenantDb } from "../db/tenant-db.ts";
5
5
  export declare const EXPIRED_FILE_REF_PURGE_BATCH = 500;
6
6
  type PurgeLog = Pick<Logger, "warn"> | undefined;
7
- type PurgeTable = Parameters<typeof deleteMany>[1];
7
+ type PurgeTable = Parameters<typeof selectMany>[1];
8
8
  export declare function purgeExpiredFileRefsWithBytes(args: {
9
9
  readonly db: TenantDb;
10
10
  readonly table: PurgeTable;
@@ -12,6 +12,7 @@ export declare function purgeExpiredFileRefsWithBytes(args: {
12
12
  readonly expiredBefore: Temporal.Instant;
13
13
  readonly files: FileContext | undefined;
14
14
  readonly log: PurgeLog;
15
+ readonly forgetRow: (id: string) => Promise<void>;
15
16
  readonly pageSize?: number;
16
17
  }): Promise<void>;
17
18
  export {};
@@ -1,4 +1,4 @@
1
- import { deleteMany, selectMany } from "../db/query.js";
1
+ import { selectMany } from "../db/query.js";
2
2
  import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
4
  const PAGE_ORDER = [
@@ -20,12 +20,12 @@ async function selectNextPage(args) {
20
20
  return [...sameInstant, ...later];
21
21
  }
22
22
  // Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
23
- // rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
23
+ // rows (forgotten through the executor, so a rebuild keeps them gone). Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
24
24
  // row whose bytes could not be removed stays and the next run retries it. A run walks the
25
25
  // expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
26
26
  // rows behind them; the run ends on the first empty page.
27
27
  export async function purgeExpiredFileRefsWithBytes(args) {
28
- const { db, table, where, expiredBefore, files, log } = args;
28
+ const { db, table, where, expiredBefore, files, log, forgetRow } = args;
29
29
  const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
30
30
  if (!files) {
31
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
@@ -53,7 +53,13 @@ export async function purgeExpiredFileRefsWithBytes(args) {
53
53
  log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
54
54
  }
55
55
  }
56
- if (purgedIds.length > 0)
57
- await deleteMany(db, table, { ...where, id: { in: purgedIds } });
56
+ for (const id of purgedIds) {
57
+ try {
58
+ await forgetRow(id);
59
+ }
60
+ catch (err) {
61
+ log?.warn(`[soft-delete-cleanup] fileRef ${id} bytes deleted but row not forgotten: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ }
58
64
  }
59
65
  }