@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.
- 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/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,206 @@
|
|
|
1
|
+
// framework#2585 — JobContext.writeAs.
|
|
2
|
+
//
|
|
3
|
+
// `hasAccess` has no system bypass, so before writeAs existed a job could
|
|
4
|
+
// only write as its own systemUser (roles: ["system"]) and every write
|
|
5
|
+
// handler gated on concrete roles was unreachable from a job: the dispatch
|
|
6
|
+
// came back access_denied and the only signal was a failed job. These tests
|
|
7
|
+
// pin both halves — ctx.write against a role-gated handler is still denied,
|
|
8
|
+
// and ctx.writeAs(actor) reaches the same handler and attributes the event
|
|
9
|
+
// to the actor rather than to SYSTEM.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { ROLES } from "../../auth/roles";
|
|
14
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
15
|
+
import { asRawClient } from "../../db/query";
|
|
16
|
+
import { createRegistry, defineFeature, type SessionUser, type WriteResult } from "../../engine";
|
|
17
|
+
import { createWorkerEntrypoint } from "../../entrypoint";
|
|
18
|
+
import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
19
|
+
import { createEventConsumerStateTable } from "../../pipeline";
|
|
20
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
21
|
+
import { waitFor } from "../../testing";
|
|
22
|
+
import { createJobRunner } from "../job-runner";
|
|
23
|
+
|
|
24
|
+
const ADMIN_USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
25
|
+
|
|
26
|
+
type ProbeOutcome = { readonly isSuccess: boolean; readonly errorCode: string | undefined };
|
|
27
|
+
|
|
28
|
+
const writeOutcomes: ProbeOutcome[] = [];
|
|
29
|
+
const writeAsOutcomes: ProbeOutcome[] = [];
|
|
30
|
+
const writeAsFailures: string[] = [];
|
|
31
|
+
|
|
32
|
+
function recordOutcome(sink: ProbeOutcome[], result: WriteResult): void {
|
|
33
|
+
sink.push({
|
|
34
|
+
isSuccess: result.isSuccess,
|
|
35
|
+
errorCode: result.isSuccess ? undefined : result.error.code,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const writeAsProbeFeature = defineFeature("writeAsProbe", (r) => {
|
|
40
|
+
const noted = r.defineEvent("adminNoted", z.object({ note: z.string() }), {
|
|
41
|
+
piiFields: "none",
|
|
42
|
+
version: 1,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
r.writeHandler(
|
|
46
|
+
"adminNote",
|
|
47
|
+
z.object({ note: z.string() }),
|
|
48
|
+
async (event, ctx) => {
|
|
49
|
+
await ctx.unsafeAppendEvent({
|
|
50
|
+
aggregateId: crypto.randomUUID(),
|
|
51
|
+
aggregateType: "write-as-probe-note",
|
|
52
|
+
type: noted.name,
|
|
53
|
+
payload: { note: event.payload.note },
|
|
54
|
+
});
|
|
55
|
+
return { isSuccess: true as const, data: { note: event.payload.note } };
|
|
56
|
+
},
|
|
57
|
+
{ access: { roles: [ROLES.TenantAdmin] } },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
r.job("writeAsSystem", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
61
|
+
const result = await ctx.write("write-as-probe:write:admin-note", {
|
|
62
|
+
note: payload["note"] as string, // @cast-boundary dynamic-key
|
|
63
|
+
});
|
|
64
|
+
recordOutcome(writeOutcomes, result);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
r.job("writeAsActor", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
68
|
+
const actor: SessionUser = {
|
|
69
|
+
id: payload["actorId"] as string, // @cast-boundary dynamic-key
|
|
70
|
+
tenantId: ctx.systemUser.tenantId,
|
|
71
|
+
roles: [ROLES.TenantAdmin],
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
const result = await ctx.writeAs(actor, "write-as-probe:write:admin-note", {
|
|
75
|
+
note: payload["note"] as string, // @cast-boundary dynamic-key
|
|
76
|
+
});
|
|
77
|
+
recordOutcome(writeAsOutcomes, result);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
writeAsFailures.push(error instanceof Error ? error.message : String(error));
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const JWT = "job-write-as-test-secret-must-be-32-chars!";
|
|
86
|
+
|
|
87
|
+
let testDb: BunTestDb;
|
|
88
|
+
let testRedis: TestRedis;
|
|
89
|
+
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
[testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
|
|
92
|
+
await createEventsTable(testDb.db);
|
|
93
|
+
await createArchivedStreamsTable(testDb.db);
|
|
94
|
+
await createEventConsumerStateTable(testDb.db);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(async () => {
|
|
98
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function redisUrl(): string {
|
|
102
|
+
return `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function uniquePrefix(label: string): string {
|
|
106
|
+
return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function notesFor(note: string): Promise<Array<{ created_by: string }>> {
|
|
110
|
+
const rows = await asRawClient(testDb.db).unsafe<{ created_by: string }>(
|
|
111
|
+
`SELECT created_by FROM kumiko_events
|
|
112
|
+
WHERE type = 'write-as-probe:event:admin-noted' AND payload->>'note' = $1`,
|
|
113
|
+
[note],
|
|
114
|
+
);
|
|
115
|
+
return [...rows];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("JobContext.writeAs reaches a role-gated write handler (framework#2585)", () => {
|
|
119
|
+
test("ctx.write is denied — the job's systemUser carries no TenantAdmin role", async () => {
|
|
120
|
+
writeOutcomes.length = 0;
|
|
121
|
+
const worker = createWorkerEntrypoint({
|
|
122
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
123
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
124
|
+
jwtSecret: JWT,
|
|
125
|
+
redisUrl: redisUrl(),
|
|
126
|
+
queueNamePrefix: uniquePrefix("job-write-as-denied"),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await worker.start();
|
|
130
|
+
try {
|
|
131
|
+
await worker.jobRunner.dispatch("write-as-probe:job:write-as-system", {
|
|
132
|
+
note: "system attempt",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(writeOutcomes.length).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
expect(writeOutcomes[0]?.isSuccess).toBe(false);
|
|
139
|
+
expect(writeOutcomes[0]?.errorCode).toBe("access_denied");
|
|
140
|
+
expect(await notesFor("system attempt")).toHaveLength(0);
|
|
141
|
+
} finally {
|
|
142
|
+
await worker.stop();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("ctx.writeAs(actor) commits and attributes the event to the actor", async () => {
|
|
147
|
+
writeAsOutcomes.length = 0;
|
|
148
|
+
const worker = createWorkerEntrypoint({
|
|
149
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
150
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
151
|
+
jwtSecret: JWT,
|
|
152
|
+
redisUrl: redisUrl(),
|
|
153
|
+
queueNamePrefix: uniquePrefix("job-write-as-allowed"),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await worker.start();
|
|
157
|
+
try {
|
|
158
|
+
await worker.jobRunner.dispatch("write-as-probe:job:write-as-actor", {
|
|
159
|
+
note: "actor attempt",
|
|
160
|
+
actorId: ADMIN_USER_ID,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(writeAsOutcomes.length).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
expect(writeAsOutcomes[0]?.isSuccess).toBe(true);
|
|
167
|
+
|
|
168
|
+
const rows = await notesFor("actor attempt");
|
|
169
|
+
expect(rows).toHaveLength(1);
|
|
170
|
+
expect(rows[0]?.created_by).toBe(ADMIN_USER_ID);
|
|
171
|
+
} finally {
|
|
172
|
+
await worker.stop();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("JobContext.writeAs before attachDispatcher()", () => {
|
|
178
|
+
test("throws the same not-attached stub ctx.write does", async () => {
|
|
179
|
+
writeAsFailures.length = 0;
|
|
180
|
+
const runner = createJobRunner({
|
|
181
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
182
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
183
|
+
redisUrl: redisUrl(),
|
|
184
|
+
consumerLane: "worker",
|
|
185
|
+
queueNamePrefix: uniquePrefix("job-write-as-bare"),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await runner.start();
|
|
189
|
+
try {
|
|
190
|
+
await runner.dispatch("write-as-probe:job:write-as-actor", {
|
|
191
|
+
note: "should never land",
|
|
192
|
+
actorId: ADMIN_USER_ID,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(writeAsFailures.length).toBe(1);
|
|
197
|
+
});
|
|
198
|
+
expect(writeAsFailures[0]).toContain(
|
|
199
|
+
"JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
|
|
200
|
+
);
|
|
201
|
+
expect(await notesFor("should never land")).toHaveLength(0);
|
|
202
|
+
} finally {
|
|
203
|
+
await runner.stop();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -144,9 +144,9 @@ export type JobRunner = {
|
|
|
144
144
|
payload: Record<string, unknown>,
|
|
145
145
|
user?: SessionUser,
|
|
146
146
|
): Promise<void>;
|
|
147
|
-
// Wires JobContext.write/queryAs to the real dispatcher — called
|
|
148
|
-
// boot, after the dispatcher exists (job-runner construction happens
|
|
149
|
-
// before it). Before this runs, JobContext.write/queryAs throw.
|
|
147
|
+
// Wires JobContext.write/writeAs/queryAs to the real dispatcher — called
|
|
148
|
+
// once at boot, after the dispatcher exists (job-runner construction happens
|
|
149
|
+
// before it). Before this runs, JobContext.write/writeAs/queryAs throw.
|
|
150
150
|
attachDispatcher(ref: DispatchWriteRef): void;
|
|
151
151
|
};
|
|
152
152
|
|
|
@@ -362,7 +362,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
362
362
|
// at job-execution time (after start()), so it is always defined by then.
|
|
363
363
|
let selfRunner: JobRunner | undefined;
|
|
364
364
|
// Set by attachDispatcher() once the boot-level dispatcher exists.
|
|
365
|
-
// JobContext.write/queryAs throw until this is set — see JobContext doc.
|
|
365
|
+
// JobContext.write/writeAs/queryAs throw until this is set — see JobContext doc.
|
|
366
366
|
let dispatchWriteRef: DispatchWriteRef | undefined;
|
|
367
367
|
|
|
368
368
|
// Counts active + waiting jobs with this name for this tenant across
|
|
@@ -581,6 +581,14 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
581
581
|
}
|
|
582
582
|
return dispatchWriteRef.write(jobSystemUser, qn, payload);
|
|
583
583
|
},
|
|
584
|
+
writeAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
585
|
+
if (!dispatchWriteRef) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
"JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
return dispatchWriteRef.write(user, qn, payload);
|
|
591
|
+
},
|
|
584
592
|
queryAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
585
593
|
if (!dispatchWriteRef) {
|
|
586
594
|
throw new Error(
|
|
@@ -32,7 +32,9 @@ const itemTable = buildEntityTable("arch-item", itemEntity);
|
|
|
32
32
|
const archFeature = defineFeature("archtest", (r) => {
|
|
33
33
|
r.entity("arch-item", itemEntity);
|
|
34
34
|
|
|
35
|
-
const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() })
|
|
35
|
+
const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() }), {
|
|
36
|
+
piiFields: "none",
|
|
37
|
+
});
|
|
36
38
|
|
|
37
39
|
const executor = createEventStoreExecutor(itemTable, itemEntity, {
|
|
38
40
|
entityName: "arch-item",
|
|
@@ -54,7 +54,9 @@ const applyObservations: ReqCtxSnapshot[] = [];
|
|
|
54
54
|
const causationFeature = defineFeature("causation", (r) => {
|
|
55
55
|
r.entity("causation-order", orderEntity);
|
|
56
56
|
|
|
57
|
-
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() })
|
|
57
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), {
|
|
58
|
+
piiFields: "none",
|
|
59
|
+
});
|
|
58
60
|
|
|
59
61
|
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
60
62
|
entityName: "causation-order",
|
|
@@ -58,7 +58,9 @@ const shippingFeature = defineFeature("shipping", (r) => {
|
|
|
58
58
|
r.entity("domain-shipment", shipmentEntity);
|
|
59
59
|
|
|
60
60
|
// Domain event. Qualified name is "shipping:event:billed".
|
|
61
|
-
const shipmentBilled = r.defineEvent("billed", z.object({ cost: z.number() })
|
|
61
|
+
const shipmentBilled = r.defineEvent("billed", z.object({ cost: z.number() }), {
|
|
62
|
+
piiFields: "none",
|
|
63
|
+
});
|
|
62
64
|
|
|
63
65
|
r.projection({
|
|
64
66
|
name: "shipment-billing",
|
|
@@ -34,14 +34,18 @@ let foreignEventName = "";
|
|
|
34
34
|
// emit this from one of its handlers — Sprint-E cross-feature-ownership
|
|
35
35
|
// guard must reject it at the append site.
|
|
36
36
|
const neighborFeature = defineFeature("neighbor", (r) => {
|
|
37
|
-
const foreign = r.defineEvent("neighbor.signal", z.object({ userId: z.uuid() })
|
|
37
|
+
const foreign = r.defineEvent("neighbor.signal", z.object({ userId: z.uuid() }), {
|
|
38
|
+
piiFields: "none",
|
|
39
|
+
});
|
|
38
40
|
foreignEventName = foreign.name;
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
const emitterFeature = defineFeature("emitter", (r) => {
|
|
42
44
|
r.entity("widget", sharedWidgetEntity);
|
|
43
45
|
|
|
44
|
-
const welcome = r.defineEvent("user.welcomed", z.object({ userId: z.uuid(), email: z.email() })
|
|
46
|
+
const welcome = r.defineEvent("user.welcomed", z.object({ userId: z.uuid(), email: z.email() }), {
|
|
47
|
+
piiFields: "none",
|
|
48
|
+
});
|
|
45
49
|
welcomeEventName = welcome.name;
|
|
46
50
|
|
|
47
51
|
r.writeHandler(
|
|
@@ -31,8 +31,12 @@ const cartTable = buildEntityTable("f4wCart", cartEntity);
|
|
|
31
31
|
const cartFeature = defineFeature("f4w", (r) => {
|
|
32
32
|
r.entity("f4wCart", cartEntity);
|
|
33
33
|
|
|
34
|
-
const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() })
|
|
35
|
-
|
|
34
|
+
const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() }), {
|
|
35
|
+
piiFields: "none",
|
|
36
|
+
});
|
|
37
|
+
const checkedOut = r.defineEvent("checkedOut", z.object({ totalCents: z.number() }), {
|
|
38
|
+
piiFields: "none",
|
|
39
|
+
});
|
|
36
40
|
|
|
37
41
|
const cartExecutor = createEventStoreExecutor(cartTable, cartEntity, {
|
|
38
42
|
entityName: "f4wCart",
|
|
@@ -30,7 +30,9 @@ const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
|
30
30
|
const processedItems: Array<{ readonly fileRefId: string }> = [];
|
|
31
31
|
|
|
32
32
|
const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
33
|
-
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) })
|
|
33
|
+
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }), {
|
|
34
|
+
piiFields: "none",
|
|
35
|
+
});
|
|
34
36
|
|
|
35
37
|
// Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
|
|
36
38
|
// to fileRef.created, appends a NEW event via unsafeAppendEvent — no
|
|
@@ -42,9 +42,11 @@ const confirmLoadCounts: number[] = [];
|
|
|
42
42
|
const mmhFeature = defineFeature("mmh", (r) => {
|
|
43
43
|
r.entity("mmh-order", orderEntity);
|
|
44
44
|
|
|
45
|
-
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }));
|
|
46
|
-
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() })
|
|
47
|
-
|
|
45
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
46
|
+
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
|
|
47
|
+
piiFields: "none",
|
|
48
|
+
});
|
|
49
|
+
const shipped = r.defineEvent("shipped", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
48
50
|
|
|
49
51
|
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
50
52
|
entityName: "mmh-order",
|
|
@@ -72,14 +72,17 @@ const feature = defineFeature("mspreb", (r) => {
|
|
|
72
72
|
const invoiceBilled = r.defineEvent(
|
|
73
73
|
"invoice-billed",
|
|
74
74
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
75
|
+
{ piiFields: "none" },
|
|
75
76
|
);
|
|
76
77
|
const paymentReceived = r.defineEvent(
|
|
77
78
|
"payment-received",
|
|
78
79
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
80
|
+
{ piiFields: "none" },
|
|
79
81
|
);
|
|
80
82
|
const escalationTriggered = r.defineEvent(
|
|
81
83
|
"escalation-triggered",
|
|
82
84
|
z.object({ customer: z.uuid() }),
|
|
85
|
+
{ piiFields: "none" },
|
|
83
86
|
);
|
|
84
87
|
|
|
85
88
|
// 1) Main rebuildable MSP — table materialized from two event types.
|
|
@@ -56,10 +56,12 @@ const mspFeature = defineFeature("msptest", (r) => {
|
|
|
56
56
|
const shipmentBilled = r.defineEvent(
|
|
57
57
|
"shipment-billed",
|
|
58
58
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
59
|
+
{ piiFields: "none" },
|
|
59
60
|
);
|
|
60
61
|
const refundIssued = r.defineEvent(
|
|
61
62
|
"refund-issued",
|
|
62
63
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
64
|
+
{ piiFields: "none" },
|
|
63
65
|
);
|
|
64
66
|
|
|
65
67
|
r.multiStreamProjection({
|
|
@@ -110,7 +110,9 @@ const MSP_EVENT_SHORT = "poison-noted";
|
|
|
110
110
|
const feature = defineFeature("poisontest", (r) => {
|
|
111
111
|
r.entity("poison-item", itemEntity);
|
|
112
112
|
r.projection(poisonProjection);
|
|
113
|
-
const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() })
|
|
113
|
+
const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }), {
|
|
114
|
+
piiFields: "none",
|
|
115
|
+
});
|
|
114
116
|
r.multiStreamProjection({
|
|
115
117
|
name: "poison-msp-counter",
|
|
116
118
|
table: mspCounterTable,
|
|
@@ -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",
|