@cosmicdrift/kumiko-framework 0.163.3 → 0.165.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/__tests__/consumer-cli.integration.test.ts +30 -0
- package/src/api/__tests__/api.test.ts +60 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -2
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
- package/src/api/routes.ts +41 -22
- package/src/api/server.ts +14 -6
- package/src/bun-db/connection.ts +3 -3
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +5 -12
- package/src/db/index.ts +0 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/queries/event-store.ts +14 -16
- package/src/engine/__tests__/boot-validator.test.ts +14 -0
- package/src/engine/__tests__/registry.test.ts +36 -0
- package/src/engine/boot-validator/entity-handler.ts +14 -25
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
- package/src/engine/feature-ast/extractors/handlers.ts +13 -18
- package/src/engine/registry-validate.ts +7 -2
- package/src/engine/types/index.ts +0 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +138 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
- package/src/event-store/event-store.ts +12 -23
- package/src/event-store/events-schema.ts +10 -3
- package/src/event-store/index.ts +1 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
- package/src/pipeline/dispatch-stream.ts +2 -7
- package/src/pipeline/event-dispatcher-delivery.ts +2 -1
- package/src/pipeline/system-hooks.ts +50 -1
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -45
|
@@ -258,6 +258,7 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
|
|
|
258
258
|
// from the parsed FeaturePattern shape alone.
|
|
259
259
|
const RAW_REF_FEATURE = `
|
|
260
260
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
261
|
+
import { chatCompleteHandler } from "./handlers";
|
|
261
262
|
|
|
262
263
|
const eventEntity = {
|
|
263
264
|
fields: { name: { type: "text", required: true } },
|
|
@@ -284,6 +285,7 @@ defineFeature("refs", (r) => {
|
|
|
284
285
|
r.entity("event", eventEntity);
|
|
285
286
|
r.entity("task", { fields: buildFields() });
|
|
286
287
|
r.writeHandler(makeHandler());
|
|
288
|
+
r.streamHandler(chatCompleteHandler);
|
|
287
289
|
r.screen(eventListScreen);
|
|
288
290
|
});
|
|
289
291
|
`;
|
|
@@ -297,6 +299,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
|
|
|
297
299
|
{ kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
|
|
298
300
|
{ kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
|
|
299
301
|
{ kind: "writeHandler", handlerName: undefined },
|
|
302
|
+
{ kind: "streamHandler", handlerName: undefined },
|
|
300
303
|
{ kind: "screen", definition: { __raw: "eventListScreen" } },
|
|
301
304
|
]);
|
|
302
305
|
});
|
|
@@ -310,6 +313,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
|
|
|
310
313
|
expect(rendered).toContain("eventEntity");
|
|
311
314
|
expect(rendered).toContain("buildFields()");
|
|
312
315
|
expect(rendered).toContain("r.writeHandler(makeHandler())");
|
|
316
|
+
expect(rendered).toContain("r.streamHandler(chatCompleteHandler)");
|
|
313
317
|
expect(rendered).toContain("r.screen(eventListScreen);");
|
|
314
318
|
// Would only appear if buildFields()'s return value got inlined.
|
|
315
319
|
expect(rendered).not.toContain("title:");
|
|
@@ -204,21 +204,24 @@ export function extractWriteHandler(
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
sourceFile: SourceFile,
|
|
210
|
-
): ExtractOutput<QueryHandlerPattern> {
|
|
211
|
-
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
212
|
-
if (parsed.kind === "error") return parsed;
|
|
213
|
-
return ok({
|
|
214
|
-
kind: "queryHandler",
|
|
207
|
+
function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { kind: "pattern" }>) {
|
|
208
|
+
return {
|
|
215
209
|
source: parsed.pattern.source,
|
|
216
210
|
handlerName: parsed.pattern.handlerName,
|
|
217
211
|
schemaSource: parsed.pattern.schemaSource,
|
|
218
212
|
handlerBody: parsed.pattern.handlerBody,
|
|
219
213
|
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
220
214
|
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
221
|
-
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function extractQueryHandler(
|
|
219
|
+
call: CallExpression,
|
|
220
|
+
sourceFile: SourceFile,
|
|
221
|
+
): ExtractOutput<QueryHandlerPattern> {
|
|
222
|
+
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
223
|
+
if (parsed.kind === "error") return parsed;
|
|
224
|
+
return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
export function extractStreamHandler(
|
|
@@ -227,13 +230,5 @@ export function extractStreamHandler(
|
|
|
227
230
|
): ExtractOutput<StreamHandlerPattern> {
|
|
228
231
|
const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
|
|
229
232
|
if (parsed.kind === "error") return parsed;
|
|
230
|
-
return ok({
|
|
231
|
-
kind: "streamHandler",
|
|
232
|
-
source: parsed.pattern.source,
|
|
233
|
-
handlerName: parsed.pattern.handlerName,
|
|
234
|
-
schemaSource: parsed.pattern.schemaSource,
|
|
235
|
-
handlerBody: parsed.pattern.handlerBody,
|
|
236
|
-
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
237
|
-
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
238
|
-
});
|
|
233
|
+
return ok({ kind: "streamHandler", ...readHandlerFields(parsed) });
|
|
239
234
|
}
|
|
@@ -564,7 +564,11 @@ export function validateEntityHookTargets(
|
|
|
564
564
|
}
|
|
565
565
|
|
|
566
566
|
export function validateJobTriggers(state: RegistryState): void {
|
|
567
|
-
// Validate: job event triggers must reference existing
|
|
567
|
+
// Validate: job event triggers must reference an existing write/query
|
|
568
|
+
// handler OR an existing r.defineEvent registration. The latter is
|
|
569
|
+
// delivered async via the job-trigger event-consumer (server.ts), not
|
|
570
|
+
// the synchronous write-handler dispatch path — see
|
|
571
|
+
// createJobTriggerEventConsumer in pipeline/system-hooks.ts.
|
|
568
572
|
// Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
|
|
569
573
|
// auch wenn nur einer fehlt fail-fast.
|
|
570
574
|
const allHandlers = allHandlerQns(state);
|
|
@@ -575,8 +579,9 @@ export function validateJobTriggers(state: RegistryState): void {
|
|
|
575
579
|
for (const t of triggers) {
|
|
576
580
|
const rawName = resolveName(t);
|
|
577
581
|
if (allHandlers.has(rawName)) continue;
|
|
582
|
+
if (state.eventMap.has(rawName)) continue;
|
|
578
583
|
throw new Error(
|
|
579
|
-
`Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
|
|
584
|
+
`Job "${jobName}" triggers on "${rawName}" but no handler or event with that name exists`,
|
|
580
585
|
);
|
|
581
586
|
}
|
|
582
587
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
|
|
2
2
|
// runtime helpers below that stay framework-side.
|
|
3
|
-
// Duplicate types (OnDeleteStrategy, ConfigScope, ConcurrencyMode, LifecycleHookType)
|
|
4
|
-
// are defined ONLY in constants.ts — re-exported here for backwards compatibility.
|
|
5
3
|
|
|
6
4
|
export type {
|
|
7
5
|
ConfigAccessor,
|
|
@@ -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,143 @@ 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
|
+
// Same tenant for both, unlike the different-aggregateId version this
|
|
204
|
+
// replaces — Postgres treats every NULL as distinct even in a plain
|
|
205
|
+
// unique index, so this does NOT exercise the partial index's `WHERE
|
|
206
|
+
// ... IS NOT NULL` clause specifically (that's provable only by a
|
|
207
|
+
// duplicate NON-null key, covered above). What this does pin: a fully-
|
|
208
|
+
// omitted key and an explicit `idempotencyKey: undefined` both serialize
|
|
209
|
+
// to a JSON-absent key (JSON.stringify drops undefined) and must behave
|
|
210
|
+
// identically, rather than one silently colliding on `"idempotencyKey":null`.
|
|
211
|
+
const omitted = await append(testDb.db, {
|
|
212
|
+
aggregateId: uuid(),
|
|
213
|
+
aggregateType: "task",
|
|
214
|
+
tenantId: tenantA,
|
|
215
|
+
expectedVersion: 0,
|
|
216
|
+
type: "task.created",
|
|
217
|
+
payload: {},
|
|
218
|
+
metadata: { userId: userA },
|
|
219
|
+
});
|
|
220
|
+
const explicitUndefined = await append(testDb.db, {
|
|
221
|
+
aggregateId: uuid(),
|
|
222
|
+
aggregateType: "task",
|
|
223
|
+
tenantId: tenantA,
|
|
224
|
+
expectedVersion: 0,
|
|
225
|
+
type: "task.created",
|
|
226
|
+
payload: {},
|
|
227
|
+
metadata: { userId: userA, idempotencyKey: undefined },
|
|
228
|
+
});
|
|
229
|
+
expect(omitted.version).toBe(1);
|
|
230
|
+
expect(explicitUndefined.version).toBe(1);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
96
234
|
describe("event-store: optimistic concurrency", () => {
|
|
97
235
|
test("wrong expectedVersion throws VersionConflictError (no write)", async () => {
|
|
98
236
|
const aggregateId = uuid();
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Glob } from "bun";
|
|
3
3
|
|
|
4
|
-
// getUnscopedAggregateStreamMaxVersion
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
const RESTRICTED_SYMBOLS = [
|
|
9
|
-
"getUnscopedAggregateStreamMaxVersion",
|
|
10
|
-
"getUnscopedAggregateStreamTenant",
|
|
11
|
-
];
|
|
4
|
+
// getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
|
|
5
|
+
// it to probe whether a foreign tenant's aggregate exists (see event-store.ts
|
|
6
|
+
// SECURITY doc). Restricted to known seed/system-internal callers; extend
|
|
7
|
+
// only for genuine new ones.
|
|
8
|
+
const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
|
|
12
9
|
|
|
13
10
|
const ALLOWED_FILES = new Set([
|
|
14
11
|
"packages/framework/src/event-store/event-store.ts",
|
|
@@ -1,19 +1,18 @@
|
|
|
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,
|
|
8
8
|
selectAggregateMaxVersion,
|
|
9
|
-
selectAggregateStreamTenant,
|
|
10
9
|
selectEventsHighWaterMark,
|
|
11
10
|
selectStreamMaxVersion,
|
|
12
11
|
} from "../db/queries/event-store";
|
|
13
12
|
import { insertOne, selectMany } from "../db/query";
|
|
14
13
|
import type { TenantId } from "../engine/types";
|
|
15
14
|
import { isStreamArchived } from "./archive";
|
|
16
|
-
import { VersionConflictError } from "./errors";
|
|
15
|
+
import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
|
|
17
16
|
import { eventsTable } from "./events-schema";
|
|
18
17
|
import { toStoredEvent } from "./row-to-stored-event";
|
|
19
18
|
|
|
@@ -91,10 +90,15 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
91
90
|
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
92
91
|
} catch (e) {
|
|
93
92
|
if (isUniqueViolation(e)) {
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
93
|
+
// Two unique constraints on this table: events_aggregate_version_uq
|
|
94
|
+
// (tenant_id, aggregate_id, version) — a concurrent writer won the
|
|
95
|
+
// race to the next version — and events_idempotency_uq (tenant_id,
|
|
96
|
+
// metadata->>'idempotencyKey') — the caller reused an idempotency key.
|
|
97
|
+
// constraintOf() tells them apart; unknown/renamed constraint falls
|
|
98
|
+
// back to VersionConflictError, the pre-existing behaviour.
|
|
99
|
+
if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
|
|
100
|
+
throw new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
|
|
101
|
+
}
|
|
98
102
|
throw new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
99
103
|
}
|
|
100
104
|
throw e;
|
|
@@ -255,21 +259,6 @@ export async function getUnscopedAggregateStreamMaxVersion(
|
|
|
255
259
|
return selectAggregateMaxVersion(db, aggregateId);
|
|
256
260
|
}
|
|
257
261
|
|
|
258
|
-
/** Stream tenant of an aggregate (the tenant_id its events live under), with no
|
|
259
|
-
* membership/tenant filter. SECURITY: existence-oracle, same caveat as
|
|
260
|
-
* getUnscopedAggregateStreamMaxVersion — seed/system-internal use only. Recovers
|
|
261
|
-
* the write target for a systemScope aggregate whose stream tenant isn't one of
|
|
262
|
-
* the subject's memberships. Returns null for unknown streams. */
|
|
263
|
-
export async function getUnscopedAggregateStreamTenant(
|
|
264
|
-
db: DbRunner,
|
|
265
|
-
aggregateId: string,
|
|
266
|
-
aggregateType: string,
|
|
267
|
-
): Promise<TenantId | null> {
|
|
268
|
-
const tenantId = await selectAggregateStreamTenant(db, aggregateId, aggregateType);
|
|
269
|
-
// DB-boundary: kumiko_events.tenant_id is a TenantId-shaped uuid column.
|
|
270
|
-
return tenantId as TenantId | null;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
262
|
// Global high-water-mark = MAX(events.id). Marten/Wolverine standard for
|
|
274
263
|
// projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
|
|
275
264
|
// the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
|
|
@@ -307,7 +296,7 @@ export async function loadEventsAfterVersion(
|
|
|
307
296
|
// prevent.
|
|
308
297
|
export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
|
|
309
298
|
|
|
310
|
-
/**
|
|
299
|
+
/** Buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) for production reads; this is test-only in practice. */
|
|
311
300
|
export async function loadAllEventsByType(
|
|
312
301
|
db: DbRunner,
|
|
313
302
|
aggregateType: string,
|
|
@@ -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,
|
|
@@ -21,7 +21,6 @@ export {
|
|
|
21
21
|
getEventsHighWaterMark,
|
|
22
22
|
getStreamVersion,
|
|
23
23
|
getUnscopedAggregateStreamMaxVersion,
|
|
24
|
-
getUnscopedAggregateStreamTenant,
|
|
25
24
|
LOAD_ALL_EVENTS_ROW_LIMIT,
|
|
26
25
|
loadAggregate,
|
|
27
26
|
loadAggregateAsOf,
|
|
@@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
4
4
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
5
|
+
import { createSecret } from "../../secrets/types";
|
|
5
6
|
import { createTestUser } from "../../stack";
|
|
6
7
|
import { createDispatcher } from "../dispatcher";
|
|
7
8
|
|
|
9
|
+
const streamCleanupState = { closed: false };
|
|
10
|
+
|
|
8
11
|
const echoFeature = defineFeature("echo", (r) => {
|
|
9
12
|
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
10
13
|
|
|
@@ -36,6 +39,29 @@ const echoFeature = defineFeature("echo", (r) => {
|
|
|
36
39
|
{ access: { roles: ["Admin"] } },
|
|
37
40
|
);
|
|
38
41
|
|
|
42
|
+
r.streamHandler(
|
|
43
|
+
"item:tail-leak",
|
|
44
|
+
z.object({}),
|
|
45
|
+
async function* () {
|
|
46
|
+
yield { apiKey: createSecret("leak-me") };
|
|
47
|
+
},
|
|
48
|
+
{ access: { roles: ["Admin"] } },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
r.streamHandler(
|
|
52
|
+
"item:tail-cleanup",
|
|
53
|
+
z.object({}),
|
|
54
|
+
async function* () {
|
|
55
|
+
try {
|
|
56
|
+
yield { i: 0 };
|
|
57
|
+
yield { i: 1 };
|
|
58
|
+
} finally {
|
|
59
|
+
streamCleanupState.closed = true;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ access: { roles: ["Admin"] } },
|
|
63
|
+
);
|
|
64
|
+
|
|
39
65
|
r.hook("validation", "item:create", (data) => {
|
|
40
66
|
if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
|
|
41
67
|
return null;
|
|
@@ -272,6 +298,32 @@ describe("dispatcher.stream", () => {
|
|
|
272
298
|
collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
|
|
273
299
|
).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
|
|
274
300
|
});
|
|
301
|
+
|
|
302
|
+
test("a chunk containing a Secret<> value aborts the stream instead of leaking it", async () => {
|
|
303
|
+
const dispatcher = createTestDispatcher();
|
|
304
|
+
|
|
305
|
+
await expect(
|
|
306
|
+
collect(
|
|
307
|
+
dispatcher.stream("echo:stream:item:tail-leak", {}, createTestUser({ roles: ["Admin"] })),
|
|
308
|
+
),
|
|
309
|
+
).rejects.toMatchObject({ message: expect.stringContaining("Secret<> leaked") });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("consumer breaking out of for-await runs the handler generator's finally block", async () => {
|
|
313
|
+
streamCleanupState.closed = false;
|
|
314
|
+
const dispatcher = createTestDispatcher();
|
|
315
|
+
const gen = dispatcher.stream(
|
|
316
|
+
"echo:stream:item:tail-cleanup",
|
|
317
|
+
{},
|
|
318
|
+
createTestUser({ roles: ["Admin"] }),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
for await (const _chunk of gen) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
expect(streamCleanupState.closed).toBe(true);
|
|
326
|
+
});
|
|
275
327
|
});
|
|
276
328
|
|
|
277
329
|
// --- postQuery hooks on standalone (entity-less) queries ---
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// createJobTriggerEventConsumer — proves r.job's trigger.on can fire on an
|
|
2
|
+
// r.defineEvent QN appended by a multiStreamProjection's unsafeAppendEvent
|
|
3
|
+
// (kumiko-framework#1505). Mirrors document-ingest-foundation's actual
|
|
4
|
+
// request-ingest MSP (upload → fileRef.created → an owned defineEvent),
|
|
5
|
+
// the motivating case for this fix — fileRef.created itself never reaches
|
|
6
|
+
// jobRunner.handleEvent because the upload route appends it via the raw
|
|
7
|
+
// event-store executor, not a write-handler dispatch (see #1505).
|
|
8
|
+
//
|
|
9
|
+
// Not covered here: a job triggered on a write/query-handler QN still
|
|
10
|
+
// firing exactly once (unaffected by the new consumer). The full suite
|
|
11
|
+
// stays green (e.g. the lane-routing sample), but that's not a positive
|
|
12
|
+
// test of the partition guard — no stored event's `type` is ever a
|
|
13
|
+
// handler QN in practice (entity events are "entity.verb"; custom
|
|
14
|
+
// write-handlers like lane-routing's don't append to the store at all),
|
|
15
|
+
// so `getWriteHandler`/`getQueryHandler` in the new consumer's handler is
|
|
16
|
+
// defense-in-depth for an input shape the framework doesn't currently
|
|
17
|
+
// produce, not something exercised end-to-end by any test today.
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { entityEventName } from "../../db";
|
|
22
|
+
import { defineFeature } from "../../engine";
|
|
23
|
+
import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
|
|
24
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
25
|
+
import { waitFor } from "../../testing";
|
|
26
|
+
|
|
27
|
+
const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
|
|
28
|
+
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
29
|
+
|
|
30
|
+
const processedItems: Array<{ readonly fileRefId: string }> = [];
|
|
31
|
+
|
|
32
|
+
const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
33
|
+
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }));
|
|
34
|
+
|
|
35
|
+
// Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
|
|
36
|
+
// to fileRef.created, appends a NEW event via unsafeAppendEvent — no
|
|
37
|
+
// write-handler behind the appended event itself.
|
|
38
|
+
r.multiStreamProjection({
|
|
39
|
+
name: "request-item",
|
|
40
|
+
apply: {
|
|
41
|
+
[FILE_REF_CREATED]: async (event, _tx, ctx) => {
|
|
42
|
+
await ctx.unsafeAppendEvent({
|
|
43
|
+
aggregateId: event.aggregateId,
|
|
44
|
+
aggregateType: "job-trigger-fixture-request",
|
|
45
|
+
type: ITEM_REQUESTED_EVENT_QN,
|
|
46
|
+
payload: { fileRefId: event.aggregateId },
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Under test: only reachable via createJobTriggerEventConsumer, since
|
|
53
|
+
// ITEM_REQUESTED_EVENT_QN is an r.defineEvent QN, not a handler QN.
|
|
54
|
+
r.job(
|
|
55
|
+
"process-item",
|
|
56
|
+
{ trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
|
|
57
|
+
async (payload) => {
|
|
58
|
+
processedItems.push({ fileRefId: payload["fileRefId"] as string });
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let stack: TestStack;
|
|
64
|
+
let provider: InMemoryFileProvider;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
provider = createInMemoryFileProvider();
|
|
68
|
+
stack = await setupTestStack({
|
|
69
|
+
features: [jobTriggerFixtureFeature],
|
|
70
|
+
files: { storageProvider: provider },
|
|
71
|
+
jobs: { consumerLane: "worker" },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await stack.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
processedItems.length = 0;
|
|
81
|
+
provider.clear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("job-trigger event consumer", () => {
|
|
85
|
+
test("a job triggers on an r.defineEvent QN appended by an MSP's unsafeAppendEvent", async () => {
|
|
86
|
+
const token = await stack.jwt.sign(TestUsers.admin);
|
|
87
|
+
const formData = new FormData();
|
|
88
|
+
formData.append("file", new File([Buffer.from("hello")], "note.txt", { type: "text/plain" }));
|
|
89
|
+
const res = await stack.app.request("/api/files", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
+
body: formData,
|
|
93
|
+
});
|
|
94
|
+
expect(res.status).toBe(201);
|
|
95
|
+
|
|
96
|
+
await waitFor(async () => {
|
|
97
|
+
// Drives both the MSP (appends item-requested off fileRef.created)
|
|
98
|
+
// and the new job-trigger consumer (reacts to it) — may need more
|
|
99
|
+
// than one pass since the MSP's append happens mid-drain.
|
|
100
|
+
await stack.eventDispatcher?.runOnce();
|
|
101
|
+
expect(processedItems).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(processedItems[0]?.fileRefId).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -707,30 +707,43 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
|
|
|
707
707
|
expect(seen).toEqual([[deletectx]]);
|
|
708
708
|
});
|
|
709
709
|
|
|
710
|
-
test
|
|
710
|
+
test.each([
|
|
711
|
+
[
|
|
712
|
+
"postSaveBatch",
|
|
713
|
+
(hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
|
|
714
|
+
({ postSaveBatch: hooks }) satisfies SystemHooks,
|
|
715
|
+
(pipeline: ReturnType<typeof createLifecycleHooks>) =>
|
|
716
|
+
pipeline.runPostSaveBatch([savectx], {}),
|
|
717
|
+
],
|
|
718
|
+
[
|
|
719
|
+
"postDeleteBatch",
|
|
720
|
+
(hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
|
|
721
|
+
({ postDeleteBatch: hooks }) satisfies SystemHooks,
|
|
722
|
+
(pipeline: ReturnType<typeof createLifecycleHooks>) =>
|
|
723
|
+
pipeline.runPostDeleteBatch([deletectx], {}),
|
|
724
|
+
],
|
|
725
|
+
])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
|
|
711
726
|
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
712
727
|
const calls: string[] = [];
|
|
713
|
-
const systemHooks
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
throw new Error("batch-hook-boom");
|
|
720
|
-
},
|
|
728
|
+
const systemHooks = buildHooks([
|
|
729
|
+
{
|
|
730
|
+
name: "failing",
|
|
731
|
+
priority: 1000,
|
|
732
|
+
fn: async () => {
|
|
733
|
+
throw new Error("batch-hook-boom");
|
|
721
734
|
},
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: "ok",
|
|
738
|
+
priority: 1001,
|
|
739
|
+
fn: async () => {
|
|
740
|
+
calls.push("ok-ran");
|
|
728
741
|
},
|
|
729
|
-
|
|
730
|
-
|
|
742
|
+
},
|
|
743
|
+
]);
|
|
731
744
|
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
732
745
|
// Must not throw.
|
|
733
|
-
await pipeline
|
|
746
|
+
await run(pipeline);
|
|
734
747
|
expect(calls).toEqual(["ok-ran"]);
|
|
735
748
|
expect(consoleSpy).toHaveBeenCalled();
|
|
736
749
|
consoleSpy.mockRestore();
|