@cosmicdrift/kumiko-framework 0.282.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.282.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.282.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.282.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
- { aggregateId, tenantId },
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 (