@cosmicdrift/kumiko-framework 0.354.0 → 0.355.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/db/entity-field-encryption.d.ts +10 -1
- package/dist/db/entity-field-encryption.js +31 -2
- package/dist/db/event-store-executor-context.d.ts +8 -2
- package/dist/db/event-store-executor-context.js +41 -9
- package/dist/db/event-store-executor-read.js +22 -11
- package/dist/db/event-store-executor-write.js +95 -56
- package/dist/db/queries/event-store.d.ts +1 -0
- package/dist/db/queries/event-store.js +11 -0
- package/dist/engine/build-app-schema.js +32 -5
- package/dist/engine/cross-tenant.d.ts +2 -1
- package/dist/engine/cross-tenant.js +18 -14
- package/dist/engine/entity-handlers.js +17 -16
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +7 -1
- package/dist/engine/registry-validate.js +82 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +35 -2
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +4 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/snapshot.d.ts +1 -0
- package/dist/event-store/snapshot.js +12 -3
- package/dist/jobs/job-runner.js +32 -17
- package/dist/observability/escape-hatch-report.d.ts +5 -2
- package/dist/observability/escape-hatch-report.js +52 -22
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/pipeline/dispatch-query.js +4 -0
- package/dist/pipeline/dispatch-shared.js +33 -6
- package/dist/pipeline/dispatch-stream.js +4 -0
- package/dist/pipeline/dispatcher-utils.d.ts +4 -0
- package/dist/pipeline/dispatcher-utils.js +10 -0
- package/dist/pipeline/dispatcher.js +7 -7
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/pre-handler-gates.js +4 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +41 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/index.js";
|
|
2
|
+
import { type Logger } from "../logging/index.js";
|
|
2
3
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
|
|
3
4
|
export declare function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string>;
|
|
4
5
|
export declare function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean;
|
|
@@ -6,7 +7,15 @@ export declare function hasSearchablePlaintext(entity: EntityDefinition, field:
|
|
|
6
7
|
export declare function encryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
|
|
7
8
|
onlyKeys?: Iterable<string>;
|
|
8
9
|
}): Promise<Record<string, unknown>>;
|
|
9
|
-
export declare function
|
|
10
|
+
export declare function logUndecryptableField(info: {
|
|
11
|
+
entityName: string | undefined;
|
|
12
|
+
rowId: unknown;
|
|
13
|
+
field: string;
|
|
14
|
+
error: unknown;
|
|
15
|
+
}, logger?: Logger): void;
|
|
16
|
+
export declare function decryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
|
|
17
|
+
onFieldError?: (field: string, error: unknown) => void;
|
|
18
|
+
}): Promise<Record<string, unknown>>;
|
|
10
19
|
export declare function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void;
|
|
11
20
|
export declare function resolveEntityFieldEncryption(): EnvelopeCipher;
|
|
12
21
|
export declare function configuredEntityFieldEncryption(): EnvelopeCipher | undefined;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
|
|
5
5
|
// the cipher's legacy fallback until re-encrypted.
|
|
6
6
|
import { collectPiiSubjectFields } from "../crypto/index.js";
|
|
7
|
+
import { createLogger } from "../logging/index.js";
|
|
7
8
|
import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider.js";
|
|
8
9
|
export function collectEncryptedFieldNames(entity) {
|
|
9
10
|
const names = new Set();
|
|
@@ -60,7 +61,25 @@ export async function encryptEntityFieldValues(row, encryptedFields, cipher, opt
|
|
|
60
61
|
}
|
|
61
62
|
return out;
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
let undecryptableFieldLogger;
|
|
65
|
+
function defaultUndecryptableFieldLogger() {
|
|
66
|
+
undecryptableFieldLogger ??= createLogger();
|
|
67
|
+
return undecryptableFieldLogger;
|
|
68
|
+
}
|
|
69
|
+
// Logs identifiers and the error class only: ciphertext, key material and the
|
|
70
|
+
// cipher's error message (which may echo envelope content) never reach the log.
|
|
71
|
+
export function logUndecryptableField(info, logger = defaultUndecryptableFieldLogger()) {
|
|
72
|
+
logger.error("encrypted field could not be decrypted; returned as null", {
|
|
73
|
+
entity: info.entityName,
|
|
74
|
+
rowId: info.rowId,
|
|
75
|
+
field: info.field,
|
|
76
|
+
errorName: info.error instanceof Error ? info.error.name : typeof info.error,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// With `onFieldError` a field that fails to decrypt (wrong key, corrupt
|
|
80
|
+
// envelope) reads as null and is reported instead of throwing, so one bad row
|
|
81
|
+
// cannot fail a whole list. Without it decrypt stays fail-loud (write paths).
|
|
82
|
+
export async function decryptEntityFieldValues(row, encryptedFields, cipher, opts) {
|
|
64
83
|
if (encryptedFields.size === 0)
|
|
65
84
|
return row;
|
|
66
85
|
const out = { ...row };
|
|
@@ -71,7 +90,17 @@ export async function decryptEntityFieldValues(row, encryptedFields, cipher) {
|
|
|
71
90
|
continue;
|
|
72
91
|
if (typeof value !== "string")
|
|
73
92
|
continue;
|
|
74
|
-
|
|
93
|
+
if (!opts?.onFieldError) {
|
|
94
|
+
out[name] = await cipher.decrypt(value, scope);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
out[name] = await cipher.decrypt(value, scope);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
out[name] = null;
|
|
102
|
+
opts.onFieldError(name, error);
|
|
103
|
+
}
|
|
75
104
|
}
|
|
76
105
|
return out;
|
|
77
106
|
}
|
|
@@ -9,11 +9,15 @@ import type { EventStoreExecutorOptions } from "./event-store-executor.js";
|
|
|
9
9
|
import type { TenantDb } from "./tenant-db.js";
|
|
10
10
|
export type Table = TableColumns<any>;
|
|
11
11
|
export declare function buildFilterWhere(field: string, op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in", value: unknown): WhereObject | null;
|
|
12
|
+
export declare function ownershipSubject(user: SessionUser, streamTenantId: TenantId | undefined): SessionUser;
|
|
12
13
|
export declare function isForeignTenantOnGlobalEntity(entity: EntityDefinition, tenantIdValue: unknown): boolean;
|
|
13
14
|
export declare function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null;
|
|
14
15
|
export declare function buildEventMetadata(user: SessionUser): EventMetadata;
|
|
15
16
|
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
16
17
|
export declare function entityEventName(entityName: string, verb: EntityLifecycleVerb): string;
|
|
18
|
+
export type DecryptForReadOptions = {
|
|
19
|
+
readonly degradeUndecryptable?: (field: string) => void;
|
|
20
|
+
};
|
|
17
21
|
export type ExecutorContext = {
|
|
18
22
|
readonly table: Table;
|
|
19
23
|
readonly entity: EntityDefinition;
|
|
@@ -21,7 +25,8 @@ export type ExecutorContext = {
|
|
|
21
25
|
readonly entityCache?: EntityCache;
|
|
22
26
|
readonly searchAdapter?: SearchAdapter;
|
|
23
27
|
readonly softDelete: boolean;
|
|
24
|
-
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
28
|
+
readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
|
|
29
|
+
readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
|
|
25
30
|
readonly idFilter: (id: EntityId) => WhereObject;
|
|
26
31
|
readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
|
|
27
32
|
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
@@ -53,8 +58,9 @@ export type ExecutorContext = {
|
|
|
53
58
|
readonly encryptForStorage: (row: Record<string, unknown>, user: SessionUser, opts?: {
|
|
54
59
|
onlyKeys?: Iterable<string>;
|
|
55
60
|
subjectSource?: Record<string, unknown>;
|
|
61
|
+
keyTenantId?: TenantId | undefined;
|
|
56
62
|
}) => Promise<Record<string, unknown>>;
|
|
57
|
-
readonly decryptForRead: (row: Record<string, unknown
|
|
63
|
+
readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
|
|
58
64
|
readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
|
|
59
65
|
readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
|
|
60
66
|
};
|
|
@@ -4,11 +4,11 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
|
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
6
6
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
7
|
-
import { InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
|
|
7
|
+
import { AccessDeniedError, InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
|
|
8
8
|
import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
|
|
9
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
10
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
11
|
-
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
11
|
+
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
12
12
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
13
13
|
import { toSnakeCase } from "./table-builder.js";
|
|
14
14
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -39,6 +39,13 @@ export function buildFilterWhere(field, op, value) {
|
|
|
39
39
|
return assertUnreachable(op, "filter op");
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
// Tenant-based write-ownership rules (from("user:tenantId", "tenantId")) compare the caller's
|
|
43
|
+
// tenant with the row's. An explicit cross-tenant grant (streamTenantId, checked against the
|
|
44
|
+
// loaded row) is evaluated as the operator acting inside the target tenant. Only this
|
|
45
|
+
// evaluation sees the target tenant: stream actor, metadata and PII keys keep the real identity.
|
|
46
|
+
export function ownershipSubject(user, streamTenantId) {
|
|
47
|
+
return streamTenantId === undefined ? user : { ...user, tenantId: streamTenantId };
|
|
48
|
+
}
|
|
42
49
|
// Returns the scalar default of a field, or undefined if the field's type
|
|
43
50
|
// doesn't carry a default or no default was declared. Only scalar types
|
|
44
51
|
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
@@ -110,7 +117,24 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
110
117
|
// user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
|
|
111
118
|
// there. Everything else stays on the caller's tenant (byte-identical to the
|
|
112
119
|
// old hardcoded user.tenantId). Single source of truth for the stream key.
|
|
113
|
-
const streamTenantFor = (user) => entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
120
|
+
const streamTenantFor = (user, requestedStreamTenantId) => entity.systemStream ? SYSTEM_TENANT_ID : (requestedStreamTenantId ?? user.tenantId);
|
|
121
|
+
// Only a system-mode db (r.systemScope() / acknowledged cross-tenant) may address another
|
|
122
|
+
// tenant's stream, and only the tenant of the row it just loaded.
|
|
123
|
+
const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
|
|
124
|
+
if (requestedStreamTenantId === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
if (db.mode !== "system") {
|
|
127
|
+
return writeFailure(new AccessDeniedError({
|
|
128
|
+
message: `${entityName}: streamTenantId requires a system-mode db`,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
if (row["tenantId"] !== requestedStreamTenantId) {
|
|
132
|
+
return writeFailure(new AccessDeniedError({
|
|
133
|
+
message: `${entityName}: streamTenantId does not match the tenant of the addressed row`,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
};
|
|
114
138
|
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
115
139
|
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
116
140
|
// shape that's incompatible with the event-store and still rejected.
|
|
@@ -156,10 +180,10 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
156
180
|
function piiKms() {
|
|
157
181
|
return options.kms ?? configuredPiiSubjectKms();
|
|
158
182
|
}
|
|
159
|
-
function kmsContextFor(user) {
|
|
183
|
+
function kmsContextFor(user, keyTenantId) {
|
|
160
184
|
return {
|
|
161
185
|
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
162
|
-
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
186
|
+
...(user && { tenantId: keyTenantId ?? user.tenantId, userId: String(user.id) }),
|
|
163
187
|
};
|
|
164
188
|
}
|
|
165
189
|
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
@@ -176,8 +200,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
176
200
|
const kms = piiKms();
|
|
177
201
|
if (hasPiiFields && kms) {
|
|
178
202
|
try {
|
|
179
|
-
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
180
|
-
tenantId: user.tenantId,
|
|
203
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user, opts?.keyTenantId), {
|
|
204
|
+
tenantId: opts?.keyTenantId ?? user.tenantId,
|
|
181
205
|
entityName,
|
|
182
206
|
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
183
207
|
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
@@ -198,14 +222,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
198
222
|
// unwrap before envelope decrypt, or the envelope cipher chokes on a
|
|
199
223
|
// still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
|
|
200
224
|
// markers; see pii-subject-encryption.integration.test.ts).
|
|
201
|
-
async function decryptForRead(row) {
|
|
225
|
+
async function decryptForRead(row, opts) {
|
|
202
226
|
let out = row;
|
|
203
227
|
const kms = piiKms();
|
|
204
228
|
if (hasPiiFields && kms) {
|
|
205
229
|
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
206
230
|
}
|
|
207
231
|
if (hasEncryptedFields) {
|
|
208
|
-
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher()
|
|
232
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
|
|
233
|
+
? {
|
|
234
|
+
onFieldError: (field, error) => {
|
|
235
|
+
logUndecryptableField({ entityName, rowId: row["id"], field, error });
|
|
236
|
+
opts.degradeUndecryptable?.(field);
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
: undefined);
|
|
209
240
|
}
|
|
210
241
|
return out;
|
|
211
242
|
}
|
|
@@ -384,6 +415,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
384
415
|
searchAdapter,
|
|
385
416
|
softDelete,
|
|
386
417
|
streamTenantFor,
|
|
418
|
+
streamTenantOverrideFailure,
|
|
387
419
|
idFilter,
|
|
388
420
|
loadById,
|
|
389
421
|
assertStreamWritable,
|
|
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
|
6
6
|
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
8
8
|
import { UnprocessableError } from "../errors/index.js";
|
|
9
|
-
import { getStreamVersion } from "../event-store/index.js";
|
|
9
|
+
import { getStreamVersion, getStreamVersions } from "../event-store/index.js";
|
|
10
10
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
@@ -64,6 +64,9 @@ function toCursorSortText(value) {
|
|
|
64
64
|
}
|
|
65
65
|
return undefined;
|
|
66
66
|
}
|
|
67
|
+
function withStreamVersion(row, streamVersion) {
|
|
68
|
+
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
69
|
+
}
|
|
67
70
|
// Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
|
|
68
71
|
// null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
|
|
69
72
|
// would change the visible order and cost the sort its index.
|
|
@@ -661,11 +664,17 @@ export function createReadVerbs(ctx) {
|
|
|
661
664
|
// field (ciphertext leaked to the caller).
|
|
662
665
|
const tableInfo = extractTableInfo(table);
|
|
663
666
|
const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
|
|
664
|
-
const
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
667
|
+
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
|
|
668
|
+
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
669
|
+
// stream without touching row.version, so the raw row version would hand
|
|
670
|
+
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
671
|
+
const streamVersions = decryptedRows.length > 0
|
|
672
|
+
? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
|
|
673
|
+
: new Map();
|
|
674
|
+
const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
|
|
675
|
+
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
676
|
+
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
677
|
+
// a cached version that differs from the stream.
|
|
669
678
|
if (entityCache && entityName && rows.length > 0) {
|
|
670
679
|
await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
|
|
671
680
|
}
|
|
@@ -718,7 +727,6 @@ export function createReadVerbs(ctx) {
|
|
|
718
727
|
// guaranteed version_conflict.
|
|
719
728
|
// A cache hit only counts when its version equals the live stream version:
|
|
720
729
|
// a late entityCache.set() can resurrect an old row after the writer's del().
|
|
721
|
-
const withVersion = (row, streamVersion) => (streamVersion > 0 ? { ...row, version: streamVersion } : row);
|
|
722
730
|
if (entityCache && entityName) {
|
|
723
731
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
724
732
|
if (cached) {
|
|
@@ -736,7 +744,7 @@ export function createReadVerbs(ctx) {
|
|
|
736
744
|
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
737
745
|
// `encrypted` field's plaintext never sits in a second at-rest
|
|
738
746
|
// store (Redis) the field-encryption feature doesn't cover.
|
|
739
|
-
return
|
|
747
|
+
return withStreamVersion(await decryptForRead(cached, { degradeUndecryptable: () => { } }), streamVersion);
|
|
740
748
|
}
|
|
741
749
|
}
|
|
742
750
|
}
|
|
@@ -747,9 +755,12 @@ export function createReadVerbs(ctx) {
|
|
|
747
755
|
// Same coerce-before-rehydrate/decrypt ordering as list() above — raw
|
|
748
756
|
// is snake_case (raw SQL); coerceRow maps it to camelCase.
|
|
749
757
|
const rowInfo = extractTableInfo(table);
|
|
750
|
-
const
|
|
751
|
-
const
|
|
752
|
-
|
|
758
|
+
const undecryptableFields = [];
|
|
759
|
+
const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
|
|
760
|
+
const result = withStreamVersion(coerced, snapshot.streamVersion);
|
|
761
|
+
// A degraded row must not be cached: re-encrypting its null would
|
|
762
|
+
// replay the missing value on later cache hits without any error log.
|
|
763
|
+
if (entityCache && entityName && undecryptableFields.length === 0) {
|
|
753
764
|
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
|
|
754
765
|
}
|
|
755
766
|
return result;
|