@cosmicdrift/kumiko-framework 0.163.3 → 0.164.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/db/pg-error.ts +1 -1
- package/src/db/queries/event-store.ts +14 -0
- package/src/event-store/__tests__/event-store.integration.test.ts +124 -0
- package/src/event-store/event-store.ts +11 -6
- package/src/event-store/events-schema.ts +10 -3
- package/src/event-store/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.164.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.164.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.18",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.164.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
package/src/db/pg-error.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// unwrap both layers so callers don't have to know which layer produced the
|
|
4
4
|
// error. Used by the event-store to distinguish a unique-violation on the
|
|
5
5
|
// aggregate-version index (optimistic-concurrency conflict) from the one on
|
|
6
|
-
// the
|
|
6
|
+
// the idempotency-key index (caller-side replay signal).
|
|
7
7
|
|
|
8
8
|
export type PgErrorInfo = {
|
|
9
9
|
readonly code: string | undefined;
|
|
@@ -6,6 +6,20 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
|
|
|
6
6
|
await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Tenant-scoped partial unique index over metadata.idempotencyKey.
|
|
10
|
+
// Expression index straight on the jsonb column — no dedicated key column,
|
|
11
|
+
// so it needs no INSERT-path change and covers admin-api's raw appends too
|
|
12
|
+
// (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
|
|
13
|
+
// on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
|
|
14
|
+
// installs that predate the index without a table rebuild.
|
|
15
|
+
export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
|
|
16
|
+
await asRawClient(db).unsafe(
|
|
17
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
|
|
18
|
+
`("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
|
|
19
|
+
`WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export type SubsequentEventInsertParams = {
|
|
10
24
|
readonly aggregateId: string;
|
|
11
25
|
readonly aggregateType: string;
|
|
@@ -6,6 +6,7 @@ import { generateId as uuid } from "../../utils";
|
|
|
6
6
|
import {
|
|
7
7
|
append,
|
|
8
8
|
createEventsTable,
|
|
9
|
+
IdempotentAppendConflictError,
|
|
9
10
|
loadAggregate,
|
|
10
11
|
loadAggregateAsOf,
|
|
11
12
|
loadAllEventsByType,
|
|
@@ -93,6 +94,129 @@ describe("event-store: append + load", () => {
|
|
|
93
94
|
});
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
describe("event-store: idempotency-key conflict", () => {
|
|
98
|
+
test("second append with a reused idempotencyKey (new aggregate+version) throws IdempotentAppendConflictError", async () => {
|
|
99
|
+
const first = uuid();
|
|
100
|
+
const second = uuid();
|
|
101
|
+
const key = uuid();
|
|
102
|
+
|
|
103
|
+
await append(testDb.db, {
|
|
104
|
+
aggregateId: first,
|
|
105
|
+
aggregateType: "task",
|
|
106
|
+
tenantId: tenantA,
|
|
107
|
+
expectedVersion: 0,
|
|
108
|
+
type: "task.created",
|
|
109
|
+
payload: { title: "Orig" },
|
|
110
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// A retried command that re-runs after the Redis idempotency guard
|
|
114
|
+
// missed its window: different aggregate, fresh expectedVersion=0 — the
|
|
115
|
+
// aggregate-version unique index has nothing to say about this pair, so
|
|
116
|
+
// only the idempotency-key index catches the duplicate.
|
|
117
|
+
await expect(
|
|
118
|
+
append(testDb.db, {
|
|
119
|
+
aggregateId: second,
|
|
120
|
+
aggregateType: "task",
|
|
121
|
+
tenantId: tenantA,
|
|
122
|
+
expectedVersion: 0,
|
|
123
|
+
type: "task.created",
|
|
124
|
+
payload: { title: "Retry" },
|
|
125
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
126
|
+
}),
|
|
127
|
+
).rejects.toThrow(IdempotentAppendConflictError);
|
|
128
|
+
|
|
129
|
+
const events = await loadAggregate(testDb.db, second, tenantA);
|
|
130
|
+
expect(events).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("subsequent-event append (expectedVersion > 0) also enforces the idempotency key", async () => {
|
|
134
|
+
const aggregateId = uuid();
|
|
135
|
+
const key = uuid();
|
|
136
|
+
|
|
137
|
+
await append(testDb.db, {
|
|
138
|
+
aggregateId,
|
|
139
|
+
aggregateType: "task",
|
|
140
|
+
tenantId: tenantA,
|
|
141
|
+
expectedVersion: 0,
|
|
142
|
+
type: "task.created",
|
|
143
|
+
payload: { title: "Orig" },
|
|
144
|
+
metadata: { userId: userA },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await append(testDb.db, {
|
|
148
|
+
aggregateId,
|
|
149
|
+
aggregateType: "task",
|
|
150
|
+
tenantId: tenantA,
|
|
151
|
+
expectedVersion: 1,
|
|
152
|
+
type: "task.updated",
|
|
153
|
+
payload: { title: "V2" },
|
|
154
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Retry of the v2 update: goes through insertSubsequentEventRow's raw
|
|
158
|
+
// INSERT ... SELECT ... WHERE EXISTS path, not insertFirstEvent — the
|
|
159
|
+
// idempotency index must catch it there too.
|
|
160
|
+
await expect(
|
|
161
|
+
append(testDb.db, {
|
|
162
|
+
aggregateId,
|
|
163
|
+
aggregateType: "task",
|
|
164
|
+
tenantId: tenantA,
|
|
165
|
+
expectedVersion: 2,
|
|
166
|
+
type: "task.updated",
|
|
167
|
+
payload: { title: "V3-retry" },
|
|
168
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow(IdempotentAppendConflictError);
|
|
171
|
+
|
|
172
|
+
const events = await loadAggregate(testDb.db, aggregateId, tenantA);
|
|
173
|
+
expect(events).toHaveLength(2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("same idempotencyKey on a different tenant does not conflict", async () => {
|
|
177
|
+
const key = uuid();
|
|
178
|
+
|
|
179
|
+
const a = await append(testDb.db, {
|
|
180
|
+
aggregateId: uuid(),
|
|
181
|
+
aggregateType: "task",
|
|
182
|
+
tenantId: tenantA,
|
|
183
|
+
expectedVersion: 0,
|
|
184
|
+
type: "task.created",
|
|
185
|
+
payload: {},
|
|
186
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
187
|
+
});
|
|
188
|
+
const b = await append(testDb.db, {
|
|
189
|
+
aggregateId: uuid(),
|
|
190
|
+
aggregateType: "task",
|
|
191
|
+
tenantId: tenantB,
|
|
192
|
+
expectedVersion: 0,
|
|
193
|
+
type: "task.created",
|
|
194
|
+
payload: {},
|
|
195
|
+
metadata: { userId: userA, idempotencyKey: key },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(a.version).toBe(1);
|
|
199
|
+
expect(b.version).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
|
|
203
|
+
const events = await Promise.all(
|
|
204
|
+
Array.from({ length: 3 }, () =>
|
|
205
|
+
append(testDb.db, {
|
|
206
|
+
aggregateId: uuid(),
|
|
207
|
+
aggregateType: "task",
|
|
208
|
+
tenantId: tenantA,
|
|
209
|
+
expectedVersion: 0,
|
|
210
|
+
type: "task.created",
|
|
211
|
+
payload: {},
|
|
212
|
+
metadata: { userId: userA },
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
expect(events).toHaveLength(3);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
96
220
|
describe("event-store: optimistic concurrency", () => {
|
|
97
221
|
test("wrong expectedVersion throws VersionConflictError (no write)", async () => {
|
|
98
222
|
const aggregateId = uuid();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
2
|
import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
3
3
|
import type { DbRunner } from "../db";
|
|
4
|
-
import { isUniqueViolation } from "../db/pg-error";
|
|
4
|
+
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
5
5
|
import {
|
|
6
6
|
insertSubsequentEventRow,
|
|
7
7
|
notifyPgChannel,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { insertOne, selectMany } from "../db/query";
|
|
14
14
|
import type { TenantId } from "../engine/types";
|
|
15
15
|
import { isStreamArchived } from "./archive";
|
|
16
|
-
import { VersionConflictError } from "./errors";
|
|
16
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
|
|
17
17
|
import { eventsTable } from "./events-schema";
|
|
18
18
|
import { toStoredEvent } from "./row-to-stored-event";
|
|
19
19
|
|
|
@@ -91,10 +91,15 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
91
91
|
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
92
92
|
} catch (e) {
|
|
93
93
|
if (isUniqueViolation(e)) {
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
94
|
+
// Two unique constraints on this table: events_aggregate_version_uq
|
|
95
|
+
// (tenant_id, aggregate_id, version) — a concurrent writer won the
|
|
96
|
+
// race to the next version — and events_idempotency_uq (tenant_id,
|
|
97
|
+
// metadata->>'idempotencyKey') — the caller reused an idempotency key.
|
|
98
|
+
// constraintOf() tells them apart; unknown/renamed constraint falls
|
|
99
|
+
// back to VersionConflictError, the pre-existing behaviour.
|
|
100
|
+
if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
|
|
101
|
+
throw new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
|
|
102
|
+
}
|
|
98
103
|
throw new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
99
104
|
}
|
|
100
105
|
throw e;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
uniqueIndex,
|
|
13
13
|
uuid,
|
|
14
14
|
} from "../db/dialect";
|
|
15
|
+
import { ensureIdempotencyKeyIndex } from "../db/queries/event-store";
|
|
15
16
|
import { unsafePushTables } from "../stack";
|
|
16
17
|
import { createArchivedStreamsTable } from "./archive";
|
|
17
18
|
import { createSnapshotsTable } from "./snapshot";
|
|
@@ -22,9 +23,11 @@ import type { EventMetadata } from "./types";
|
|
|
22
23
|
// INSERT ... SELECT ... WHERE EXISTS isn't ergonomic in the typed builder.
|
|
23
24
|
//
|
|
24
25
|
// HTTP-level retry idempotency is handled by pipeline/idempotency.ts
|
|
25
|
-
// (Redis-backed check + cached-response replay).
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// (Redis-backed check + cached-response replay); metadata.requestId is
|
|
27
|
+
// purely a trace marker (no uniqueness constraint — one request may write
|
|
28
|
+
// N events). Callers that need a hard per-event guarantee as a second line
|
|
29
|
+
// of defense set metadata.idempotencyKey, enforced by the tenant-scoped
|
|
30
|
+
// partial unique index ensureIdempotencyKeyIndex() creates below.
|
|
28
31
|
|
|
29
32
|
export const eventsTable = pgTable(
|
|
30
33
|
"kumiko_events",
|
|
@@ -77,6 +80,10 @@ export async function createEventsTable(db: DbConnection): Promise<void> {
|
|
|
77
80
|
if (!(await tableExists(db, "public.kumiko_events"))) {
|
|
78
81
|
await unsafePushTables(db, { kumikoEvents: eventsTable });
|
|
79
82
|
}
|
|
83
|
+
// Runs unconditionally (both fresh + already-existing table) so installs
|
|
84
|
+
// that predate the idempotency-key index get healed the same way
|
|
85
|
+
// ensureSnapshotVersionColumn heals kumiko_snapshots.
|
|
86
|
+
await ensureIdempotencyKeyIndex(db);
|
|
80
87
|
await createArchivedStreamsTable(db);
|
|
81
88
|
await createSnapshotsTable(db);
|
|
82
89
|
}
|
package/src/event-store/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
isStreamArchived,
|
|
13
13
|
restoreStream,
|
|
14
14
|
} from "./archive";
|
|
15
|
-
export { ArchivedStreamError, VersionConflictError } from "./errors";
|
|
15
|
+
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError } from "./errors";
|
|
16
16
|
export {
|
|
17
17
|
append,
|
|
18
18
|
EVENTS_PUBSUB_CHANNEL,
|