@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
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { checkWriteFieldOwnership } from "../engine/field-access";
|
|
2
|
+
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership";
|
|
3
|
+
import type { EntityId } from "../engine/types";
|
|
4
|
+
import {
|
|
5
|
+
VersionConflictError as FrameworkVersionConflict,
|
|
6
|
+
InternalError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
UnprocessableError,
|
|
9
|
+
writeFailure,
|
|
10
|
+
} from "../errors";
|
|
11
|
+
import {
|
|
12
|
+
append,
|
|
13
|
+
VersionConflictError as EventStoreVersionConflict,
|
|
14
|
+
getStreamVersion,
|
|
15
|
+
} from "../event-store";
|
|
16
|
+
import { generateId } from "../utils";
|
|
17
|
+
import { applyEntityEvent } from "./apply-entity-event";
|
|
18
|
+
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
19
|
+
import type { DbRow } from "./connection";
|
|
20
|
+
import type { EventStoreExecutor } from "./event-store-executor";
|
|
21
|
+
import {
|
|
22
|
+
buildEventMetadata,
|
|
23
|
+
type ExecutorContext,
|
|
24
|
+
entityEventName,
|
|
25
|
+
tryMapUniqueViolation,
|
|
26
|
+
} from "./event-store-executor-context";
|
|
27
|
+
import { selectMany } from "./query";
|
|
28
|
+
|
|
29
|
+
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
30
|
+
// store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
|
|
31
|
+
// behavior-preserving relocation, not a redesign: every closure below is
|
|
32
|
+
// unchanged from the original, just relocated behind an explicit
|
|
33
|
+
// ExecutorContext instead of capturing the factory's local scope directly.
|
|
34
|
+
|
|
35
|
+
export function createWriteVerbs(
|
|
36
|
+
ctx: ExecutorContext,
|
|
37
|
+
): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
|
|
38
|
+
const {
|
|
39
|
+
table,
|
|
40
|
+
entity,
|
|
41
|
+
entityName,
|
|
42
|
+
entityCache,
|
|
43
|
+
softDelete,
|
|
44
|
+
streamTenantFor,
|
|
45
|
+
encryptForStorage,
|
|
46
|
+
decryptForRead,
|
|
47
|
+
applyDefaults,
|
|
48
|
+
stripSensitive,
|
|
49
|
+
loadById,
|
|
50
|
+
assertStreamWritable,
|
|
51
|
+
} = ctx;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async create(payload, user, db) {
|
|
55
|
+
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
56
|
+
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
57
|
+
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
58
|
+
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
59
|
+
const aggregateId = explicitId ?? generateId();
|
|
60
|
+
const { id: _id, ...payloadWithoutId } = payload;
|
|
61
|
+
const data = applyDefaults(payloadWithoutId);
|
|
62
|
+
|
|
63
|
+
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
64
|
+
// only the new row is checked. No Straddle concern for creates.
|
|
65
|
+
if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
|
|
66
|
+
return writeFailure(
|
|
67
|
+
new UnprocessableError("ownership_denied", {
|
|
68
|
+
i18nKey: "errors.ownershipDenied",
|
|
69
|
+
details: { scope: "entity", entityName, action: "create", userId: user.id },
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Field-level write-ownership on create — mirror of entity-level but
|
|
75
|
+
// per declared field. Role-level was already checked by the
|
|
76
|
+
// dispatcher; here we enforce ownership-rules against the new row.
|
|
77
|
+
const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
|
|
78
|
+
if (fieldDeniedCreate) {
|
|
79
|
+
return writeFailure(
|
|
80
|
+
new UnprocessableError("ownership_denied", {
|
|
81
|
+
i18nKey: "errors.ownershipDenied",
|
|
82
|
+
details: {
|
|
83
|
+
scope: "field",
|
|
84
|
+
entityName,
|
|
85
|
+
action: "create",
|
|
86
|
+
field: fieldDeniedCreate,
|
|
87
|
+
userId: user.id,
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
94
|
+
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
95
|
+
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
96
|
+
// subjectSource carries the freshly minted aggregateId: the create
|
|
97
|
+
// payload has no id column, but a pii:true self-subject resolves from it.
|
|
98
|
+
const flatCreateData = flattenCompoundTypes(data, entity);
|
|
99
|
+
const flatData = await encryptForStorage(flatCreateData, user, {
|
|
100
|
+
subjectSource: { ...flatCreateData, id: aggregateId },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// 1. Append event (same TX as the projection write — both must succeed
|
|
104
|
+
// or both roll back; the dispatcher wraps both in one transaction).
|
|
105
|
+
// flatData is already table ciphertext for pii/encrypted fields, so
|
|
106
|
+
// the immutable log never sees plaintext and replay reproduces the
|
|
107
|
+
// row byte-identically (#967).
|
|
108
|
+
//
|
|
109
|
+
// `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
|
|
110
|
+
// deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
|
|
111
|
+
// ist es legitim dass create kollidiert — selbe id, schon vorhandener
|
|
112
|
+
// stream → version_conflict statt internal_error. Update hat den
|
|
113
|
+
// selben catch (siehe line 493+).
|
|
114
|
+
let event: Awaited<ReturnType<typeof append>>;
|
|
115
|
+
try {
|
|
116
|
+
event = await append(db.raw, {
|
|
117
|
+
aggregateId,
|
|
118
|
+
aggregateType: entityName,
|
|
119
|
+
tenantId: streamTenantFor(user),
|
|
120
|
+
expectedVersion: 0,
|
|
121
|
+
type: entityEventName(entityName, "created"),
|
|
122
|
+
payload: flatData,
|
|
123
|
+
metadata: buildEventMetadata(user),
|
|
124
|
+
});
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
127
|
+
// Try to look up the real stream-version for the diagnostic — but
|
|
128
|
+
// wrap defensively: when `append` raised the unique-violation, the
|
|
129
|
+
// current TX is already aborted, and a second query on the same
|
|
130
|
+
// runner would re-throw "current transaction is aborted". Update-
|
|
131
|
+
// path doesn't have this problem (it queries getStreamVersion
|
|
132
|
+
// BEFORE the try-block). Falling back to a sentinel keeps the
|
|
133
|
+
// version_conflict mapping reliable; the actual current version
|
|
134
|
+
// is recoverable client-side via a fresh detail-query if needed.
|
|
135
|
+
let currentVersion = -1;
|
|
136
|
+
try {
|
|
137
|
+
currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
|
|
138
|
+
} catch {
|
|
139
|
+
// Aborted TX or any lookup failure — keep the sentinel.
|
|
140
|
+
}
|
|
141
|
+
return writeFailure(
|
|
142
|
+
new FrameworkVersionConflict({
|
|
143
|
+
entityId: aggregateId,
|
|
144
|
+
expectedVersion: 0,
|
|
145
|
+
currentVersion,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
|
|
153
|
+
// rebuildProjection für Replay nutzt, mit demselben StoredEvent →
|
|
154
|
+
// Live==Rebuild by-construction (#967).
|
|
155
|
+
//
|
|
156
|
+
// F8-Patch: app-level unique-violations (z.B. (tenantId, email)
|
|
157
|
+
// auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
|
|
158
|
+
// aus der projection-INSERT. Ohne den catch propagiert das als
|
|
159
|
+
// unhandled exception → 500 internal_error. Map auf
|
|
160
|
+
// UniqueViolationError 409 damit Designer/Frontend einen sauberen
|
|
161
|
+
// "duplicate" zeigen können statt cryptic "internal server error".
|
|
162
|
+
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
163
|
+
try {
|
|
164
|
+
result = await applyEntityEvent(event, table, entity, db.raw);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
const mapped = tryMapUniqueViolation(e, entityName);
|
|
167
|
+
if (mapped) return mapped;
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
if (result.kind !== "applied" || result.row === null) {
|
|
171
|
+
return writeFailure(new InternalError({ message: "projection insert returned no row" }));
|
|
172
|
+
}
|
|
173
|
+
const row = result.row;
|
|
174
|
+
// Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
|
|
175
|
+
// Compound-Types in einem Pass.
|
|
176
|
+
const projection = await decryptForRead(
|
|
177
|
+
rehydrateCompoundTypes(row as DbRow, entity) as DbRow,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (entityCache && entityName) {
|
|
181
|
+
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
isSuccess: true,
|
|
186
|
+
data: {
|
|
187
|
+
kind: "save",
|
|
188
|
+
id: aggregateId,
|
|
189
|
+
data: projection,
|
|
190
|
+
changes: data,
|
|
191
|
+
previous: {},
|
|
192
|
+
isNew: true,
|
|
193
|
+
entityName,
|
|
194
|
+
// Persisted event carries ciphertext by design — the caller-facing
|
|
195
|
+
// echo must be plaintext like every other response field (#820).
|
|
196
|
+
event: { ...event, payload: stripSensitive(flatCreateData) },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async update(payload, user, db, updateOptions) {
|
|
202
|
+
const previous = await loadById(payload.id, db);
|
|
203
|
+
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
204
|
+
|
|
205
|
+
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
206
|
+
// done above), build post-change row via shallow merge. Straddle-safe
|
|
207
|
+
// multi-role check: at least one role must accept BOTH old and new —
|
|
208
|
+
// prevents the attack where role A passes old, role B passes new and
|
|
209
|
+
// aggregation would wrongly allow a row-grab.
|
|
210
|
+
const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
|
|
211
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
212
|
+
return writeFailure(
|
|
213
|
+
new UnprocessableError("ownership_denied", {
|
|
214
|
+
i18nKey: "errors.ownershipDenied",
|
|
215
|
+
details: {
|
|
216
|
+
scope: "entity",
|
|
217
|
+
entityName,
|
|
218
|
+
action: "update",
|
|
219
|
+
userId: user.id,
|
|
220
|
+
entityId: payload.id,
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Field-level write-ownership on update — this is the path the
|
|
227
|
+
// dispatcher could not evaluate (no oldRow). Now that we have
|
|
228
|
+
// `previous`, we can run the ownership rules per field against both
|
|
229
|
+
// sides and reject individual fields the user isn't entitled to
|
|
230
|
+
// touch on this specific row.
|
|
231
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
|
|
232
|
+
if (fieldDeniedUpdate) {
|
|
233
|
+
return writeFailure(
|
|
234
|
+
new UnprocessableError("ownership_denied", {
|
|
235
|
+
i18nKey: "errors.ownershipDenied",
|
|
236
|
+
details: {
|
|
237
|
+
scope: "field",
|
|
238
|
+
entityName,
|
|
239
|
+
action: "update",
|
|
240
|
+
field: fieldDeniedUpdate,
|
|
241
|
+
userId: user.id,
|
|
242
|
+
entityId: payload.id,
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
249
|
+
|
|
250
|
+
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
251
|
+
// can bump the stream between CRUD writes (domain event on the same
|
|
252
|
+
// aggregate); a stale row.version here would make the next CRUD write
|
|
253
|
+
// trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
|
|
254
|
+
// with version_conflict.
|
|
255
|
+
const currentVersion = await getStreamVersion(
|
|
256
|
+
db.raw,
|
|
257
|
+
String(payload.id),
|
|
258
|
+
streamTenantFor(user),
|
|
259
|
+
);
|
|
260
|
+
if (!updateOptions?.skipOptimisticLock) {
|
|
261
|
+
if (payload.version === undefined) {
|
|
262
|
+
return writeFailure(
|
|
263
|
+
new FrameworkVersionConflict({
|
|
264
|
+
entityId: payload.id,
|
|
265
|
+
expectedVersion: 0,
|
|
266
|
+
currentVersion,
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (currentVersion !== payload.version) {
|
|
271
|
+
return writeFailure(
|
|
272
|
+
new FrameworkVersionConflict({
|
|
273
|
+
entityId: payload.id,
|
|
274
|
+
expectedVersion: payload.version,
|
|
275
|
+
currentVersion,
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
283
|
+
// subjectSource: partial changes may carry a pii field without its
|
|
284
|
+
// ownerField — the merged row still names the subject.
|
|
285
|
+
const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
|
|
286
|
+
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
287
|
+
onlyKeys: Object.keys(payload.changes),
|
|
288
|
+
subjectSource: mergedNew,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
292
|
+
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
293
|
+
// previous value to decrement/undo when a parent-FK moves — without it
|
|
294
|
+
// you'd have to snapshot-and-diff on every apply, and replays would
|
|
295
|
+
// break. Storage cost is acceptable (rows are bounded), correctness is
|
|
296
|
+
// not negotiable. `previous` came from loadById(), which decrypts —
|
|
297
|
+
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
298
|
+
// fields doesn't land in the immutable log (flatChanges is already
|
|
299
|
+
// ciphertext from encryptForStorage above).
|
|
300
|
+
const event = await append(db.raw, {
|
|
301
|
+
aggregateId: String(payload.id),
|
|
302
|
+
aggregateType: entityName,
|
|
303
|
+
tenantId: streamTenantFor(user),
|
|
304
|
+
expectedVersion: currentVersion,
|
|
305
|
+
type: entityEventName(entityName, "updated"),
|
|
306
|
+
payload: {
|
|
307
|
+
changes: flatChanges,
|
|
308
|
+
previous: await encryptForStorage(previous, user),
|
|
309
|
+
},
|
|
310
|
+
metadata: buildEventMetadata(user),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
|
|
314
|
+
// apply liest nur `changes`, und die sind live wie im Replay
|
|
315
|
+
// identischer Ciphertext (#967).
|
|
316
|
+
//
|
|
317
|
+
// F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
|
|
318
|
+
// — ein update das einen unique-Index verletzt (z.B. email-update
|
|
319
|
+
// auf einen schon-existierenden Wert) wird mit 409 unique_violation
|
|
320
|
+
// statt 500 internal_error rückgemeldet.
|
|
321
|
+
let result: Awaited<ReturnType<typeof applyEntityEvent>>;
|
|
322
|
+
try {
|
|
323
|
+
result = await applyEntityEvent(event, table, entity, db.raw);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
const mapped = tryMapUniqueViolation(e, entityName);
|
|
326
|
+
if (mapped) return mapped;
|
|
327
|
+
throw e;
|
|
328
|
+
}
|
|
329
|
+
if (result.kind !== "applied" || result.row === null) {
|
|
330
|
+
return writeFailure(new InternalError({ message: "projection update returned no row" }));
|
|
331
|
+
}
|
|
332
|
+
const row = result.row;
|
|
333
|
+
const data = await decryptForRead(rehydrateCompoundTypes(row as DbRow, entity) as DbRow);
|
|
334
|
+
|
|
335
|
+
if (entityCache && entityName) {
|
|
336
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
isSuccess: true,
|
|
341
|
+
data: {
|
|
342
|
+
kind: "save",
|
|
343
|
+
id: data["id"] as EntityId, // @cast-boundary engine-payload
|
|
344
|
+
data,
|
|
345
|
+
changes: payload.changes,
|
|
346
|
+
previous,
|
|
347
|
+
isNew: false,
|
|
348
|
+
entityName,
|
|
349
|
+
event: {
|
|
350
|
+
...event,
|
|
351
|
+
payload: {
|
|
352
|
+
changes: stripSensitive(flatChangesPlain),
|
|
353
|
+
previous: stripSensitive(previous),
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
} catch (e) {
|
|
359
|
+
// The pre-check above eliminates the common stale-version case; this
|
|
360
|
+
// branch catches the narrow race where two writers both read version=N
|
|
361
|
+
// and both pass the local check — the unique index on (aggregate_id,
|
|
362
|
+
// version) serializes them, one wins, the other lands here.
|
|
363
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
364
|
+
return writeFailure(
|
|
365
|
+
new FrameworkVersionConflict({
|
|
366
|
+
entityId: payload.id,
|
|
367
|
+
expectedVersion: payload.version ?? 0,
|
|
368
|
+
currentVersion,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
async delete(payload, user, db) {
|
|
377
|
+
const existing = await loadById(payload.id, db);
|
|
378
|
+
if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
379
|
+
|
|
380
|
+
// H.2 — entity-level write-ownership on delete. Only the pre-delete
|
|
381
|
+
// row matters (there's no "new" row for a delete); passing existing
|
|
382
|
+
// twice to userCanWriteFieldRow makes the Straddle check trivial
|
|
383
|
+
// (same row on both sides) while keeping the multi-role-atomic shape.
|
|
384
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
385
|
+
return writeFailure(
|
|
386
|
+
new UnprocessableError("ownership_denied", {
|
|
387
|
+
i18nKey: "errors.ownershipDenied",
|
|
388
|
+
details: {
|
|
389
|
+
scope: "entity",
|
|
390
|
+
entityName,
|
|
391
|
+
action: "delete",
|
|
392
|
+
userId: user.id,
|
|
393
|
+
entityId: payload.id,
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
400
|
+
|
|
401
|
+
// Stream-version authoritative (see update() for rationale).
|
|
402
|
+
const currentVersion = await getStreamVersion(
|
|
403
|
+
db.raw,
|
|
404
|
+
String(payload.id),
|
|
405
|
+
streamTenantFor(user),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Deletes carry the full pre-delete row as `previous`. That's what
|
|
409
|
+
// projections and downstream consumers need to reverse any aggregates —
|
|
410
|
+
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
411
|
+
// to rebuild from the event log alone. `existing` came from loadById(),
|
|
412
|
+
// which decrypts — re-encrypt before persisting so plaintext doesn't
|
|
413
|
+
// land in the immutable log.
|
|
414
|
+
const event = await append(db.raw, {
|
|
415
|
+
aggregateId: String(payload.id),
|
|
416
|
+
aggregateType: entityName,
|
|
417
|
+
tenantId: streamTenantFor(user),
|
|
418
|
+
expectedVersion: currentVersion,
|
|
419
|
+
type: entityEventName(entityName, "deleted"),
|
|
420
|
+
payload: { previous: await encryptForStorage(existing, user) },
|
|
421
|
+
metadata: buildEventMetadata(user),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
|
|
425
|
+
// sensitive-Drift weil das Event-Payload nur `previous` ist und das
|
|
426
|
+
// wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
|
|
427
|
+
// (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
|
|
428
|
+
// dasselbe — kein payload-override nötig.
|
|
429
|
+
const deleteResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
430
|
+
if (deleteResult.kind !== "applied") {
|
|
431
|
+
return writeFailure(
|
|
432
|
+
new InternalError({ message: "projection delete: applyEntityEvent skipped" }),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (entityCache && entityName) {
|
|
437
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
isSuccess: true,
|
|
442
|
+
data: {
|
|
443
|
+
kind: "delete",
|
|
444
|
+
id: payload.id,
|
|
445
|
+
data: existing,
|
|
446
|
+
entityName,
|
|
447
|
+
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
// Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
|
|
453
|
+
// hard-deletes the row regardless of softDelete — and, being an auto-verb,
|
|
454
|
+
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
455
|
+
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
456
|
+
async forget(payload, user, db) {
|
|
457
|
+
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
458
|
+
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
459
|
+
const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
|
|
460
|
+
|
|
461
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
462
|
+
return writeFailure(
|
|
463
|
+
new UnprocessableError("ownership_denied", {
|
|
464
|
+
i18nKey: "errors.ownershipDenied",
|
|
465
|
+
details: {
|
|
466
|
+
scope: "entity",
|
|
467
|
+
entityName,
|
|
468
|
+
action: "delete",
|
|
469
|
+
userId: user.id,
|
|
470
|
+
entityId: payload.id,
|
|
471
|
+
},
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
477
|
+
const currentVersion = await getStreamVersion(
|
|
478
|
+
db.raw,
|
|
479
|
+
String(payload.id),
|
|
480
|
+
streamTenantFor(user),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const event = await append(db.raw, {
|
|
484
|
+
aggregateId: String(payload.id),
|
|
485
|
+
aggregateType: entityName,
|
|
486
|
+
tenantId: streamTenantFor(user),
|
|
487
|
+
expectedVersion: currentVersion,
|
|
488
|
+
type: entityEventName(entityName, "forgotten"),
|
|
489
|
+
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
490
|
+
// plaintext must not land in the immutable log, least of all on forget.
|
|
491
|
+
payload: { previous: await encryptForStorage(existing, user) },
|
|
492
|
+
metadata: buildEventMetadata(user),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
496
|
+
if (forgetResult.kind !== "applied") {
|
|
497
|
+
return writeFailure(
|
|
498
|
+
new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (entityCache && entityName) {
|
|
503
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
isSuccess: true,
|
|
508
|
+
data: {
|
|
509
|
+
kind: "delete",
|
|
510
|
+
id: payload.id,
|
|
511
|
+
data: existing,
|
|
512
|
+
entityName,
|
|
513
|
+
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
async restore(payload, user, db) {
|
|
519
|
+
if (!softDelete) {
|
|
520
|
+
return writeFailure(
|
|
521
|
+
new UnprocessableError("soft_delete_not_enabled", {
|
|
522
|
+
i18nKey: "errors.softDeleteNotEnabled",
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const [row] = await selectMany(db.raw, table, { id: payload.id });
|
|
528
|
+
if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
529
|
+
const data = row as DbRow;
|
|
530
|
+
if (!data["isDeleted"]) {
|
|
531
|
+
return writeFailure(
|
|
532
|
+
new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// H.2 — entity-level write-ownership on restore. Same shape as delete:
|
|
537
|
+
// only the stored row matters. Stored row carries pre-soft-delete
|
|
538
|
+
// teamId/... fields, so the ownership predicate still applies cleanly.
|
|
539
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
|
|
540
|
+
return writeFailure(
|
|
541
|
+
new UnprocessableError("ownership_denied", {
|
|
542
|
+
i18nKey: "errors.ownershipDenied",
|
|
543
|
+
details: {
|
|
544
|
+
scope: "entity",
|
|
545
|
+
entityName,
|
|
546
|
+
action: "restore",
|
|
547
|
+
userId: user.id,
|
|
548
|
+
entityId: payload.id,
|
|
549
|
+
},
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
555
|
+
|
|
556
|
+
// Stream-version authoritative (see update() for rationale).
|
|
557
|
+
const currentVersion = await getStreamVersion(
|
|
558
|
+
db.raw,
|
|
559
|
+
String(payload.id),
|
|
560
|
+
streamTenantFor(user),
|
|
561
|
+
);
|
|
562
|
+
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
563
|
+
// delete for symmetry. Projections that decremented on delete use
|
|
564
|
+
// `previous` to re-increment on restore without re-querying the entity
|
|
565
|
+
// table. `data` is the raw stored row — pii/encrypted fields are
|
|
566
|
+
// already ciphertext, no re-encrypt needed.
|
|
567
|
+
const event = await append(db.raw, {
|
|
568
|
+
aggregateId: String(payload.id),
|
|
569
|
+
aggregateType: entityName,
|
|
570
|
+
tenantId: streamTenantFor(user),
|
|
571
|
+
expectedVersion: currentVersion,
|
|
572
|
+
type: entityEventName(entityName, "restored"),
|
|
573
|
+
payload: { previous: data },
|
|
574
|
+
metadata: buildEventMetadata(user),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
|
|
578
|
+
// false + version-Bump in die Tabelle — keine sensitive-Drift, daher
|
|
579
|
+
// kein payload-override nötig.
|
|
580
|
+
const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
581
|
+
if (restoreResult.kind !== "applied" || restoreResult.row === null) {
|
|
582
|
+
return writeFailure(new InternalError({ message: "projection restore returned no row" }));
|
|
583
|
+
}
|
|
584
|
+
const restored = restoreResult.row;
|
|
585
|
+
|
|
586
|
+
if (entityCache && entityName) {
|
|
587
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
|
|
591
|
+
// decryptForRead matches create/update/list/detail: the caller-facing
|
|
592
|
+
// row and `previous` snapshot must be plaintext for `encrypted` fields,
|
|
593
|
+
// same as every other executor method — `data`/`restored` are raw rows
|
|
594
|
+
// (selectMany / applyEntityEvent), never decrypted before this point.
|
|
595
|
+
const restoredHydrated = await decryptForRead(
|
|
596
|
+
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
const previousPlain = await decryptForRead(data);
|
|
600
|
+
return {
|
|
601
|
+
isSuccess: true,
|
|
602
|
+
data: {
|
|
603
|
+
kind: "save",
|
|
604
|
+
id: payload.id,
|
|
605
|
+
data: restoredHydrated,
|
|
606
|
+
changes: { isDeleted: false },
|
|
607
|
+
previous: previousPlain,
|
|
608
|
+
isNew: false,
|
|
609
|
+
entityName,
|
|
610
|
+
event: { ...event, payload: { previous: stripSensitive(previousPlain) } },
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|