@cosmicdrift/kumiko-framework 0.249.0 → 0.250.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 (53) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/full-stack.integration.test.ts +3 -1
  3. package/src/crypto/__tests__/event-pii.test.ts +25 -3
  4. package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
  5. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
  6. package/src/crypto/event-pii.ts +7 -1
  7. package/src/crypto/index.ts +1 -0
  8. package/src/crypto/kms-wiring.ts +23 -10
  9. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
  10. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
  11. package/src/db/entity-table-meta.ts +9 -2
  12. package/src/db/event-store-executor-read.ts +124 -7
  13. package/src/db/table-builder.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +132 -2
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
  16. package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
  17. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  18. package/src/engine/entity-handlers.ts +4 -0
  19. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
  20. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
  21. package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
  22. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
  25. package/src/engine/feature-ast/extractors/events.ts +24 -0
  26. package/src/engine/feature-ast/patcher.ts +6 -1
  27. package/src/engine/feature-ast/patterns.ts +6 -0
  28. package/src/engine/feature-ast/render.ts +3 -4
  29. package/src/engine/feature-config-events-jobs.ts +33 -9
  30. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  31. package/src/engine/registry-facade.ts +10 -1
  32. package/src/engine/registry-state.ts +8 -0
  33. package/src/engine/registry-validate.ts +54 -3
  34. package/src/engine/types/index.ts +1 -0
  35. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
  36. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
  37. package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
  38. package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
  39. package/src/jobs/job-runner.ts +12 -4
  40. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  41. package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
  42. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
  43. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
  44. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
  45. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
  46. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
  47. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
  48. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
  49. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
  50. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
  51. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
  52. package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
  53. package/src/pipeline/system-hooks.ts +94 -25
@@ -6,9 +6,18 @@ import {
6
6
  isPiiCiphertext,
7
7
  PII_ERASED_SENTINEL,
8
8
  } from "../crypto";
9
- import type { DbRow } from "../db/connection";
9
+ import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db";
10
+ import type { DbConnection, DbRow } from "../db/connection";
10
11
  import { tenantChannel } from "../engine/constants";
11
- import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
12
+ import type {
13
+ AppContext,
14
+ EntityDefinition,
15
+ EntityId,
16
+ JobRunnerRef,
17
+ Registry,
18
+ SessionUser,
19
+ TenantId,
20
+ } from "../engine/types";
12
21
  import type { SearchAdapter, SearchDocument } from "../search/types";
13
22
  import type { EventConsumer } from "./event-dispatcher";
14
23
 
@@ -21,25 +30,29 @@ import type { EventConsumer } from "./event-dispatcher";
21
30
  //
22
31
  // Event → Search-Op Mapping:
23
32
  //
24
- // <entity>.created → index(tenantId, doc)
25
- // <entity>.updated → index(tenantId, doc) // re-index mit neuem state
26
- // <entity>.restored → index(tenantId, doc) // wiederbeleben
33
+ // <entity>.created → index(tenantId, doc) // state = event.payload
34
+ // <entity>.updated → index(tenantId, doc) // state = { ...previous, ...changes }
35
+ // <entity>.restored → index(tenantId, doc) // state = event.payload.previous
27
36
  // <entity>.deleted → remove(tenantId, type, id)
37
+ // <entity>.forgotten → remove(tenantId, type, id)
38
+ // <entity>.<named> → index or remove, state read from the live
39
+ // projection row (#2765) — a named domain event
40
+ // carries only a payload slice, not the full
41
+ // entity, so there's no fixed shape to reconstruct
42
+ // state from the way created/updated/restored do.
43
+ // Only queried when the entity declares searchable
44
+ // fields/extensions; no row (hard/soft-deleted) →
45
+ // remove, same as the deleted branch.
28
46
  //
29
- // Der Document-State wird aus dem Event rekonstruiert (kein SaveContext
30
- // mehr available). Regel:
47
+ // Sensitive fields never reach this consumer for created/updated/restored —
48
+ // event-store-executor.ts strips them before they land in the event log.
49
+ // The projection-row path (named events) reads them from the live table
50
+ // instead, so the same sensitive+searchable boot-guard (entity-handler.ts)
51
+ // keeps them out there too.
31
52
  //
32
- // created: state = event.payload // ganze entity ist im payload
33
- // updated: state = { ...previous, ...changes } // rekonstruiert neuen state
34
- // restored: state = event.payload.previous // restored field-set
35
- //
36
- // Sensitive fields sind aus dem event log bereits gestrippt (event-store-
37
- // executor.ts), also kriegt der Search-Index sie ebenfalls nicht — das ist
38
- // die gleiche Garantie wie vorher beim postSave-hook.
39
- //
40
- // Batch-Variante gibt's aktuell nicht mehr — jeder Event triggert einen
41
- // eigenen index()-call. Wenn Performance nach Scale-Messung das erfordert,
42
- // kann der event-dispatcher später eine Batch-Handler-Variante bekommen.
53
+ // No batch variant right now — every event triggers its own index() call.
54
+ // If a scale measurement later calls for it, the event-dispatcher could
55
+ // grow a batch-handler variant.
43
56
  export const SEARCH_CONSUMER_NAME = "system:consumer:search";
44
57
 
45
58
  export function createSearchEventConsumer(
@@ -55,7 +68,7 @@ export function createSearchEventConsumer(
55
68
  // killing the consumer near-instantly). Upgrade path: time-based
56
69
  // dead-lettering in event-dispatcher-delivery.ts if 2min isn't enough.
57
70
  errorPolicy: { maxAttempts: 1200 },
58
- handler: async (event) => {
71
+ handler: async (event, ctx) => {
59
72
  const entityName = event.aggregateType;
60
73
  const verb = event.type.split(".").pop();
61
74
  const tenantId = event.tenantId;
@@ -67,14 +80,41 @@ export function createSearchEventConsumer(
67
80
  return;
68
81
  }
69
82
 
70
- if (verb !== "created" && verb !== "updated" && verb !== "restored") {
71
- // skip: other event types (custom domain events, future verbs) don't
72
- // carry a search-indexable payload shape. If a future feature needs
73
- // them indexed, it registers its own multiStreamProjection.
74
- return;
83
+ let state: Record<string, unknown>;
84
+ if (verb === "created" || verb === "updated" || verb === "restored") {
85
+ state = reconstructStateForSearch(event.payload, verb);
86
+ } else {
87
+ // #2765 — named domain event: no fixed payload shape to reconstruct
88
+ // state from. Only worth a projection read when the entity actually
89
+ // declared searchable fields/extensions — same check
90
+ // buildSearchDocument makes below, done here first so a non-
91
+ // searchable entity's named events stay a no-op like before.
92
+ const entity = registry.getEntity(entityName);
93
+ const isSearchable =
94
+ entity !== undefined &&
95
+ (registry.getSearchableFields(entityName).length > 0 ||
96
+ registry.getSearchPayloadExtensions(entityName).length > 0);
97
+ // skip: entity declares no searchable fields/extensions — nothing to
98
+ // index, stays a no-op with no extra query, same as before #2765.
99
+ if (!entity || !isSearchable) return;
100
+
101
+ const row = await readProjectionRowForSearch(
102
+ ctx,
103
+ registry,
104
+ entityName,
105
+ entity,
106
+ tenantId,
107
+ event.aggregateId,
108
+ );
109
+ // skip: no live row (hard-deleted stream, or soft-deleted) — same
110
+ // outcome as the deleted/forgotten branch above.
111
+ if (!row) {
112
+ await searchAdapter.remove(tenantId, entityName, event.aggregateId);
113
+ return;
114
+ }
115
+ state = row;
75
116
  }
76
117
 
77
- let state = reconstructStateForSearch(event.payload, verb);
78
118
  state = await decryptSearchableSubjectFields(entityName, state, registry);
79
119
  // skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
80
120
  if (hasErasedSearchableSubjectField(entityName, state, registry)) {
@@ -91,6 +131,35 @@ export function createSearchEventConsumer(
91
131
  };
92
132
  }
93
133
 
134
+ // #2765 — reads the live projection row for a named domain event, tenant-
135
+ // scoped through createTenantDb the same way MultiStreamProjection appliers
136
+ // scope their apply()-writes (see api/server.ts), on the table the registry
137
+ // actually booted (entityTableFromRegistry) rather than one freshly derived
138
+ // from the entity definition alone. undefined means "no live row" — a hard
139
+ // delete or a soft-delete — which the caller treats as a remove.
140
+ async function readProjectionRowForSearch(
141
+ ctx: AppContext,
142
+ registry: Registry,
143
+ entityName: string,
144
+ entity: EntityDefinition,
145
+ tenantId: TenantId,
146
+ entityId: EntityId,
147
+ ): Promise<Record<string, unknown> | undefined> {
148
+ // @cast-boundary engine-bridge — consumer ctx.db is always the outer
149
+ // DbConnection: event-dispatcher creation is gated on it being set
150
+ // (api/server.ts), never a HandlerContext's already-tenant-scoped TenantDb.
151
+ const baseDb = ctx.db as DbConnection | undefined;
152
+ if (!baseDb) return undefined;
153
+ const table = entityTableFromRegistry(registry, entityName, entity);
154
+ const where: Record<string, unknown> = { id: entityId };
155
+ if (entity.softDelete && table["isDeleted"]) {
156
+ where["isDeleted"] = false;
157
+ }
158
+ const row = await createTenantDb(baseDb, tenantId).fetchOne(table, where);
159
+ if (!row) return undefined;
160
+ return rehydrateCompoundTypes(row, entity);
161
+ }
162
+
94
163
  // #1610 — subject-annotated searchable fields are ciphertext in the event
95
164
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
96
165
  // values rather than indexing blobs.