@cosmicdrift/kumiko-framework 0.347.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.
@@ -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.
@@ -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) {
@@ -34,6 +34,7 @@ const WRITE_HANDLER_KEY_KINDS = {
34
34
  additionalRateLimits: "modeled",
35
35
  escapeHatch: "modeled",
36
36
  tenantlessAnonymous: "opaque",
37
+ reserveBeforeTransaction: "opaque",
37
38
  perform: "opaque",
38
39
  };
39
40
  const QUERY_HANDLER_KEY_KINDS = {
@@ -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
- async function insertFirstEvent(db, event, newVersion, eventVersion) {
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 origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
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 origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
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.
@@ -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.347.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.347.0",
210
- "@cosmicdrift/kumiko-types": "0.347.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.347.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,11 @@
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
+ },
2
9
  {
3
10
  "version": "0.347.0",
4
11
  "type": "fix",