@cosmicdrift/kumiko-framework 0.346.0 → 0.348.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/dist/api/auth-routes.js +20 -5
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +3 -0
- package/dist/api/routes.js +36 -5
- package/dist/api/server.js +12 -10
- package/dist/db/queries/event-store.d.ts +1 -0
- package/dist/db/queries/event-store.js +3 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/event-store/event-store.js +23 -4
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/seed-clock.d.ts +15 -0
- package/dist/event-store/seed-clock.js +44 -0
- package/dist/pipeline/dispatch-batch.js +26 -10
- package/dist/pipeline/dispatch-shared.js +11 -0
- package/dist/pipeline/dispatch-write.js +9 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +16 -0
- package/dist/pipeline/pre-transaction-reservations.js +61 -0
- package/package.json +4 -4
- package/src/changes.json +25 -0
package/dist/api/auth-routes.js
CHANGED
|
@@ -320,6 +320,11 @@ export function createRedisLoginRateLimiter(redis, maxAttempts = 10, windowMs =
|
|
|
320
320
|
},
|
|
321
321
|
};
|
|
322
322
|
}
|
|
323
|
+
// A missing, malformed or non-object body reads as {} so callers answer with their own 400.
|
|
324
|
+
async function readJsonObjectOrEmpty(req) {
|
|
325
|
+
const raw = await req.json().catch(() => null);
|
|
326
|
+
return raw !== null && typeof raw === "object" ? raw : {};
|
|
327
|
+
}
|
|
323
328
|
export function createAuthRoutes(dispatcher, jwt, config) {
|
|
324
329
|
const api = new Hono();
|
|
325
330
|
// Default to "lax": CSRF control comes from the double-submit token, and
|
|
@@ -708,8 +713,12 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
708
713
|
}
|
|
709
714
|
// @cast-boundary engine-payload — generic dispatcher.write result for signup-confirm
|
|
710
715
|
const data = result.data;
|
|
711
|
-
if (data.kind !== "auth-session")
|
|
712
|
-
|
|
716
|
+
if (data.kind !== "auth-session") {
|
|
717
|
+
// No session yet, but the account and a claimed handover exist: hand
|
|
718
|
+
// the signup landing to the client so the login that follows lands
|
|
719
|
+
// where a signup without the MFA gate would have.
|
|
720
|
+
return pendingMfaStepResponse(c, data, landingPathFragment({ flow: "signup", ...data.signup }));
|
|
721
|
+
}
|
|
713
722
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
714
723
|
const token = await mintSessionAndRespond(c, data.session);
|
|
715
724
|
const landingPath = landingPathFragment({
|
|
@@ -935,7 +944,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
935
944
|
// POST /auth/switch-tenant — switch to a different tenant
|
|
936
945
|
api.post(Routes.authSwitchTenant, async (c) => {
|
|
937
946
|
const user = getUser(c);
|
|
938
|
-
const body = await c.req
|
|
947
|
+
const body = await readJsonObjectOrEmpty(c.req);
|
|
939
948
|
// The id seeds a SYSTEM identity in resolveActiveMembership — reject anything
|
|
940
949
|
// that is not a non-empty string before it gets that far.
|
|
941
950
|
if (!isNonEmptyString(body.tenantId)) {
|
|
@@ -1028,15 +1037,21 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
1028
1037
|
});
|
|
1029
1038
|
return api;
|
|
1030
1039
|
}
|
|
1031
|
-
function pendingMfaStepResponse(c, step) {
|
|
1040
|
+
function pendingMfaStepResponse(c, step, landing = {}) {
|
|
1032
1041
|
if (step.kind === "mfa-setup-required") {
|
|
1033
1042
|
return c.json({
|
|
1034
1043
|
isSuccess: true,
|
|
1035
1044
|
mfaSetupRequired: true,
|
|
1036
1045
|
preauthSetupToken: step.preauthSetupToken,
|
|
1046
|
+
...landing,
|
|
1037
1047
|
});
|
|
1038
1048
|
}
|
|
1039
|
-
return c.json({
|
|
1049
|
+
return c.json({
|
|
1050
|
+
isSuccess: true,
|
|
1051
|
+
mfaRequired: true,
|
|
1052
|
+
challengeToken: step.challengeToken,
|
|
1053
|
+
...landing,
|
|
1054
|
+
});
|
|
1040
1055
|
}
|
|
1041
1056
|
// --- shared route builders for token flows ---------------------------------
|
|
1042
1057
|
// Password-reset, email-verification and signup share the exact same request
|
|
@@ -29,6 +29,9 @@ export function runWithOrigin(origin, fn) {
|
|
|
29
29
|
// The outermost dispatch wins — a nested ctx.write/writeAs must not
|
|
30
30
|
// overwrite the entry handler that the irreversible-operation gate reads.
|
|
31
31
|
entryHandler: current?.entryHandler ?? origin.entryHandler,
|
|
32
|
+
chargedRateLimitBuckets: current?.entryHandler
|
|
33
|
+
? current.chargedRateLimitBuckets
|
|
34
|
+
: new Set(),
|
|
32
35
|
}, fn);
|
|
33
36
|
}
|
|
34
37
|
// Clears entryHandler so the next dispatch inside fn becomes the new entry —
|
package/dist/api/routes.js
CHANGED
|
@@ -26,7 +26,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
26
26
|
const api = new Hono();
|
|
27
27
|
api.post(Routes.write, async (c) => {
|
|
28
28
|
const user = getUser(c);
|
|
29
|
-
const body = await c
|
|
29
|
+
const body = await readDispatchBody(c);
|
|
30
30
|
try {
|
|
31
31
|
assertPayloadDepthAllowed(body.payload);
|
|
32
32
|
assertPatAllowed(user, body.type);
|
|
@@ -42,7 +42,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
42
42
|
});
|
|
43
43
|
api.post(Routes.batch, async (c) => {
|
|
44
44
|
const user = getUser(c);
|
|
45
|
-
const body = await c
|
|
45
|
+
const body = await readJsonObject(c);
|
|
46
46
|
if (!Array.isArray(body.commands)) {
|
|
47
47
|
// Client-shape violation → ValidationError (400, code=validation_error)
|
|
48
48
|
// matches what a Zod-level schema failure would produce if /batch had
|
|
@@ -91,7 +91,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
91
91
|
});
|
|
92
92
|
api.post(Routes.query, async (c) => {
|
|
93
93
|
const user = getUser(c);
|
|
94
|
-
const body = await c
|
|
94
|
+
const body = await readDispatchBody(c);
|
|
95
95
|
try {
|
|
96
96
|
assertPayloadDepthAllowed(body.payload);
|
|
97
97
|
assertPatAllowed(user, body.type);
|
|
@@ -104,7 +104,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
104
104
|
});
|
|
105
105
|
api.post(Routes.command, async (c) => {
|
|
106
106
|
const user = getUser(c);
|
|
107
|
-
const body = await c
|
|
107
|
+
const body = await readDispatchBody(c);
|
|
108
108
|
try {
|
|
109
109
|
assertPayloadDepthAllowed(body.payload);
|
|
110
110
|
assertPatAllowed(user, body.type);
|
|
@@ -127,7 +127,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
127
127
|
// queryErrorResponse instead of a flushed-200 error frame (framework#1517).
|
|
128
128
|
api.post(Routes.stream, async (c) => {
|
|
129
129
|
const user = getUser(c);
|
|
130
|
-
const body = await c
|
|
130
|
+
const body = await readDispatchBody(c);
|
|
131
131
|
const requestId = requestContext.get()?.requestId;
|
|
132
132
|
let generator;
|
|
133
133
|
try {
|
|
@@ -255,6 +255,37 @@ function payloadDepth(value, depth) {
|
|
|
255
255
|
}
|
|
256
256
|
return max;
|
|
257
257
|
}
|
|
258
|
+
function invalidBodyError(path, expected, received) {
|
|
259
|
+
return new ValidationError({
|
|
260
|
+
fields: [
|
|
261
|
+
{
|
|
262
|
+
path,
|
|
263
|
+
code: "invalid_type",
|
|
264
|
+
i18nKey: "errors.validation.invalid_type",
|
|
265
|
+
params: { expected, received },
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// Malformed or non-object JSON is a client error; letting c.req.json() throw
|
|
271
|
+
// would surface as an unclassified 500.
|
|
272
|
+
// @cast-boundary request-body — shape is validated per route right after
|
|
273
|
+
async function readJsonObject(c) {
|
|
274
|
+
const raw = await c.req.json().catch(() => undefined);
|
|
275
|
+
if (raw === undefined)
|
|
276
|
+
throw invalidBodyError("body", "json", "malformed");
|
|
277
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
278
|
+
throw invalidBodyError("body", "object", raw === null ? "null" : typeof raw);
|
|
279
|
+
}
|
|
280
|
+
return raw;
|
|
281
|
+
}
|
|
282
|
+
async function readDispatchBody(c) {
|
|
283
|
+
const body = await readJsonObject(c);
|
|
284
|
+
if (typeof body.type !== "string" || body.type === "") {
|
|
285
|
+
throw invalidBodyError("type", "string", typeof body.type);
|
|
286
|
+
}
|
|
287
|
+
return { type: body.type, payload: body.payload, requestId: body.requestId };
|
|
288
|
+
}
|
|
258
289
|
// Rejects payloads nested deeper than MAX_PAYLOAD_DEPTH before they reach
|
|
259
290
|
// per-handler Zod validation — recursive Zod parsing (and any downstream
|
|
260
291
|
// jsonb serialization of custom-fields payloads) walks the same object
|
package/dist/api/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles
|
|
|
7
7
|
import { runsInLane } from "../engine/run-in.js";
|
|
8
8
|
import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
|
|
9
9
|
import { isFileField, } from "../engine/types/index.js";
|
|
10
|
+
import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
|
|
10
11
|
import { createFileContext } from "../files/file-handle.js";
|
|
11
12
|
import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
|
|
12
13
|
import { makeFileProviderResolver } from "../files/provider-resolver.js";
|
|
@@ -65,6 +66,7 @@ export function withFileProviderResolver(registry, context, cacheSync) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
export function buildServer(options) {
|
|
69
|
+
assertSeedModeNotInProduction();
|
|
68
70
|
// Single effective hop-count for every IP-derived rate-limit + requestMeta
|
|
69
71
|
// below — the top-level option wins over the deprecated
|
|
70
72
|
// `auth.trustedProxyHops` fallback.
|
|
@@ -144,19 +146,19 @@ export function buildServer(options) {
|
|
|
144
146
|
throw new Error(`ServerOptions.instanceId / KUMIKO_INSTANCE_ID cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}" — ` +
|
|
145
147
|
`pick any other stable string.`);
|
|
146
148
|
}
|
|
147
|
-
// Warn when we fell back to a random UUID
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// KUMIKO_INSTANCE_ID is explicit — those are
|
|
154
|
-
// suite sets KUMIKO_INSTANCE_ID="test-instance"
|
|
149
|
+
// Warn when we fell back to a random UUID. The built-in SSE consumer is
|
|
150
|
+
// delivery="shared" (one cursor for all pods, fw#2625), so it no longer
|
|
151
|
+
// writes a row per instance. The id still labels this process in the
|
|
152
|
+
// app.started event and in per-instance consumers (opt-in delivery), where a
|
|
153
|
+
// fresh id per boot leaves an orphaned cursor-row that pins pruneEvents
|
|
154
|
+
// (retention-guard uses MIN(lastProcessedEventId) across all shards). Silent
|
|
155
|
+
// when options.instanceId or KUMIKO_INSTANCE_ID is explicit — those are
|
|
156
|
+
// deliberate choices (the test suite sets KUMIKO_INSTANCE_ID="test-instance").
|
|
155
157
|
const instanceIdWasRandom = options.instanceId === undefined && !process.env["KUMIKO_INSTANCE_ID"];
|
|
156
158
|
if (instanceIdWasRandom) {
|
|
157
159
|
console.warn(`[kumiko:boot] No ServerOptions.instanceId / KUMIKO_INSTANCE_ID set — generated a random UUID (${resolvedInstanceId}). ` +
|
|
158
|
-
`
|
|
159
|
-
`Set KUMIKO_INSTANCE_ID to
|
|
160
|
+
`SSE no longer needs a per-instance cursor, but the id labels this process in the app.started event and in per-instance consumers; without a stable id each restart leaves an orphaned cursor-row behind for those and pins events-retention on its last cursor. ` +
|
|
161
|
+
`Set KUMIKO_INSTANCE_ID to the pod name (e.g. from the Kubernetes downward API metadata.name) in production.`);
|
|
160
162
|
}
|
|
161
163
|
// Observability — Noop by default so no call-site needs to null-check.
|
|
162
164
|
// Every handler/middleware that reaches for ctx.tracer / ctx.metrics gets
|
|
@@ -14,6 +14,7 @@ export type SubsequentEventInsertParams = {
|
|
|
14
14
|
readonly metadata: Record<string, unknown>;
|
|
15
15
|
readonly createdBy: string;
|
|
16
16
|
readonly expectedVersion: number;
|
|
17
|
+
readonly createdAt?: string | undefined;
|
|
17
18
|
};
|
|
18
19
|
export type SubsequentEventInsertRow = {
|
|
19
20
|
readonly id: string | bigint;
|
|
@@ -104,11 +104,11 @@ function duplicateIdempotencyKeyErrorOr(e) {
|
|
|
104
104
|
export async function insertSubsequentEventRow(db, params) {
|
|
105
105
|
const rows = (await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
|
|
106
106
|
aggregate_id, aggregate_type, tenant_id, version,
|
|
107
|
-
type, event_version, payload, metadata, created_by
|
|
107
|
+
type, event_version, payload, metadata, created_by, created_at
|
|
108
108
|
)
|
|
109
109
|
SELECT $1::uuid, $2, $3::uuid, $4,
|
|
110
110
|
$5, $6, $7::jsonb,
|
|
111
|
-
$8::jsonb, $9
|
|
111
|
+
$8::jsonb, $9, COALESCE($11::timestamptz, now())
|
|
112
112
|
WHERE EXISTS (
|
|
113
113
|
SELECT 1 FROM "kumiko_events"
|
|
114
114
|
WHERE aggregate_id = $1::uuid
|
|
@@ -126,6 +126,7 @@ export async function insertSubsequentEventRow(db, params) {
|
|
|
126
126
|
params.metadata,
|
|
127
127
|
params.createdBy,
|
|
128
128
|
params.expectedVersion,
|
|
129
|
+
params.createdAt ?? null,
|
|
129
130
|
]));
|
|
130
131
|
return rows[0];
|
|
131
132
|
}
|
|
@@ -52,6 +52,7 @@ export function defineWriteHandler(def,
|
|
|
52
52
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
53
53
|
...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
|
|
54
54
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
55
|
+
...(def.reserveBeforeTransaction && { reserveBeforeTransaction: def.reserveBeforeTransaction }),
|
|
55
56
|
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
56
57
|
};
|
|
57
58
|
if ("perform" in def && def.perform !== undefined) {
|
|
@@ -107,6 +107,9 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
107
107
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
108
108
|
...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
|
|
109
109
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
110
|
+
...(def.reserveBeforeTransaction && {
|
|
111
|
+
reserveBeforeTransaction: def.reserveBeforeTransaction,
|
|
112
|
+
}),
|
|
110
113
|
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
111
114
|
// Forward the pipeline-build closure so boot-validators and
|
|
112
115
|
// Designer/AI tooling can inspect the step list. Absent on
|
|
@@ -16,6 +16,7 @@ import { isStreamArchived } from "./archive.js";
|
|
|
16
16
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
17
17
|
import { eventsTable } from "./events-schema.js";
|
|
18
18
|
import { toStoredEvent } from "./row-to-stored-event.js";
|
|
19
|
+
import { currentSeedCreatedAt } from "./seed-clock.js";
|
|
19
20
|
// Append one event atomically. Two guarantees combined:
|
|
20
21
|
//
|
|
21
22
|
// 1. UNIQUE (tenant_id, aggregate_id, version) serializes concurrent writers
|
|
@@ -48,13 +49,17 @@ export async function append(db, event) {
|
|
|
48
49
|
aggregateId: event.aggregateId,
|
|
49
50
|
});
|
|
50
51
|
const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
|
|
52
|
+
const seedCreatedAt = currentSeedCreatedAt();
|
|
53
|
+
if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
|
|
54
|
+
await assertSeedTimeNotBeforePredecessor(db, toStore, seedCreatedAt);
|
|
55
|
+
}
|
|
51
56
|
const newVersion = toStore.expectedVersion + 1;
|
|
52
57
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
53
58
|
try {
|
|
54
59
|
await claimXactId(db);
|
|
55
60
|
const row = toStore.expectedVersion === 0
|
|
56
|
-
? await insertFirstEvent(db, toStore, newVersion, eventVersion)
|
|
57
|
-
: await insertSubsequentEvent(db, toStore, newVersion, eventVersion);
|
|
61
|
+
? await insertFirstEvent(db, toStore, newVersion, eventVersion, seedCreatedAt)
|
|
62
|
+
: await insertSubsequentEvent(db, toStore, newVersion, eventVersion, seedCreatedAt);
|
|
58
63
|
// NOTIFY after the INSERT: outside a transaction each statement commits
|
|
59
64
|
// on its own, so a NOTIFY sent first would wake the dispatcher before
|
|
60
65
|
// the row exists.
|
|
@@ -95,7 +100,19 @@ function stampOrigin(event) {
|
|
|
95
100
|
},
|
|
96
101
|
};
|
|
97
102
|
}
|
|
98
|
-
|
|
103
|
+
// loadAggregateAsOf and projection rebuilds order by created_at, so a stream
|
|
104
|
+
// whose versions go backwards in time would read and replay wrongly.
|
|
105
|
+
async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
|
|
106
|
+
const [predecessor] = await selectMany(db, eventsTable, {
|
|
107
|
+
aggregateId: event.aggregateId,
|
|
108
|
+
tenantId: event.tenantId,
|
|
109
|
+
version: event.expectedVersion,
|
|
110
|
+
});
|
|
111
|
+
if (predecessor && predecessor.createdAt.epochMilliseconds > seedCreatedAt.epochMilliseconds) {
|
|
112
|
+
throw new Error(`Seed write for ${event.aggregateType} ${event.aggregateId} v${event.expectedVersion + 1} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessor.createdAt.toString()}). Write a stream's events in ascending time.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
|
|
99
116
|
const row = await insertOne(db, eventsTable, {
|
|
100
117
|
aggregateId: event.aggregateId,
|
|
101
118
|
aggregateType: event.aggregateType,
|
|
@@ -106,6 +123,7 @@ async function insertFirstEvent(db, event, newVersion, eventVersion) {
|
|
|
106
123
|
payload: event.payload,
|
|
107
124
|
metadata: event.metadata,
|
|
108
125
|
createdBy: event.metadata.userId,
|
|
126
|
+
...(seedCreatedAt !== undefined && { createdAt: seedCreatedAt }),
|
|
109
127
|
});
|
|
110
128
|
if (!row)
|
|
111
129
|
throw new Error("insertFirstEvent: INSERT RETURNING produced no row");
|
|
@@ -115,7 +133,7 @@ async function insertFirstEvent(db, event, newVersion, eventVersion) {
|
|
|
115
133
|
// INSERT … SELECT … WHERE EXISTS is awkward in the typed builder, so this
|
|
116
134
|
// one stays raw. Uses ${eventsTable} for the table reference so renames
|
|
117
135
|
// don't silently break the statement.
|
|
118
|
-
async function insertSubsequentEvent(db, event, newVersion, eventVersion) {
|
|
136
|
+
async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
|
|
119
137
|
const row = await insertSubsequentEventRow(db, {
|
|
120
138
|
aggregateId: event.aggregateId,
|
|
121
139
|
aggregateType: event.aggregateType,
|
|
@@ -127,6 +145,7 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion) {
|
|
|
127
145
|
metadata: event.metadata,
|
|
128
146
|
createdBy: event.metadata.userId,
|
|
129
147
|
expectedVersion: event.expectedVersion,
|
|
148
|
+
createdAt: seedCreatedAt?.toString(),
|
|
130
149
|
});
|
|
131
150
|
if (!row)
|
|
132
151
|
throw new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
@@ -7,6 +7,7 @@ export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
|
7
7
|
export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
|
|
8
8
|
export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadLetterRow, rebuildDeadLetterTable, recordRebuildDeadLetters, type SkippedApply, } from "./rebuild-dead-letter.js";
|
|
9
9
|
export { toStoredEvent } from "./row-to-stored-event.js";
|
|
10
|
+
export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
|
|
10
11
|
export { createSnapshotsTable, type LoadAggregateWithSnapshotOptions, type LoadAggregateWithSnapshotResult, loadAggregateWithSnapshot, loadLatestSnapshot, type SaveSnapshotArgs, type Snapshot, type SnapshotReducer, saveSnapshot, snapshotsTable, } from "./snapshot.js";
|
|
11
12
|
export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, type TransferAggregateStreamsArgs, transferAggregateStreams, } from "./transfer.js";
|
|
12
13
|
export { type EventUpcasters, makeUpcastCtx, type UpcasterErrorPolicy, type UpcastOptions, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
|
|
@@ -7,6 +7,7 @@ export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
|
7
7
|
export { appendProvenanceEvent } from "./provenance-append.js";
|
|
8
8
|
export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetterTable, recordRebuildDeadLetters, } from "./rebuild-dead-letter.js";
|
|
9
9
|
export { toStoredEvent } from "./row-to-stored-event.js";
|
|
10
|
+
export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
|
|
10
11
|
export { createSnapshotsTable, loadAggregateWithSnapshot, loadLatestSnapshot, saveSnapshot, snapshotsTable, } from "./snapshot.js";
|
|
11
12
|
export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, transferAggregateStreams, } from "./transfer.js";
|
|
12
13
|
export { makeUpcastCtx, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
|
+
export declare const SEED_MODE_ENV = "KUMIKO_TEST_SEED";
|
|
3
|
+
export declare class SeedModeDisabledError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function isSeedModeEnabled(env?: Readonly<Record<string, string | undefined>>): boolean;
|
|
7
|
+
export declare function assertSeedModeNotInProduction(env?: Readonly<Record<string, string | undefined>>): void;
|
|
8
|
+
/**
|
|
9
|
+
* Runs `fn` so that every event appended inside it (through the normal write
|
|
10
|
+
* path: handlers, validation, projections) is stored with `createdAt` instead
|
|
11
|
+
* of the database `now()`. Throws without running `fn` unless seed mode is on.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runSeedWritesAt<T>(createdAt: Temporal.Instant, fn: () => Promise<T>): Promise<T>;
|
|
14
|
+
/** Back-dated event time for the current scope, undefined outside runSeedWritesAt. */
|
|
15
|
+
export declare function currentSeedCreatedAt(): Temporal.Instant | undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
export const SEED_MODE_ENV = "KUMIKO_TEST_SEED";
|
|
4
|
+
export class SeedModeDisabledError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "SeedModeDisabledError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
// The only mode switch: the e2e/demo seed flag, never under production.
|
|
11
|
+
export function isSeedModeEnabled(env = process.env) {
|
|
12
|
+
return env[SEED_MODE_ENV] === "1" && env["NODE_ENV"] !== "production";
|
|
13
|
+
}
|
|
14
|
+
// A server booted with the seed flag AND a production NODE_ENV is a
|
|
15
|
+
// misconfiguration that would leave back-dating half-armed; refuse to boot.
|
|
16
|
+
export function assertSeedModeNotInProduction(env = process.env) {
|
|
17
|
+
if (env[SEED_MODE_ENV] === "1" && env["NODE_ENV"] === "production") {
|
|
18
|
+
throw new SeedModeDisabledError(`${SEED_MODE_ENV}=1 is set together with NODE_ENV=production. Seed mode back-dates events and must never run in production; unset ${SEED_MODE_ENV}.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Not part of requestContext on purpose: nothing parsed from a request can
|
|
22
|
+
// reach this store, only code that calls runSeedWritesAt in-process.
|
|
23
|
+
const seedClockStorage = new AsyncLocalStorage();
|
|
24
|
+
/**
|
|
25
|
+
* Runs `fn` so that every event appended inside it (through the normal write
|
|
26
|
+
* path: handlers, validation, projections) is stored with `createdAt` instead
|
|
27
|
+
* of the database `now()`. Throws without running `fn` unless seed mode is on.
|
|
28
|
+
*/
|
|
29
|
+
export async function runSeedWritesAt(createdAt, fn) {
|
|
30
|
+
if (!isSeedModeEnabled()) {
|
|
31
|
+
throw new SeedModeDisabledError(`runSeedWritesAt needs ${SEED_MODE_ENV}=1 and NODE_ENV!=="production"; nothing was written.`);
|
|
32
|
+
}
|
|
33
|
+
return seedClockStorage.run({ createdAt }, fn);
|
|
34
|
+
}
|
|
35
|
+
/** Back-dated event time for the current scope, undefined outside runSeedWritesAt. */
|
|
36
|
+
export function currentSeedCreatedAt() {
|
|
37
|
+
const scope = seedClockStorage.getStore();
|
|
38
|
+
if (scope === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!isSeedModeEnabled()) {
|
|
41
|
+
throw new SeedModeDisabledError("Seed scope is active but seed mode is off; refusing to back-date events.");
|
|
42
|
+
}
|
|
43
|
+
return scope.createdAt;
|
|
44
|
+
}
|
|
@@ -6,6 +6,7 @@ import { parseJsonSafe } from "../utils/safe-json.js";
|
|
|
6
6
|
import { resolveDbSource } from "./dispatch-shared.js";
|
|
7
7
|
import { executeNestedWrite } from "./dispatch-write.js";
|
|
8
8
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
9
|
+
import { reserveBeforeTransaction, runAsCommand, } from "./pre-transaction-reservations.js";
|
|
9
10
|
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
|
|
10
11
|
import { maskBatchResultForClient } from "./write-result-masking.js";
|
|
11
12
|
// afterCommit hooks fire in flushAfterCommit, outside the command's scope.
|
|
@@ -17,6 +18,15 @@ function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
|
|
|
17
18
|
afterCommitHooks[i] = () => runWithWriteOrigin(origin, original);
|
|
18
19
|
}
|
|
19
20
|
}
|
|
21
|
+
async function runReservedCommand(scope, cmd, index, tx) {
|
|
22
|
+
const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
|
|
23
|
+
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
|
|
24
|
+
origins.push(origin);
|
|
25
|
+
const hookStart = afterCommitHooks.length;
|
|
26
|
+
const res = await runAsCommand(reservations.reservedIndexes.has(index) ? cmd.type : undefined, () => runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
|
|
27
|
+
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
28
|
+
return res;
|
|
29
|
+
}
|
|
20
30
|
// Core batch logic extracted so write() and command() can reuse it
|
|
21
31
|
// (a single write = batch of one, running in its own transaction).
|
|
22
32
|
export async function runBatch(ctx, commands, user, requestId, inheritedOrigin) {
|
|
@@ -134,6 +144,17 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
134
144
|
return;
|
|
135
145
|
await runWithWriteOrigin(strictest, flushBatchHooksInner);
|
|
136
146
|
};
|
|
147
|
+
// Reserve before the transaction opens so no extra connection is held while the handler tx is
|
|
148
|
+
// open; the release below runs only after that tx has ended without committing.
|
|
149
|
+
const reservations = await reserveBeforeTransaction(ctx, commands, user, inheritedOrigin);
|
|
150
|
+
if (!reservations.isSuccess) {
|
|
151
|
+
return releaseOrFinalize({
|
|
152
|
+
isSuccess: false,
|
|
153
|
+
error: reservations.error,
|
|
154
|
+
failedIndex: reservations.failedIndex,
|
|
155
|
+
results,
|
|
156
|
+
}, reservations.error.httpStatus >= 500);
|
|
157
|
+
}
|
|
137
158
|
// batch() opens its own outer transaction — needs the top-level
|
|
138
159
|
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
139
160
|
const db = resolveDbSource(ctx, undefined);
|
|
@@ -145,15 +166,12 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
145
166
|
const cmd = commands[i];
|
|
146
167
|
if (!cmd)
|
|
147
168
|
continue;
|
|
148
|
-
const
|
|
149
|
-
origins.push(origin);
|
|
150
|
-
const hookStart = afterCommitHooks.length;
|
|
151
|
-
const res = await runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, undefined, afterCommitHooks));
|
|
152
|
-
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
169
|
+
const res = await runReservedCommand({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, cmd, i, undefined);
|
|
153
170
|
results.push(res);
|
|
154
171
|
if (!res.isSuccess) {
|
|
155
172
|
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
156
173
|
// matching the semantic "failure = side-effects don't fire".
|
|
174
|
+
await reservations.releaseAll();
|
|
157
175
|
return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
|
|
158
176
|
}
|
|
159
177
|
}
|
|
@@ -168,11 +186,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
168
186
|
const cmd = commands[i];
|
|
169
187
|
if (!cmd)
|
|
170
188
|
continue;
|
|
171
|
-
const
|
|
172
|
-
origins.push(origin);
|
|
173
|
-
const hookStart = afterCommitHooks.length;
|
|
174
|
-
const res = await runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks));
|
|
175
|
-
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
189
|
+
const res = await runReservedCommand({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, cmd, i, tx);
|
|
176
190
|
results.push(res);
|
|
177
191
|
if (!res.isSuccess) {
|
|
178
192
|
throw new BatchRollback(i, res.error);
|
|
@@ -182,6 +196,8 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
182
196
|
});
|
|
183
197
|
}
|
|
184
198
|
catch (e) {
|
|
199
|
+
// Rollback or failed COMMIT: the transaction is over, so the reserved capacity goes back.
|
|
200
|
+
await reservations.releaseAll();
|
|
185
201
|
if (e instanceof BatchRollback) {
|
|
186
202
|
// Thrown inside the callback, so the tx rolled back. A 4xx is
|
|
187
203
|
// deterministic and stays cached; a 5xx may be transient.
|
|
@@ -910,12 +910,23 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
|
|
|
910
910
|
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
911
911
|
});
|
|
912
912
|
}
|
|
913
|
+
// A nested dispatch re-hitting a bucket its entry dispatch already charged
|
|
914
|
+
// (e.g. a per:"ip" handler delegating via queryAs) must not cost a second token.
|
|
915
|
+
// The mark is set only after a successful enforce and includes the limit
|
|
916
|
+
// parameters: a denied or failed check never lets a later nested call through,
|
|
917
|
+
// and a nested handler with a stricter limit on the same bucket is still checked.
|
|
918
|
+
const charged = reqCtx?.chargedRateLimitBuckets;
|
|
919
|
+
const chargedKey = `${bucket.key}|${effective.limit}|${effective.windowSeconds}|${effective.cost}`;
|
|
920
|
+
// skip: this exact bucket, limit and window was already charged by the entry dispatch
|
|
921
|
+
if (charged?.has(chargedKey))
|
|
922
|
+
return;
|
|
913
923
|
try {
|
|
914
924
|
await context.rateLimit.enforce(bucket.key, {
|
|
915
925
|
limit: effective.limit,
|
|
916
926
|
windowSeconds: effective.windowSeconds,
|
|
917
927
|
cost: effective.cost,
|
|
918
928
|
});
|
|
929
|
+
charged?.add(chargedKey);
|
|
919
930
|
}
|
|
920
931
|
catch (error) {
|
|
921
932
|
// Backend failure (Redis down): fail closed as 503, same as L1/L2.
|
|
@@ -15,6 +15,7 @@ import { assertNoSecretLeak } from "../secrets/index.js";
|
|
|
15
15
|
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, checkFeatureEnabled, enforcePayloadRateLimits, enforceRateLimit, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
16
16
|
import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
17
17
|
import { handlerAccessError } from "./handler-access-error.js";
|
|
18
|
+
import { consumeReservation } from "./pre-transaction-reservations.js";
|
|
18
19
|
import { runProjections } from "./projections-runner.js";
|
|
19
20
|
function getTable(ctx, entityName) {
|
|
20
21
|
const { registry, tableCache } = ctx;
|
|
@@ -345,6 +346,14 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
345
346
|
if (!parsed.success) {
|
|
346
347
|
return writeFailure(validationErrorFromZod(parsed.error));
|
|
347
348
|
}
|
|
349
|
+
// A handler that reserves before the transaction cannot do so from inside an open one; running
|
|
350
|
+
// it unreserved would skip the check entirely, so a nested ctx.write of a different capped
|
|
351
|
+
// handler is rejected instead.
|
|
352
|
+
if (handler.reserveBeforeTransaction && !consumeReservation(type)) {
|
|
353
|
+
return writeFailure(new InternalError({
|
|
354
|
+
message: `handler "${type}" reserves before the transaction and cannot run as a nested write unless the top-level command reserved it`,
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
348
357
|
try {
|
|
349
358
|
await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, parsed.data);
|
|
350
359
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SessionUser } from "../engine/types/index.js";
|
|
2
|
+
import { type WriteErrorInfo } from "../errors/index.js";
|
|
3
|
+
import type { BatchCommand, DispatchContext } from "./dispatch-shared.js";
|
|
4
|
+
import type { WriteOrigin } from "./write-origin.js";
|
|
5
|
+
export declare function runAsCommand<T>(reservedType: string | undefined, fn: () => Promise<T>): Promise<T>;
|
|
6
|
+
export declare function consumeReservation(type: string): boolean;
|
|
7
|
+
export type PreTransactionReservations = {
|
|
8
|
+
readonly isSuccess: true;
|
|
9
|
+
readonly reservedIndexes: ReadonlySet<number>;
|
|
10
|
+
releaseAll(): Promise<void>;
|
|
11
|
+
} | {
|
|
12
|
+
readonly isSuccess: false;
|
|
13
|
+
readonly error: WriteErrorInfo;
|
|
14
|
+
readonly failedIndex: number;
|
|
15
|
+
};
|
|
16
|
+
export declare function reserveBeforeTransaction(ctx: DispatchContext, commands: readonly BatchCommand[], user: SessionUser, inheritedOrigin: WriteOrigin | undefined): Promise<PreTransactionReservations>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { hasAccess } from "../engine/access.js";
|
|
3
|
+
import { toWriteErrorInfo } from "../errors/index.js";
|
|
4
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
5
|
+
import { buildHandlerContext, checkFeatureEnabled, isMemberResolutionPrincipal, } from "./dispatch-shared.js";
|
|
6
|
+
import { wrapToKumiko } from "./dispatcher-utils.js";
|
|
7
|
+
import { effectiveWriteOrigin, rootWriteOrigin } from "./write-origin.js";
|
|
8
|
+
// One-shot permission per top-level command: the handler that reserved before the transaction
|
|
9
|
+
// consumes it on entry. A capped handler reached any other way (nested ctx.write, writeAs, a
|
|
10
|
+
// second execution of the same type) finds it spent and is rejected instead of running unreserved.
|
|
11
|
+
const reservedForCommand = new AsyncLocalStorage();
|
|
12
|
+
export function runAsCommand(reservedType, fn) {
|
|
13
|
+
return reservedForCommand.run(new Set(reservedType === undefined ? [] : [reservedType]), fn);
|
|
14
|
+
}
|
|
15
|
+
export function consumeReservation(type) {
|
|
16
|
+
return reservedForCommand.getStore()?.delete(type) === true;
|
|
17
|
+
}
|
|
18
|
+
async function releaseAll(ctx, releases) {
|
|
19
|
+
const log = createFallbackLogger("dispatcher", ctx.appContext.log);
|
|
20
|
+
for (const release of [...releases].reverse()) {
|
|
21
|
+
try {
|
|
22
|
+
await release();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
log.error("releasing a pre-transaction reservation failed", { error });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Same phase as the access/schema gates but before any transaction: a caller who would be denied
|
|
30
|
+
// or sends an invalid payload never reserves anything.
|
|
31
|
+
export async function reserveBeforeTransaction(ctx, commands, user, inheritedOrigin) {
|
|
32
|
+
const releases = [];
|
|
33
|
+
const reservedIndexes = new Set();
|
|
34
|
+
if (isMemberResolutionPrincipal(user))
|
|
35
|
+
return { isSuccess: true, reservedIndexes, releaseAll: async () => { } };
|
|
36
|
+
for (const [index, command] of commands.entries()) {
|
|
37
|
+
try {
|
|
38
|
+
const handler = ctx.registry.getWriteHandler(command.type);
|
|
39
|
+
if (!handler?.reserveBeforeTransaction)
|
|
40
|
+
continue;
|
|
41
|
+
if (!hasAccess(user, handler.access))
|
|
42
|
+
continue;
|
|
43
|
+
if (await checkFeatureEnabled(ctx, command.type, user.tenantId))
|
|
44
|
+
continue;
|
|
45
|
+
const parsed = handler.schema.safeParse(command.payload);
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
continue;
|
|
48
|
+
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, command.type, user), inheritedOrigin);
|
|
49
|
+
const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
|
|
50
|
+
const release = await handler.reserveBeforeTransaction({ type: command.type, payload: parsed.data, user }, handlerContext);
|
|
51
|
+
if (release)
|
|
52
|
+
releases.push(release);
|
|
53
|
+
reservedIndexes.add(index);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
await releaseAll(ctx, releases);
|
|
57
|
+
return { isSuccess: false, error: toWriteErrorInfo(wrapToKumiko(error)), failedIndex: index };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { isSuccess: true, reservedIndexes, releaseAll: () => releaseAll(ctx, releases) };
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.348.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>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.348.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.348.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.348.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.348.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "runSeedWritesAt runs regular writes with a caller-set event time, only in seed mode",
|
|
6
|
+
"detail": "runSeedWritesAt: regular writes with a caller-set event time for E2E and demo seeds\n`runSeedWritesAt(createdAt, fn)` from `@cosmicdrift/kumiko-framework/event-store` runs `fn` so that every event appended inside it is stored with `createdAt` instead of the database `now()`. The writes still go through the normal path (handler, validation, event append, projections), so projections that read the event time, such as entity `insertedAt`/`modifiedAt`, see the back-dated value. The helper only works with `KUMIKO_TEST_SEED=1` and `NODE_ENV` other than `production`; otherwise it throws `SeedModeDisabledError` before `fn` runs. `buildServer` now refuses to boot when `KUMIKO_TEST_SEED=1` is set together with `NODE_ENV=production`. Nothing from a request (payload, headers, seed-route body) can set the time; call the helper from an in-process seeder such as an `extraSeeders` entry or a `runDevApp` seed function. Events keep their global id order, so back-dated events are still delivered to consumers; only `created_at` order differs. A seed write dated before its stream predecessor is rejected, because `loadAggregateAsOf` and projection rebuilds order by `created_at`. Events written by async consumers or jobs in reaction to a seeded event are not back-dated; the seeder has to write those itself inside the scope. Await everything inside `fn`.",
|
|
7
|
+
"migration": "Seeders that need history (for example 90 days of status changes) wrap their dispatcher or ctx.write calls in `runSeedWritesAt(createdAt, async () => { ... })` instead of writing events or projection rows directly. Boot the server with `KUMIKO_TEST_SEED=1` and a non-production `NODE_ENV`; a boot with both the flag and `NODE_ENV=production` now fails. Within one aggregate stream, write the events in ascending time."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.347.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "Dispatch routes reject malformed or type-less request bodies with 400 instead of 500",
|
|
13
|
+
"detail": "Dispatch routes answer 400 for unusable request bodies\n`/api/write`, `/api/query`, `/api/command`, `/api/batch` and `/api/stream` return a validation error (400) for malformed JSON, a non-object body or a missing handler `type`. Before, those requests ended in an unclassified 500. `/api/auth/switch-tenant` treats a non-object body like a missing `tenantId`."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.347.0",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "Missing-instance-id warning and docs reflect shared SSE delivery and recommend the pod name",
|
|
19
|
+
"detail": "Boot warning and docs no longer claim SSE is per instance\nThe built-in SSE consumers share one cursor since fw#2625 and fw#2630. The missing-`KUMIKO_INSTANCE_ID` warning, the consumer-state comment and `docs/reference/infra-requirements.md` now say what the id is still for (the `app.started` event and per-instance consumers) and recommend the pod name."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.347.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "A request costs one rate-limit token per bucket even when the handler nests queryAs or ctx.query calls into handlers sharing that bucket",
|
|
25
|
+
"detail": "Nested dispatches no longer charge a rate-limit bucket twice\nA handler that calls `ctx.query`, `ctx.queryAs` or `ctx.write` into another handler with a limit on the same bucket (for example `per: \"ip\"`) used to cost two tokens per request. Each entry dispatch now charges a bucket once, and nested calls reuse that charge. Nested handlers that limit a different bucket are still limited, and every command of a batch still charges on its own. The public `/media` route had half its advertised limit because of this."
|
|
26
|
+
},
|
|
2
27
|
{
|
|
3
28
|
"version": "0.345.0",
|
|
4
29
|
"type": "fix",
|