@cosmicdrift/kumiko-framework 0.143.0 → 0.145.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 +2 -2
- package/src/db/queries/event-store.ts +20 -3
- package/src/engine/__tests__/define-feature-crud.test.ts +55 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +52 -0
- package/src/engine/define-feature.ts +40 -2
- package/src/engine/types/feature.ts +8 -1
- package/src/engine/types/handlers.ts +17 -1
- package/src/engine/types/index.ts +1 -0
- package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
- package/src/event-store/index.ts +1 -0
- package/src/event-store/snapshot.ts +61 -14
- package/src/pipeline/dispatcher.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.145.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.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.145.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
|
-
[
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
+
});
|
package/src/event-store/index.ts
CHANGED
|
@@ -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:
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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:
|
|
38
|
-
// the aggregate state in the reducer's current shape
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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"))
|
|
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
|
|
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
|
-
{
|
|
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>>(
|