@cosmicdrift/kumiko-framework 0.143.0 → 0.144.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.143.0",
3
+ "version": "0.144.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>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.143.0",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.144.0",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -135,18 +135,35 @@ export type SaveSnapshotParams = {
135
135
  // Plain object — see SubsequentEventInsertParams on why pre-stringified
136
136
  // JSON double-encodes under Bun.SQL's ::jsonb binding.
137
137
  readonly state: Record<string, unknown>;
138
+ readonly snapshotVersion: number;
138
139
  };
139
140
 
141
+ // kumiko_snapshots predates snapshot_version — idempotent heal for existing
142
+ // installs, run from the same ensure path as table creation.
143
+ export async function ensureSnapshotVersionColumn(db: AnyDb): Promise<void> {
144
+ await asRawClient(db).unsafe(
145
+ `ALTER TABLE "kumiko_snapshots" ADD COLUMN IF NOT EXISTS "snapshot_version" integer NOT NULL DEFAULT 1`,
146
+ );
147
+ }
148
+
140
149
  export async function upsertSnapshot(db: AnyDb, params: SaveSnapshotParams): Promise<void> {
141
150
  await asRawClient(db).unsafe(
142
151
  `INSERT INTO "kumiko_snapshots"
143
- ("aggregate_id", "tenant_id", "aggregate_type", "version", "state")
144
- VALUES ($1, $2, $3, $4, $5::jsonb)
152
+ ("aggregate_id", "tenant_id", "aggregate_type", "version", "state", "snapshot_version")
153
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6)
145
154
  ON CONFLICT ("aggregate_id", "version") DO UPDATE SET
146
155
  "state" = $5::jsonb,
147
156
  "aggregate_type" = $3,
157
+ "snapshot_version" = $6,
148
158
  "created_at" = now()`,
149
- [params.aggregateId, params.tenantId, params.aggregateType, params.version, params.state],
159
+ [
160
+ params.aggregateId,
161
+ params.tenantId,
162
+ params.aggregateType,
163
+ params.version,
164
+ params.state,
165
+ params.snapshotVersion,
166
+ ],
150
167
  );
151
168
  }
152
169
 
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../define-feature";
3
+ import { registerEntityCrud } from "../entity-handlers";
4
+ import { createEntity, createTextField } from "../factories";
5
+
6
+ const taskEntity = createEntity({
7
+ table: "crud_sugar_tasks",
8
+ fields: {
9
+ title: createTextField({ required: true }),
10
+ },
11
+ softDelete: true,
12
+ });
13
+
14
+ const access = {
15
+ write: { access: { roles: ["Admin"] } },
16
+ read: { access: { openToAll: true } },
17
+ } as const;
18
+
19
+ describe("r.crud", () => {
20
+ test("registers the same entity + handlers as registerEntityCrud", () => {
21
+ const viaCrud = defineFeature("crud-sugar", (r) => {
22
+ r.crud("task", taskEntity, access);
23
+ });
24
+ const viaHelper = defineFeature("crud-sugar", (r) => {
25
+ registerEntityCrud(r, "task", taskEntity, access);
26
+ });
27
+
28
+ expect(viaCrud.entities).toEqual(viaHelper.entities);
29
+ expect(Object.keys(viaCrud.writeHandlers)).toEqual(Object.keys(viaHelper.writeHandlers));
30
+ expect(Object.keys(viaCrud.queryHandlers)).toEqual(Object.keys(viaHelper.queryHandlers));
31
+ expect(Object.keys(viaCrud.writeHandlers)).toEqual([
32
+ "task:create",
33
+ "task:update",
34
+ "task:delete",
35
+ "task:restore",
36
+ ]);
37
+ expect(Object.keys(viaCrud.queryHandlers)).toEqual(["task:list", "task:detail"]);
38
+ expect(viaCrud.writeHandlers["task:create"]?.access).toEqual({ roles: ["Admin"] });
39
+ expect(viaCrud.queryHandlers["task:list"]?.access).toEqual({ openToAll: true });
40
+ });
41
+
42
+ test("returns an EntityRef like r.entity", () => {
43
+ defineFeature("crud-sugar", (r) => {
44
+ const ref = r.crud("task", taskEntity, access);
45
+ expect(ref).toEqual({ name: "task", table: "crud_sugar_tasks" });
46
+ });
47
+ });
48
+
49
+ test("verbs opt-out skips handlers", () => {
50
+ const feature = defineFeature("crud-sugar", (r) => {
51
+ r.crud("task", taskEntity, { ...access, verbs: { delete: false, restore: false } });
52
+ });
53
+ expect(Object.keys(feature.writeHandlers)).toEqual(["task:create", "task:update"]);
54
+ });
55
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../define-feature";
3
+ import type { DeclarativeEventMigration, EventUpcastCtx } from "../types";
4
+
5
+ // Transforms under test are pure — ctx is never touched.
6
+ const upcastCtx = {} as EventUpcastCtx;
7
+
8
+ function compile(spec: DeclarativeEventMigration) {
9
+ const feature = defineFeature("billing", (r) => {
10
+ r.eventMigration("invoicePaid", 1, 2, spec);
11
+ });
12
+ const def = feature.eventMigrations["invoicePaid"]?.[0];
13
+ if (!def) throw new Error("migration not registered");
14
+ return (payload: unknown) => def.transform(payload, upcastCtx);
15
+ }
16
+
17
+ describe("declarative eventMigration", () => {
18
+ test("rename moves the value and drops the old key; missing source is a no-op", async () => {
19
+ const run = compile({ rename: { amount: "amountCents", missing: "other" } });
20
+ expect(await run({ amount: 5, currency: "EUR" })).toEqual({
21
+ amountCents: 5,
22
+ currency: "EUR",
23
+ });
24
+ });
25
+
26
+ test("default fills absent keys only — existing values win", async () => {
27
+ const run = compile({ default: { currency: "EUR", status: "open" } });
28
+ expect(await run({ currency: "CHF" })).toEqual({ currency: "CHF", status: "open" });
29
+ });
30
+
31
+ test("map runs after rename on the new key; absent keys are skipped", async () => {
32
+ const run = compile({
33
+ rename: { amount: "amountCents" },
34
+ map: { amountCents: (v) => Math.round(Number(v) * 100), missing: () => "never" },
35
+ });
36
+ expect(await run({ amount: 19.99 })).toEqual({ amountCents: 1999 });
37
+ });
38
+
39
+ test("non-object payload fails loud", () => {
40
+ const run = compile({ default: { a: 1 } });
41
+ expect(() => run("not-an-object")).toThrow(/object payload/);
42
+ expect(() => run([1, 2])).toThrow(/object payload/);
43
+ });
44
+
45
+ test("imperative function variant is stored untouched", () => {
46
+ const fn = (payload: unknown) => payload;
47
+ const feature = defineFeature("billing", (r) => {
48
+ r.eventMigration("invoicePaid", 1, 2, fn);
49
+ });
50
+ expect(feature.eventMigrations["invoicePaid"]?.[0]?.transform).toBe(fn);
51
+ });
52
+ });
@@ -3,6 +3,7 @@ import type { EntityTableMeta } from "../db/entity-table-meta";
3
3
  import { toTableName } from "../db/table-builder";
4
4
  import { LifecycleHookTypes } from "./constants";
5
5
  import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
6
+ import { type RegisterEntityCrudOptions, registerEntityCrud } from "./entity-handlers";
6
7
  import { isKebabSegment, QnTypes, qn, toKebab } from "./qualified-name";
7
8
  import type {
8
9
  AccessRule,
@@ -14,6 +15,7 @@ import type {
14
15
  ConfigKeyHandle,
15
16
  ConfigKeyType,
16
17
  ConfigSeedDef,
18
+ DeclarativeEventMigration,
17
19
  EntityDefinition,
18
20
  EntityProjectionExtension,
19
21
  EntityRef,
@@ -261,6 +263,15 @@ export function defineFeature<const TName extends string, TExports = undefined>(
261
263
  return { name: entityName, table: definition.table ?? toTableName(entityName) };
262
264
  },
263
265
 
266
+ crud(
267
+ entityName: string,
268
+ definition: EntityDefinition,
269
+ options?: RegisterEntityCrudOptions,
270
+ ): EntityRef {
271
+ registerEntityCrud(registrar, entityName, definition, options);
272
+ return { name: entityName, table: definition.table ?? toTableName(entityName) };
273
+ },
274
+
264
275
  writeHandler<TName extends string, TSchema extends ZodType>(
265
276
  nameOrDef: string | WriteHandlerDefinition<TName, TSchema>,
266
277
  schema?: TSchema,
@@ -568,7 +579,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
568
579
  eventName: string,
569
580
  fromVersion: number,
570
581
  toVersion: number,
571
- transform: EventUpcastFn,
582
+ transform: EventUpcastFn | DeclarativeEventMigration,
572
583
  ): void {
573
584
  if (toVersion !== fromVersion + 1) {
574
585
  throw new Error(
@@ -590,7 +601,9 @@ export function defineFeature<const TName extends string, TExports = undefined>(
590
601
  `a migration from v${fromVersion} is already registered. Each step may only be declared once.`,
591
602
  );
592
603
  }
593
- list.push({ eventName: qualified, fromVersion, toVersion, transform });
604
+ const transformFn =
605
+ typeof transform === "function" ? transform : compileEventMigration(transform);
606
+ list.push({ eventName: qualified, fromVersion, toVersion, transform: transformFn });
594
607
  eventMigrations[eventName] = list;
595
608
  },
596
609
 
@@ -1039,3 +1052,28 @@ export function defineFeature<const TName extends string, TExports = undefined>(
1039
1052
  ...(envSchema !== undefined && { envSchema }),
1040
1053
  };
1041
1054
  }
1055
+
1056
+ // Compile the declarative {rename, default, map} migration spec into an
1057
+ // EventUpcastFn. Fixed order: rename → default → map.
1058
+ function compileEventMigration(spec: DeclarativeEventMigration): EventUpcastFn {
1059
+ return (payload) => {
1060
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
1061
+ throw new Error("Declarative event migration expects an object payload");
1062
+ }
1063
+ // @cast-boundary parse — payload is guarded as a plain object above
1064
+ const next = { ...(payload as Record<string, unknown>) };
1065
+ for (const [from, to] of Object.entries(spec.rename ?? {})) {
1066
+ if (from in next) {
1067
+ next[to] = next[from];
1068
+ delete next[from];
1069
+ }
1070
+ }
1071
+ for (const [key, value] of Object.entries(spec.default ?? {})) {
1072
+ if (!(key in next)) next[key] = value;
1073
+ }
1074
+ for (const [key, fn] of Object.entries(spec.map ?? {})) {
1075
+ if (key in next) next[key] = fn(next[key]);
1076
+ }
1077
+ return next;
1078
+ };
1079
+ }
@@ -7,6 +7,7 @@ import type { EntityTableMeta } from "../../db/entity-table-meta";
7
7
  type PgTable = unknown;
8
8
 
9
9
  import type { QueryHandlerDefinition, WriteHandlerDefinition } from "../define-handler";
10
+ import type { RegisterEntityCrudOptions } from "../entity-handlers";
10
11
  import type {
11
12
  ConfigKeyDefinition,
12
13
  ConfigKeyHandle,
@@ -33,6 +34,7 @@ import type {
33
34
  ClaimKeyDefinition,
34
35
  ClaimKeyHandle,
35
36
  ClaimKeyType,
37
+ DeclarativeEventMigration,
36
38
  EntityRef,
37
39
  EventDef,
38
40
  EventMigrationDef,
@@ -424,6 +426,11 @@ export type FeatureRegistrar<TFeature extends string = string> = {
424
426
  options?: { readonly table?: unknown },
425
427
  ): EntityRef;
426
428
 
429
+ // One-call CRUD for an event-sourced entity — delegates to registerEntityCrud():
430
+ // r.entity + create/update/delete/restore/list/detail handlers per verb flag.
431
+ // Access stays explicit — no openToAll default.
432
+ crud(name: string, definition: EntityDefinition, options?: RegisterEntityCrudOptions): EntityRef;
433
+
427
434
  writeHandler<TName extends string, TSchema extends ZodType>(
428
435
  def: WriteHandlerDefinition<TName, TSchema>,
429
436
  ): HandlerRef;
@@ -542,7 +549,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
542
549
  eventName: string,
543
550
  fromVersion: number,
544
551
  toVersion: number,
545
- transform: EventUpcastFn,
552
+ transform: EventUpcastFn | DeclarativeEventMigration,
546
553
  ): void;
547
554
 
548
555
  readsConfig(...qualifiedKeys: string[]): void;
@@ -431,7 +431,8 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
431
431
  // hold the state (e.g. just reduced the stream in a queryHandler, or
432
432
  // finished a write batch) pass it in alongside the version it reflects.
433
433
  // The framework handles storage + upsert semantics; the snapshot policy
434
- // (every N events, every M minutes, on-demand) stays with the feature.
434
+ // (every N events, every M minutes, on-demand) stays with the feature
435
+ // or pass { snapshotEvery } to loadAggregateWithSnapshot for the common case.
435
436
  // Snapshots are a perf optimisation — the event log remains the source
436
437
  // of truth.
437
438
  readonly snapshotAggregate: (args: {
@@ -439,6 +440,9 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
439
440
  readonly aggregateType: string;
440
441
  readonly version: number;
441
442
  readonly state: Record<string, unknown>;
443
+ // Reducer-shape generation stamped onto the snapshot (default 1) — see
444
+ // loadAggregateWithSnapshot's snapshotVersion option.
445
+ readonly snapshotVersion?: number;
442
446
  }) => Promise<void>;
443
447
 
444
448
  // Snapshot-aware rehydrate. Loads the latest snapshot (if any), runs the
@@ -454,6 +458,7 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
454
458
  aggregateId: string,
455
459
  reducer: import("../../event-store").SnapshotReducer<TState>,
456
460
  initial: TState,
461
+ options?: Omit<import("../../event-store").LoadAggregateWithSnapshotOptions, "upcastEvent">,
457
462
  ) => Promise<import("../../event-store").LoadAggregateWithSnapshotResult<TState>>;
458
463
 
459
464
  // Read rows from a registered projection table, tenant-scoped to the
@@ -721,6 +726,17 @@ export type EventUpcastCtx = {
721
726
 
722
727
  export type EventUpcastFn = (payload: unknown, ctx: EventUpcastCtx) => unknown | Promise<unknown>;
723
728
 
729
+ // Declarative single-step migration — common payload transforms without an
730
+ // imperative function. Applied in fixed order: rename → default → map.
731
+ export type DeclarativeEventMigration = {
732
+ // old key → new key; a missing source key is a no-op
733
+ readonly rename?: Readonly<Record<string, string>>;
734
+ // set only when the key is absent — never overwrites an existing value
735
+ readonly default?: Readonly<Record<string, unknown>>;
736
+ // per-key value transform; skipped when the key is absent
737
+ readonly map?: Readonly<Record<string, (value: unknown) => unknown>>;
738
+ };
739
+
724
740
  export type EventMigrationDef = {
725
741
  // Qualified event name, matching r.defineEvent(...).name.
726
742
  readonly eventName: string;
@@ -133,6 +133,7 @@ export type {
133
133
  ClaimKeyHandle,
134
134
  ClaimKeyJsType,
135
135
  ClaimKeyType,
136
+ DeclarativeEventMigration,
136
137
  EntityRef,
137
138
  EventDef,
138
139
  EventMigrationDef,
@@ -242,3 +242,89 @@ describe("snapshot-store — loadAggregateWithSnapshot", () => {
242
242
  expect(archived.state.count).toBe(10);
243
243
  });
244
244
  });
245
+
246
+ describe("snapshot-store — auto-snapshot policy (snapshotEvery)", () => {
247
+ test("first load persists a snapshot at the folded version; second load hits it", async () => {
248
+ const aggId = await seedAggregate(250);
249
+
250
+ const first = await loadAggregateWithSnapshot<CounterState>(
251
+ bun.db,
252
+ aggId,
253
+ tenant,
254
+ reducer,
255
+ initial,
256
+ { snapshotEvery: 100 },
257
+ );
258
+ expect(first.snapshotHit).toBe(false);
259
+ expect(first.version).toBe(250);
260
+ expect(first.state).toEqual(await loadFullState(aggId));
261
+
262
+ const stored = await loadLatestSnapshot<CounterState>(bun.db, aggId, tenant);
263
+ expect(stored?.version).toBe(250);
264
+ expect(stored?.snapshotVersion).toBe(1);
265
+ expect(stored?.aggregateType).toBe("counter");
266
+
267
+ const second = await loadAggregateWithSnapshot<CounterState>(
268
+ bun.db,
269
+ aggId,
270
+ tenant,
271
+ reducer,
272
+ initial,
273
+ { snapshotEvery: 100 },
274
+ );
275
+ expect(second.snapshotHit).toBe(true);
276
+ expect(second.state).toEqual(first.state);
277
+ expect(second.version).toBe(250);
278
+ });
279
+
280
+ test("fewer folded events than snapshotEvery → no snapshot written", async () => {
281
+ const aggId = await seedAggregate(10);
282
+ await loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
283
+ snapshotEvery: 100,
284
+ });
285
+ expect(await loadLatestSnapshot(bun.db, aggId, tenant)).toBeNull();
286
+ });
287
+
288
+ test("snapshotVersion bump ignores the stored snapshot and restamps on auto-save", async () => {
289
+ const aggId = await seedAggregate(120);
290
+ await loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
291
+ snapshotEvery: 100,
292
+ });
293
+
294
+ // Shape bump: the generation-1 snapshot must be ignored (full replay),
295
+ // and the auto-save restamps generation 2 at the same event version.
296
+ const bumped = await loadAggregateWithSnapshot<CounterState>(
297
+ bun.db,
298
+ aggId,
299
+ tenant,
300
+ reducer,
301
+ initial,
302
+ { snapshotEvery: 100, snapshotVersion: 2 },
303
+ );
304
+ expect(bumped.snapshotHit).toBe(false);
305
+ expect(bumped.state).toEqual(await loadFullState(aggId));
306
+
307
+ const restamped = await loadLatestSnapshot<CounterState>(bun.db, aggId, tenant);
308
+ expect(restamped?.snapshotVersion).toBe(2);
309
+ expect(restamped?.version).toBe(120);
310
+
311
+ const third = await loadAggregateWithSnapshot<CounterState>(
312
+ bun.db,
313
+ aggId,
314
+ tenant,
315
+ reducer,
316
+ initial,
317
+ { snapshotEvery: 100, snapshotVersion: 2 },
318
+ );
319
+ expect(third.snapshotHit).toBe(true);
320
+ });
321
+
322
+ test("invalid snapshotEvery fails loud", async () => {
323
+ const aggId = await seedAggregate(1);
324
+ await expect(
325
+ loadAggregateWithSnapshot<CounterState>(bun.db, aggId, tenant, reducer, initial, {
326
+ snapshotEvery: 0,
327
+ }),
328
+ ).rejects.toThrow(/snapshotEvery/);
329
+ });
330
+ });
@@ -41,6 +41,7 @@ export {
41
41
  export { toStoredEvent } from "./row-to-stored-event";
42
42
  export {
43
43
  createSnapshotsTable,
44
+ type LoadAggregateWithSnapshotOptions,
44
45
  type LoadAggregateWithSnapshotResult,
45
46
  loadAggregateWithSnapshot,
46
47
  loadLatestSnapshot,
@@ -12,7 +12,7 @@ import {
12
12
  text,
13
13
  uuid,
14
14
  } from "../db/dialect";
15
- import { upsertSnapshot } from "../db/queries/event-store";
15
+ import { ensureSnapshotVersionColumn, upsertSnapshot } from "../db/queries/event-store";
16
16
  import { selectMany } from "../db/query";
17
17
  import { tableExists } from "../db/schema-inspection";
18
18
  import type { TenantId } from "../engine/types";
@@ -30,18 +30,17 @@ import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
30
30
  // 3. loadEventsAfterVersion(aggregate, N) → only the delta
31
31
  // 4. reducer(snapshot, delta) → current state
32
32
  //
33
- // Write path: feature authors opt in via ctx.snapshotAggregate. Policy
34
- // (every N events, every M minutes, on-demand) is a feature-level decision
35
- // the framework only offers the storage primitive.
33
+ // Write path: manual via ctx.snapshotAggregate, or automatic via the
34
+ // { snapshotEvery: N } load option the read path persists a fresh
35
+ // snapshot whenever it folded at least N delta events.
36
36
  //
37
- // Schema-migration policy: NO built-in snapshot versioning. A snapshot stores
38
- // the aggregate state in the reducer's current shape. When the reducer's
39
- // shape changes (added field, renamed property, moved compound), invalidate
40
- // the cacheDELETE from kumiko_snapshots WHERE aggregate_type = '...'.
41
- // The read path then falls back to full replay (which runs the upcaster
42
- // chain on events) until the next snapshotAggregate call. Cheaper than a
43
- // second migration mechanism; snapshots are a perf optimisation, not a
44
- // source of truth.
37
+ // Schema-migration policy: explicit generations, no reducer hashing. A
38
+ // snapshot stores the aggregate state in the reducer's current shape plus
39
+ // a caller-declared snapshot_version. Bump { snapshotVersion } when the
40
+ // reducer's shape changes stored snapshots with another generation are
41
+ // ignored (full replay through the upcaster chain on events) and restamped
42
+ // on the next auto-save. Snapshots stay a perf optimisation, not a source
43
+ // of truth.
45
44
  //
46
45
  // Upcaster interaction: the raw API (loadAggregateWithSnapshot below) does
47
46
  // NOT apply the upcaster chain on delta events — same layering as raw
@@ -63,6 +62,9 @@ export const snapshotsTable = pgTable(
63
62
  // returns events with version > this value.
64
63
  version: integer("version").notNull(),
65
64
  state: jsonb("state").$type<Record<string, unknown>>().notNull(),
65
+ // Reducer-shape generation — snapshots from another generation are
66
+ // ignored on load. See LoadAggregateWithSnapshotOptions.snapshotVersion.
67
+ snapshotVersion: integer("snapshot_version").notNull().default(sql`1`),
66
68
  createdAt: instant("created_at", { precision: 3 }).notNull().default(sql`now()`),
67
69
  },
68
70
  (t) => ({
@@ -76,7 +78,13 @@ export const snapshotsTable = pgTable(
76
78
 
77
79
  export async function createSnapshotsTable(db: DbConnection): Promise<void> {
78
80
  // skip: table already exists — idempotent boot + test-setup call
79
- if (await tableExists(db, "public.kumiko_snapshots")) return;
81
+ if (await tableExists(db, "public.kumiko_snapshots")) {
82
+ // Installs that predate snapshot_version get healed by the same
83
+ // idempotent ensure that `kumiko schema apply` / test-setup already runs.
84
+ await ensureSnapshotVersionColumn(db);
85
+ // skip: table already ensured — only the column heal above was needed
86
+ return;
87
+ }
80
88
  await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
81
89
  }
82
90
 
@@ -86,6 +94,7 @@ export type Snapshot<TState extends Record<string, unknown> = Record<string, unk
86
94
  readonly aggregateType: string;
87
95
  readonly version: number;
88
96
  readonly state: TState;
97
+ readonly snapshotVersion: number;
89
98
  readonly createdAt: Temporal.Instant;
90
99
  };
91
100
 
@@ -95,6 +104,8 @@ export type SaveSnapshotArgs = {
95
104
  readonly aggregateType: string;
96
105
  readonly version: number;
97
106
  readonly state: Record<string, unknown>;
107
+ // Reducer-shape generation (default 1) — see LoadAggregateWithSnapshotOptions.
108
+ readonly snapshotVersion?: number;
98
109
  };
99
110
 
100
111
  // Upsert-style save so re-snapshotting the same (aggregateId, version) is
@@ -108,6 +119,7 @@ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promis
108
119
  aggregateType: args.aggregateType,
109
120
  version: args.version,
110
121
  state: args.state,
122
+ snapshotVersion: args.snapshotVersion ?? 1,
111
123
  });
112
124
  }
113
125
 
@@ -123,6 +135,7 @@ export async function loadLatestSnapshot<
123
135
  aggregateType: string;
124
136
  version: number;
125
137
  state: unknown;
138
+ snapshotVersion: number;
126
139
  createdAt: Temporal.Instant;
127
140
  };
128
141
  const rows = await selectMany<SnapRow>(
@@ -139,6 +152,7 @@ export async function loadLatestSnapshot<
139
152
  aggregateType: row.aggregateType,
140
153
  version: row.version,
141
154
  state: row.state as TState, // @cast-boundary engine-payload
155
+ snapshotVersion: row.snapshotVersion,
142
156
  createdAt: row.createdAt,
143
157
  };
144
158
  }
@@ -167,6 +181,15 @@ export type LoadAggregateWithSnapshotOptions = {
167
181
  // r.eventMigration so feature code always sees current-version payloads.
168
182
  // Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
169
183
  readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
184
+ // Auto-snapshot policy: when the fold applied at least this many delta
185
+ // events, persist a fresh snapshot at the folded version (best-effort —
186
+ // a failed save never fails the load). Omit to keep snapshotting manual.
187
+ readonly snapshotEvery?: number;
188
+ // Reducer-shape generation stamped onto saved snapshots (default 1). A
189
+ // stored snapshot with a different generation is ignored — full replay
190
+ // through the upcaster chain — and restamped on the next auto-save. Bump
191
+ // whenever the reducer's state shape changes.
192
+ readonly snapshotVersion?: number;
170
193
  };
171
194
 
172
195
  // Snapshot-aware rehydrate. Loads the latest snapshot (if any), applies
@@ -192,7 +215,17 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
192
215
  return { state: initial, version: 0, snapshotHit: false };
193
216
  }
194
217
  }
195
- const snapshot = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
218
+ const shapeVersion = options?.snapshotVersion ?? 1;
219
+ if (
220
+ options?.snapshotEvery !== undefined &&
221
+ (!Number.isInteger(options.snapshotEvery) || options.snapshotEvery < 1)
222
+ ) {
223
+ throw new Error(
224
+ `loadAggregateWithSnapshot: snapshotEvery must be an integer >= 1, got ${String(options.snapshotEvery)}`,
225
+ );
226
+ }
227
+ const stored = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
228
+ const snapshot = stored && stored.snapshotVersion === shapeVersion ? stored : null;
196
229
  const baseState = snapshot ? snapshot.state : initial;
197
230
  const afterVersion = snapshot ? snapshot.version : 0;
198
231
  const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
@@ -204,6 +237,20 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
204
237
  }
205
238
  const lastDelta = delta[delta.length - 1];
206
239
  const latestVersion = lastDelta ? lastDelta.version : afterVersion;
240
+ if (options?.snapshotEvery !== undefined && lastDelta && delta.length >= options.snapshotEvery) {
241
+ try {
242
+ await saveSnapshot(db, {
243
+ aggregateId,
244
+ tenantId,
245
+ aggregateType: lastDelta.aggregateType,
246
+ version: latestVersion,
247
+ state,
248
+ snapshotVersion: shapeVersion,
249
+ });
250
+ } catch {
251
+ // Best-effort cache write — losing it only costs the next load a replay.
252
+ }
253
+ }
207
254
  return {
208
255
  state,
209
256
  version: latestVersion,
@@ -61,6 +61,7 @@ import {
61
61
  type StoredEvent,
62
62
  } from "../event-store/event-store";
63
63
  import {
64
+ type LoadAggregateWithSnapshotOptions,
64
65
  type LoadAggregateWithSnapshotResult,
65
66
  loadAggregateWithSnapshot,
66
67
  type SnapshotReducer,
@@ -451,6 +452,7 @@ export function createDispatcher(
451
452
  readonly aggregateType: string;
452
453
  readonly version: number;
453
454
  readonly state: Record<string, unknown>;
455
+ readonly snapshotVersion?: number;
454
456
  }): Promise<void> => {
455
457
  const dbSource = resolveDbSource(tx);
456
458
  if (!dbSource) {
@@ -464,12 +466,14 @@ export function createDispatcher(
464
466
  aggregateType: snapshotArgs.aggregateType,
465
467
  version: snapshotArgs.version,
466
468
  state: snapshotArgs.state,
469
+ snapshotVersion: snapshotArgs.snapshotVersion,
467
470
  });
468
471
  },
469
472
  loadAggregateWithSnapshot: async <TState extends Record<string, unknown>>(
470
473
  aggregateId: string,
471
474
  reducer: SnapshotReducer<TState>,
472
475
  initial: TState,
476
+ loadOptions?: Omit<LoadAggregateWithSnapshotOptions, "upcastEvent">,
473
477
  ): Promise<LoadAggregateWithSnapshotResult<TState>> => {
474
478
  const dbSource = resolveDbSource(tx);
475
479
  if (!dbSource) {
@@ -490,7 +494,10 @@ export function createDispatcher(
490
494
  user.tenantId,
491
495
  reducer,
492
496
  initial,
493
- { upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx) }, // @wrapper-known semantic-alias
497
+ {
498
+ ...loadOptions,
499
+ upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
500
+ },
494
501
  );
495
502
  },
496
503
  queryProjection: async <T = Record<string, unknown>>(