@cosmicdrift/kumiko-framework 0.396.0 → 0.397.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crypto/index.d.ts +1 -0
- package/dist/crypto/index.js +1 -0
- package/dist/crypto/pii-ciphertext-corrupt.d.ts +8 -0
- package/dist/crypto/pii-ciphertext-corrupt.js +18 -0
- package/dist/crypto/pii-field-encryption.js +33 -11
- package/dist/db/entity-column-meta.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +7 -7
- package/dist/db/event-store-executor-read-sort.d.ts +4 -1
- package/dist/db/event-store-executor-read-sort.js +19 -4
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +2 -2
- package/dist/db/queries/event-store-payload-scrub.d.ts +12 -0
- package/dist/db/queries/event-store-payload-scrub.js +20 -0
- package/dist/engine/factories.js +1 -1
- package/dist/engine/schema-builder.js +4 -2
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +16 -3
- package/dist/files/file-ref-entity.d.ts +1 -0
- package/dist/files/file-ref-table.d.ts +1 -0
- package/dist/files/sweep-stale-pending-file-refs.d.ts +10 -0
- package/dist/files/sweep-stale-pending-file-refs.js +46 -0
- package/dist/migrations/event-payload-scrub.d.ts +31 -0
- package/dist/migrations/event-payload-scrub.js +132 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +2 -0
- package/dist/migrations/pii-event-backfill.d.ts +10 -1
- package/dist/migrations/pii-event-backfill.js +18 -9
- package/dist/pipeline/dispatch-write.js +3 -0
- package/package.json +4 -4
- package/src/changes.json +36 -0
package/dist/crypto/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/crypto/index.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 } =
|
|
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 } =
|
|
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 ?
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
function keysetBoundarySql(sortCol, idCol, cursor,
|
|
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 =
|
|
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
|
-
|
|
84
|
-
|
|
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
|
+
}
|
package/dist/engine/factories.js
CHANGED
|
@@ -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 `{}
|
|
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
|
|
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":
|
package/dist/files/feature.d.ts
CHANGED
|
@@ -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;
|
package/dist/files/feature.js
CHANGED
|
@@ -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:
|
|
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";
|
package/dist/migrations/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
285
|
-
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.397.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,7 +203,7 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.397.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"hono": "^4.13.1",
|
|
209
209
|
"i18next": "^26.1.0",
|
|
@@ -225,9 +225,9 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.397.0",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
|
-
"fflate": "^0.8.
|
|
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,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.397.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Scrub primitive for event payload fields",
|
|
6
|
+
"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."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.397.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "jsonb fields can default to an empty array",
|
|
12
|
+
"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."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.397.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Abandoned form uploads can expire",
|
|
18
|
+
"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."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.397.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "Corrupt PII ciphertext has its own error type",
|
|
24
|
+
"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`."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.397.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "sortNulls places rows without a value in list sorting",
|
|
30
|
+
"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."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.397.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "Transition guard ignores an unchanged state value",
|
|
36
|
+
"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."
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "0.396.0",
|
|
4
40
|
"type": "breaking",
|