@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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 (150) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/workflow-engine.md +2 -2
  22. package/agents/examples.md +2 -1
  23. package/agents/features.md +3 -3
  24. package/dist/api/auth-routes.d.ts +0 -1
  25. package/dist/api/auth-routes.js +44 -43
  26. package/dist/api/index.d.ts +1 -1
  27. package/dist/api/index.js +1 -1
  28. package/dist/api/server.d.ts +2 -2
  29. package/dist/api/server.js +21 -4
  30. package/dist/bun-db/connection.js +3 -16
  31. package/dist/bun-db/index.d.ts +1 -1
  32. package/dist/bun-db/index.js +1 -1
  33. package/dist/bun-db/query.d.ts +7 -0
  34. package/dist/bun-db/query.js +50 -18
  35. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  36. package/dist/crypto/owner-transition-rekey.js +7 -3
  37. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  38. package/dist/crypto/pii-field-encryption.js +9 -2
  39. package/dist/crypto/subject-resolver.d.ts +1 -0
  40. package/dist/crypto/subject-resolver.js +7 -0
  41. package/dist/db/api.d.ts +4 -3
  42. package/dist/db/apply-entity-event.js +26 -2
  43. package/dist/db/blind-index-cleanup.js +2 -1
  44. package/dist/db/bun-provider.js +9 -18
  45. package/dist/db/dialect.d.ts +1 -1
  46. package/dist/db/dialect.js +2 -0
  47. package/dist/db/eagerload.js +2 -1
  48. package/dist/db/event-store-executor-context.d.ts +1 -0
  49. package/dist/db/event-store-executor-context.js +1 -0
  50. package/dist/db/event-store-executor-write.d.ts +1 -1
  51. package/dist/db/event-store-executor-write.js +70 -47
  52. package/dist/db/index.d.ts +1 -0
  53. package/dist/db/index.js +1 -0
  54. package/dist/db/migrate-runner.d.ts +1 -0
  55. package/dist/db/migrate-runner.js +56 -31
  56. package/dist/db/queries/backfill-pii.js +63 -25
  57. package/dist/db/queries/event-consumer.js +2 -1
  58. package/dist/db/queries/event-store-transfer.js +15 -4
  59. package/dist/db/queries/event-store.js +11 -10
  60. package/dist/db/queries/pii-backfill-state.js +3 -2
  61. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  62. package/dist/db/queries/postgres-array-literal.js +6 -0
  63. package/dist/db/queries/projection-rebuild.js +3 -2
  64. package/dist/db/queries/shadow-swap.d.ts +1 -1
  65. package/dist/db/queries/shadow-swap.js +12 -8
  66. package/dist/db/query.d.ts +1 -1
  67. package/dist/db/query.js +1 -1
  68. package/dist/db/retired-framework-tables.js +22 -4
  69. package/dist/db/table-builder.js +6 -2
  70. package/dist/db/tenant-db.d.ts +2 -0
  71. package/dist/db/tenant-db.js +20 -1
  72. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  73. package/dist/derivatives/delete-stored-file.js +9 -0
  74. package/dist/derivatives/index.d.ts +1 -1
  75. package/dist/derivatives/index.js +1 -1
  76. package/dist/engine/boot-validator/entity-handler.js +3 -0
  77. package/dist/engine/boot-validator/query-refs.js +14 -2
  78. package/dist/engine/dedupe-features.js +31 -4
  79. package/dist/engine/entity-handlers.js +14 -7
  80. package/dist/engine/index.d.ts +1 -1
  81. package/dist/engine/index.js +1 -1
  82. package/dist/engine/screen-helpers.d.ts +1 -0
  83. package/dist/engine/screen-helpers.js +2 -2
  84. package/dist/engine/steps/_read-source.js +5 -2
  85. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  86. package/dist/engine/tier-resolver-extension.js +6 -0
  87. package/dist/entrypoint/index.d.ts +2 -2
  88. package/dist/errors/docs-url.js +6 -6
  89. package/dist/errors/index.d.ts +1 -1
  90. package/dist/errors/index.js +1 -1
  91. package/dist/errors/subject-erased.d.ts +1 -0
  92. package/dist/errors/subject-erased.js +6 -0
  93. package/dist/event-store/admin-api.js +3 -2
  94. package/dist/event-store/errors.d.ts +9 -0
  95. package/dist/event-store/errors.js +20 -0
  96. package/dist/event-store/event-store.d.ts +6 -0
  97. package/dist/event-store/event-store.js +28 -6
  98. package/dist/event-store/index.d.ts +1 -1
  99. package/dist/event-store/index.js +1 -1
  100. package/dist/files/create-file-ref.d.ts +2 -1
  101. package/dist/files/create-file-ref.js +16 -2
  102. package/dist/files/dispose-removed-file-refs.js +21 -6
  103. package/dist/files/feature.d.ts +1 -0
  104. package/dist/files/feature.js +2 -1
  105. package/dist/files/file-handle.js +4 -0
  106. package/dist/files/file-routes.js +9 -5
  107. package/dist/files/index.d.ts +1 -1
  108. package/dist/files/index.js +1 -1
  109. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  110. package/dist/files/resolve-content-type.js +40 -4
  111. package/dist/files/storage-tracking.js +5 -4
  112. package/dist/files/types.js +5 -8
  113. package/dist/files/zip-entry-names.d.ts +1 -0
  114. package/dist/files/zip-entry-names.js +46 -0
  115. package/dist/jobs/job-runner.d.ts +1 -0
  116. package/dist/jobs/job-runner.js +116 -33
  117. package/dist/observability/escape-hatch-report.d.ts +1 -0
  118. package/dist/observability/escape-hatch-report.js +31 -2
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/metrics-handle.d.ts +1 -0
  122. package/dist/observability/metrics-handle.js +7 -0
  123. package/dist/observability/standard-metrics.js +4 -4
  124. package/dist/pipeline/dispatch-query.d.ts +2 -1
  125. package/dist/pipeline/dispatch-query.js +6 -6
  126. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  127. package/dist/pipeline/dispatch-shared.js +41 -30
  128. package/dist/pipeline/dispatch-stream.js +10 -4
  129. package/dist/pipeline/dispatcher.js +3 -1
  130. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  131. package/dist/pipeline/event-dispatcher.js +11 -9
  132. package/dist/pipeline/event-retention.d.ts +1 -0
  133. package/dist/pipeline/event-retention.js +7 -1
  134. package/dist/pipeline/notify-scope.js +3 -1
  135. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  136. package/dist/schema-cli.js +12 -1
  137. package/dist/stack/db.d.ts +2 -2
  138. package/dist/stack/test-stack.js +2 -1
  139. package/dist/testing/index.d.ts +1 -0
  140. package/dist/testing/index.js +1 -0
  141. package/dist/testing/multipart-helper.d.ts +6 -0
  142. package/dist/testing/multipart-helper.js +1 -1
  143. package/dist/testing/zip-fixture.d.ts +1 -0
  144. package/dist/testing/zip-fixture.js +33 -0
  145. package/dist/ui-types/card-column-roles.js +4 -2
  146. package/dist/ui-types/index.d.ts +1 -1
  147. package/dist/ui-types/index.js +1 -1
  148. package/dist/upgrade-cli.js +46 -13
  149. package/package.json +4 -4
  150. package/src/changes.json +305 -0
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { ConflictError } from "./classes.js";
2
3
  import { FrameworkReasons } from "./reasons.js";
3
4
  export function subjectErasedConflict(cause) {
@@ -7,3 +8,8 @@ export function subjectErasedConflict(cause) {
7
8
  ...(cause instanceof Error && { cause }),
8
9
  });
9
10
  }
11
+ export function isSubjectErasedConflict(e) {
12
+ return (e instanceof ConflictError &&
13
+ isPlainObject(e.details) &&
14
+ e.details["reason"] === FrameworkReasons.subjectErased);
15
+ }
@@ -7,6 +7,7 @@
7
7
  // Guard-Script `scripts/guard-admin-api.ts` blockt Aufrufe aus App-Code —
8
8
  // Allowlist: samples/*/migration/, scripts/migrations/, die Definition
9
9
  // selbst, das Guard-Script selbst.
10
+ import { instantToTimestamptzParam } from "../bun-db/query.js";
10
11
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
11
12
  import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
12
13
  import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
@@ -53,7 +54,7 @@ function rawEventParams(event, newVersion, eventVersion) {
53
54
  eventVersion,
54
55
  payload: event.payload,
55
56
  metadata: event.metadata,
56
- createdAt: event.createdAt.toString(),
57
+ createdAt: instantToTimestamptzParam(event.createdAt),
57
58
  createdBy: event.createdBy,
58
59
  };
59
60
  }
@@ -88,7 +89,7 @@ export async function appendRawBatch(runner, events) {
88
89
  const newVersion = e.expectedVersion + 1;
89
90
  const eventVersion = e.eventVersion ?? 1;
90
91
  const baseIdx = params.length;
91
- params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, e.createdAt.toString(), e.createdBy);
92
+ params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, instantToTimestamptzParam(e.createdAt), e.createdBy);
92
93
  return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
93
94
  });
94
95
  try {
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  export declare class VersionConflictError extends Error {
2
3
  readonly aggregateId: string;
3
4
  readonly expectedVersion: number;
@@ -8,6 +9,14 @@ export declare class IdempotentAppendConflictError extends Error {
8
9
  readonly idempotencyKey: string;
9
10
  constructor(tenantId: string, idempotencyKey: string);
10
11
  }
12
+ export declare class SeedTimeOrderError extends Error {
13
+ readonly aggregateType: string;
14
+ readonly aggregateId: string;
15
+ readonly version: number;
16
+ readonly seedCreatedAt: Temporal.Instant;
17
+ readonly predecessorCreatedAt: Temporal.Instant;
18
+ constructor(aggregateType: string, aggregateId: string, version: number, seedCreatedAt: Temporal.Instant, predecessorCreatedAt: Temporal.Instant);
19
+ }
11
20
  export declare class ArchivedStreamError extends Error {
12
21
  readonly tenantId: string;
13
22
  readonly aggregateId: string;
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // Failure modes of the event-store's append() path. Surfaced as typed
2
3
  // errors so the executor layer can map them to the framework's
3
4
  // WriteResult error contract (version_conflict).
@@ -31,6 +32,25 @@ export class IdempotentAppendConflictError extends Error {
31
32
  this.idempotencyKey = idempotencyKey;
32
33
  }
33
34
  }
35
+ // Thrown when a back-dated seed write would put a stream's next version
36
+ // before its predecessor in time. loadAggregateAsOf and projection rebuilds
37
+ // order by created_at, so such a stream would read and replay wrongly.
38
+ export class SeedTimeOrderError extends Error {
39
+ aggregateType;
40
+ aggregateId;
41
+ version;
42
+ seedCreatedAt;
43
+ predecessorCreatedAt;
44
+ constructor(aggregateType, aggregateId, version, seedCreatedAt, predecessorCreatedAt) {
45
+ super(`Seed write for ${aggregateType} ${aggregateId} v${version} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessorCreatedAt.toString()}). Write a stream's events in ascending time.`);
46
+ this.name = "SeedTimeOrderError";
47
+ this.aggregateType = aggregateType;
48
+ this.aggregateId = aggregateId;
49
+ this.version = version;
50
+ this.seedCreatedAt = seedCreatedAt;
51
+ this.predecessorCreatedAt = predecessorCreatedAt;
52
+ }
53
+ }
34
54
  // Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
35
55
  // are read-only — restoreStream() makes them writable again. The archive
36
56
  // state is not carried on the events themselves; it lives on the sparse
@@ -20,6 +20,12 @@ export declare function loadAggregate(db: DbRunner, aggregateId: string, tenantI
20
20
  readonly includeArchived?: boolean;
21
21
  readonly aggregateType?: string;
22
22
  }): Promise<readonly StoredEvent[]>;
23
+ export declare function loadAggregateForWriting(db: DbRunner, aggregateId: string, tenantId: TenantId, options: {
24
+ readonly aggregateType: string;
25
+ }): Promise<{
26
+ readonly events: readonly StoredEvent[];
27
+ readonly streamVersion: number;
28
+ }>;
23
29
  export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, tenantId: TenantId, asOf: Temporal.Instant, options?: {
24
30
  readonly includeArchived?: boolean;
25
31
  }): Promise<readonly StoredEvent[]>;
@@ -2,12 +2,13 @@
2
2
  import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
+ import { instantToTimestamptzParam } from "../bun-db/query.js";
5
6
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
6
7
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
7
8
  import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
8
9
  import { insertOne, selectMany } from "../db/query.js";
9
10
  import { isStreamArchived } from "./archive.js";
10
- import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
11
+ import { IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
11
12
  import { eventsTable } from "./events-schema.js";
12
13
  import { toStoredEvent } from "./row-to-stored-event.js";
13
14
  import { currentSeedCreatedAt } from "./seed-clock.js";
@@ -44,14 +45,16 @@ export async function append(db, event) {
44
45
  });
45
46
  const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
46
47
  const seedCreatedAt = currentSeedCreatedAt();
47
- if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
48
- await assertSeedTimeNotBeforePredecessor(db, toStore, seedCreatedAt);
49
- }
50
48
  const newVersion = toStore.expectedVersion + 1;
51
49
  const eventVersion = toStore.eventVersion ?? 1;
52
50
  try {
53
51
  const row = await runAtomicEventWrite(db, async (runner) => {
54
52
  await claimXactId(runner);
53
+ // Inside the write transaction so a concurrent live append cannot slip
54
+ // between the check and the insert.
55
+ if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
56
+ await assertSeedTimeNotBeforePredecessor(runner, toStore, seedCreatedAt);
57
+ }
55
58
  const inserted = toStore.expectedVersion === 0
56
59
  ? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
57
60
  : await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
@@ -105,7 +108,7 @@ async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
105
108
  version: event.expectedVersion,
106
109
  });
107
110
  if (predecessor && predecessor.createdAt.epochMilliseconds > seedCreatedAt.epochMilliseconds) {
108
- throw new Error(`Seed write for ${event.aggregateType} ${event.aggregateId} v${event.expectedVersion + 1} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessor.createdAt.toString()}). Write a stream's events in ascending time.`);
111
+ throw new SeedTimeOrderError(event.aggregateType, event.aggregateId, event.expectedVersion + 1, seedCreatedAt, predecessor.createdAt);
109
112
  }
110
113
  }
111
114
  async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
@@ -141,7 +144,7 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
141
144
  metadata: event.metadata,
142
145
  createdBy: event.metadata.userId,
143
146
  expectedVersion: event.expectedVersion,
144
- createdAt: seedCreatedAt?.toString(),
147
+ createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
145
148
  });
146
149
  if (!row)
147
150
  throw new VersionConflictError(event.aggregateId, event.expectedVersion);
@@ -201,6 +204,25 @@ export async function loadAggregate(db, aggregateId, tenantId, options) {
201
204
  : { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
202
205
  return rows.map(toStoredEvent);
203
206
  }
207
+ // Events and the stream's latest version from ONE read, so both come from the same
208
+ // snapshot: a commit landing between two separate statements would leave the caller
209
+ // pinning a version its loaded events never covered. The aggregateType filter is applied
210
+ // after the read because a foreign type sharing the aggregateId still occupies versions
211
+ // the next append has to stack on. Archived streams load empty but keep their real version.
212
+ export async function loadAggregateForWriting(db, aggregateId, tenantId, options) {
213
+ if (options.aggregateType === "") {
214
+ throw new Error("loadAggregateForWriting: aggregateType must be a non-empty string");
215
+ }
216
+ if (await isStreamArchived(db, tenantId, aggregateId)) {
217
+ return { events: [], streamVersion: await getStreamVersion(db, aggregateId, tenantId) };
218
+ }
219
+ const rows = await selectMany(db, eventsTable, { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
220
+ const all = rows.map(toStoredEvent);
221
+ return {
222
+ events: all.filter((e) => e.aggregateType === options.aggregateType),
223
+ streamVersion: all.at(-1)?.version ?? 0,
224
+ };
225
+ }
204
226
  // Load events up to a point in time. Used for asOf queries that reconstruct
205
227
  // historical state. Includes events whose created_at is <= asOf. Same
206
228
  // archive semantics as loadAggregate.
@@ -1,7 +1,7 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
3
3
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
- export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
4
+ export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
5
  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.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
@@ -1,7 +1,7 @@
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
3
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
- export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
4
+ export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
5
  export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent } from "./provenance-append.js";
@@ -1,4 +1,4 @@
1
- import type { TenantDb } from "../db/tenant-db.js";
1
+ import { type TenantDb } from "../db/tenant-db.js";
2
2
  import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.js";
3
3
  import type { WriteFailure } from "../errors/index.js";
4
4
  export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
@@ -24,6 +24,7 @@ export type StoreFileInput = {
24
24
  export type StoreFileDeps = {
25
25
  readonly user: SessionUser;
26
26
  readonly tenantDb: TenantDb;
27
+ readonly rowInOwnTransaction?: boolean;
27
28
  readonly registry: Registry | undefined;
28
29
  readonly defaultMaxUploadSize: string;
29
30
  readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
2
+ import { runInOwnTransaction } from "../db/tenant-db.js";
2
3
  import { isFileField, } from "../engine/types/index.js";
3
4
  import { generateId } from "../utils/index.js";
4
5
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
@@ -149,14 +150,27 @@ export async function storeFileAsFileRef(deps, input) {
149
150
  // A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
150
151
  // crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
151
152
  // Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
152
- const row = await createFileRefRow(deps, upload.uploaded);
153
- if ("failure" in row) {
153
+ // A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
154
+ const deleteUploadedBytes = async () => {
154
155
  try {
155
156
  await deps.deleteBytes(upload.uploaded.storageKey);
156
157
  }
157
158
  catch {
158
159
  // skip: the create failure is the error worth reporting; the leftover bytes stay in storage.
159
160
  }
161
+ };
162
+ let row;
163
+ try {
164
+ row = deps.rowInOwnTransaction
165
+ ? await runInOwnTransaction(deps.tenantDb, (txDb) => createFileRefRow({ user: deps.user, tenantDb: txDb }, upload.uploaded))
166
+ : await createFileRefRow(deps, upload.uploaded);
167
+ }
168
+ catch (e) {
169
+ await deleteUploadedBytes();
170
+ throw e;
171
+ }
172
+ if ("failure" in row) {
173
+ await deleteUploadedBytes();
160
174
  return { kind: "failed", failure: row.failure };
161
175
  }
162
176
  return { kind: "created", fileRef: row.fileRef };
@@ -3,16 +3,31 @@ import { reraiseAsKumikoError } from "../errors/index.js";
3
3
  import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
4
  import { getFileRefExecutor } from "./create-file-ref.js";
5
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
- function isBoundTo(row, entityName, entityId, fieldName) {
6
+ // The bound fieldName may differ from the field the id was removed from: an id
7
+ // moved A -> B stays bound to A (binding never re-targets).
8
+ function isBoundToRecord(row, entityName, entityId, fileFields) {
9
+ const boundField = row["fieldName"];
7
10
  return (row["entityType"] === entityName &&
8
11
  row["entityId"] === entityId &&
9
- row["fieldName"] === fieldName);
12
+ typeof boundField === "string" &&
13
+ isFileField(fileFields[boundField]));
14
+ }
15
+ // Across all file fields: an id moved from one field to another is still in use.
16
+ function fileRefIdsStoredInAnyField(fields, data) {
17
+ const ids = new Set();
18
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
19
+ if (!isFileField(fieldDef))
20
+ continue;
21
+ for (const fileRefId of fileRefIdsOf(data[fieldName]))
22
+ ids.add(fileRefId);
23
+ }
24
+ return ids;
10
25
  }
11
26
  // An update that drops or replaces a file id leaves the fileRef readable to
12
27
  // everyone who may read the record. Deleting it goes through the same executor
13
28
  // path as DELETE /files/:id (soft delete, bytes kept until retention), so
14
29
  // record readers get 404 afterwards. Only fileRefs bound to exactly this
15
- // record and field are touched: an id that is pending, shared or bound
30
+ // record are touched: an id that is pending, shared or bound
16
31
  // elsewhere is not this save's to dispose. The authority is the caller's write
17
32
  // right on the record (the fileRef entity declares no write access of its own).
18
33
  export async function disposeRemovedFileRefs(args) {
@@ -33,15 +48,15 @@ export async function disposeRemovedFileRefs(args) {
33
48
  return;
34
49
  const entityId = String(result.id);
35
50
  const executor = getFileRefExecutor();
51
+ const keptAnywhere = fileRefIdsStoredInAnyField(entity.fields, result.data);
36
52
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
37
53
  if (!isFileField(fieldDef) || !(fieldName in result.changes))
38
54
  continue;
39
- const kept = new Set(fileRefIdsOf(result.data[fieldName]));
40
55
  for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
41
- if (kept.has(fileRefId))
56
+ if (keptAnywhere.has(fileRefId))
42
57
  continue;
43
58
  const row = await executor.detail({ id: fileRefId }, user, db);
44
- if (row === null || !isBoundTo(row, entityName, entityId, fieldName))
59
+ if (row === null || !isBoundToRecord(row, entityName, entityId, entity.fields))
45
60
  continue;
46
61
  const removed = await executor.delete({ id: fileRefId }, user, db);
47
62
  if (!removed.isSuccess)
@@ -7,4 +7,5 @@ export type FilesFeatureOptions = {
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
9
  };
10
+ export declare const FILES_FEATURE: "files";
10
11
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -13,8 +13,9 @@ export { fileRefEntity } from "./file-ref-entity.js";
13
13
  // userData/tenantData-Extensions sichtbar und registriert die implizite
14
14
  // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
15
15
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
16
+ export const FILES_FEATURE = "files";
16
17
  export function createFilesFeature(options = {}) {
17
- return defineFeature("files", (r) => {
18
+ return defineFeature(FILES_FEATURE, (r) => {
18
19
  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?)`.");
19
20
  r.uiHints({
20
21
  displayLabel: "Files · Metadata",
@@ -62,6 +62,10 @@ export function createFileContext(resolve, tenantId) {
62
62
  assertSafeStorageKey(row.storageKey);
63
63
  return createFileHandle(row.storageKey, getProvider);
64
64
  },
65
+ listFromFileRef: async (row) => {
66
+ assertSafeStorageKey(row.storageKey);
67
+ return (await getProvider()).list(storageKeyStemPrefix(row.storageKey));
68
+ },
65
69
  list: async (prefix) => {
66
70
  if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
67
71
  throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
@@ -4,8 +4,9 @@ import { getUser } from "../api/auth-middleware.js";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { selectMany } from "../bun-db/index.js";
6
6
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
7
- import { createTenantDb } from "../db/tenant-db.js";
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
10
  import { isFileField, isUuid, } from "../engine/types/index.js";
10
11
  import { buildContentDispositionHeader } from "./content-disposition.js";
11
12
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
@@ -32,8 +33,11 @@ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReade
32
33
  return "allow";
33
34
  if (privilegedRoles.some((role) => user.roles.includes(role)))
34
35
  return "allow";
35
- const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
36
- if (isUnclaimed &&
36
+ // System-generated documents are created without attachTo too, but belong to a person's data, not to an inbox.
37
+ const isUnclaimedUpload = fileRef.entityType === null &&
38
+ fileRef.entityId === null &&
39
+ fileRef.insertedById !== SYSTEM_USER_ID;
40
+ if (isUnclaimedUpload &&
37
41
  operation === "read" &&
38
42
  unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
39
43
  return "allow";
@@ -88,8 +92,7 @@ export function createFileRoutes(options) {
88
92
  const executor = getFileRefExecutor();
89
93
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
90
94
  const api = new Hono();
91
- const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
92
- const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
95
+ const tenantDbFor = (tenantId) => createProjectingTenantDb(db, tenantId, options.registry);
93
96
  const fileNameIsPersonal = piiSubjectFields.includes("fileName");
94
97
  function kmsContextFor() {
95
98
  return { requestId: requestContext.get()?.requestId ?? "file-routes" };
@@ -171,6 +174,7 @@ export function createFileRoutes(options) {
171
174
  const outcome = await storeFileAsFileRef({
172
175
  user,
173
176
  tenantDb: tenantDbFor(user.tenantId),
177
+ rowInOwnTransaction: true,
174
178
  registry: options.registry,
175
179
  defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
176
180
  writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
@@ -1,6 +1,6 @@
1
1
  export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.js";
2
2
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
3
- export { createFilesFeature } from "./feature.js";
3
+ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
4
4
  export type { FileContext, FileHandle } from "./file-handle.js";
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
6
6
  export { fileRefEntity } from "./file-ref-entity.js";
@@ -1,5 +1,5 @@
1
1
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
2
- export { createFilesFeature } from "./feature.js";
2
+ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
3
3
  // `createFileHandle` is an implementation detail — construct handles via
4
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
@@ -1,5 +1,5 @@
1
1
  import { deleteMany, selectMany } from "../db/query.js";
2
- import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
2
+ import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
4
  const PAGE_ORDER = [
5
5
  { col: "deletedAt", direction: "asc" },
@@ -31,7 +31,6 @@ export async function purgeExpiredFileRefsWithBytes(args) {
31
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
32
32
  return;
33
33
  }
34
- const store = { list: files.list, delete: (key) => files.ref(key).delete() };
35
34
  let cursor;
36
35
  for (;;) {
37
36
  const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
@@ -43,7 +42,7 @@ export async function purgeExpiredFileRefsWithBytes(args) {
43
42
  const purgedIds = [];
44
43
  for (const row of rows) {
45
44
  try {
46
- const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
45
+ const failedKeys = await deleteFileRefBytes(files, row, "soft-delete-cleanup:fileRef");
47
46
  if (failedKeys.length === 0) {
48
47
  purgedIds.push(row.id);
49
48
  continue;
@@ -13,6 +13,41 @@ const UNAMBIGUOUS_SNIFFED_TYPES = new Set([
13
13
  ]);
14
14
  const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04];
15
15
  const XML_PROBE_BYTES = 512;
16
+ // Root elements of markup that starts with `<` but is not a document the XML
17
+ // ingest providers should receive: HTML (documents and fragments), SVG.
18
+ const NON_XML_ROOT_ELEMENTS = new Set([
19
+ "html",
20
+ "head",
21
+ "body",
22
+ "script",
23
+ "style",
24
+ "meta",
25
+ "link",
26
+ "title",
27
+ "svg",
28
+ "div",
29
+ "span",
30
+ "p",
31
+ "a",
32
+ "img",
33
+ "br",
34
+ "hr",
35
+ "table",
36
+ "ul",
37
+ "ol",
38
+ "li",
39
+ "h1",
40
+ "h2",
41
+ "h3",
42
+ "h4",
43
+ "h5",
44
+ "h6",
45
+ "section",
46
+ "article",
47
+ "font",
48
+ "center",
49
+ ]);
50
+ const ROOT_ELEMENT_PATTERN = /^<(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/;
16
51
  const EXTENSION_FALLBACK = {
17
52
  xml: "application/xml",
18
53
  pdf: "application/pdf",
@@ -38,8 +73,8 @@ function hasPrefix(bytes, prefix) {
38
73
  return prefix.every((byte, i) => bytes[i] === byte);
39
74
  }
40
75
  // Textual start only: a NUL in the probe means binary (or UTF-16, which is
41
- // deliberately not detected). HTML also starts with `<` and must not be
42
- // relabelled as XML.
76
+ // deliberately not detected). Without an XML declaration the root element
77
+ // decides: HTML/SVG roots (also namespace-prefixed) are not relabelled as XML.
43
78
  function looksLikeXml(bytes) {
44
79
  const probe = bytes.subarray(0, XML_PROBE_BYTES);
45
80
  if (probe.includes(0))
@@ -47,9 +82,10 @@ function looksLikeXml(bytes) {
47
82
  const text = new TextDecoder().decode(probe).replace(/^/, "").trimStart();
48
83
  if (text.startsWith("<?xml"))
49
84
  return true;
50
- if (!/^<[A-Za-z_]/.test(text))
85
+ const root = ROOT_ELEMENT_PATTERN.exec(text)?.[1];
86
+ if (root === undefined)
51
87
  return false;
52
- return !/^<(html|head|body|script)\b/i.test(text);
88
+ return !NON_XML_ROOT_ELEMENTS.has(root.toLowerCase());
53
89
  }
54
90
  function extensionOf(filename) {
55
91
  const dot = filename.lastIndexOf(".");
@@ -12,6 +12,7 @@
12
12
  // into createApp / setupTestStack alongside their domain features.
13
13
  import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect.js";
14
14
  import { entityEventName, executeRawQueryRead } from "../db/index.js";
15
+ import { toPostgresArrayLiteral } from "../db/queries/postgres-array-literal.js";
15
16
  import { incrementCounter } from "../db/query.js";
16
17
  import { defineFeature, qn, toKebab } from "../engine/index.js";
17
18
  import { SHARED_INSTANCE_SENTINEL, selectConsumerCursorForUpdate, } from "../pipeline/index.js";
@@ -141,12 +142,12 @@ export async function transferTenantStorageUsage(args) {
141
142
  if (!cursor)
142
143
  return;
143
144
  const rows = await executeRawQueryRead(db, `SELECT "id"::text AS id, "type", "payload"::text AS payload FROM kumiko_events
144
- WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3)
145
- AND "type" = ANY($4) AND "id" <= $5`, [
145
+ WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3::uuid[])
146
+ AND "type" = ANY($4::text[]) AND "id" <= $5`, [
146
147
  sourceTenantId,
147
148
  FILE_REF_AGGREGATE_TYPE,
148
- fileRefIds,
149
- TRACKED_FILE_REF_EVENT_TYPES,
149
+ toPostgresArrayLiteral(fileRefIds),
150
+ toPostgresArrayLiteral(TRACKED_FILE_REF_EVENT_TYPES),
150
151
  cursor.lastProcessedEventId,
151
152
  ]);
152
153
  let bytes = 0;
@@ -1,3 +1,4 @@
1
+ import { readZipEntryNames } from "./zip-entry-names.js";
1
2
  // Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
2
3
  // a module that calls createEntity at load time.
3
4
  export const FILE_REF_ENTITY_NAME = "fileRef";
@@ -111,13 +112,9 @@ const MAGIC_BYTE_SIGNATURES = [
111
112
  // so a byte search finds the docx part directory without unzipping. The main
112
113
  // part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
113
114
  const DOCX_PART_PREFIX = "word/";
114
- function containsAscii(bytes, ascii) {
115
- const first = ascii.charCodeAt(0);
116
- for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
117
- if (startsWithAscii(bytes, ascii, i))
118
- return true;
119
- }
120
- return false;
115
+ function hasDocxPart(zipBytes) {
116
+ const names = readZipEntryNames(zipBytes);
117
+ return names?.some((name) => name.startsWith(DOCX_PART_PREFIX)) ?? false;
121
118
  }
122
119
  function startsWithBytes(bytes, signature) {
123
120
  if (bytes.length < signature.length)
@@ -237,7 +234,7 @@ export function validateFileContent(fileName, content, accept) {
237
234
  return { kind: "ok" };
238
235
  const sniffed = sniffMimeType(content);
239
236
  // sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
240
- if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
237
+ if (sniffed === DOCX_MIME_TYPE && !hasDocxPart(content)) {
241
238
  return {
242
239
  kind: "rejected",
243
240
  error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
@@ -0,0 +1 @@
1
+ export declare function readZipEntryNames(bytes: Uint8Array): readonly string[] | null;
@@ -0,0 +1,46 @@
1
+ const EOCD_SIGNATURE = 0x06054b50;
2
+ const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
3
+ const EOCD_MIN_LENGTH = 22;
4
+ const EOCD_MAX_COMMENT_LENGTH = 0xffff;
5
+ const CENTRAL_HEADER_FIXED_LENGTH = 46;
6
+ // Entry names from the central directory, which (unlike local headers) always
7
+ // carries sizes and names even for streamed ZIPs written with data descriptors.
8
+ // Returns null when the container is not a readable (non-ZIP64) ZIP.
9
+ export function readZipEntryNames(bytes) {
10
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
11
+ const eocdOffset = findEndOfCentralDirectory(view);
12
+ if (eocdOffset === null)
13
+ return null;
14
+ const entryCount = view.getUint16(eocdOffset + 10, true);
15
+ let offset = view.getUint32(eocdOffset + 16, true);
16
+ const decoder = new TextDecoder();
17
+ const names = [];
18
+ for (let i = 0; i < entryCount; i++) {
19
+ if (offset + CENTRAL_HEADER_FIXED_LENGTH > view.byteLength)
20
+ return null;
21
+ if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE)
22
+ return null;
23
+ const nameLength = view.getUint16(offset + 28, true);
24
+ const extraLength = view.getUint16(offset + 30, true);
25
+ const commentLength = view.getUint16(offset + 32, true);
26
+ const nameStart = offset + CENTRAL_HEADER_FIXED_LENGTH;
27
+ if (nameStart + nameLength > view.byteLength)
28
+ return null;
29
+ names.push(decoder.decode(bytes.subarray(nameStart, nameStart + nameLength)));
30
+ offset = nameStart + nameLength + extraLength + commentLength;
31
+ }
32
+ return names;
33
+ }
34
+ function findEndOfCentralDirectory(view) {
35
+ const lowest = Math.max(0, view.byteLength - EOCD_MIN_LENGTH - EOCD_MAX_COMMENT_LENGTH);
36
+ for (let offset = view.byteLength - EOCD_MIN_LENGTH; offset >= lowest; offset--) {
37
+ if (view.getUint32(offset, true) !== EOCD_SIGNATURE)
38
+ continue;
39
+ // A signature inside the archive comment is skipped: the real record's
40
+ // comment length reaches exactly to the end of the buffer.
41
+ const commentLength = view.getUint16(offset + 20, true);
42
+ if (offset + EOCD_MIN_LENGTH + commentLength === view.byteLength)
43
+ return offset;
44
+ }
45
+ return null;
46
+ }
@@ -60,6 +60,7 @@ export type JobRunnerOptions = {
60
60
  consumerLane?: JobRunIn | undefined;
61
61
  queueNamePrefix?: string | undefined;
62
62
  bootRedisTimeoutMs?: number | undefined;
63
+ schedulerSeenRefreshMs?: number | undefined;
63
64
  jobRetention?: {
64
65
  completedAgeSec?: number | undefined;
65
66
  failedAgeSec?: number | undefined;