@cosmicdrift/kumiko-framework 0.249.0 → 0.251.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/full-stack.integration.test.ts +3 -1
- package/src/crypto/__tests__/event-pii.test.ts +25 -3
- package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
- package/src/crypto/event-pii.ts +7 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/kms-wiring.ts +23 -10
- package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
- package/src/db/entity-table-meta.ts +9 -2
- package/src/db/event-store-executor-read.ts +124 -7
- package/src/db/table-builder.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +132 -2
- package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +184 -0
- package/src/engine/boot-validator/index.ts +5 -1
- package/src/engine/boot-validator/projection-list-screens.ts +42 -0
- package/src/engine/boot-validator/screens.ts +45 -29
- package/src/engine/entity-handlers.ts +4 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
- package/src/engine/feature-ast/extractors/events.ts +24 -0
- package/src/engine/feature-ast/patcher.ts +6 -1
- package/src/engine/feature-ast/patterns.ts +6 -0
- package/src/engine/feature-ast/render.ts +3 -4
- package/src/engine/feature-config-events-jobs.ts +33 -9
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/registry-facade.ts +10 -1
- package/src/engine/registry-state.ts +8 -0
- package/src/engine/registry-validate.ts +54 -3
- package/src/engine/types/index.ts +1 -0
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
- package/src/jobs/job-runner.ts +12 -4
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
- package/src/pipeline/system-hooks.ts +94 -25
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// #2765 — named domain events (r.defineEvent, not created/updated/restored/
|
|
2
|
+
// deleted/forgotten) on an entity with searchable fields must still reach
|
|
3
|
+
// the search index. The event payload only carries the named event's own
|
|
4
|
+
// field slice, so the consumer reads the live projection row instead.
|
|
5
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
|
|
8
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
9
|
+
import {
|
|
10
|
+
resetEventStore,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
TestUsers,
|
|
14
|
+
unsafeCreateEntityTable,
|
|
15
|
+
} from "../../stack";
|
|
16
|
+
import { SEARCH_CONSUMER_NAME } from "../system-hooks";
|
|
17
|
+
|
|
18
|
+
const noteEntity = createEntity({
|
|
19
|
+
table: "read_named_search_notes",
|
|
20
|
+
fields: {
|
|
21
|
+
label: createTextField({ required: true, maxLength: 100, searchable: true }),
|
|
22
|
+
},
|
|
23
|
+
softDelete: true,
|
|
24
|
+
});
|
|
25
|
+
const noteTable = buildEntityTable("note", noteEntity);
|
|
26
|
+
|
|
27
|
+
// No DDL table for this one — proves the searchable-fields gate runs
|
|
28
|
+
// before any query, not after a failed one.
|
|
29
|
+
const ghostEntity = createEntity({
|
|
30
|
+
table: "read_named_search_ghosts",
|
|
31
|
+
fields: {
|
|
32
|
+
note: createTextField({ required: true, maxLength: 50 }),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const ROW_LABEL = "row-projection-value";
|
|
37
|
+
|
|
38
|
+
const namedSearchFeature = defineFeature("named-search", (r) => {
|
|
39
|
+
r.entity("note", noteEntity);
|
|
40
|
+
r.entity("ghost", ghostEntity);
|
|
41
|
+
|
|
42
|
+
const relabeled = r.defineEvent("relabeled", z.object({ label: z.string() }), {
|
|
43
|
+
piiFields: "none",
|
|
44
|
+
});
|
|
45
|
+
const poked = r.defineEvent("poked", z.object({}), { piiFields: "none" });
|
|
46
|
+
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
"note:relabel",
|
|
49
|
+
z.object({ id: z.uuid(), label: z.string() }),
|
|
50
|
+
async (event, ctx) => {
|
|
51
|
+
await ctx.unsafeAppendEvent({
|
|
52
|
+
aggregateId: event.payload.id,
|
|
53
|
+
aggregateType: "note",
|
|
54
|
+
type: relabeled.name,
|
|
55
|
+
payload: { label: event.payload.label },
|
|
56
|
+
});
|
|
57
|
+
// Bypasses the executor on purpose (raw SQL, not updateMany — the
|
|
58
|
+
// executor-built table is write-locked to the executor) so the row
|
|
59
|
+
// ends up with a value distinct from the event payload above. Proves
|
|
60
|
+
// the search consumer reads the row, not the payload.
|
|
61
|
+
await asRawClient(ctx.db).unsafe(
|
|
62
|
+
`UPDATE read_named_search_notes SET label = $1 WHERE id = $2`,
|
|
63
|
+
[ROW_LABEL, event.payload.id],
|
|
64
|
+
);
|
|
65
|
+
return { isSuccess: true as const, data: { id: event.payload.id } };
|
|
66
|
+
},
|
|
67
|
+
{ access: { roles: ["Admin"] } },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
r.writeHandler(
|
|
71
|
+
"ghost:poke",
|
|
72
|
+
z.object({ id: z.uuid() }),
|
|
73
|
+
async (event, ctx) => {
|
|
74
|
+
await ctx.unsafeAppendEvent({
|
|
75
|
+
aggregateId: event.payload.id,
|
|
76
|
+
aggregateType: "ghost",
|
|
77
|
+
type: poked.name,
|
|
78
|
+
payload: {},
|
|
79
|
+
});
|
|
80
|
+
return { isSuccess: true as const, data: { id: event.payload.id } };
|
|
81
|
+
},
|
|
82
|
+
{ access: { roles: ["Admin"] } },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let stack: TestStack;
|
|
87
|
+
const admin = TestUsers.admin;
|
|
88
|
+
|
|
89
|
+
function noteExecutor() {
|
|
90
|
+
return createEventStoreExecutor(noteTable, noteEntity, {
|
|
91
|
+
entityName: "note",
|
|
92
|
+
searchAdapter: stack.search,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function tenantDb() {
|
|
97
|
+
return createTenantDb(stack.db, admin.tenantId, "system");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
stack = await setupTestStack({ features: [namedSearchFeature] });
|
|
102
|
+
await unsafeCreateEntityTable(stack.db, noteEntity, "note");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
await stack.cleanup();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
await resetEventStore(stack, ["read_named_search_notes"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("search consumer: named domain events (#2765)", () => {
|
|
114
|
+
test("named event on a searchable entity indexes from the projection row, not the payload", async () => {
|
|
115
|
+
const created = await noteExecutor().create({ label: "original-label" }, admin, tenantDb());
|
|
116
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
117
|
+
const id = String(created.data.id);
|
|
118
|
+
await stack.eventDispatcher?.runOnce();
|
|
119
|
+
|
|
120
|
+
const beforeHits = await stack.search.search(admin.tenantId, "original-label", {
|
|
121
|
+
filterType: "note",
|
|
122
|
+
});
|
|
123
|
+
expect(beforeHits.some((h) => String(h.entityId) === id)).toBe(true);
|
|
124
|
+
|
|
125
|
+
await stack.http.writeOk(
|
|
126
|
+
"named-search:write:note:relabel",
|
|
127
|
+
{ id, label: "payload-label-should-not-be-indexed" },
|
|
128
|
+
admin,
|
|
129
|
+
);
|
|
130
|
+
await stack.eventDispatcher?.runOnce();
|
|
131
|
+
|
|
132
|
+
const payloadHits = await stack.search.search(
|
|
133
|
+
admin.tenantId,
|
|
134
|
+
"payload-label-should-not-be-indexed",
|
|
135
|
+
{ filterType: "note" },
|
|
136
|
+
);
|
|
137
|
+
expect(payloadHits).toHaveLength(0);
|
|
138
|
+
|
|
139
|
+
const rowHits = await stack.search.search(admin.tenantId, ROW_LABEL, { filterType: "note" });
|
|
140
|
+
expect(rowHits.some((h) => String(h.entityId) === id)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("named event on an entity without searchable fields triggers no query and no index", async () => {
|
|
144
|
+
const id = crypto.randomUUID();
|
|
145
|
+
// Ghost has no DDL table — if the consumer queried it anyway, runOnce()
|
|
146
|
+
// would reject instead of completing (relation does not exist).
|
|
147
|
+
await stack.http.writeOk("named-search:write:ghost:poke", { id }, admin);
|
|
148
|
+
const result = await stack.eventDispatcher?.runOnce();
|
|
149
|
+
expect(result?.byConsumer[SEARCH_CONSUMER_NAME]?.failed).toBe(0);
|
|
150
|
+
|
|
151
|
+
const hits = await stack.search.search(admin.tenantId, "poked", { filterType: "ghost" });
|
|
152
|
+
expect(hits).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("named event with no live projection row removes the index entry", async () => {
|
|
156
|
+
const created = await noteExecutor().create({ label: "vanishing-label" }, admin, tenantDb());
|
|
157
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
158
|
+
const id = String(created.data.id);
|
|
159
|
+
await stack.eventDispatcher?.runOnce();
|
|
160
|
+
expect(
|
|
161
|
+
(await stack.search.search(admin.tenantId, "vanishing-label", { filterType: "note" })).some(
|
|
162
|
+
(h) => String(h.entityId) === id,
|
|
163
|
+
),
|
|
164
|
+
).toBe(true);
|
|
165
|
+
|
|
166
|
+
// Hard-delete the row directly, bypassing executor.forget — simulates a
|
|
167
|
+
// projection row that's simply gone, distinct from the deleted/forgotten
|
|
168
|
+
// verb path covered by the regression test below.
|
|
169
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_named_search_notes WHERE id = $1`, [id]);
|
|
170
|
+
|
|
171
|
+
await stack.http.writeOk("named-search:write:note:relabel", { id, label: "irrelevant" }, admin);
|
|
172
|
+
await stack.eventDispatcher?.runOnce();
|
|
173
|
+
|
|
174
|
+
const after = await stack.search.search(admin.tenantId, "vanishing-label", {
|
|
175
|
+
filterType: "note",
|
|
176
|
+
});
|
|
177
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("named event on a soft-deleted entity does not resurrect the index entry", async () => {
|
|
181
|
+
const created = await noteExecutor().create({ label: "soft-deleted-label" }, admin, tenantDb());
|
|
182
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
183
|
+
const id = created.data.id;
|
|
184
|
+
await stack.eventDispatcher?.runOnce();
|
|
185
|
+
expect(
|
|
186
|
+
(
|
|
187
|
+
await stack.search.search(admin.tenantId, "soft-deleted-label", { filterType: "note" })
|
|
188
|
+
).some((h) => String(h.entityId) === String(id)),
|
|
189
|
+
).toBe(true);
|
|
190
|
+
|
|
191
|
+
const deleted = await noteExecutor().delete({ id }, admin, tenantDb());
|
|
192
|
+
if (!deleted.isSuccess) throw new Error("delete failed");
|
|
193
|
+
await stack.eventDispatcher?.runOnce();
|
|
194
|
+
|
|
195
|
+
// The row still physically exists (isDeleted: true) — a plain fetchOne
|
|
196
|
+
// without the soft-delete filter would find it and re-index it.
|
|
197
|
+
await stack.http.writeOk(
|
|
198
|
+
"named-search:write:note:relabel",
|
|
199
|
+
{ id: String(id), label: "irrelevant" },
|
|
200
|
+
admin,
|
|
201
|
+
);
|
|
202
|
+
await stack.eventDispatcher?.runOnce();
|
|
203
|
+
|
|
204
|
+
const after = await stack.search.search(admin.tenantId, ROW_LABEL, { filterType: "note" });
|
|
205
|
+
expect(after.some((h) => String(h.entityId) === String(id))).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("created/updated/restored/deleted/forgotten still index/remove as before (regression)", async () => {
|
|
209
|
+
const created = await noteExecutor().create({ label: "crud-created" }, admin, tenantDb());
|
|
210
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
211
|
+
const id = created.data.id;
|
|
212
|
+
await stack.eventDispatcher?.runOnce();
|
|
213
|
+
expect(
|
|
214
|
+
(await stack.search.search(admin.tenantId, "crud-created", { filterType: "note" })).some(
|
|
215
|
+
(h) => String(h.entityId) === String(id),
|
|
216
|
+
),
|
|
217
|
+
).toBe(true);
|
|
218
|
+
|
|
219
|
+
const updated = await noteExecutor().update(
|
|
220
|
+
{ id, changes: { label: "crud-updated" } },
|
|
221
|
+
admin,
|
|
222
|
+
tenantDb(),
|
|
223
|
+
{ skipOptimisticLock: true },
|
|
224
|
+
);
|
|
225
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
226
|
+
await stack.eventDispatcher?.runOnce();
|
|
227
|
+
expect(
|
|
228
|
+
(await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
|
|
229
|
+
(h) => String(h.entityId) === String(id),
|
|
230
|
+
),
|
|
231
|
+
).toBe(true);
|
|
232
|
+
|
|
233
|
+
const deleted = await noteExecutor().delete({ id }, admin, tenantDb());
|
|
234
|
+
if (!deleted.isSuccess) throw new Error("delete failed");
|
|
235
|
+
await stack.eventDispatcher?.runOnce();
|
|
236
|
+
expect(
|
|
237
|
+
(await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
|
|
238
|
+
(h) => String(h.entityId) === String(id),
|
|
239
|
+
),
|
|
240
|
+
).toBe(false);
|
|
241
|
+
|
|
242
|
+
const restored = await noteExecutor().restore({ id }, admin, tenantDb());
|
|
243
|
+
if (!restored.isSuccess) throw new Error("restore failed");
|
|
244
|
+
await stack.eventDispatcher?.runOnce();
|
|
245
|
+
expect(
|
|
246
|
+
(await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
|
|
247
|
+
(h) => String(h.entityId) === String(id),
|
|
248
|
+
),
|
|
249
|
+
).toBe(true);
|
|
250
|
+
|
|
251
|
+
const forgotten = await noteExecutor().forget({ id }, admin, tenantDb());
|
|
252
|
+
if (!forgotten.isSuccess) throw new Error("forget failed");
|
|
253
|
+
await stack.eventDispatcher?.runOnce();
|
|
254
|
+
expect(
|
|
255
|
+
(await stack.search.search(admin.tenantId, "crud-updated", { filterType: "note" })).some(
|
|
256
|
+
(h) => String(h.entityId) === String(id),
|
|
257
|
+
),
|
|
258
|
+
).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -50,7 +50,9 @@ const admin = TestUsers.admin;
|
|
|
50
50
|
|
|
51
51
|
const tryAppendFeature = defineFeature("tae", (r) => {
|
|
52
52
|
r.entity("taeMarker", markerEntity);
|
|
53
|
-
const appended = r.defineEvent("appended", z.object({ note: z.string() })
|
|
53
|
+
const appended = r.defineEvent("appended", z.object({ note: z.string() }), {
|
|
54
|
+
piiFields: "none",
|
|
55
|
+
});
|
|
54
56
|
|
|
55
57
|
r.writeHandler(
|
|
56
58
|
"doc:try-append",
|
|
@@ -6,9 +6,18 @@ import {
|
|
|
6
6
|
isPiiCiphertext,
|
|
7
7
|
PII_ERASED_SENTINEL,
|
|
8
8
|
} from "../crypto";
|
|
9
|
-
import
|
|
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 {
|
|
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) //
|
|
26
|
-
// <entity>.restored → index(tenantId, doc) //
|
|
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
|
-
//
|
|
30
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|