@cosmicdrift/kumiko-framework 0.146.3 → 0.147.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/package.json +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
|
@@ -1,129 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
collectPiiSubjectFields,
|
|
4
|
-
computeBlindIndex,
|
|
5
|
-
configuredBlindIndexKey,
|
|
6
|
-
configuredPiiSubjectKms,
|
|
7
|
-
decryptPiiFieldValues,
|
|
8
|
-
encryptPiiFieldValues,
|
|
9
|
-
type KmsContext,
|
|
10
|
-
type LocalKeyKmsAdapter,
|
|
11
|
-
} from "../crypto";
|
|
12
|
-
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
13
|
-
import type { WhereObject } from "../db/query";
|
|
14
|
-
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
15
|
-
import { checkWriteFieldOwnership } from "../engine/field-access";
|
|
16
|
-
import {
|
|
17
|
-
buildOwnershipClause,
|
|
18
|
-
shiftParams,
|
|
19
|
-
userCanCreateFieldRow,
|
|
20
|
-
userCanWriteFieldRow,
|
|
21
|
-
} from "../engine/ownership";
|
|
1
|
+
import type { LocalKeyKmsAdapter } from "../crypto";
|
|
22
2
|
import type {
|
|
23
3
|
DeleteContext,
|
|
24
4
|
EntityDefinition,
|
|
25
5
|
EntityId,
|
|
26
|
-
FieldDefinition,
|
|
27
6
|
SaveContext,
|
|
28
7
|
SessionUser,
|
|
29
|
-
TenantId,
|
|
30
8
|
WriteResult,
|
|
31
9
|
} from "../engine/types";
|
|
32
|
-
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
33
|
-
import {
|
|
34
|
-
VersionConflictError as FrameworkVersionConflict,
|
|
35
|
-
InternalError,
|
|
36
|
-
NotFoundError,
|
|
37
|
-
UniqueViolationError,
|
|
38
|
-
UnprocessableError,
|
|
39
|
-
type WriteFailure,
|
|
40
|
-
writeFailure,
|
|
41
|
-
} from "../errors";
|
|
42
|
-
import {
|
|
43
|
-
ArchivedStreamError,
|
|
44
|
-
append,
|
|
45
|
-
type EventMetadata,
|
|
46
|
-
VersionConflictError as EventStoreVersionConflict,
|
|
47
|
-
getStreamVersion,
|
|
48
|
-
isStreamArchived,
|
|
49
|
-
} from "../event-store";
|
|
50
10
|
import type { EntityCache } from "../pipeline/entity-cache";
|
|
51
11
|
import type { SearchAdapter } from "../search/types";
|
|
52
12
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import type { DbRow } from "./connection";
|
|
57
|
-
import { decodeCursor, encodeCursor } from "./cursor";
|
|
58
|
-
import type { TableColumns } from "./dialect";
|
|
59
|
-
import {
|
|
60
|
-
collectEncryptedFieldNames,
|
|
61
|
-
decryptEntityFieldValues,
|
|
62
|
-
encryptEntityFieldValues,
|
|
63
|
-
resolveEntityFieldEncryption,
|
|
64
|
-
} from "./entity-field-encryption";
|
|
13
|
+
import { buildExecutorContext, type Table } from "./event-store-executor-context";
|
|
14
|
+
import { createReadVerbs } from "./event-store-executor-read";
|
|
15
|
+
import { createWriteVerbs } from "./event-store-executor-write";
|
|
65
16
|
import type { CursorResult } from "./index";
|
|
66
|
-
import { constraintOf, isUniqueViolation } from "./pg-error";
|
|
67
|
-
import { toSnakeCase } from "./table-builder";
|
|
68
|
-
import type { TenantDb } from "./tenant-db";
|
|
69
17
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
case "eq":
|
|
84
|
-
return { [field]: value };
|
|
85
|
-
case "ne":
|
|
86
|
-
return { [field]: { ne: value } };
|
|
87
|
-
case "lt":
|
|
88
|
-
return { [field]: { lt: value } };
|
|
89
|
-
case "gt":
|
|
90
|
-
return { [field]: { gt: value } };
|
|
91
|
-
case "in":
|
|
92
|
-
if (Array.isArray(value) && value.length > 0) {
|
|
93
|
-
return { [field]: value };
|
|
94
|
-
}
|
|
95
|
-
return null; // no-match short-circuit
|
|
96
|
-
default:
|
|
97
|
-
assertUnreachable(op, "filter op");
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Returns the scalar default of a field, or undefined if the field's type
|
|
102
|
-
// doesn't carry a default or no default was declared. Only scalar types
|
|
103
|
-
// (text/number/boolean/select) support creation-time defaults — money/date/
|
|
104
|
-
// file/embedded fields don't.
|
|
105
|
-
function scalarDefault(field: FieldDefinition): unknown {
|
|
106
|
-
switch (field.type) {
|
|
107
|
-
case "text":
|
|
108
|
-
case "longText":
|
|
109
|
-
case "number":
|
|
110
|
-
case "boolean":
|
|
111
|
-
case "select":
|
|
112
|
-
return field.default;
|
|
113
|
-
default:
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Lifecycle verbs the event-store-executor auto-emits. MSPs that react
|
|
119
|
-
// to entity creates/updates/etc should reference this helper instead of
|
|
120
|
-
// hardcoding the string — a future rename in the executor then surfaces
|
|
121
|
-
// as a type error at every call site rather than a silent miss.
|
|
122
|
-
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
18
|
+
// The executor writes events + auto-projection (entity table) in one TX.
|
|
19
|
+
// It no longer knows about user projections — those are driven by the
|
|
20
|
+
// pipeline, which reads the StoredEvent surfaced on SaveContext/DeleteContext
|
|
21
|
+
// and iterates the registry itself. Executor-level `registry` options were
|
|
22
|
+
// removed to close the silent-bypass hole where a caller forgetting to pass
|
|
23
|
+
// one would skip projections without any signal.
|
|
24
|
+
//
|
|
25
|
+
// Split into three files (#1005, Welle 2): this facade holds the public
|
|
26
|
+
// types + the createEventStoreExecutor() factory. Context-building (crypto/
|
|
27
|
+
// ownership helpers shared by every verb) lives in
|
|
28
|
+
// event-store-executor-context.ts; the write verbs (create/update/delete/
|
|
29
|
+
// forget/restore) in event-store-executor-write.ts; the read verbs (list/
|
|
30
|
+
// detail) in event-store-executor-read.ts.
|
|
123
31
|
|
|
124
|
-
export
|
|
125
|
-
|
|
126
|
-
}
|
|
32
|
+
export type { EntityLifecycleVerb } from "./event-store-executor-context";
|
|
33
|
+
export { entityEventName } from "./event-store-executor-context";
|
|
127
34
|
|
|
128
35
|
export type EventStoreExecutorOptions = {
|
|
129
36
|
searchAdapter?: SearchAdapter;
|
|
@@ -135,68 +42,24 @@ export type EventStoreExecutorOptions = {
|
|
|
135
42
|
kms?: LocalKeyKmsAdapter;
|
|
136
43
|
};
|
|
137
44
|
|
|
138
|
-
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
139
|
-
// (create + update Pfade) → WriteFailure(UniqueViolationError 409).
|
|
140
|
-
// Andere Errors propagieren via re-throw. Lokal extrahiert weil das
|
|
141
|
-
// Pattern an zwei Stellen im executor lebt — der Caller wrap't den
|
|
142
|
-
// applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
|
|
143
|
-
//
|
|
144
|
-
// Returns WriteFailure on match, null otherwise (caller re-throws).
|
|
145
|
-
function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null {
|
|
146
|
-
if (!isUniqueViolation(e)) return null;
|
|
147
|
-
const constraintName = constraintOf(e);
|
|
148
|
-
return writeFailure(
|
|
149
|
-
new UniqueViolationError(
|
|
150
|
-
{
|
|
151
|
-
entityName,
|
|
152
|
-
...(constraintName !== undefined && { constraintName }),
|
|
153
|
-
},
|
|
154
|
-
{ cause: e instanceof Error ? e : undefined },
|
|
155
|
-
),
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Build the metadata envelope for an append. userId always set; requestId +
|
|
160
|
-
// correlation + causation come from the AsyncLocalStorage request-context
|
|
161
|
-
// when present (e.g. HTTP request, MSP-apply, job run). requestId is a pure
|
|
162
|
-
// trace marker — HTTP-level retry idempotency runs separately via
|
|
163
|
-
// pipeline/idempotency.ts (Redis-cached response replay), so a single
|
|
164
|
-
// request can write N events freely without the events-table needing a
|
|
165
|
-
// uniqueness constraint.
|
|
166
|
-
function buildEventMetadata(user: SessionUser): EventMetadata {
|
|
167
|
-
const reqCtx = requestContext.get();
|
|
168
|
-
return {
|
|
169
|
-
userId: String(user.id),
|
|
170
|
-
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
171
|
-
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
172
|
-
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// The executor writes events + auto-projection (entity table) in one TX.
|
|
177
|
-
// It no longer knows about user projections — those are driven by the
|
|
178
|
-
// pipeline, which reads the StoredEvent surfaced on SaveContext/DeleteContext
|
|
179
|
-
// and iterates the registry itself. Executor-level `registry` options were
|
|
180
|
-
// removed to close the silent-bypass hole where a caller forgetting to pass
|
|
181
|
-
// one would skip projections without any signal.
|
|
182
45
|
export type EventStoreExecutor = {
|
|
183
46
|
create: (
|
|
184
47
|
payload: Record<string, unknown>,
|
|
185
48
|
user: SessionUser,
|
|
186
|
-
db: TenantDb,
|
|
49
|
+
db: import("./tenant-db").TenantDb,
|
|
187
50
|
) => Promise<WriteResult<SaveContext>>;
|
|
188
51
|
|
|
189
52
|
update: (
|
|
190
53
|
payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
|
|
191
54
|
user: SessionUser,
|
|
192
|
-
db: TenantDb,
|
|
55
|
+
db: import("./tenant-db").TenantDb,
|
|
193
56
|
options?: { skipOptimisticLock?: boolean },
|
|
194
57
|
) => Promise<WriteResult<SaveContext>>;
|
|
195
58
|
|
|
196
59
|
delete: (
|
|
197
60
|
payload: { id: EntityId },
|
|
198
61
|
user: SessionUser,
|
|
199
|
-
db: TenantDb,
|
|
62
|
+
db: import("./tenant-db").TenantDb,
|
|
200
63
|
) => Promise<WriteResult<DeleteContext>>;
|
|
201
64
|
|
|
202
65
|
// Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
|
|
@@ -206,13 +69,13 @@ export type EventStoreExecutor = {
|
|
|
206
69
|
forget: (
|
|
207
70
|
payload: { id: EntityId },
|
|
208
71
|
user: SessionUser,
|
|
209
|
-
db: TenantDb,
|
|
72
|
+
db: import("./tenant-db").TenantDb,
|
|
210
73
|
) => Promise<WriteResult<DeleteContext>>;
|
|
211
74
|
|
|
212
75
|
restore: (
|
|
213
76
|
payload: { id: EntityId },
|
|
214
77
|
user: SessionUser,
|
|
215
|
-
db: TenantDb,
|
|
78
|
+
db: import("./tenant-db").TenantDb,
|
|
216
79
|
) => Promise<WriteResult<SaveContext>>;
|
|
217
80
|
|
|
218
81
|
list: (
|
|
@@ -242,7 +105,7 @@ export type EventStoreExecutor = {
|
|
|
242
105
|
| undefined;
|
|
243
106
|
},
|
|
244
107
|
user: SessionUser,
|
|
245
|
-
db: TenantDb,
|
|
108
|
+
db: import("./tenant-db").TenantDb,
|
|
246
109
|
/** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
|
|
247
110
|
* Executor beim Build keinen SearchAdapter via Options bekommen
|
|
248
111
|
* hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
|
|
@@ -262,7 +125,7 @@ export type EventStoreExecutor = {
|
|
|
262
125
|
detail: (
|
|
263
126
|
payload: { id: EntityId },
|
|
264
127
|
user: SessionUser,
|
|
265
|
-
db: TenantDb,
|
|
128
|
+
db: import("./tenant-db").TenantDb,
|
|
266
129
|
) => Promise<Record<string, unknown> | null>;
|
|
267
130
|
};
|
|
268
131
|
|
|
@@ -271,1009 +134,9 @@ export function createEventStoreExecutor(
|
|
|
271
134
|
entity: EntityDefinition,
|
|
272
135
|
options: EventStoreExecutorOptions,
|
|
273
136
|
): EventStoreExecutor {
|
|
274
|
-
const
|
|
275
|
-
const softDelete = entity.softDelete ?? false;
|
|
276
|
-
|
|
277
|
-
// Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
|
|
278
|
-
// user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
|
|
279
|
-
// there. Everything else stays on the caller's tenant (byte-identical to the
|
|
280
|
-
// old hardcoded user.tenantId). Single source of truth for the stream key.
|
|
281
|
-
const streamTenantFor = (user: SessionUser): TenantId =>
|
|
282
|
-
entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
283
|
-
|
|
284
|
-
// idType default (undefined) is now "uuid" — the ES-pivot made UUID the
|
|
285
|
-
// only valid aggregate-id type. Explicit `idType: "serial"` is the only
|
|
286
|
-
// shape that's incompatible with the event-store and still rejected.
|
|
287
|
-
if (entity.idType !== undefined && entity.idType !== "uuid") {
|
|
288
|
-
throw new Error(
|
|
289
|
-
`event-store-executor requires entity "${entityName}" to declare idType: "uuid" — ` +
|
|
290
|
-
`got idType: "${entity.idType}". ` +
|
|
291
|
-
`The events-table keys aggregates by uuid(aggregate_id); non-UUID PKs would ` +
|
|
292
|
-
`require a schema split the framework does not currently support. ` +
|
|
293
|
-
`Fix: remove the \`idType\`-override from createEntity({...}) for "${entityName}" ` +
|
|
294
|
-
`(the default is "uuid"). The framework auto-assigns UUIDs on create — ` +
|
|
295
|
-
`you do not need to generate them yourself. ` +
|
|
296
|
-
`See docs/plans/architecture/event-sourcing-pivot.md (section "UUID-only aggregate IDs") for the full rationale.`,
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Pre-compute defaults once so create() doesn't loop the entity every call.
|
|
301
|
-
const fieldDefaults: Record<string, unknown> = {};
|
|
302
|
-
for (const [name, field] of Object.entries(entity.fields)) {
|
|
303
|
-
const def = scalarDefault(field);
|
|
304
|
-
if (def !== undefined) fieldDefaults[name] = def;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Pre-compute the set of sensitive field names once. The event log stores
|
|
308
|
-
// these fields as table ciphertext (boot validates sensitive ⇒ pii |
|
|
309
|
-
// encrypted, #967) — the set only strips the caller-facing event echo so
|
|
310
|
-
// responses never carry the value (#820).
|
|
311
|
-
const sensitiveFields = new Set<string>();
|
|
312
|
-
for (const [name, field] of Object.entries(entity.fields)) {
|
|
313
|
-
if ("sensitive" in field && field.sensitive === true) {
|
|
314
|
-
sensitiveFields.add(name);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
319
|
-
const hasEncryptedFields = encryptedFields.size > 0;
|
|
320
|
-
|
|
321
|
-
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
322
|
-
const hasPiiFields = piiSubjectFields.length > 0;
|
|
323
|
-
|
|
324
|
-
function fieldCipher(): EnvelopeCipher {
|
|
325
|
-
if (options.encryption) return options.encryption;
|
|
326
|
-
return resolveEntityFieldEncryption();
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// No adapter configured = crypto-shredding off; pii fields stay plaintext
|
|
330
|
-
// (pre-#724 behavior). The hard boot gate ships with the prod-grade
|
|
331
|
-
// PgKmsAdapter (phase E).
|
|
332
|
-
function piiKms(): LocalKeyKmsAdapter | undefined {
|
|
333
|
-
return options.kms ?? configuredPiiSubjectKms();
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function kmsContextFor(user?: SessionUser): KmsContext {
|
|
337
|
-
return {
|
|
338
|
-
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
339
|
-
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
344
|
-
// MasterKeyProvider. Callers MUST await — a missed await here writes
|
|
345
|
-
// "[object Promise]" into the projection, which the Promise return
|
|
346
|
-
// types turn into a compile error at every call site.
|
|
347
|
-
async function encryptForStorage(
|
|
348
|
-
row: Record<string, unknown>,
|
|
349
|
-
user: SessionUser,
|
|
350
|
-
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
351
|
-
): Promise<Record<string, unknown>> {
|
|
352
|
-
let out = row;
|
|
353
|
-
if (hasEncryptedFields) {
|
|
354
|
-
out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
|
|
355
|
-
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
const kms = piiKms();
|
|
359
|
-
if (hasPiiFields && kms) {
|
|
360
|
-
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
361
|
-
tenantId: user.tenantId,
|
|
362
|
-
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
363
|
-
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
return out;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
370
|
-
let out = row;
|
|
371
|
-
if (hasEncryptedFields) {
|
|
372
|
-
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
|
|
373
|
-
}
|
|
374
|
-
const kms = piiKms();
|
|
375
|
-
if (hasPiiFields && kms) {
|
|
376
|
-
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
377
|
-
}
|
|
378
|
-
return out;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
382
|
-
if (Object.keys(fieldDefaults).length === 0) return payload;
|
|
383
|
-
const result: Record<string, unknown> = { ...payload };
|
|
384
|
-
for (const [name, def] of Object.entries(fieldDefaults)) {
|
|
385
|
-
if (result[name] === undefined) result[name] = def;
|
|
386
|
-
}
|
|
387
|
-
return result;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function stripSensitive(payload: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
391
|
-
if (!payload) return {};
|
|
392
|
-
if (sensitiveFields.size === 0) return payload;
|
|
393
|
-
const result: Record<string, unknown> = {};
|
|
394
|
-
for (const [key, value] of Object.entries(payload)) {
|
|
395
|
-
if (sensitiveFields.has(key)) continue;
|
|
396
|
-
result[key] = value;
|
|
397
|
-
}
|
|
398
|
-
return result;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function idFilter(id: EntityId): WhereObject {
|
|
402
|
-
const filter: WhereObject = { id };
|
|
403
|
-
if (softDelete && table["isDeleted"]) filter["isDeleted"] = false;
|
|
404
|
-
return filter;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
async function loadById(id: EntityId, db: TenantDb): Promise<Record<string, unknown> | null> {
|
|
408
|
-
const row = await db.fetchOne(table, idFilter(id));
|
|
409
|
-
if (!row) return null;
|
|
410
|
-
return await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity));
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Archive guard for the CRUD write paths. Archived streams are read-only —
|
|
414
|
-
// ctx.appendEvent (append-event-core) already enforces this, but the
|
|
415
|
-
// executor appends directly via append() and getStreamVersion() ignores
|
|
416
|
-
// the archive flag, so without this check a PATCH/DELETE on an archived
|
|
417
|
-
// entity would silently land an event and break the read-only contract
|
|
418
|
-
// (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
|
|
419
|
-
// to mirror the appendEvent path exactly — same 500 + rolled-back tx.
|
|
420
|
-
// Creates skip this: a fresh UUID can't be archived, and a deterministic-id
|
|
421
|
-
// re-create onto an archived stream collides on the unique index →
|
|
422
|
-
// version_conflict, which already blocks the write.
|
|
423
|
-
async function assertStreamWritable(
|
|
424
|
-
db: TenantDb,
|
|
425
|
-
id: EntityId,
|
|
426
|
-
tenantId: TenantId,
|
|
427
|
-
): Promise<void> {
|
|
428
|
-
if (await isStreamArchived(db.raw, tenantId, String(id))) {
|
|
429
|
-
throw new ArchivedStreamError(tenantId, String(id));
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// SELECT a row by id with the ownership clause applied at the DB layer.
|
|
434
|
-
// Detail() uses this both on cold path and as a cache-revalidation probe.
|
|
435
|
-
async function loadWithOwnership(
|
|
436
|
-
db: TenantDb,
|
|
437
|
-
idWhere: WhereObject,
|
|
438
|
-
ownership:
|
|
439
|
-
| { kind: "pass" }
|
|
440
|
-
| { kind: "empty" }
|
|
441
|
-
| { kind: "sql"; sqlText: string; params: readonly unknown[] },
|
|
442
|
-
): Promise<Record<string, unknown>[]> {
|
|
443
|
-
if (ownership.kind === "empty") return [];
|
|
444
|
-
if (ownership.kind === "pass") {
|
|
445
|
-
const row = await db.fetchOne(table, idWhere);
|
|
446
|
-
return row ? [row as Record<string, unknown>] : [];
|
|
447
|
-
}
|
|
448
|
-
// ownership has raw SQL — splice it into a raw query alongside the
|
|
449
|
-
// idFilter + tenant-filter that TenantDb would have added.
|
|
450
|
-
const tableName = String(
|
|
451
|
-
(table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
|
|
452
|
-
);
|
|
453
|
-
const colSql = (field: string): string =>
|
|
454
|
-
`"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
|
|
455
|
-
const whereParts: string[] = [];
|
|
456
|
-
const params: unknown[] = [];
|
|
457
|
-
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
458
|
-
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
459
|
-
whereParts.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
460
|
-
}
|
|
461
|
-
for (const [field, value] of Object.entries(idWhere)) {
|
|
462
|
-
if (typeof value === "boolean") {
|
|
463
|
-
whereParts.push(`${colSql(field)} = ${value ? "TRUE" : "FALSE"}`);
|
|
464
|
-
} else {
|
|
465
|
-
params.push(value);
|
|
466
|
-
whereParts.push(`${colSql(field)} = $${params.length}`);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
const shifted = shiftParams(ownership, params.length);
|
|
470
|
-
whereParts.push(shifted.sqlText);
|
|
471
|
-
for (const p of shifted.params) params.push(p);
|
|
472
|
-
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
473
|
-
return [...(await executeRawQuery<Record<string, unknown>>(db.raw, sqlText, params))];
|
|
474
|
-
}
|
|
475
|
-
|
|
137
|
+
const ctx = buildExecutorContext(table, entity, options);
|
|
476
138
|
return {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
480
|
-
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
481
|
-
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
482
|
-
const aggregateId = explicitId ?? generateId();
|
|
483
|
-
const { id: _id, ...payloadWithoutId } = payload;
|
|
484
|
-
const data = applyDefaults(payloadWithoutId);
|
|
485
|
-
|
|
486
|
-
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
487
|
-
// only the new row is checked. No Straddle concern for creates.
|
|
488
|
-
if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
|
|
489
|
-
return writeFailure(
|
|
490
|
-
new UnprocessableError("ownership_denied", {
|
|
491
|
-
i18nKey: "errors.ownershipDenied",
|
|
492
|
-
details: { scope: "entity", entityName, action: "create", userId: user.id },
|
|
493
|
-
}),
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Field-level write-ownership on create — mirror of entity-level but
|
|
498
|
-
// per declared field. Role-level was already checked by the
|
|
499
|
-
// dispatcher; here we enforce ownership-rules against the new row.
|
|
500
|
-
const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
|
|
501
|
-
if (fieldDeniedCreate) {
|
|
502
|
-
return writeFailure(
|
|
503
|
-
new UnprocessableError("ownership_denied", {
|
|
504
|
-
i18nKey: "errors.ownershipDenied",
|
|
505
|
-
details: {
|
|
506
|
-
scope: "field",
|
|
507
|
-
entityName,
|
|
508
|
-
action: "create",
|
|
509
|
-
field: fieldDeniedCreate,
|
|
510
|
-
userId: user.id,
|
|
511
|
-
},
|
|
512
|
-
}),
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
517
|
-
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
518
|
-
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
519
|
-
// subjectSource carries the freshly minted aggregateId: the create
|
|
520
|
-
// payload has no id column, but a pii:true self-subject resolves from it.
|
|
521
|
-
const flatCreateData = flattenCompoundTypes(data, entity);
|
|
522
|
-
const flatData = await encryptForStorage(flatCreateData, user, {
|
|
523
|
-
subjectSource: { ...flatCreateData, id: aggregateId },
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
// 1. Append event (same TX as the projection write — both must succeed
|
|
527
|
-
// or both roll back; the dispatcher wraps both in one transaction).
|
|
528
|
-
// flatData is already table ciphertext for pii/encrypted fields, so
|
|
529
|
-
// the immutable log never sees plaintext and replay reproduces the
|
|
530
|
-
// row byte-identically (#967).
|
|
531
|
-
//
|
|
532
|
-
// `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
|
|
533
|
-
// deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
|
|
534
|
-
// ist es legitim dass create kollidiert — selbe id, schon vorhandener
|
|
535
|
-
// stream → version_conflict statt internal_error. Update hat den
|
|
536
|
-
// selben catch (siehe line 493+).
|
|
537
|
-
let event: Awaited<ReturnType<typeof append>>;
|
|
538
|
-
try {
|
|
539
|
-
event = await append(db.raw, {
|
|
540
|
-
aggregateId,
|
|
541
|
-
aggregateType: entityName,
|
|
542
|
-
tenantId: streamTenantFor(user),
|
|
543
|
-
expectedVersion: 0,
|
|
544
|
-
type: entityEventName(entityName, "created"),
|
|
545
|
-
payload: flatData,
|
|
546
|
-
metadata: buildEventMetadata(user),
|
|
547
|
-
});
|
|
548
|
-
} catch (e) {
|
|
549
|
-
if (e instanceof EventStoreVersionConflict) {
|
|
550
|
-
// Try to look up the real stream-version for the diagnostic — but
|
|
551
|
-
// wrap defensively: when `append` raised the unique-violation, the
|
|
552
|
-
// current TX is already aborted, and a second query on the same
|
|
553
|
-
// runner would re-throw "current transaction is aborted". Update-
|
|
554
|
-
// path doesn't have this problem (it queries getStreamVersion
|
|
555
|
-
// BEFORE the try-block). Falling back to a sentinel keeps the
|
|
556
|
-
// version_conflict mapping reliable; the actual current version
|
|
557
|
-
// is recoverable client-side via a fresh detail-query if needed.
|
|
558
|
-
let currentVersion = -1;
|
|
559
|
-
try {
|
|
560
|
-
currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
|
|
561
|
-
} catch {
|
|
562
|
-
// Aborted TX or any lookup failure — keep the sentinel.
|
|
563
|
-
}
|
|
564
|
-
return writeFailure(
|
|
565
|
-
new FrameworkVersionConflict({
|
|
566
|
-
entityId: aggregateId,
|
|
567
|
-
expectedVersion: 0,
|
|
568
|
-
currentVersion,
|
|
569
|
-
}),
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
throw e;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
|
|
576
|
-
// rebuildProjection für Replay nutzt, mit demselben StoredEvent →
|
|
577
|
-
// Live==Rebuild by-construction (#967).
|
|
578
|
-
//
|
|
579
|
-
// F8-Patch: app-level unique-violations (z.B. (tenantId, email)
|
|
580
|
-
// auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
|
|
581
|
-
// aus der projection-INSERT. Ohne den catch propagiert das als
|
|
582
|
-
// unhandled exception → 500 internal_error. Map auf
|
|
583
|
-
// UniqueViolationError 409 damit Designer/Frontend einen sauberen
|
|
584
|
-
// "duplicate" zeigen können statt cryptic "internal server error".
|
|
585
|
-
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
586
|
-
try {
|
|
587
|
-
result = await applyEntityEvent(event, table, entity, db.raw);
|
|
588
|
-
} catch (e) {
|
|
589
|
-
const mapped = tryMapUniqueViolation(e, entityName);
|
|
590
|
-
if (mapped) return mapped;
|
|
591
|
-
throw e;
|
|
592
|
-
}
|
|
593
|
-
if (result.kind !== "applied" || result.row === null) {
|
|
594
|
-
return writeFailure(new InternalError({ message: "projection insert returned no row" }));
|
|
595
|
-
}
|
|
596
|
-
const row = result.row;
|
|
597
|
-
// Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
|
|
598
|
-
// Compound-Types in einem Pass.
|
|
599
|
-
const projection = await decryptForRead(
|
|
600
|
-
rehydrateCompoundTypes(row as DbRow, entity) as DbRow,
|
|
601
|
-
);
|
|
602
|
-
|
|
603
|
-
if (entityCache && entityName) {
|
|
604
|
-
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return {
|
|
608
|
-
isSuccess: true,
|
|
609
|
-
data: {
|
|
610
|
-
kind: "save",
|
|
611
|
-
id: aggregateId,
|
|
612
|
-
data: projection,
|
|
613
|
-
changes: data,
|
|
614
|
-
previous: {},
|
|
615
|
-
isNew: true,
|
|
616
|
-
entityName,
|
|
617
|
-
// Persisted event carries ciphertext by design — the caller-facing
|
|
618
|
-
// echo must be plaintext like every other response field (#820).
|
|
619
|
-
event: { ...event, payload: stripSensitive(flatCreateData) },
|
|
620
|
-
},
|
|
621
|
-
};
|
|
622
|
-
},
|
|
623
|
-
|
|
624
|
-
async update(payload, user, db, updateOptions) {
|
|
625
|
-
const previous = await loadById(payload.id, db);
|
|
626
|
-
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
627
|
-
|
|
628
|
-
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
629
|
-
// done above), build post-change row via shallow merge. Straddle-safe
|
|
630
|
-
// multi-role check: at least one role must accept BOTH old and new —
|
|
631
|
-
// prevents the attack where role A passes old, role B passes new and
|
|
632
|
-
// aggregation would wrongly allow a row-grab.
|
|
633
|
-
const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
|
|
634
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
635
|
-
return writeFailure(
|
|
636
|
-
new UnprocessableError("ownership_denied", {
|
|
637
|
-
i18nKey: "errors.ownershipDenied",
|
|
638
|
-
details: {
|
|
639
|
-
scope: "entity",
|
|
640
|
-
entityName,
|
|
641
|
-
action: "update",
|
|
642
|
-
userId: user.id,
|
|
643
|
-
entityId: payload.id,
|
|
644
|
-
},
|
|
645
|
-
}),
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Field-level write-ownership on update — this is the path the
|
|
650
|
-
// dispatcher could not evaluate (no oldRow). Now that we have
|
|
651
|
-
// `previous`, we can run the ownership rules per field against both
|
|
652
|
-
// sides and reject individual fields the user isn't entitled to
|
|
653
|
-
// touch on this specific row.
|
|
654
|
-
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
|
|
655
|
-
if (fieldDeniedUpdate) {
|
|
656
|
-
return writeFailure(
|
|
657
|
-
new UnprocessableError("ownership_denied", {
|
|
658
|
-
i18nKey: "errors.ownershipDenied",
|
|
659
|
-
details: {
|
|
660
|
-
scope: "field",
|
|
661
|
-
entityName,
|
|
662
|
-
action: "update",
|
|
663
|
-
field: fieldDeniedUpdate,
|
|
664
|
-
userId: user.id,
|
|
665
|
-
entityId: payload.id,
|
|
666
|
-
},
|
|
667
|
-
}),
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
672
|
-
|
|
673
|
-
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
674
|
-
// can bump the stream between CRUD writes (domain event on the same
|
|
675
|
-
// aggregate); a stale row.version here would make the next CRUD write
|
|
676
|
-
// trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
|
|
677
|
-
// with version_conflict.
|
|
678
|
-
const currentVersion = await getStreamVersion(
|
|
679
|
-
db.raw,
|
|
680
|
-
String(payload.id),
|
|
681
|
-
streamTenantFor(user),
|
|
682
|
-
);
|
|
683
|
-
if (!updateOptions?.skipOptimisticLock) {
|
|
684
|
-
if (payload.version === undefined) {
|
|
685
|
-
return writeFailure(
|
|
686
|
-
new FrameworkVersionConflict({
|
|
687
|
-
entityId: payload.id,
|
|
688
|
-
expectedVersion: 0,
|
|
689
|
-
currentVersion,
|
|
690
|
-
}),
|
|
691
|
-
);
|
|
692
|
-
}
|
|
693
|
-
if (currentVersion !== payload.version) {
|
|
694
|
-
return writeFailure(
|
|
695
|
-
new FrameworkVersionConflict({
|
|
696
|
-
entityId: payload.id,
|
|
697
|
-
expectedVersion: payload.version,
|
|
698
|
-
currentVersion,
|
|
699
|
-
}),
|
|
700
|
-
);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
try {
|
|
705
|
-
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
706
|
-
// subjectSource: partial changes may carry a pii field without its
|
|
707
|
-
// ownerField — the merged row still names the subject.
|
|
708
|
-
const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
|
|
709
|
-
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
710
|
-
onlyKeys: Object.keys(payload.changes),
|
|
711
|
-
subjectSource: mergedNew,
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
715
|
-
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
716
|
-
// previous value to decrement/undo when a parent-FK moves — without it
|
|
717
|
-
// you'd have to snapshot-and-diff on every apply, and replays would
|
|
718
|
-
// break. Storage cost is acceptable (rows are bounded), correctness is
|
|
719
|
-
// not negotiable. `previous` came from loadById(), which decrypts —
|
|
720
|
-
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
721
|
-
// fields doesn't land in the immutable log (flatChanges is already
|
|
722
|
-
// ciphertext from encryptForStorage above).
|
|
723
|
-
const event = await append(db.raw, {
|
|
724
|
-
aggregateId: String(payload.id),
|
|
725
|
-
aggregateType: entityName,
|
|
726
|
-
tenantId: streamTenantFor(user),
|
|
727
|
-
expectedVersion: currentVersion,
|
|
728
|
-
type: entityEventName(entityName, "updated"),
|
|
729
|
-
payload: {
|
|
730
|
-
changes: flatChanges,
|
|
731
|
-
previous: await encryptForStorage(previous, user),
|
|
732
|
-
},
|
|
733
|
-
metadata: buildEventMetadata(user),
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
// Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
|
|
737
|
-
// apply liest nur `changes`, und die sind live wie im Replay
|
|
738
|
-
// identischer Ciphertext (#967).
|
|
739
|
-
//
|
|
740
|
-
// F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
|
|
741
|
-
// — ein update das einen unique-Index verletzt (z.B. email-update
|
|
742
|
-
// auf einen schon-existierenden Wert) wird mit 409 unique_violation
|
|
743
|
-
// statt 500 internal_error rückgemeldet.
|
|
744
|
-
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
745
|
-
try {
|
|
746
|
-
result = await applyEntityEvent(event, table, entity, db.raw);
|
|
747
|
-
} catch (e) {
|
|
748
|
-
const mapped = tryMapUniqueViolation(e, entityName);
|
|
749
|
-
if (mapped) return mapped;
|
|
750
|
-
throw e;
|
|
751
|
-
}
|
|
752
|
-
if (result.kind !== "applied" || result.row === null) {
|
|
753
|
-
return writeFailure(new InternalError({ message: "projection update returned no row" }));
|
|
754
|
-
}
|
|
755
|
-
const row = result.row;
|
|
756
|
-
const data = await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
|
|
757
|
-
|
|
758
|
-
if (entityCache && entityName) {
|
|
759
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
return {
|
|
763
|
-
isSuccess: true,
|
|
764
|
-
data: {
|
|
765
|
-
kind: "save",
|
|
766
|
-
id: data["id"] as EntityId, // @cast-boundary engine-payload
|
|
767
|
-
data,
|
|
768
|
-
changes: payload.changes,
|
|
769
|
-
previous,
|
|
770
|
-
isNew: false,
|
|
771
|
-
entityName,
|
|
772
|
-
event: {
|
|
773
|
-
...event,
|
|
774
|
-
payload: {
|
|
775
|
-
changes: stripSensitive(flatChangesPlain),
|
|
776
|
-
previous: stripSensitive(previous),
|
|
777
|
-
},
|
|
778
|
-
},
|
|
779
|
-
},
|
|
780
|
-
};
|
|
781
|
-
} catch (e) {
|
|
782
|
-
// The pre-check above eliminates the common stale-version case; this
|
|
783
|
-
// branch catches the narrow race where two writers both read version=N
|
|
784
|
-
// and both pass the local check — the unique index on (aggregate_id,
|
|
785
|
-
// version) serializes them, one wins, the other lands here.
|
|
786
|
-
if (e instanceof EventStoreVersionConflict) {
|
|
787
|
-
return writeFailure(
|
|
788
|
-
new FrameworkVersionConflict({
|
|
789
|
-
entityId: payload.id,
|
|
790
|
-
expectedVersion: payload.version ?? 0,
|
|
791
|
-
currentVersion,
|
|
792
|
-
}),
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
throw e;
|
|
796
|
-
}
|
|
797
|
-
},
|
|
798
|
-
|
|
799
|
-
async delete(payload, user, db) {
|
|
800
|
-
const existing = await loadById(payload.id, db);
|
|
801
|
-
if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
802
|
-
|
|
803
|
-
// H.2 — entity-level write-ownership on delete. Only the pre-delete
|
|
804
|
-
// row matters (there's no "new" row for a delete); passing existing
|
|
805
|
-
// twice to userCanWriteFieldRow makes the Straddle check trivial
|
|
806
|
-
// (same row on both sides) while keeping the multi-role-atomic shape.
|
|
807
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
808
|
-
return writeFailure(
|
|
809
|
-
new UnprocessableError("ownership_denied", {
|
|
810
|
-
i18nKey: "errors.ownershipDenied",
|
|
811
|
-
details: {
|
|
812
|
-
scope: "entity",
|
|
813
|
-
entityName,
|
|
814
|
-
action: "delete",
|
|
815
|
-
userId: user.id,
|
|
816
|
-
entityId: payload.id,
|
|
817
|
-
},
|
|
818
|
-
}),
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
823
|
-
|
|
824
|
-
// Stream-version authoritative (see update() for rationale).
|
|
825
|
-
const currentVersion = await getStreamVersion(
|
|
826
|
-
db.raw,
|
|
827
|
-
String(payload.id),
|
|
828
|
-
streamTenantFor(user),
|
|
829
|
-
);
|
|
830
|
-
|
|
831
|
-
// Deletes carry the full pre-delete row as `previous`. That's what
|
|
832
|
-
// projections and downstream consumers need to reverse any aggregates —
|
|
833
|
-
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
834
|
-
// to rebuild from the event log alone. `existing` came from loadById(),
|
|
835
|
-
// which decrypts — re-encrypt before persisting so plaintext doesn't
|
|
836
|
-
// land in the immutable log.
|
|
837
|
-
const event = await append(db.raw, {
|
|
838
|
-
aggregateId: String(payload.id),
|
|
839
|
-
aggregateType: entityName,
|
|
840
|
-
tenantId: streamTenantFor(user),
|
|
841
|
-
expectedVersion: currentVersion,
|
|
842
|
-
type: entityEventName(entityName, "deleted"),
|
|
843
|
-
payload: { previous: await encryptForStorage(existing, user) },
|
|
844
|
-
metadata: buildEventMetadata(user),
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
// Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
|
|
848
|
-
// sensitive-Drift weil das Event-Payload nur `previous` ist und das
|
|
849
|
-
// wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
|
|
850
|
-
// (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
|
|
851
|
-
// dasselbe — kein payload-override nötig.
|
|
852
|
-
const deleteResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
853
|
-
if (deleteResult.kind !== "applied") {
|
|
854
|
-
return writeFailure(
|
|
855
|
-
new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
|
|
856
|
-
);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
if (entityCache && entityName) {
|
|
860
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
return {
|
|
864
|
-
isSuccess: true,
|
|
865
|
-
data: {
|
|
866
|
-
kind: "delete",
|
|
867
|
-
id: payload.id,
|
|
868
|
-
data: existing,
|
|
869
|
-
entityName,
|
|
870
|
-
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
871
|
-
},
|
|
872
|
-
};
|
|
873
|
-
},
|
|
874
|
-
|
|
875
|
-
// Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
|
|
876
|
-
// hard-deletes the row regardless of softDelete — and, being an auto-verb,
|
|
877
|
-
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
878
|
-
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
879
|
-
async forget(payload, user, db) {
|
|
880
|
-
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
881
|
-
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
882
|
-
const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
|
|
883
|
-
|
|
884
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
885
|
-
return writeFailure(
|
|
886
|
-
new UnprocessableError("ownership_denied", {
|
|
887
|
-
i18nKey: "errors.ownershipDenied",
|
|
888
|
-
details: {
|
|
889
|
-
scope: "entity",
|
|
890
|
-
entityName,
|
|
891
|
-
action: "delete",
|
|
892
|
-
userId: user.id,
|
|
893
|
-
entityId: payload.id,
|
|
894
|
-
},
|
|
895
|
-
}),
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
900
|
-
const currentVersion = await getStreamVersion(
|
|
901
|
-
db.raw,
|
|
902
|
-
String(payload.id),
|
|
903
|
-
streamTenantFor(user),
|
|
904
|
-
);
|
|
905
|
-
|
|
906
|
-
const event = await append(db.raw, {
|
|
907
|
-
aggregateId: String(payload.id),
|
|
908
|
-
aggregateType: entityName,
|
|
909
|
-
tenantId: streamTenantFor(user),
|
|
910
|
-
expectedVersion: currentVersion,
|
|
911
|
-
type: entityEventName(entityName, "forgotten"),
|
|
912
|
-
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
913
|
-
// plaintext must not land in the immutable log, least of all on forget.
|
|
914
|
-
payload: { previous: await encryptForStorage(existing, user) },
|
|
915
|
-
metadata: buildEventMetadata(user),
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
919
|
-
if (forgetResult.kind !== "applied") {
|
|
920
|
-
return writeFailure(
|
|
921
|
-
new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
if (entityCache && entityName) {
|
|
926
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
return {
|
|
930
|
-
isSuccess: true,
|
|
931
|
-
data: {
|
|
932
|
-
kind: "delete",
|
|
933
|
-
id: payload.id,
|
|
934
|
-
data: existing,
|
|
935
|
-
entityName,
|
|
936
|
-
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
937
|
-
},
|
|
938
|
-
};
|
|
939
|
-
},
|
|
940
|
-
|
|
941
|
-
async restore(payload, user, db) {
|
|
942
|
-
if (!softDelete) {
|
|
943
|
-
return writeFailure(
|
|
944
|
-
new UnprocessableError("soft_delete_not_enabled", {
|
|
945
|
-
i18nKey: "errors.softDeleteNotEnabled",
|
|
946
|
-
}),
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
const [row] = await selectMany(db.raw, table, { id: payload.id });
|
|
951
|
-
if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
952
|
-
const data = row as DbRow;
|
|
953
|
-
if (!data["isDeleted"]) {
|
|
954
|
-
return writeFailure(
|
|
955
|
-
new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }),
|
|
956
|
-
);
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// H.2 — entity-level write-ownership on restore. Same shape as delete:
|
|
960
|
-
// only the stored row matters. Stored row carries pre-soft-delete
|
|
961
|
-
// teamId/... fields, so the ownership predicate still applies cleanly.
|
|
962
|
-
if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
|
|
963
|
-
return writeFailure(
|
|
964
|
-
new UnprocessableError("ownership_denied", {
|
|
965
|
-
i18nKey: "errors.ownershipDenied",
|
|
966
|
-
details: {
|
|
967
|
-
scope: "entity",
|
|
968
|
-
entityName,
|
|
969
|
-
action: "restore",
|
|
970
|
-
userId: user.id,
|
|
971
|
-
entityId: payload.id,
|
|
972
|
-
},
|
|
973
|
-
}),
|
|
974
|
-
);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
978
|
-
|
|
979
|
-
// Stream-version authoritative (see update() for rationale).
|
|
980
|
-
const currentVersion = await getStreamVersion(
|
|
981
|
-
db.raw,
|
|
982
|
-
String(payload.id),
|
|
983
|
-
streamTenantFor(user),
|
|
984
|
-
);
|
|
985
|
-
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
986
|
-
// delete for symmetry. Projections that decremented on delete use
|
|
987
|
-
// `previous` to re-increment on restore without re-querying the entity
|
|
988
|
-
// table. `data` is the raw stored row — pii/encrypted fields are
|
|
989
|
-
// already ciphertext, no re-encrypt needed.
|
|
990
|
-
const event = await append(db.raw, {
|
|
991
|
-
aggregateId: String(payload.id),
|
|
992
|
-
aggregateType: entityName,
|
|
993
|
-
tenantId: streamTenantFor(user),
|
|
994
|
-
expectedVersion: currentVersion,
|
|
995
|
-
type: entityEventName(entityName, "restored"),
|
|
996
|
-
payload: { previous: data },
|
|
997
|
-
metadata: buildEventMetadata(user),
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
// Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
|
|
1001
|
-
// false + version-Bump in die Tabelle — keine sensitive-Drift, daher
|
|
1002
|
-
// kein payload-override nötig.
|
|
1003
|
-
const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
1004
|
-
if (restoreResult.kind !== "applied" || restoreResult.row === null) {
|
|
1005
|
-
return writeFailure(new InternalError({ message: "projection restore returned no row" }));
|
|
1006
|
-
}
|
|
1007
|
-
const restored = restoreResult.row;
|
|
1008
|
-
|
|
1009
|
-
if (entityCache && entityName) {
|
|
1010
|
-
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
|
|
1014
|
-
// decryptForRead matches create/update/list/detail: the caller-facing
|
|
1015
|
-
// row and `previous` snapshot must be plaintext for `encrypted` fields,
|
|
1016
|
-
// same as every other executor method — `data`/`restored` are raw rows
|
|
1017
|
-
// (selectMany / applyEntityEvent), never decrypted before this point.
|
|
1018
|
-
const restoredHydrated = await decryptForRead(
|
|
1019
|
-
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
1020
|
-
);
|
|
1021
|
-
|
|
1022
|
-
const previousPlain = await decryptForRead(data);
|
|
1023
|
-
return {
|
|
1024
|
-
isSuccess: true,
|
|
1025
|
-
data: {
|
|
1026
|
-
kind: "save",
|
|
1027
|
-
id: payload.id,
|
|
1028
|
-
data: restoredHydrated,
|
|
1029
|
-
changes: { isDeleted: false },
|
|
1030
|
-
previous: previousPlain,
|
|
1031
|
-
isNew: false,
|
|
1032
|
-
entityName,
|
|
1033
|
-
event: { ...event, payload: { previous: stripSensitive(previousPlain) } },
|
|
1034
|
-
},
|
|
1035
|
-
};
|
|
1036
|
-
},
|
|
1037
|
-
|
|
1038
|
-
// list + detail are unchanged from crud-executor — projections are the
|
|
1039
|
-
// read-model and serve these queries directly.
|
|
1040
|
-
async list(payload, user, db, runtimeOptions) {
|
|
1041
|
-
const limit = payload.limit ?? 50;
|
|
1042
|
-
const offset = payload.offset ?? 0;
|
|
1043
|
-
const totalCount = payload.totalCount === true;
|
|
1044
|
-
|
|
1045
|
-
// H.2 — entity-level read ownership. Decide before touching search or
|
|
1046
|
-
// the DB: `empty` means there's no row the user could ever see, so
|
|
1047
|
-
// skip both paths and return an empty page.
|
|
1048
|
-
const ownership = buildOwnershipClause(user, entity.access?.read, table);
|
|
1049
|
-
if (ownership.kind === "empty") {
|
|
1050
|
-
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
let filterIds: EntityId[] | undefined;
|
|
1054
|
-
// Build-Time options.searchAdapter gewinnt; runtime-Override ist
|
|
1055
|
-
// Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
|
|
1056
|
-
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
1057
|
-
// beim Definition-Time noch keinen Server-Context hat).
|
|
1058
|
-
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
1059
|
-
if (payload.search && effectiveSearchAdapter && entityName) {
|
|
1060
|
-
const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
|
|
1061
|
-
filterType: entityName,
|
|
1062
|
-
});
|
|
1063
|
-
filterIds = results.map((r) => r.entityId);
|
|
1064
|
-
if (filterIds.length === 0) {
|
|
1065
|
-
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// Build the WHERE clause as raw SQL — ownership produces a
|
|
1070
|
-
// parameterised fragment that we splice in alongside simple WhereObject
|
|
1071
|
-
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
1072
|
-
const tableName = String(
|
|
1073
|
-
(table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
|
|
1074
|
-
);
|
|
1075
|
-
const whereSql: string[] = [];
|
|
1076
|
-
const params: unknown[] = [];
|
|
1077
|
-
const colSql = (field: string): string =>
|
|
1078
|
-
`"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
|
|
1079
|
-
|
|
1080
|
-
// Tenant-Filter (replicates TenantDb's readWhere semantics).
|
|
1081
|
-
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
1082
|
-
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
1083
|
-
whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
1084
|
-
}
|
|
1085
|
-
if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
|
|
1086
|
-
whereSql.push(`${colSql("isDeleted")} = FALSE`);
|
|
1087
|
-
}
|
|
1088
|
-
if (payload.cursor) {
|
|
1089
|
-
params.push(decodeCursor(payload.cursor));
|
|
1090
|
-
whereSql.push(`${colSql("id")} > $${params.length}`);
|
|
1091
|
-
}
|
|
1092
|
-
if (filterIds) {
|
|
1093
|
-
const placeholders = filterIds.map((id) => {
|
|
1094
|
-
params.push(id);
|
|
1095
|
-
return `$${params.length}`;
|
|
1096
|
-
});
|
|
1097
|
-
whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
1098
|
-
}
|
|
1099
|
-
if (ownership.kind === "sql") {
|
|
1100
|
-
const shifted = shiftParams(
|
|
1101
|
-
{ sqlText: ownership.sqlText, params: ownership.params },
|
|
1102
|
-
params.length,
|
|
1103
|
-
);
|
|
1104
|
-
whereSql.push(shifted.sqlText);
|
|
1105
|
-
for (const p of shifted.params) params.push(p);
|
|
1106
|
-
}
|
|
1107
|
-
const applyFilter = (f: {
|
|
1108
|
-
readonly field: string;
|
|
1109
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
1110
|
-
readonly value: unknown;
|
|
1111
|
-
}): void => {
|
|
1112
|
-
if (table[f.field] === undefined) {
|
|
1113
|
-
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
1114
|
-
return;
|
|
1115
|
-
}
|
|
1116
|
-
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
1117
|
-
if (screen === null) {
|
|
1118
|
-
whereSql.push("FALSE");
|
|
1119
|
-
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
for (const [field, value] of Object.entries(screen)) {
|
|
1123
|
-
if (Array.isArray(value)) {
|
|
1124
|
-
const placeholders = value.map((v) => {
|
|
1125
|
-
params.push(v);
|
|
1126
|
-
return `$${params.length}`;
|
|
1127
|
-
});
|
|
1128
|
-
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
1129
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1130
|
-
const opMap: Record<string, string> = {
|
|
1131
|
-
gt: ">",
|
|
1132
|
-
gte: ">=",
|
|
1133
|
-
lt: "<",
|
|
1134
|
-
lte: "<=",
|
|
1135
|
-
ne: "<>",
|
|
1136
|
-
};
|
|
1137
|
-
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
1138
|
-
if (!(opKey in value)) continue;
|
|
1139
|
-
params.push((value as Record<string, unknown>)[opKey]);
|
|
1140
|
-
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
1141
|
-
}
|
|
1142
|
-
} else {
|
|
1143
|
-
// Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
|
|
1144
|
-
// in bun-db/query.ts — Equality auf lookupable-Feldern matcht
|
|
1145
|
-
// Klartext-Arm ODER HMAC-Arm.
|
|
1146
|
-
const bidxKey = configuredBlindIndexKey();
|
|
1147
|
-
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
1148
|
-
params.push(value, computeBlindIndex(bidxKey, value));
|
|
1149
|
-
whereSql.push(
|
|
1150
|
-
`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
|
|
1151
|
-
);
|
|
1152
|
-
} else {
|
|
1153
|
-
params.push(value);
|
|
1154
|
-
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
};
|
|
1159
|
-
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
1160
|
-
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
1161
|
-
|
|
1162
|
-
const orderByClause =
|
|
1163
|
-
payload.sort && table[payload.sort]
|
|
1164
|
-
? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}`
|
|
1165
|
-
: "";
|
|
1166
|
-
const useOffset = !payload.cursor && offset > 0;
|
|
1167
|
-
const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
|
|
1168
|
-
|
|
1169
|
-
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
1170
|
-
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
1171
|
-
|
|
1172
|
-
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
1173
|
-
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
|
|
1174
|
-
// Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
|
|
1175
|
-
// names, while the encrypted/pii field lists are camelCase — decrypting
|
|
1176
|
-
// first silently skipped every multi-word field (ciphertext leaked to
|
|
1177
|
-
// the caller).
|
|
1178
|
-
const tableInfo = extractTableInfo(table);
|
|
1179
|
-
const encryptedRows = rawRows.map((r) =>
|
|
1180
|
-
coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
|
|
1181
|
-
);
|
|
1182
|
-
const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
1183
|
-
|
|
1184
|
-
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
1185
|
-
// base — edit flows reload via detail(), which reconciles the stream version.
|
|
1186
|
-
// Cache the still-encrypted form: same at-rest guarantee as detail()'s
|
|
1187
|
-
// encryptForStorage round-trip, without paying a re-encrypt.
|
|
1188
|
-
if (entityCache && entityName && rows.length > 0) {
|
|
1189
|
-
await entityCache.mset(
|
|
1190
|
-
user.tenantId,
|
|
1191
|
-
entityName,
|
|
1192
|
-
encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
const lastRow = rows[rows.length - 1];
|
|
1197
|
-
const nextCursor =
|
|
1198
|
-
rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
|
|
1199
|
-
|
|
1200
|
-
// total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
|
|
1201
|
-
// Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
|
|
1202
|
-
// wie der entsprechende WHERE — bei indexed columns billig genug.
|
|
1203
|
-
// Bei Search-Path ist `total = filterIds.length` ohne extra Query.
|
|
1204
|
-
let total: number | undefined;
|
|
1205
|
-
if (totalCount) {
|
|
1206
|
-
if (filterIds) {
|
|
1207
|
-
total = filterIds.length;
|
|
1208
|
-
} else {
|
|
1209
|
-
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
1210
|
-
const countRows = await executeRawQuery<{ count: number }>(db.raw, countSql, params);
|
|
1211
|
-
total = countRows[0]?.count ?? 0;
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
return { rows, nextCursor, ...(total !== undefined && { total }) };
|
|
1216
|
-
},
|
|
1217
|
-
|
|
1218
|
-
async detail(payload, user, db) {
|
|
1219
|
-
// H.2 — ownership check. `empty` → the user can never see this row
|
|
1220
|
-
// regardless of its id. Return null (same shape as "not found", so a
|
|
1221
|
-
// probing attacker can't distinguish "no access" from "doesn't exist").
|
|
1222
|
-
const ownership = buildOwnershipClause(user, entity.access?.read, table);
|
|
1223
|
-
if (ownership.kind === "empty") return null;
|
|
1224
|
-
|
|
1225
|
-
const idWhere = idFilter(payload.id);
|
|
1226
|
-
|
|
1227
|
-
// Stream-version authoritative (same policy as update/Block 0):
|
|
1228
|
-
// ctx.appendEvent (lifecycle-writes like incident:post-update) bumps
|
|
1229
|
-
// the stream WITHOUT touching row.version — a detail-read that hands
|
|
1230
|
-
// out the stale row.version dooms the next CRUD update built on it
|
|
1231
|
-
// (entityEdit loads detail.version as its optimistic-lock base) to a
|
|
1232
|
-
// guaranteed version_conflict.
|
|
1233
|
-
const withStreamVersion = async (
|
|
1234
|
-
row: Record<string, unknown>,
|
|
1235
|
-
): Promise<Record<string, unknown>> => {
|
|
1236
|
-
const streamVersion = await getStreamVersion(
|
|
1237
|
-
db.raw,
|
|
1238
|
-
String(payload.id),
|
|
1239
|
-
streamTenantFor(user),
|
|
1240
|
-
);
|
|
1241
|
-
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
1242
|
-
};
|
|
1243
|
-
|
|
1244
|
-
if (entityCache && entityName) {
|
|
1245
|
-
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
1246
|
-
if (cached) {
|
|
1247
|
-
if (ownership.kind === "sql") {
|
|
1248
|
-
// Re-check ownership predicate against the live row — the cache
|
|
1249
|
-
// is keyed only by tenant + id, not by role.
|
|
1250
|
-
const checkRows = await loadWithOwnership(db, idWhere, ownership);
|
|
1251
|
-
if (checkRows.length === 0) return null;
|
|
1252
|
-
}
|
|
1253
|
-
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
1254
|
-
// `encrypted` field's plaintext never sits in a second at-rest
|
|
1255
|
-
// store (Redis) the field-encryption feature doesn't cover.
|
|
1256
|
-
return withStreamVersion(await decryptForRead(cached));
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
const rows = await loadWithOwnership(db, idWhere, ownership);
|
|
1261
|
-
const raw = rows[0];
|
|
1262
|
-
if (!raw) return null;
|
|
1263
|
-
const row = await decryptForRead(rehydrateCompoundTypes(raw, entity));
|
|
1264
|
-
const rowInfo = extractTableInfo(table);
|
|
1265
|
-
const coerced = coerceRow(row, rowInfo);
|
|
1266
|
-
|
|
1267
|
-
if (entityCache && entityName) {
|
|
1268
|
-
await entityCache.set(
|
|
1269
|
-
user.tenantId,
|
|
1270
|
-
entityName,
|
|
1271
|
-
payload.id,
|
|
1272
|
-
await encryptForStorage(coerced, user),
|
|
1273
|
-
);
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
return withStreamVersion(coerced);
|
|
1277
|
-
},
|
|
139
|
+
...createWriteVerbs(ctx),
|
|
140
|
+
...createReadVerbs(ctx),
|
|
1278
141
|
};
|
|
1279
142
|
}
|