@cosmicdrift/kumiko-framework 0.281.0 → 0.283.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.283.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.283.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.283.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.283.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "loadAggregate and ctx.fetchForWriting gain an optional aggregateType filter, plus handle.hasEvent(...)",
|
|
6
|
+
"detail": "`loadAggregate(db, aggregateId, tenantId, options)` accepts an optional `options.aggregateType`. Without it, behavior is unchanged — every event on the aggregateId's stream is returned, same as today. With it, only events matching that `aggregateType` are returned, so two features that happen to share an `aggregateId` (e.g. reusing an upstream id as the key for a second, unrelated aggregate type) no longer leak each other's events into a business-rule check that only expected its own type.\n`ctx.fetchForWriting({ aggregateId, aggregateType, ... })` now passes `aggregateType` through to `loadAggregate`, so `handle.events` is scoped the same way. The returned `AggregateStreamHandle` also gains `hasEvent(type: string): boolean`, an idempotency check against the fetch-time `events` snapshot — cheaper and safer than an `appendIfAbsent`-style helper, which risks masking a genuine optimistic-concurrency conflict as an already-applied write.",
|
|
7
|
+
"migration": "No action required — purely additive. Every existing loadAggregate/fetchForWriting\ncall keeps its current behavior; opt into the aggregateType filter only where an\naggregateId is shared across more than one aggregate type."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.281.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -361,6 +361,97 @@ describe("event-store: tenant isolation", () => {
|
|
|
361
361
|
});
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
describe("event-store: loadAggregate aggregateType filter (#2979)", () => {
|
|
365
|
+
test("without aggregateType, both types on one aggregateId are returned (no breaking change)", async () => {
|
|
366
|
+
const aggregateId = uuid();
|
|
367
|
+
await append(testDb.db, {
|
|
368
|
+
aggregateId,
|
|
369
|
+
aggregateType: "cart",
|
|
370
|
+
tenantId: tenantA,
|
|
371
|
+
expectedVersion: 0,
|
|
372
|
+
type: "cart.created",
|
|
373
|
+
payload: {},
|
|
374
|
+
metadata: { userId: userA },
|
|
375
|
+
});
|
|
376
|
+
await append(testDb.db, {
|
|
377
|
+
aggregateId,
|
|
378
|
+
aggregateType: "reservation",
|
|
379
|
+
tenantId: tenantA,
|
|
380
|
+
expectedVersion: 1,
|
|
381
|
+
type: "reservation.created",
|
|
382
|
+
payload: {},
|
|
383
|
+
metadata: { userId: userA },
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const events = await loadAggregate(testDb.db, aggregateId, tenantA);
|
|
387
|
+
expect(events.map((e) => e.type)).toEqual(["cart.created", "reservation.created"]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("with aggregateType, a foreign-type event on the same aggregateId is excluded (solon-style id collision)", async () => {
|
|
391
|
+
const aggregateId = uuid();
|
|
392
|
+
await append(testDb.db, {
|
|
393
|
+
aggregateId,
|
|
394
|
+
aggregateType: "cart",
|
|
395
|
+
tenantId: tenantA,
|
|
396
|
+
expectedVersion: 0,
|
|
397
|
+
type: "cart.created",
|
|
398
|
+
payload: {},
|
|
399
|
+
metadata: { userId: userA },
|
|
400
|
+
});
|
|
401
|
+
await append(testDb.db, {
|
|
402
|
+
aggregateId,
|
|
403
|
+
aggregateType: "reservation",
|
|
404
|
+
tenantId: tenantA,
|
|
405
|
+
expectedVersion: 1,
|
|
406
|
+
type: "reservation.created",
|
|
407
|
+
payload: {},
|
|
408
|
+
metadata: { userId: userA },
|
|
409
|
+
});
|
|
410
|
+
await append(testDb.db, {
|
|
411
|
+
aggregateId,
|
|
412
|
+
aggregateType: "cart",
|
|
413
|
+
tenantId: tenantA,
|
|
414
|
+
expectedVersion: 2,
|
|
415
|
+
type: "cart.itemAdded",
|
|
416
|
+
payload: {},
|
|
417
|
+
metadata: { userId: userA },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const cartEvents = await loadAggregate(testDb.db, aggregateId, tenantA, {
|
|
421
|
+
aggregateType: "cart",
|
|
422
|
+
});
|
|
423
|
+
expect(cartEvents.map((e) => e.type)).toEqual(["cart.created", "cart.itemAdded"]);
|
|
424
|
+
|
|
425
|
+
const reservationEvents = await loadAggregate(testDb.db, aggregateId, tenantA, {
|
|
426
|
+
aggregateType: "reservation",
|
|
427
|
+
});
|
|
428
|
+
expect(reservationEvents.map((e) => e.type)).toEqual(["reservation.created"]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("aggregateType filter does not weaken tenant isolation", async () => {
|
|
432
|
+
const aggregateId = uuid();
|
|
433
|
+
await append(testDb.db, {
|
|
434
|
+
aggregateId,
|
|
435
|
+
aggregateType: "cart",
|
|
436
|
+
tenantId: tenantA,
|
|
437
|
+
expectedVersion: 0,
|
|
438
|
+
type: "cart.created",
|
|
439
|
+
payload: {},
|
|
440
|
+
metadata: { userId: userA },
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const crossTenant = await loadAggregate(testDb.db, aggregateId, tenantB, {
|
|
444
|
+
aggregateType: "cart",
|
|
445
|
+
});
|
|
446
|
+
expect(crossTenant).toHaveLength(0);
|
|
447
|
+
|
|
448
|
+
const sameTenant = await loadAggregate(testDb.db, aggregateId, tenantA, {
|
|
449
|
+
aggregateType: "cart",
|
|
450
|
+
});
|
|
451
|
+
expect(sameTenant).toHaveLength(1);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
364
455
|
describe("event-store: requestId is a trace marker (no DB-level uniqueness)", () => {
|
|
365
456
|
test("same (tenant, requestId) twice → both events persist, no collision", async () => {
|
|
366
457
|
// Idempotency is an HTTP-level concern, handled via Redis in
|
|
@@ -208,11 +208,17 @@ function buildStoredEvent(
|
|
|
208
208
|
// { includeArchived: true } for ops tools / audit that must see the tail
|
|
209
209
|
// of an archived aggregate. The archive check is a single indexed lookup —
|
|
210
210
|
// negligible on the hot path.
|
|
211
|
+
//
|
|
212
|
+
// { aggregateType } is opt-in: without it, a stream is loaded by
|
|
213
|
+
// aggregateId alone, matching prior behavior. Callers that share id
|
|
214
|
+
// generation across multiple aggregate types on the same id (e.g. a solon-
|
|
215
|
+
// style collision) must pass it to avoid folding a foreign type's events
|
|
216
|
+
// into the loaded stream.
|
|
211
217
|
export async function loadAggregate(
|
|
212
218
|
db: DbRunner,
|
|
213
219
|
aggregateId: string,
|
|
214
220
|
tenantId: TenantId,
|
|
215
|
-
options?: { readonly includeArchived?: boolean },
|
|
221
|
+
options?: { readonly includeArchived?: boolean; readonly aggregateType?: string },
|
|
216
222
|
): Promise<readonly StoredEvent[]> {
|
|
217
223
|
if (!options?.includeArchived) {
|
|
218
224
|
const archived = await isStreamArchived(db, tenantId, aggregateId);
|
|
@@ -221,7 +227,9 @@ export async function loadAggregate(
|
|
|
221
227
|
const rows = await selectMany<SelectedEvent>(
|
|
222
228
|
db,
|
|
223
229
|
eventsTable,
|
|
224
|
-
|
|
230
|
+
options?.aggregateType
|
|
231
|
+
? { aggregateId, tenantId, aggregateType: options.aggregateType }
|
|
232
|
+
: { aggregateId, tenantId },
|
|
225
233
|
{ orderBy: { col: "version", direction: "asc" } },
|
|
226
234
|
);
|
|
227
235
|
return rows.map(toStoredEvent);
|
|
@@ -14,7 +14,7 @@ import { asRawClient } from "../../db/query-api";
|
|
|
14
14
|
import { buildEntityTable } from "../../db/table-builder";
|
|
15
15
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
16
|
import { UnprocessableError, writeFailure } from "../../errors";
|
|
17
|
-
import { loadAggregate } from "../../event-store";
|
|
17
|
+
import { append, loadAggregate } from "../../event-store";
|
|
18
18
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
19
19
|
|
|
20
20
|
// --- Feature ---
|
|
@@ -109,6 +109,28 @@ const cartFeature = defineFeature("f4w", (r) => {
|
|
|
109
109
|
{ access: { roles: ["Admin"] } },
|
|
110
110
|
);
|
|
111
111
|
|
|
112
|
+
// Reports the raw event types + a hasEvent probe seen through the handle —
|
|
113
|
+
// lets a test assert on both aggregateType-scoping and idempotency-checks
|
|
114
|
+
// without adding another executor.
|
|
115
|
+
r.writeHandler(
|
|
116
|
+
"cart:fetch-info",
|
|
117
|
+
z.object({ id: z.uuid(), probeType: z.string() }),
|
|
118
|
+
async (event, ctx) => {
|
|
119
|
+
const stream = await ctx.fetchForWriting({
|
|
120
|
+
aggregateId: event.payload.id,
|
|
121
|
+
aggregateType: "f4wCart",
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
isSuccess: true as const,
|
|
125
|
+
data: {
|
|
126
|
+
types: stream.events.map((e) => e.type),
|
|
127
|
+
hasProbe: stream.hasEvent(event.payload.probeType),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
{ access: { roles: ["Admin"] } },
|
|
132
|
+
);
|
|
133
|
+
|
|
112
134
|
// Fetch with expectedVersion — OCC gate for external callers.
|
|
113
135
|
r.writeHandler(
|
|
114
136
|
"cart:add-with-occ",
|
|
@@ -282,4 +304,61 @@ describe("Runde 3 / C.2a — ctx.fetchForWriting", () => {
|
|
|
282
304
|
expect(types).toContain("f4w:event:checked-out");
|
|
283
305
|
expect(types).not.toContain("f4w:event:item-added");
|
|
284
306
|
});
|
|
307
|
+
|
|
308
|
+
test("#2979: a foreign-type event planted on the same aggregateId is excluded from the handle", async () => {
|
|
309
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
310
|
+
"f4w:write:cart:create",
|
|
311
|
+
{ customer: "grace" },
|
|
312
|
+
admin,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// Solon-style id collision: another aggregateType lands its own event on
|
|
316
|
+
// the exact same aggregateId, outside the cart executor entirely.
|
|
317
|
+
await append(stack.db, {
|
|
318
|
+
aggregateId: created.id,
|
|
319
|
+
aggregateType: "reservation",
|
|
320
|
+
tenantId: admin.tenantId,
|
|
321
|
+
expectedVersion: 1,
|
|
322
|
+
type: "reservation:event:created",
|
|
323
|
+
payload: {},
|
|
324
|
+
metadata: { userId: String(admin.id) },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const info = await stack.http.writeOk<{ types: string[]; hasProbe: boolean }>(
|
|
328
|
+
"f4w:write:cart:fetch-info",
|
|
329
|
+
{ id: created.id, probeType: "reservation:event:created" },
|
|
330
|
+
admin,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
expect(info.types).toEqual(["f4wCart.created"]);
|
|
334
|
+
expect(info.hasProbe).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("#2979: hasEvent reflects the fetch-time snapshot for the handle's own aggregateType", async () => {
|
|
338
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
339
|
+
"f4w:write:cart:create",
|
|
340
|
+
{ customer: "heidi" },
|
|
341
|
+
admin,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const before = await stack.http.writeOk<{ hasProbe: boolean }>(
|
|
345
|
+
"f4w:write:cart:fetch-info",
|
|
346
|
+
{ id: created.id, probeType: "f4w:event:checked-out" },
|
|
347
|
+
admin,
|
|
348
|
+
);
|
|
349
|
+
expect(before.hasProbe).toBe(false);
|
|
350
|
+
|
|
351
|
+
await stack.http.writeOk(
|
|
352
|
+
"f4w:write:cart:checkout",
|
|
353
|
+
{ id: created.id, totalCents: 1500 },
|
|
354
|
+
admin,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const after = await stack.http.writeOk<{ hasProbe: boolean }>(
|
|
358
|
+
"f4w:write:cart:fetch-info",
|
|
359
|
+
{ id: created.id, probeType: "f4w:event:checked-out" },
|
|
360
|
+
admin,
|
|
361
|
+
);
|
|
362
|
+
expect(after.hasProbe).toBe(true);
|
|
363
|
+
});
|
|
285
364
|
});
|
|
@@ -549,7 +549,9 @@ export async function buildHandlerContext(
|
|
|
549
549
|
// when the caller just wants to append — but most callers also want
|
|
550
550
|
// the events (business-rule checks), so fetch both in parallel.
|
|
551
551
|
const [storedEvents, fetchedVersion] = await Promise.all([
|
|
552
|
-
loadAggregate(dbSource, args.aggregateId, user.tenantId
|
|
552
|
+
loadAggregate(dbSource, args.aggregateId, user.tenantId, {
|
|
553
|
+
aggregateType: args.aggregateType,
|
|
554
|
+
}),
|
|
553
555
|
getStreamVersion(dbSource, args.aggregateId, user.tenantId),
|
|
554
556
|
]);
|
|
555
557
|
const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
|
|
@@ -596,6 +598,7 @@ export async function buildHandlerContext(
|
|
|
596
598
|
return handleVersion;
|
|
597
599
|
},
|
|
598
600
|
appendOne,
|
|
601
|
+
hasEvent: (type: string) => events.some((e) => e.type === type),
|
|
599
602
|
};
|
|
600
603
|
},
|
|
601
604
|
loadAggregate: async (
|