@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.163.3",
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.163.3",
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.163.3",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.164.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -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 request-id idempotency index (replay signal).
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
- // Only constraint left on the events table: events_aggregate_version_uq
95
- // on (tenant_id, aggregate_id, version). A unique violation here always
96
- // means a concurrent writer in the same tenant won the race to the
97
- // next version retry-able conflict.
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). The event-store itself
26
- // imposes no idempotency indexa single HTTP request may write N events
27
- // freely, metadata.requestId is purely a trace marker.
26
+ // (Redis-backed check + cached-response replay); metadata.requestId is
27
+ // purely a trace marker (no uniqueness constraintone 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
  }
@@ -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,