@cosmicdrift/kumiko-framework 0.146.4 → 0.147.1

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.
Files changed (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. 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
+ }