@cosmicdrift/kumiko-framework 0.396.0 → 0.398.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.
@@ -7,5 +7,6 @@ export { KeyAlreadyExistsError, KeyErasedError, type KmsAdapter, type KmsContext
7
7
  export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.ts";
8
8
  export { type KmsWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.ts";
9
9
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.ts";
10
+ export { PII_CIPHERTEXT_CORRUPT_CODE, PiiCiphertextCorruptError, } from "./pii-ciphertext-corrupt.ts";
10
11
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.ts";
11
12
  export { collectPiiEncryptedFields, collectPiiSubjectFields, isRecordKeyedField, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.ts";
@@ -7,5 +7,6 @@ export { KeyAlreadyExistsError, KeyErasedError, subjectIdSchema, subjectIdToKey,
7
7
  export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.js";
8
8
  export { requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
9
9
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
10
+ export { PII_CIPHERTEXT_CORRUPT_CODE, PiiCiphertextCorruptError, } from "./pii-ciphertext-corrupt.js";
10
11
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
11
12
  export { collectPiiEncryptedFields, collectPiiSubjectFields, isRecordKeyedField, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -0,0 +1,8 @@
1
+ import { KumikoError } from "../errors/kumiko-error.ts";
2
+ export declare const PII_CIPHERTEXT_CORRUPT_CODE = "pii_ciphertext_corrupt";
3
+ export declare class PiiCiphertextCorruptError extends KumikoError {
4
+ readonly field: string;
5
+ readonly code = "pii_ciphertext_corrupt";
6
+ readonly httpStatus = 500;
7
+ constructor(field: string, cause?: Error);
8
+ }
@@ -0,0 +1,18 @@
1
+ import { KumikoError } from "../errors/kumiko-error.js";
2
+ export const PII_CIPHERTEXT_CORRUPT_CODE = "pii_ciphertext_corrupt";
3
+ // The stored value does not decrypt under its subject's key although the key store answered:
4
+ // damaged bytes, a value copied into another field or subject (the AAD binds both), or a value
5
+ // written under another key. A retry fails the same way, unlike EncryptionUnavailableError.
6
+ export class PiiCiphertextCorruptError extends KumikoError {
7
+ field;
8
+ code = PII_CIPHERTEXT_CORRUPT_CODE;
9
+ httpStatus = 500;
10
+ constructor(field, cause) {
11
+ super({
12
+ message: `the stored PII value of field "${field}" does not decrypt under its subject key`,
13
+ i18nKey: "errors.internal",
14
+ ...(cause && { cause }),
15
+ });
16
+ this.field = field;
17
+ }
18
+ }
@@ -18,6 +18,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
18
18
  import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
19
19
  import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, PII_ERASED_SENTINEL, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
20
20
  import { classifyKmsOutage } from "./kms-outage.js";
21
+ import { PiiCiphertextCorruptError } from "./pii-ciphertext-corrupt.js";
21
22
  import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
22
23
  import { resolveSubjectForField } from "./subject-resolver.js";
23
24
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
@@ -58,14 +59,35 @@ function parseCiphertext(value) {
58
59
  };
59
60
  }
60
61
  function decryptValue(dek, blob, subject, field, hasAad, isJson) {
61
- const iv = blob.subarray(0, IV_LENGTH);
62
- const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
63
- const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
64
- const decipher = createDecipheriv("aes-256-gcm", dek, iv);
65
- if (hasAad)
66
- decipher.setAAD(buildAad(subject, field, isJson));
67
- decipher.setAuthTag(tag);
68
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
62
+ try {
63
+ const iv = blob.subarray(0, IV_LENGTH);
64
+ const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
65
+ const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
66
+ const decipher = createDecipheriv("aes-256-gcm", dek, iv);
67
+ if (hasAad)
68
+ decipher.setAAD(buildAad(subject, field, isJson));
69
+ decipher.setAuthTag(tag);
70
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
71
+ }
72
+ catch (e) {
73
+ throw new PiiCiphertextCorruptError(field, e instanceof Error ? e : undefined);
74
+ }
75
+ }
76
+ function parseCiphertextOf(value, field) {
77
+ try {
78
+ return parseCiphertext(value);
79
+ }
80
+ catch (e) {
81
+ throw new PiiCiphertextCorruptError(field, e instanceof Error ? e : undefined);
82
+ }
83
+ }
84
+ function parseDecryptedJson(plain, field) {
85
+ try {
86
+ return JSON.parse(plain);
87
+ }
88
+ catch (e) {
89
+ throw new PiiCiphertextCorruptError(field, e instanceof Error ? e : undefined);
90
+ }
69
91
  }
70
92
  // First write to a PII field of a new subject creates the key implicitly
71
93
  // (user-signup / tenant-create need no extra hook). The AlreadyExists catch
@@ -118,7 +140,7 @@ export async function encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx,
118
140
  export async function decryptPiiValueForSubject(kms, value, kmsCtx, field) {
119
141
  if (!isPiiCiphertext(value))
120
142
  return value;
121
- const { subject, blob, hasAad, isJson } = parseCiphertext(value);
143
+ const { subject, blob, hasAad, isJson } = parseCiphertextOf(value, field);
122
144
  if (isJson) {
123
145
  throw new Error(`PII field "${field}" holds a jsonb ciphertext — decrypt it via decryptPiiFieldValues`);
124
146
  }
@@ -193,11 +215,11 @@ export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
193
215
  // work during rollout).
194
216
  if (!isPiiCiphertext(value))
195
217
  continue;
196
- const { subject, blob, hasAad, isJson } = parseCiphertext(value);
218
+ const { subject, blob, hasAad, isJson } = parseCiphertextOf(value, name);
197
219
  try {
198
220
  const key = await kms.getKey(subject, kmsCtx);
199
221
  const plain = decryptValue(key, blob, subject, name, hasAad, isJson);
200
- const decoded = isJson ? JSON.parse(plain) : plain;
222
+ const decoded = isJson ? parseDecryptedJson(plain, name) : plain;
201
223
  out[name] = decoded;
202
224
  }
203
225
  catch (e) {
@@ -90,7 +90,7 @@ const COLUMN_META_EMITTERS = {
90
90
  reference: referenceColumnMeta,
91
91
  money: moneyColumnMeta,
92
92
  embedded: (snake, field) => jsonbColumn(snake, field.multiple === true ? EMPTY_JSONB_ARRAY : EMPTY_JSONB_OBJECT),
93
- jsonb: (snake) => jsonbColumn(snake, EMPTY_JSONB_OBJECT),
93
+ jsonb: (snake, field) => jsonbColumn(snake, field.emptyValue === "array" ? EMPTY_JSONB_ARRAY : EMPTY_JSONB_OBJECT),
94
94
  // Real PG `date` — Temporal.PlainDate, no time-of-day/timezone component.
95
95
  date: (snake, field) => requiredColumn(snake, "date", field),
96
96
  timestamp: (snake, field) => requiredColumn(snake, "timestamptz", field),
@@ -1,6 +1,6 @@
1
1
  import { UnprocessableError } from "../errors/index.js";
2
2
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
3
- import { renderSortSql } from "./event-store-executor-read-sort.js";
3
+ import { renderSortSql, sortsNullsFirst } from "./event-store-executor-read-sort.js";
4
4
  import { executeRawQueryRead } from "./queries/raw-sql.js";
5
5
  import { physicalColumnName } from "./table-builder.js";
6
6
  // Defense-in-depth pagination guard. The handler boundary (entityListSchema)
@@ -48,13 +48,13 @@ function toCursorSortText(value) {
48
48
  }
49
49
  return undefined;
50
50
  }
51
- // Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
52
- // null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
53
- // would change the visible order and cost the sort its index.
54
- function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
51
+ // Keyset boundary for `ORDER BY <sort> <dir> [NULLS FIRST|LAST], id ASC`. Without a declared
52
+ // `sortNulls` Postgres' default applies (ASC → NULLS LAST, DESC → NULLS FIRST), which keeps the
53
+ // sort on a plain index; a declared one may need an index with the same NULLS order.
54
+ function keysetBoundarySql(sortCol, idCol, cursor, order, params) {
55
55
  params.push(cursor.id);
56
56
  const afterId = `${idCol} > $${params.length}`;
57
- const nullsComeFirst = descending;
57
+ const { descending, nullsComeFirst } = order;
58
58
  if (cursor.sortValue === null) {
59
59
  return nullsComeFirst
60
60
  ? `(${sortCol} IS NOT NULL OR ${afterId})`
@@ -75,7 +75,7 @@ export function appendCursorBoundary(where, encodedCursor, sort) {
75
75
  where.whereSql.push(`${where.colSql("id")} > $${where.params.length}`);
76
76
  }
77
77
  else {
78
- where.whereSql.push(keysetBoundarySql(renderSortSql(sort, where), where.colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, sort.descending, where.params));
78
+ where.whereSql.push(keysetBoundarySql(renderSortSql(sort, where), where.colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, { descending: sort.descending, nullsComeFirst: sortsNullsFirst(sort) }, where.params));
79
79
  }
80
80
  }
81
81
  export function encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table) {
@@ -1,4 +1,4 @@
1
- import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
1
+ import type { EntityDefinition, SortNulls } from "@cosmicdrift/kumiko-types/fields";
2
2
  import { type OwnershipClause } from "../engine/ownership.ts";
3
3
  import type { SessionUser } from "../engine/types/index.ts";
4
4
  import type { EventStoreExecutor } from "./event-store-executor.ts";
@@ -13,15 +13,18 @@ export type ListSort = {
13
13
  readonly kind: "column";
14
14
  readonly field: string;
15
15
  readonly descending: boolean;
16
+ readonly nulls: SortNulls | undefined;
16
17
  } | {
17
18
  readonly kind: "referenceLabel";
18
19
  readonly field: string;
19
20
  readonly descending: boolean;
21
+ readonly nulls: SortNulls | undefined;
20
22
  readonly labelSql: (params: unknown[]) => string;
21
23
  };
22
24
  export type SortedListSort = Exclude<ListSort, {
23
25
  readonly kind: "unsorted";
24
26
  }>;
27
+ export declare function sortsNullsFirst(sort: SortedListSort): boolean;
25
28
  export declare function resolveListSort(request: {
26
29
  readonly payload: Pick<ListPayload, "sort" | "sortDirection">;
27
30
  readonly table: Table;
@@ -7,6 +7,19 @@ import { schemaTableName } from "./table-introspection.js";
7
7
  // Projected alongside the row when a reference sort is active, so the keyset
8
8
  // cursor carries the label that was ordered by instead of the FK's UUID.
9
9
  const SORT_LABEL_ALIAS = "__kumiko_sort_label";
10
+ function declaredSortNulls(entity, field) {
11
+ const def = entity.fields[field];
12
+ return def !== undefined && "sortNulls" in def ? def.sortNulls : undefined;
13
+ }
14
+ // Postgres' own null order without an explicit NULLS clause: last ascending, first descending.
15
+ export function sortsNullsFirst(sort) {
16
+ return sort.nulls === undefined ? sort.descending : sort.nulls === "first";
17
+ }
18
+ function nullsClause(sort) {
19
+ if (sort.nulls === undefined)
20
+ return "";
21
+ return sort.nulls === "first" ? " NULLS FIRST" : " NULLS LAST";
22
+ }
10
23
  // Resolve a `sortable: true` reference field's label inline for
11
24
  // ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
12
25
  // row per entity row, the read model stays untouched, and the lookup rides the
@@ -80,8 +93,10 @@ export function resolveListSort(request) {
80
93
  if (field === undefined)
81
94
  return { kind: "unsorted" };
82
95
  const descending = payload.sortDirection === "desc";
83
- if (entity.fields[field]?.type !== "reference")
84
- return { kind: "column", field, descending };
96
+ const nulls = declaredSortNulls(entity, field);
97
+ if (entity.fields[field]?.type !== "reference") {
98
+ return { kind: "column", field, descending, nulls };
99
+ }
85
100
  // A reference column stores a UUID, so it is never sorted by
86
101
  // directly: either the field opted into `sortable` and we order by the
87
102
  // target row's label, or the sort is dropped to plain id order.
@@ -91,7 +106,7 @@ export function resolveListSort(request) {
91
106
  : undefined;
92
107
  return labelSql === undefined
93
108
  ? { kind: "unsorted" }
94
- : { kind: "referenceLabel", field, descending, labelSql };
109
+ : { kind: "referenceLabel", field, descending, nulls, labelSql };
95
110
  }
96
111
  export function renderSortSql(sort, where) {
97
112
  return sort.kind === "referenceLabel" ? sort.labelSql(where.params) : where.colSql(sort.field);
@@ -102,7 +117,7 @@ export function renderListOrderBy(sort, where) {
102
117
  return { orderByClause: ` ORDER BY ${idAscending}`, sortLabelProjection: "" };
103
118
  }
104
119
  const sortSql = renderSortSql(sort, where);
105
- const direction = sort.descending ? "DESC" : "ASC";
120
+ const direction = `${sort.descending ? "DESC" : "ASC"}${nullsClause(sort)}`;
106
121
  if (sort.kind === "column") {
107
122
  return {
108
123
  orderByClause: ` ORDER BY ${sortSql} ${direction}, ${idAscending}`,
@@ -40,3 +40,5 @@ export type PiiBackfillBatchOptions = PiiBackfillOptions & {
40
40
  };
41
41
  export declare function backfillEventPiiEncryption(db: DbRunner, registry: Registry, options?: PiiBackfillOptions): Promise<PiiBackfillResult>;
42
42
  export declare function backfillEventPiiEncryptionBatch(db: DbRunner, registry: Registry, options?: PiiBackfillBatchOptions): Promise<PiiBackfillBatchResult>;
43
+ export declare function isLifecycleEventOf(eventType: string, aggregateType: string): boolean;
44
+ export declare function lifecycleSections(payload: Record<string, unknown>): Record<string, unknown>[];
@@ -611,7 +611,7 @@ function projectionRowToCamel(entity, row) {
611
611
  }
612
612
  return camel;
613
613
  }
614
- function isLifecycleEventOf(eventType, aggregateType) {
614
+ export function isLifecycleEventOf(eventType, aggregateType) {
615
615
  if (!eventType.startsWith(`${aggregateType}.`))
616
616
  return false;
617
617
  const verb = eventType.slice(aggregateType.length + 1);
@@ -620,7 +620,7 @@ function isLifecycleEventOf(eventType, aggregateType) {
620
620
  // created events carry the fields flat; updated carries { changes, previous };
621
621
  // deleted/forgotten/restored carry { previous }. Returned sections are the
622
622
  // mutable objects INSIDE the payload clone — encryptField writes in place.
623
- function lifecycleSections(payload) {
623
+ export function lifecycleSections(payload) {
624
624
  const sections = [];
625
625
  if (isRecord(payload["changes"]))
626
626
  sections.push(payload["changes"]);
@@ -0,0 +1,12 @@
1
+ import type { DbRunner } from "../connection.ts";
2
+ export type ScrubEventRow = {
3
+ readonly id: bigint | string;
4
+ readonly aggregate_id: string;
5
+ readonly aggregate_type: string;
6
+ readonly tenant_id: string;
7
+ readonly type: string;
8
+ readonly payload: Record<string, unknown>;
9
+ };
10
+ export declare function selectEventsOfTypeAfter(db: DbRunner, eventType: string, afterEventId: string, limit: number): Promise<readonly ScrubEventRow[]>;
11
+ export declare function replaceEventPayload(db: DbRunner, eventId: bigint | string, payload: Record<string, unknown>): Promise<void>;
12
+ export declare function deleteSnapshotsOfAggregates(db: DbRunner, aggregateIds: readonly string[]): Promise<number>;
@@ -0,0 +1,20 @@
1
+ import { asRawClient } from "../query.js";
2
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
3
+ export function selectEventsOfTypeAfter(db, eventType, afterEventId, limit) {
4
+ return asRawClient(db).unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
5
+ FROM "kumiko_events"
6
+ WHERE "type" = $1 AND "id" > $2::bigint
7
+ ORDER BY "id" ASC
8
+ LIMIT $3`, [eventType, afterEventId, limit]);
9
+ }
10
+ export async function replaceEventPayload(db, eventId, payload) {
11
+ await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
12
+ payload,
13
+ eventId,
14
+ ]);
15
+ }
16
+ // Snapshots may cache the replaced values of the streams; the next snapshotting load recreates them.
17
+ export async function deleteSnapshotsOfAggregates(db, aggregateIds) {
18
+ const deleted = await asRawClient(db).unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [toPostgresArrayLiteral([...aggregateIds])]);
19
+ return deleted.length;
20
+ }
@@ -277,7 +277,7 @@ export function createEmbeddedListField(schema, overrides) {
277
277
  };
278
278
  }
279
279
  // Free-form jsonb column — see the `JsonbFieldDef` documentation. Schema-less,
280
- // defaults to `{}`, and is NOT NULL. Primarily used by the custom-fields
280
+ // defaults to `{}` (`[]` with `emptyValue: "array"`), and is NOT NULL. Primarily used by the custom-fields
281
281
  // bundle for the host entity's `customFields` column; other valid uses include
282
282
  // tenant configuration blobs, AI-inferred metadata, and future tag arrays.
283
283
  export function createJsonbField(overrides) {
@@ -315,8 +315,10 @@ export function fieldToZod(field, currencies, opts = {}) {
315
315
  return embeddedFieldToZod(field);
316
316
  case "jsonb":
317
317
  // Free-form jsonb — keys are tenant-/runtime-defined. Validation
318
- // passes through any plain object.
319
- return z.record(z.string(), z.unknown());
318
+ // passes through any plain object, or any array for emptyValue "array".
319
+ return field.emptyValue === "array"
320
+ ? z.array(z.unknown())
321
+ : z.record(z.string(), z.unknown());
320
322
  case "date":
321
323
  return withDateBounds(z.string().date(), field.min, field.max);
322
324
  case "timestamp":
@@ -7,6 +7,7 @@ export type FilesFeatureOptions = {
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
9
  readonly uploadSweepGraceHours?: number;
10
+ readonly pendingUploadGraceHours?: number;
10
11
  };
11
12
  export declare const FILES_FEATURE: "files";
12
13
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -2,6 +2,7 @@ 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
4
  import { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
5
+ import { sweepStalePendingFileRefs } from "./sweep-stale-pending-file-refs.js";
5
6
  import { fileUploadLedgerTable } from "./upload-ledger.js";
6
7
  export { fileRefEntity } from "./file-ref-entity.js";
7
8
  // `fileRef` is a regular ES entity. Uploads and deletes go through the standard
@@ -15,14 +16,18 @@ export { fileRefEntity } from "./file-ref-entity.js";
15
16
  // rebuildProjection. It lives in the framework next to file-routes and
16
17
  // fileRefsTable; bundled-features/files only re-exports it.
17
18
  export const FILES_FEATURE = "files";
18
- function assertValidSweepGraceHours(hours) {
19
+ function assertValidSweepGraceHours(option, hours) {
19
20
  if (!Number.isInteger(hours) || hours < MIN_UPLOAD_SWEEP_GRACE_HOURS) {
20
- throw new Error(`createFilesFeature: uploadSweepGraceHours must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
21
+ throw new Error(`createFilesFeature: ${option} must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
21
22
  }
22
23
  }
23
24
  export function createFilesFeature(options = {}) {
24
25
  const uploadSweepGraceHours = options.uploadSweepGraceHours ?? DEFAULT_UPLOAD_SWEEP_GRACE_HOURS;
25
- assertValidSweepGraceHours(uploadSweepGraceHours);
26
+ assertValidSweepGraceHours("uploadSweepGraceHours", uploadSweepGraceHours);
27
+ const { pendingUploadGraceHours } = options;
28
+ if (pendingUploadGraceHours !== undefined) {
29
+ assertValidSweepGraceHours("pendingUploadGraceHours", pendingUploadGraceHours);
30
+ }
26
31
  return defineFeature(FILES_FEATURE, (r) => {
27
32
  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?)`.");
28
33
  r.uiHints({
@@ -46,6 +51,14 @@ export function createFilesFeature(options = {}) {
46
51
  graceHours: uploadSweepGraceHours,
47
52
  log: ctx.obs.log,
48
53
  });
54
+ if (pendingUploadGraceHours !== undefined) {
55
+ await sweepStalePendingFileRefs({
56
+ db: ctx.db,
57
+ now: ctx.tz.now(),
58
+ graceHours: pendingUploadGraceHours,
59
+ log: ctx.obs.log,
60
+ });
61
+ }
49
62
  },
50
63
  });
51
64
  return { routeOptions: options };
@@ -14,6 +14,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
14
14
  readonly description?: string;
15
15
  readonly required?: boolean;
16
16
  readonly sortable?: boolean;
17
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
17
18
  readonly filterable?: boolean;
18
19
  readonly sensitive?: boolean;
19
20
  readonly default?: number;
@@ -13,6 +13,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
13
13
  readonly description?: string;
14
14
  readonly required?: boolean;
15
15
  readonly sortable?: boolean;
16
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
16
17
  readonly filterable?: boolean;
17
18
  readonly sensitive?: boolean;
18
19
  readonly default?: number;
@@ -0,0 +1,10 @@
1
+ import type { Logger } from "@cosmicdrift/kumiko-types/logger";
2
+ import type { TenantDb } from "../db/tenant-db.ts";
3
+ export declare const STALE_PENDING_FILE_REF_SWEEP_BATCH = 500;
4
+ export declare function sweepStalePendingFileRefs(args: {
5
+ readonly db: TenantDb;
6
+ readonly now: Temporal.Instant;
7
+ readonly graceHours: number;
8
+ readonly log: Pick<Logger, "warn"> | undefined;
9
+ readonly pageSize?: number;
10
+ }): Promise<number>;
@@ -0,0 +1,46 @@
1
+ import { selectMany } from "../db/query.js";
2
+ import { createSystemUser, SYSTEM_USER_ID } from "../engine/system-user.js";
3
+ import { getFileRefExecutor } from "./create-file-ref.js";
4
+ import { fileRefsTable } from "./file-ref-table.js";
5
+ export const STALE_PENDING_FILE_REF_SWEEP_BATCH = 500;
6
+ // A pending upload names its target field (entityType set) but no record yet. Uploads without a
7
+ // target field (inbox, dropzone) and files the system generated are kept on purpose.
8
+ function isStalePendingUpload(row) {
9
+ return row.entityType !== null && row.insertedById !== SYSTEM_USER_ID;
10
+ }
11
+ // Soft-deletes pending uploads whose record never came, through the fileRef executor, so the
12
+ // soft-delete cleanup later removes their bytes like those of any deleted file.
13
+ export async function sweepStalePendingFileRefs(args) {
14
+ const { db, now, graceHours, log } = args;
15
+ const pageSize = args.pageSize ?? STALE_PENDING_FILE_REF_SWEEP_BATCH;
16
+ const cutoff = now.subtract({ hours: graceHours });
17
+ const executor = getFileRefExecutor();
18
+ const user = createSystemUser(db.tenantId);
19
+ let swept = 0;
20
+ let cursor;
21
+ for (;;) {
22
+ const rows = await selectMany(db, fileRefsTable, {
23
+ entityId: null,
24
+ isDeleted: false,
25
+ insertedAt: { lt: cutoff },
26
+ ...(cursor !== undefined && { id: { gt: cursor } }),
27
+ }, {
28
+ orderBy: { col: "id", direction: "asc" },
29
+ fields: ["id", "entityType", "insertedById"],
30
+ limit: pageSize,
31
+ });
32
+ const lastRow = rows[rows.length - 1];
33
+ // skip: an empty page means every due row has been visited
34
+ if (lastRow === undefined)
35
+ return swept;
36
+ cursor = lastRow.id;
37
+ for (const row of rows.filter(isStalePendingUpload)) {
38
+ const removed = await executor.delete({ id: row.id }, user, db);
39
+ if (removed.isSuccess) {
40
+ swept++;
41
+ continue;
42
+ }
43
+ log?.warn(`[pending-upload-sweep] pending fileRef ${row.id} not deleted, retried next run: ${removed.error.code}`);
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,31 @@
1
+ import type { DbConnection } from "../db/connection.ts";
2
+ import type { Registry } from "../engine/types/index.ts";
3
+ import type { Logger } from "../logging/types.ts";
4
+ import { type PiiRebuildRun } from "./pii-event-backfill.ts";
5
+ export type EventScrubCandidate = {
6
+ readonly eventId: string;
7
+ readonly eventType: string;
8
+ readonly aggregateId: string;
9
+ readonly aggregateType: string;
10
+ readonly tenantId: string;
11
+ readonly field: string;
12
+ readonly payload: Readonly<Record<string, unknown>>;
13
+ };
14
+ export type ScrubEventPayloadOptions = {
15
+ readonly eventType: string;
16
+ readonly fields: readonly string[];
17
+ readonly reason: string;
18
+ readonly shouldScrub?: (value: unknown, candidate: EventScrubCandidate) => boolean | Promise<boolean>;
19
+ readonly dryRun?: boolean;
20
+ readonly batchSize?: number;
21
+ readonly logger?: Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>;
22
+ };
23
+ export type ScrubEventPayloadResult = {
24
+ readonly scannedEvents: number;
25
+ readonly scrubbedEvents: number;
26
+ readonly scrubbedFields: number;
27
+ readonly deletedSnapshots: number;
28
+ readonly queuedTables: readonly string[];
29
+ readonly rebuild: PiiRebuildRun | null;
30
+ };
31
+ export declare function scrubEventPayloadFields(db: DbConnection, registry: Registry, options: ScrubEventPayloadOptions): Promise<ScrubEventPayloadResult>;
@@ -0,0 +1,132 @@
1
+ // Replaces chosen payload fields of stored events with PII_ERASED_SENTINEL and rebuilds the
2
+ // projections fed by them. For personal data that crypto-shredding cannot reach: plaintext from
3
+ // before the field was declared personal without a subject left to erase, or a ciphertext that no
4
+ // longer decrypts. A read-model UPDATE alone would come back with the next rebuild.
5
+ import { PII_ERASED_SENTINEL } from "../crypto/pii-field-encryption.js";
6
+ import { isLifecycleEventOf, lifecycleSections } from "../db/queries/backfill-pii.js";
7
+ import { deleteSnapshotsOfAggregates, replaceEventPayload, selectEventsOfTypeAfter, } from "../db/queries/event-store-payload-scrub.js";
8
+ import { runInSystemRowLevelSecurityScope } from "../db/row-level-security-scope.js";
9
+ import { withoutStatementTimeout } from "../db/statement-timeout.js";
10
+ import { assertReason } from "../engine/escape-hatch-reason.js";
11
+ import { createFallbackLogger } from "../logging/utils.js";
12
+ import { createPendingRebuildsTable } from "./pending-rebuilds.js";
13
+ import { affectedProjectionTables, ensureInfraTable, queueRebuildTables, rebuildQueuedTables, } from "./pii-event-backfill.js";
14
+ const EVENT_SCRUB_MIGRATION_PREFIX = "event-scrub:";
15
+ const DEFAULT_BATCH_SIZE = 500;
16
+ function hasValue(value) {
17
+ return value !== undefined && value !== null && value !== PII_ERASED_SENTINEL;
18
+ }
19
+ function payloadSections(row, payload) {
20
+ return isLifecycleEventOf(row.type, row.aggregate_type) ? lifecycleSections(payload) : [payload];
21
+ }
22
+ async function scrubPayload(row, options) {
23
+ const payload = structuredClone(row.payload);
24
+ let scrubbed = 0;
25
+ for (const section of payloadSections(row, payload)) {
26
+ for (const field of options.fields) {
27
+ const value = section[field];
28
+ if (!hasValue(value))
29
+ continue;
30
+ const candidate = {
31
+ eventId: String(row.id),
32
+ eventType: row.type,
33
+ aggregateId: row.aggregate_id,
34
+ aggregateType: row.aggregate_type,
35
+ tenantId: row.tenant_id,
36
+ field,
37
+ payload: row.payload,
38
+ };
39
+ if (options.shouldScrub && !(await options.shouldScrub(value, candidate)))
40
+ continue;
41
+ section[field] = PII_ERASED_SENTINEL;
42
+ scrubbed++;
43
+ }
44
+ }
45
+ return { payload, scrubbed };
46
+ }
47
+ // One transaction per batch: payloads, the snapshots that may cache the old values and the
48
+ // rebuild queue move together, so an abort leaves no scrubbed stream with a stale snapshot.
49
+ async function scrubOneBatch(db, registry, args) {
50
+ const { options, totals } = args;
51
+ return db.begin(async (tx) => {
52
+ const rows = await selectEventsOfTypeAfter(tx, options.eventType, args.afterEventId, args.batchSize);
53
+ const last = rows[rows.length - 1];
54
+ if (last === undefined)
55
+ return { lastEventId: null, scannedAll: true };
56
+ const touchedAggregates = new Set();
57
+ const touchedAggregateTypes = new Set();
58
+ for (const row of rows) {
59
+ totals.scannedEvents++;
60
+ const { payload, scrubbed } = await scrubPayload(row, options);
61
+ if (scrubbed === 0)
62
+ continue;
63
+ totals.scrubbedEvents++;
64
+ totals.scrubbedFields += scrubbed;
65
+ if (options.dryRun)
66
+ continue;
67
+ await replaceEventPayload(tx, row.id, payload);
68
+ touchedAggregates.add(row.aggregate_id);
69
+ touchedAggregateTypes.add(row.aggregate_type);
70
+ }
71
+ if (touchedAggregates.size > 0) {
72
+ totals.deletedSnapshots += await deleteSnapshotsOfAggregates(tx, [...touchedAggregates]);
73
+ const tables = affectedProjectionTables(registry, touchedAggregateTypes, new Set([options.eventType]));
74
+ await queueRebuildTables(tx, tables, args.migrationId);
75
+ for (const table of tables)
76
+ args.queuedTables.add(table);
77
+ }
78
+ return { lastEventId: String(last.id), scannedAll: rows.length < args.batchSize };
79
+ });
80
+ }
81
+ async function scrubAllBatches(db, registry, options) {
82
+ const log = createFallbackLogger("event-payload-scrub", options.logger);
83
+ if (!options.dryRun) {
84
+ await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
85
+ }
86
+ const migrationId = `${EVENT_SCRUB_MIGRATION_PREFIX}${crypto.randomUUID()}`;
87
+ const totals = {
88
+ scannedEvents: 0,
89
+ scrubbedEvents: 0,
90
+ scrubbedFields: 0,
91
+ deletedSnapshots: 0,
92
+ };
93
+ const queuedTables = new Set();
94
+ let afterEventId = "0";
95
+ for (;;) {
96
+ const batch = await scrubOneBatch(db, registry, {
97
+ options,
98
+ afterEventId,
99
+ batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
100
+ migrationId,
101
+ totals,
102
+ queuedTables,
103
+ });
104
+ if (batch.lastEventId === null)
105
+ break;
106
+ afterEventId = batch.lastEventId;
107
+ if (batch.scannedAll)
108
+ break;
109
+ }
110
+ const rebuild = queuedTables.size === 0
111
+ ? null
112
+ : await rebuildQueuedTables(db, registry, {
113
+ migrationIdPrefix: migrationId,
114
+ signal: undefined,
115
+ log,
116
+ failureMessage: "Projection rebuild after an event payload scrub failed; the tables stay queued in kumiko_pending_rebuilds",
117
+ });
118
+ log.debug?.("Event payload scrub finished", {
119
+ eventType: options.eventType,
120
+ fields: options.fields,
121
+ reason: options.reason,
122
+ ...totals,
123
+ });
124
+ return { ...totals, queuedTables: [...queuedTables], rebuild };
125
+ }
126
+ export function scrubEventPayloadFields(db, registry, options) {
127
+ assertReason("scrubEventPayloadFields", options.reason);
128
+ if (options.fields.length === 0) {
129
+ throw new Error("scrubEventPayloadFields: name at least one payload field to scrub");
130
+ }
131
+ return runInSystemRowLevelSecurityScope("event payload scrub rewrites one event type across all tenants", () => withoutStatementTimeout(db, (unboundedDb) => scrubAllBatches(unboundedDb, registry, options)));
132
+ }
@@ -1,3 +1,4 @@
1
+ export { type EventScrubCandidate, type ScrubEventPayloadOptions, type ScrubEventPayloadResult, scrubEventPayloadFields, } from "./event-payload-scrub.ts";
1
2
  export { assertKumikoSchemaCurrent, SchemaDriftError, } from "./kumiko-drift.ts";
2
3
  export { enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.ts";
3
4
  export { runPiiEventBackfill } from "./pii-event-backfill.ts";
@@ -1,4 +1,6 @@
1
1
  // Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
2
+ // Erases chosen payload fields of stored events and rebuilds the projections fed by them.
3
+ export { scrubEventPayloadFields, } from "./event-payload-scrub.js";
2
4
  export { assertKumikoSchemaCurrent, SchemaDriftError, } from "./kumiko-drift.js";
3
5
  // Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
4
6
  export { enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
@@ -1,9 +1,10 @@
1
- import type { DbConnection } from "../db/connection.ts";
1
+ import type { DbConnection, DbRunner } from "../db/connection.ts";
2
2
  import { type PiiBackfillFailure } from "../db/queries/backfill-pii.ts";
3
3
  import type { Registry } from "../engine/types/index.ts";
4
4
  import type { Logger } from "../logging/types.ts";
5
5
  export declare const piiBackfillStateTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
6
6
  export declare function createPiiBackfillStateTable(db: DbConnection): Promise<void>;
7
+ export declare function ensureInfraTable(db: DbConnection, tableName: string, create: (db: DbConnection) => Promise<void>): Promise<void>;
7
8
  export declare function piiAnnotationEntries(registry: Registry): readonly string[];
8
9
  export declare function piiAnnotationFingerprint(registry: Registry): string;
9
10
  export type PiiEventBackfillOptions = {
@@ -38,4 +39,12 @@ export type PiiEventBackfillResult = {
38
39
  readonly queuedTables: readonly string[];
39
40
  readonly rebuild: PiiRebuildRun | null;
40
41
  };
42
+ export declare function queueRebuildTables(db: DbRunner, tableNames: readonly string[], migrationId: string): Promise<void>;
43
+ export declare function affectedProjectionTables(registry: Registry, touchedAggregateTypes: ReadonlySet<string>, touchedEventTypes: ReadonlySet<string>): readonly string[];
41
44
  export declare function runPiiEventBackfill(db: DbConnection, registry: Registry, options?: PiiEventBackfillOptions): Promise<PiiEventBackfillResult>;
45
+ export declare function rebuildQueuedTables(db: DbConnection, registry: Registry, options: {
46
+ readonly migrationIdPrefix: string;
47
+ readonly signal: AbortSignal | undefined;
48
+ readonly log: Pick<Logger, "error" | "warn">;
49
+ readonly failureMessage: string;
50
+ }): Promise<PiiRebuildRun | null>;
@@ -56,7 +56,7 @@ export async function createPiiBackfillStateTable(db) {
56
56
  }
57
57
  // Replicas boot concurrently: two of them can both pass the tableExists gate and
58
58
  // one CREATE then fails. The table existing afterwards means the other won.
59
- async function ensureInfraTable(db, tableName, create) {
59
+ export async function ensureInfraTable(db, tableName, create) {
60
60
  try {
61
61
  await create(db);
62
62
  }
@@ -86,12 +86,12 @@ export function piiAnnotationFingerprint(registry) {
86
86
  .update([...piiAnnotationEntries(registry), PII_BACKFILL_TRANSFORM_VERSION].join("\n"))
87
87
  .digest("hex");
88
88
  }
89
- async function queueRebuildTables(db, tableNames, migrationId) {
89
+ export async function queueRebuildTables(db, tableNames, migrationId) {
90
90
  for (const tableName of tableNames) {
91
91
  await upsertOnConflict(db, pendingRebuildsTable, { tableName, migrationId }, { conflictKeys: ["tableName"], update: { migrationId } });
92
92
  }
93
93
  }
94
- function affectedProjectionTables(registry, touchedAggregateTypes, touchedEventTypes) {
94
+ export function affectedProjectionTables(registry, touchedAggregateTypes, touchedEventTypes) {
95
95
  const tables = new Set();
96
96
  for (const [name, def] of registry.getAllProjections()) {
97
97
  const sources = [
@@ -253,7 +253,14 @@ async function backfillPiiEvents(db, registry, options) {
253
253
  };
254
254
  const aborted = await scanAllBatches(ctx, progress, options.signal, initialState?.failedEventIds ?? []);
255
255
  reportRunOutcome(log, progress);
256
- const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
256
+ const rebuild = aborted
257
+ ? null
258
+ : await rebuildQueuedTables(db, registry, {
259
+ migrationIdPrefix: PII_BACKFILL_MIGRATION_PREFIX,
260
+ signal: options.signal,
261
+ log,
262
+ failureMessage: "Projection rebuild after PII backfill failed; tables stay queued and are retried on the next boot",
263
+ });
257
264
  const queuedTables = [...progress.queuedTables];
258
265
  log.debug("PII event backfill finished", {
259
266
  mode,
@@ -281,8 +288,11 @@ function maxBigInt(a, b) {
281
288
  // Only our own queue rows: runPendingRebuilds would drain the whole queue and
282
289
  // silently clear entries this process cannot map (a worker with a narrower
283
290
  // feature set would swallow rebuilds queued by the API pod or by schema apply).
284
- async function rebuildQueuedTables(db, registry, signal, log) {
285
- const ownRows = (await listPendingRebuildRows(db)).filter((row) => row.migrationId.startsWith(PII_BACKFILL_MIGRATION_PREFIX));
291
+ // Rebuilds the projections of the tables queued under the run's migration id prefix; a failed one
292
+ // stays queued.
293
+ export async function rebuildQueuedTables(db, registry, options) {
294
+ const { signal, log } = options;
295
+ const ownRows = (await listPendingRebuildRows(db)).filter((row) => row.migrationId.startsWith(options.migrationIdPrefix));
286
296
  if (ownRows.length === 0)
287
297
  return null;
288
298
  const tableToProjection = buildProjectionTableIndex(registry);
@@ -317,8 +327,7 @@ async function rebuildQueuedTables(db, registry, signal, log) {
317
327
  failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
318
328
  }
319
329
  }
320
- if (failed.length > 0) {
321
- log.error("Projection rebuild after PII backfill failed; tables stay queued and are retried on the next boot", { failed });
322
- }
330
+ if (failed.length > 0)
331
+ log.error(options.failureMessage, { failed });
323
332
  return { rebuilt, failed, skippedTables };
324
333
  }
@@ -465,6 +465,9 @@ async function guardFieldTransition(ctx, args) {
465
465
  }
466
466
  const currentValue = row[fieldName] ??
467
467
  row[toSnakeCase(fieldName)]; // @cast-boundary engine-bridge
468
+ // skip: an unchanged value is no transition (an edit form resends the current status)
469
+ if (currentValue === newValue)
470
+ return;
468
471
  guardTransition(getTransitions(ctx, { entityName, fieldName, map: transitionMap }), currentValue, newValue);
469
472
  }
470
473
  // The handler itself plus the lifecycle pipeline run under the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.396.0",
3
+ "version": "0.398.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,12 +203,12 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.396.0",
206
+ "@cosmicdrift/kumiko-types": "0.398.0",
207
207
  "bullmq": "^5.76.7",
208
- "hono": "^4.13.1",
208
+ "hono": "^4.13.14",
209
209
  "i18next": "^26.1.0",
210
210
  "ioredis": "^5.10.1",
211
- "jose": "^6.2.3",
211
+ "jose": "^6.2.13",
212
212
  "pino": "^10.3.1",
213
213
  "temporal-polyfill": "^0.3.2",
214
214
  "uuid": "^14.0.0",
@@ -225,9 +225,9 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.396.0",
228
+ "@cosmicdrift/kumiko-testing": "0.398.0",
229
229
  "bun-types": "^1.4.4",
230
- "fflate": "^0.8.2",
230
+ "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.58.0",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,46 @@
1
1
  [
2
+ {
3
+ "version": "0.398.0",
4
+ "type": "fix",
5
+ "title": "jose updated to 6.2.13",
6
+ "detail": "Dependency bump of jose in framework and bundled-features. No API change for consumers."
7
+ },
8
+ {
9
+ "version": "0.397.0",
10
+ "type": "improvement",
11
+ "title": "Scrub primitive for event payload fields",
12
+ "detail": "Plaintext written before a field was declared personal without a subject to erase, or a ciphertext that no longer decrypts, stayed in `kumiko_events`, and the only way out was a raw `UPDATE` on the event store, which the event-store-writes guard rejects. The scrub walks the events of one type in batches, reaches the `changes` and `previous` sections of entity lifecycle events, lets `shouldScrub(value, candidate)` narrow the values (for example to those that throw `PiiCiphertextCorruptError`), drops the snapshots of touched streams and queues the affected projection and multi-stream projection tables in `kumiko_pending_rebuilds` in the same transaction, then rebuilds them; a failed rebuild stays queued. `reason` is required and placeholders are refused; `dryRun` only counts."
13
+ },
14
+ {
15
+ "version": "0.397.0",
16
+ "type": "improvement",
17
+ "title": "jsonb fields can default to an empty array",
18
+ "detail": "A jsonb field that stores a list had the fixed column default `{}`, so a row that never wrote it read an object while every other row read an array, and the generated write schema only accepted plain objects. `emptyValue: \"array\"` switches the column default to `[]` and the write schema to an array; the default stays `\"object\"`. The schema migration renders `ALTER COLUMN ... SET DEFAULT '[]'::jsonb`; existing rows that never wrote the field get `[]` with the next rebuild of the projection."
19
+ },
20
+ {
21
+ "version": "0.397.0",
22
+ "type": "improvement",
23
+ "title": "Abandoned form uploads can expire",
24
+ "detail": "An upload sent with entityType and fieldName but without a record stays pending until a save binds it. When the form was abandoned, the fileRef row and its bytes stayed forever without an owner that deletes them. With `pendingUploadGraceHours` the `files:job:sweep-orphaned-uploads` job soft-deletes such rows through the fileRef executor once they are older than the grace period. Uploads without a target field (inbox, dropzone) and files the system user created are kept. It is opt-in because an app that stores fileRef ids without binding them would lose those files."
25
+ },
26
+ {
27
+ "version": "0.397.0",
28
+ "type": "improvement",
29
+ "title": "Corrupt PII ciphertext has its own error type",
30
+ "detail": "`decryptPiiFieldValues`, `decryptPiiValueForSubject` and every read path built on them threw Node's generic \"unable to authenticate data\" error for a failed GCM auth tag, the same shape as any other failure. Now a failed auth tag, an AAD mismatch (value copied into another field or subject), a malformed ciphertext and an undecodable jsonb plaintext throw `PiiCiphertextCorruptError` with the field name; it is a KumikoError, so it keeps its code through the dispatcher. An export can blank one such row and still fail loud on an outage, which keeps its own `EncryptionUnavailableError`."
31
+ },
32
+ {
33
+ "version": "0.397.0",
34
+ "type": "improvement",
35
+ "title": "sortNulls places rows without a value in list sorting",
36
+ "detail": "Without the option the list keeps Postgres' order (nulls last ascending, first descending), which a plain index serves. With `sortNulls` the list query adds `NULLS FIRST` or `NULLS LAST` to the order and the keyset cursor follows it, so paging across the rows without a value stays complete. A sort that must stay on an index needs an index with the same NULLS order."
37
+ },
38
+ {
39
+ "version": "0.397.0",
40
+ "type": "fix",
41
+ "title": "Transition guard ignores an unchanged state value",
42
+ "detail": "An update whose `changes` carry the state field with the value the row already has is no transition, so the guard skips it instead of rejecting it as a self-transition that the map does not list. Before, a rename on a `sold` or `finished` row failed as soon as the client sent the current status along."
43
+ },
2
44
  {
3
45
  "version": "0.396.0",
4
46
  "type": "breaking",