@cosmicdrift/kumiko-framework 0.146.4 → 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.
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
@@ -1,129 +1,36 @@
1
- import { requestContext } from "../api/request-context";
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 { assertUnreachable, generateId } from "../utils";
54
- import { applyEntityEvent } from "./apply-entity-event";
55
- import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
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
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
71
- type Table = TableColumns<any>;
72
-
73
- // Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
74
- // Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
75
- // `op` ist auf {eq,ne,lt,gt,in} normalisiert; "in" mit empty-array ist
76
- // explizit no-match.
77
- function buildFilterWhere(
78
- field: string,
79
- op: "eq" | "ne" | "lt" | "gt" | "in",
80
- value: unknown,
81
- ): WhereObject | null {
82
- switch (op) {
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 function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
125
- return `${entityName}.${verb}`;
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 { searchAdapter, entityName, entityCache } = options;
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
- async create(payload, user, db) {
478
- // Respect an explicit id in the payload (seed pattern, SCIM import). Without
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
  }