@cosmicdrift/kumiko-framework 0.312.0 → 0.314.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.
Files changed (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/upgrade-cli.test.ts +28 -0
  3. package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
  4. package/src/api/auth-middleware.ts +30 -1
  5. package/src/api/auth-routes.ts +27 -2
  6. package/src/api/origin-middleware.ts +23 -20
  7. package/src/api/server.ts +10 -1
  8. package/src/changes.json +48 -0
  9. package/src/crypto/pg-kms-adapter.ts +2 -1
  10. package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
  11. package/src/db/api.ts +6 -0
  12. package/src/db/bun-provider.ts +8 -3
  13. package/src/db/connection.ts +3 -3
  14. package/src/db/eagerload.ts +2 -9
  15. package/src/db/index.ts +1 -1
  16. package/src/db/postgres-provider.ts +7 -4
  17. package/src/db/queries/event-consumer.ts +4 -3
  18. package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
  19. package/src/engine/boot-validator/transfer-graph.ts +2 -1
  20. package/src/engine/index.ts +3 -0
  21. package/src/engine/parse-ref-target.ts +6 -0
  22. package/src/engine/registry-validate.ts +3 -12
  23. package/src/engine/types/index.ts +1 -0
  24. package/src/files/__tests__/files.integration.test.ts +128 -1
  25. package/src/files/__tests__/storage-tracking.test.ts +46 -0
  26. package/src/files/file-routes.ts +10 -3
  27. package/src/files/index.ts +9 -1
  28. package/src/files/storage-tracking.ts +160 -15
  29. package/src/files/types.ts +57 -2
  30. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
  31. package/src/jobs/job-runner.ts +27 -0
  32. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
  33. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +34 -6
  34. package/src/pipeline/event-dispatcher-delivery.ts +22 -1
  35. package/src/pipeline/event-dispatcher.ts +25 -2
  36. package/src/pipeline/index.ts +4 -1
  37. package/src/pipeline/system-hooks.ts +6 -2
  38. package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
  39. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
  40. package/src/stack/drain-event-consumers.ts +74 -0
  41. package/src/stack/index.ts +1 -0
  42. package/src/stack/redis.ts +3 -0
  43. package/src/stack/test-stack.ts +13 -4
  44. package/src/upgrade-cli.ts +17 -3
@@ -468,7 +468,8 @@ export async function buildSearchDocument(
468
468
  // die auf optimistic-update setzen merken das nicht; strictly-waiting
469
469
  // Clients müssten poll-after-write.
470
470
  //
471
- // Tests drain deterministisch via `await stack.eventDispatcher.runOnce()`.
471
+ // Tests drain deterministically via `await stack.eventDispatcher.runOnce()`,
472
+ // or via `drainEventConsumers` for this consumer name specifically.
472
473
  export const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
473
474
 
474
475
  export function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventConsumer {
@@ -542,7 +543,10 @@ export function createJobTriggerEventConsumer(
542
543
  tenantId: event.tenantId,
543
544
  roles: [],
544
545
  };
545
- await jobRunner.handleEvent(event.type, event.payload, user);
546
+ await jobRunner.handleEvent(event.type, event.payload, user, {
547
+ id: event.id,
548
+ headers: event.metadata.headers ?? {},
549
+ });
546
550
  },
547
551
  };
548
552
  }
@@ -0,0 +1,166 @@
1
+ // drainEventConsumers — pass-budgeted alternative to
2
+ // `while ((await eventDispatcher.runOnce())?.processed ?? 0) > 0) {}`, which
3
+ // under-drains a backlog bigger than one dispatcher batch and gives no
4
+ // diagnostics when a consumer is stuck.
5
+
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
+ import { insertOne } from "../../db";
8
+ import { defineFeature } from "../../engine";
9
+ import { eventsTable } from "../../event-store";
10
+ import { drainEventConsumers } from "../drain-event-consumers";
11
+ import { resetEventStore } from "../table-helpers";
12
+ import { setupTestStack, type TestStack } from "../test-stack";
13
+ import { TestUsers } from "../test-users";
14
+
15
+ const admin = TestUsers.admin;
16
+ const TRACKER_QN = "drain-test:projection:tracker";
17
+ const POISON_QN = "drain-test:projection:poison";
18
+
19
+ // Distinct from POISON_TARGET_NAME (test 3's own poison consumer) so the two
20
+ // scenarios can't accidentally cross-trigger each other's throw branch.
21
+ const CASCADE_TRIGGER_NAME = "cascade-trigger";
22
+ const CASCADE_POISON_NAME = "cascade-poison-must-not-be-processed";
23
+ const POISON_TARGET_NAME = "always-poison";
24
+
25
+ let observed: string[] = [];
26
+
27
+ const drainTestFeature = defineFeature("drain-test", (r) => {
28
+ r.multiStreamProjection({
29
+ name: "tracker",
30
+ apply: {
31
+ "drain-test.tick": async (event, tx) => {
32
+ const name = (event.payload as { name: string }).name;
33
+ if (name === CASCADE_POISON_NAME) {
34
+ throw new Error(`tracker must not process the post-snapshot cascade: ${name}`);
35
+ }
36
+ observed.push(name);
37
+ // Simulates a consumer whose own handler writes a follow-up event —
38
+ // drainEventConsumers snapshots the target once, so this write must
39
+ // never be waited on.
40
+ if (name === CASCADE_TRIGGER_NAME) {
41
+ await insertOne(tx, eventsTable, {
42
+ aggregateId: crypto.randomUUID(),
43
+ aggregateType: "drain-test-source",
44
+ tenantId: admin.tenantId,
45
+ version: 1,
46
+ type: "drain-test.tick",
47
+ eventVersion: 1,
48
+ payload: { name: CASCADE_POISON_NAME },
49
+ metadata: { userId: admin.id },
50
+ createdBy: admin.id,
51
+ });
52
+ }
53
+ },
54
+ },
55
+ });
56
+ r.multiStreamProjection({
57
+ name: "poison",
58
+ apply: {
59
+ "drain-test.tick": async (event) => {
60
+ const name = (event.payload as { name: string }).name;
61
+ if (name === POISON_TARGET_NAME) {
62
+ throw new Error(`boom: ${name}`);
63
+ }
64
+ },
65
+ },
66
+ });
67
+ });
68
+
69
+ async function appendTick(name: string): Promise<void> {
70
+ await insertOne(stack.db, eventsTable, {
71
+ aggregateId: crypto.randomUUID(),
72
+ aggregateType: "drain-test-source",
73
+ tenantId: admin.tenantId,
74
+ version: 1,
75
+ type: "drain-test.tick",
76
+ eventVersion: 1,
77
+ payload: { name },
78
+ metadata: { userId: admin.id },
79
+ createdBy: admin.id,
80
+ });
81
+ }
82
+
83
+ let stack: TestStack;
84
+
85
+ beforeAll(async () => {
86
+ stack = await setupTestStack({ features: [drainTestFeature], systemHooks: [] });
87
+ });
88
+
89
+ afterEach(async () => {
90
+ observed = [];
91
+ await resetEventStore(stack);
92
+ });
93
+
94
+ afterAll(async () => {
95
+ await stack.cleanup();
96
+ });
97
+
98
+ describe("drainEventConsumers", () => {
99
+ test("backlog larger than the dispatcher batch size gets fully processed", async () => {
100
+ const names = Array.from({ length: 220 }, (_, i) => `evt-${i}`);
101
+ for (const name of names) await appendTick(name);
102
+
103
+ await drainEventConsumers({ db: stack.db, eventDispatcher: stack.eventDispatcher }, [
104
+ TRACKER_QN,
105
+ ]);
106
+
107
+ expect(observed.sort()).toEqual([...names].sort());
108
+ });
109
+
110
+ test("an event written after the HWM snapshot does not block", async () => {
111
+ await appendTick(CASCADE_TRIGGER_NAME);
112
+
113
+ await drainEventConsumers({ db: stack.db, eventDispatcher: stack.eventDispatcher }, [
114
+ TRACKER_QN,
115
+ ]);
116
+
117
+ // The cascade event was written by the handler DURING the drain, after
118
+ // the target snapshot — proven never processed (would throw otherwise).
119
+ expect(observed).toEqual([CASCADE_TRIGGER_NAME]);
120
+ });
121
+
122
+ test("a throwing consumer exhausts the budget and the error contains lastError", async () => {
123
+ await appendTick(POISON_TARGET_NAME);
124
+
125
+ let caughtMessage: string | undefined;
126
+ try {
127
+ await drainEventConsumers(
128
+ { db: stack.db, eventDispatcher: stack.eventDispatcher },
129
+ [POISON_QN],
130
+ { maxPasses: 2 },
131
+ );
132
+ } catch (e) {
133
+ caughtMessage = e instanceof Error ? e.message : String(e);
134
+ }
135
+
136
+ expect(caughtMessage).toBeDefined();
137
+ expect(caughtMessage).toContain(POISON_QN);
138
+ expect(caughtMessage).toContain(`lastError=boom: ${POISON_TARGET_NAME}`);
139
+ });
140
+
141
+ test("an unknown consumer name throws", async () => {
142
+ let caughtMessage: string | undefined;
143
+ try {
144
+ await drainEventConsumers({ db: stack.db, eventDispatcher: stack.eventDispatcher }, [
145
+ "drain-test:projection:does-not-exist",
146
+ ]);
147
+ } catch (e) {
148
+ caughtMessage = e instanceof Error ? e.message : String(e);
149
+ }
150
+
151
+ expect(caughtMessage).toContain(
152
+ 'consumer "drain-test:projection:does-not-exist" not registered or per-instance',
153
+ );
154
+ });
155
+
156
+ test("missing eventDispatcher raises a clear error", async () => {
157
+ let caughtMessage: string | undefined;
158
+ try {
159
+ await drainEventConsumers({ db: stack.db, eventDispatcher: undefined }, [TRACKER_QN]);
160
+ } catch (e) {
161
+ caughtMessage = e instanceof Error ? e.message : String(e);
162
+ }
163
+
164
+ expect(caughtMessage).toContain("eventDispatcher is undefined");
165
+ });
166
+ });
@@ -8,6 +8,7 @@ import * as z from "zod";
8
8
  import { defineFeature } from "../../engine";
9
9
  import { InternalError, writeFailure } from "../../errors";
10
10
  import { waitFor } from "../../testing";
11
+ import { generateId } from "../../utils";
11
12
  import { setupTestStack, type TestStack } from "../test-stack";
12
13
  import { TestUsers } from "../test-users";
13
14
 
@@ -57,11 +58,22 @@ const jobContextFeature = defineFeature("jobcontextcheck", (r) => {
57
58
  });
58
59
  });
59
60
 
61
+ const laneIsolationRuns: Array<{ from: unknown }> = [];
62
+
63
+ const laneIsolationFeature = defineFeature("laneisolation", (r) => {
64
+ r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload) => {
65
+ laneIsolationRuns.push({ from: payload["from"] });
66
+ });
67
+ });
68
+
60
69
  let stack: TestStack | undefined;
70
+ let secondStack: TestStack | undefined;
61
71
 
62
72
  afterEach(async () => {
63
73
  if (stack) await stack.cleanup();
74
+ if (secondStack) await secondStack.cleanup();
64
75
  stack = undefined;
76
+ secondStack = undefined;
65
77
  });
66
78
 
67
79
  describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", () => {
@@ -98,6 +110,54 @@ describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", ()
98
110
  });
99
111
  });
100
112
 
113
+ // Stack "api" consumes only the api lane, so its worker-lane job can only run
114
+ // if the "worker" stack's consumer reads the same queue.
115
+ describe("setupTestStack({ jobs }) queue isolation between parallel stacks", () => {
116
+ async function dispatchFromApiStackThenRunOwnJob(): Promise<void> {
117
+ await stack?.jobRunner?.dispatch("laneisolation:job:record", { from: "api-stack" });
118
+ await secondStack?.jobRunner?.dispatch("laneisolation:job:record", { from: "worker-stack" });
119
+ await waitFor(() => {
120
+ expect(laneIsolationRuns.some((run) => run.from === "worker-stack")).toBe(true);
121
+ });
122
+ }
123
+
124
+ test("without an explicit queueNamePrefix, one stack's consumer never runs another stack's job", async () => {
125
+ laneIsolationRuns.length = 0;
126
+ [stack, secondStack] = await Promise.all([
127
+ setupTestStack({ features: [laneIsolationFeature], jobs: { consumerLane: "api" } }),
128
+ setupTestStack({ features: [laneIsolationFeature], jobs: { consumerLane: "worker" } }),
129
+ ]);
130
+
131
+ await dispatchFromApiStackThenRunOwnJob();
132
+
133
+ expect(laneIsolationRuns.map((run) => run.from)).toEqual(["worker-stack"]);
134
+ });
135
+
136
+ test("an explicit shared queueNamePrefix wins, so both stacks read one queue", async () => {
137
+ laneIsolationRuns.length = 0;
138
+ const queueNamePrefix = `laneisolation-shared-${generateId()}`;
139
+ [stack, secondStack] = await Promise.all([
140
+ setupTestStack({
141
+ features: [laneIsolationFeature],
142
+ jobs: { consumerLane: "api", queueNamePrefix },
143
+ }),
144
+ setupTestStack({
145
+ features: [laneIsolationFeature],
146
+ jobs: { consumerLane: "worker", queueNamePrefix },
147
+ }),
148
+ ]);
149
+
150
+ await dispatchFromApiStackThenRunOwnJob();
151
+
152
+ await waitFor(() => {
153
+ expect(laneIsolationRuns.map((run) => run.from).sort()).toEqual([
154
+ "api-stack",
155
+ "worker-stack",
156
+ ]);
157
+ });
158
+ });
159
+ });
160
+
101
161
  describe("setupTestStack({ jobs }) job context matches the request-path context", () => {
102
162
  test("job handler's context carries tracer + searchAdapter, mirrors prod's effectiveFeatures-less job context", async () => {
103
163
  jobContextFields.length = 0;
@@ -0,0 +1,74 @@
1
+ import type { DbConnection } from "../db";
2
+ import { getEventsHighWaterMark } from "../event-store";
3
+ import { type ConsumerRecoveryState, getConsumerState } from "../pipeline";
4
+ import type { TestStack } from "./test-stack";
5
+
6
+ const DEFAULT_MAX_PASSES = 25;
7
+
8
+ // Drives eventDispatcher.runOnce() until every named consumer has caught up
9
+ // to the events high-water mark captured at call time. No "no progress ⇒
10
+ // throw" shortcut: a pass can legitimately advance zero cursors even though
11
+ // work remains — runOnce's FOR UPDATE SKIP LOCKED lets a concurrent pass
12
+ // (the test stack's own 50ms poll timer) hold a consumer's row, and a
13
+ // consumer coming off a thrown-pass backoff sits idle until its retryAtMs
14
+ // elapses. A bounded pass budget tolerates both without pretending to detect
15
+ // "stuck" from a single empty pass. The target is a snapshot: events a
16
+ // consumer's own handler writes afterward (cascades) are not waited on.
17
+ export async function drainEventConsumers(
18
+ stack: Pick<TestStack, "db" | "eventDispatcher">,
19
+ consumerNames: readonly [string, ...string[]],
20
+ options?: { readonly maxPasses?: number },
21
+ ): Promise<void> {
22
+ const { db, eventDispatcher } = stack;
23
+ if (!eventDispatcher) {
24
+ throw new Error(
25
+ "drainEventConsumers: stack.eventDispatcher is undefined — no system consumer or " +
26
+ "multiStreamProjection is wired for this stack.",
27
+ );
28
+ }
29
+ const maxPasses = options?.maxPasses ?? DEFAULT_MAX_PASSES;
30
+ const target = await getEventsHighWaterMark(db);
31
+
32
+ for (let pass = 0; pass < maxPasses; pass++) {
33
+ await eventDispatcher.runOnce();
34
+ const states = await loadConsumerStates(db, consumerNames);
35
+ // skip: every named consumer has already caught up to the target snapshot — the
36
+ // remaining budgeted passes are unnecessary, not a swallowed error.
37
+ if (states.every((state) => state.lastProcessedEventId >= target)) return;
38
+ if (pass === maxPasses - 1) {
39
+ throw budgetExhaustedError(maxPasses, target, states);
40
+ }
41
+ }
42
+ }
43
+
44
+ async function loadConsumerStates(
45
+ db: DbConnection,
46
+ consumerNames: readonly [string, ...string[]],
47
+ ): Promise<readonly ConsumerRecoveryState[]> {
48
+ return Promise.all(
49
+ consumerNames.map(async (name) => {
50
+ const state = await getConsumerState(db, name);
51
+ if (!state) {
52
+ throw new Error(`drainEventConsumers: consumer "${name}" not registered or per-instance`);
53
+ }
54
+ return state;
55
+ }),
56
+ );
57
+ }
58
+
59
+ function budgetExhaustedError(
60
+ maxPasses: number,
61
+ target: bigint,
62
+ states: readonly ConsumerRecoveryState[],
63
+ ): Error {
64
+ const unfinished = states.filter((state) => state.lastProcessedEventId < target);
65
+ const lines = unfinished.map(
66
+ (state) =>
67
+ ` ${state.name}: lastProcessedEventId=${state.lastProcessedEventId} target=${target} ` +
68
+ `status=${state.status} attempts=${state.attempts} lastError=${state.lastError ?? "null"}`,
69
+ );
70
+ return new Error(
71
+ `drainEventConsumers: budget of ${maxPasses} passes exhausted, consumers still behind target:\n` +
72
+ lines.join("\n"),
73
+ );
74
+ }
@@ -14,6 +14,7 @@ export {
14
14
  createTestDb,
15
15
  type TestDb,
16
16
  } from "./db";
17
+ export { drainEventConsumers } from "./drain-event-consumers";
17
18
  export { createEventCollector, type EventCollector } from "./event-collector";
18
19
  export { pushEntityProjectionTables } from "./push-entity-projection-tables";
19
20
  export { createTestRedis, type TestRedis } from "./redis";
@@ -10,6 +10,8 @@ export type TestRedis = {
10
10
  // kumiko-framework #1036/2) — callers needing a fresh connection should use
11
11
  // this raw string, not rebuild one from parsed options.
12
12
  redisUrl: string;
13
+ /** The per-test `keyPrefix` on `redis`, e.g. `kt:1a2b3c4d:`. */
14
+ keyPrefix: string;
13
15
  /** Delete every key this test created (prefix-scoped). Replaces the old
14
16
  * `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
15
17
  flushNamespace: () => Promise<void>;
@@ -43,6 +45,7 @@ export async function createTestRedis(): Promise<TestRedis> {
43
45
  return {
44
46
  redis,
45
47
  redisUrl,
48
+ keyPrefix: prefix,
46
49
  flushNamespace,
47
50
  cleanup: async () => {
48
51
  await flushNamespace();
@@ -52,7 +52,8 @@ export type TestStack = {
52
52
  // event-triggered job reaching for ctx.files die where the request path works.
53
53
  context: AppContext;
54
54
  // Present whenever a system consumer (SSE, Search) or
55
- // r.multiStreamProjection is wired. Tests drain it via runOnce() for
55
+ // r.multiStreamProjection is wired. Tests drain it via runOnce() (or the
56
+ // drainEventConsumers helper for a named-consumer backlog) for
56
57
  // deterministic assertion — no timer-induced flakiness.
57
58
  eventDispatcher?: EventDispatcher;
58
59
  // Only set when the caller passed `lifecycle` via options. Tests that
@@ -167,6 +168,8 @@ export type TestStackOptions = {
167
168
  * `runOnBoot`/cron jobs against a caller-owned consumer). */
168
169
  jobs?: {
169
170
  consumerLane?: JobRunIn;
171
+ /** Default: unique per stack, derived from its test Redis keyPrefix. Pass
172
+ * the same value to every runner that has to share this stack's queues. */
170
173
  queueNamePrefix?: string;
171
174
  /** Source of active tenant ids for `perTenant: true` jobs. Omit when the
172
175
  * tenant feature is among `options.features` — the framework then
@@ -188,6 +191,13 @@ export type TestStackOptions = {
188
191
 
189
192
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
190
193
 
194
+ // BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
195
+ // parallel stacks sharing the prod default queue name would consume each
196
+ // other's jobs. BullMQ rejects `:` in queue names.
197
+ function queueNamePrefixForTestRedis(keyPrefix: string): string {
198
+ return keyPrefix.split(":").filter(Boolean).join("-");
199
+ }
200
+
191
201
  export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
192
202
  const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
193
203
  const enabledHooks = options.systemHooks ?? ["sse", "search"];
@@ -355,9 +365,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
355
365
  // password/username/tls/path (pr-review kumiko-framework #1036/2).
356
366
  redisUrl: testRedis.redisUrl,
357
367
  ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
358
- ...(options.jobs.queueNamePrefix !== undefined && {
359
- queueNamePrefix: options.jobs.queueNamePrefix,
360
- }),
368
+ queueNamePrefix:
369
+ options.jobs.queueNamePrefix ?? queueNamePrefixForTestRedis(testRedis.keyPrefix),
361
370
  ...(options.jobs.getActiveTenantIds !== undefined && {
362
371
  getActiveTenantIds: options.jobs.getActiveTenantIds,
363
372
  }),
@@ -304,9 +304,6 @@ function readMarkerVersion(targetDir: string): string | null {
304
304
  }
305
305
  }
306
306
 
307
- /** Highest pending version strictly below the earliest open manual breaking
308
- * entry (no codemod). Falls back to highest non-breaking pending when every
309
- * pending version is at/after that manual — never advances onto the manual. */
310
307
  /** Highest pending version strictly below the earliest open manual breaking
311
308
  * entry (no codemod). Falls back to highest non-breaking pending when every
312
309
  * pending version is at/after that manual. If only manuals remain, returns
@@ -340,6 +337,21 @@ function markerVersionForPending(
340
337
  return fallbackInstalled;
341
338
  }
342
339
 
340
+ function logManualMarkerOutcome(
341
+ out: UpgradeCliOut,
342
+ manualEntries: readonly ChangelogEntry[],
343
+ markerVersion: string,
344
+ ): void {
345
+ if (manualEntries.length > 0) {
346
+ const markerHeldBack = manualEntries.some((e) => compareVersions(e.version, markerVersion) > 0);
347
+ out.log(
348
+ markerHeldBack
349
+ ? ` ⚠ Marker stays at ${markerVersion}, so the upgrade guard keeps listing the manual change(s) above. Migrate them by hand, then run --apply again to acknowledge them.`
350
+ : ` ⚠ Marker moved past the manual change(s) above to ${markerVersion}; the upgrade guard no longer lists them, so make sure they are migrated.`,
351
+ );
352
+ }
353
+ }
354
+
343
355
  // Runs every pending breaking entry's codemod, oldest version first (so a
344
356
  // later codemod can assume an earlier one already ran). Stops on the first
345
357
  // failure — no partial marker. Writes the marker whenever dryRun is false —
@@ -394,6 +406,7 @@ async function applyCodemods(
394
406
  codemods: [],
395
407
  });
396
408
  out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
409
+ logManualMarkerOutcome(out, manualEntries, markerVer);
397
410
  }
398
411
  return 0;
399
412
  }
@@ -442,6 +455,7 @@ async function applyCodemods(
442
455
  if (pendingManual.length > 0) {
443
456
  out.log(` ⚠ ${pendingManual.length} breaking change(s) still need manual migration.`);
444
457
  }
458
+ logManualMarkerOutcome(out, manualEntries, latestVersion);
445
459
  return 0;
446
460
  }
447
461