@cosmicdrift/kumiko-framework 0.320.0 → 0.321.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 (32) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/client-ip.test.ts +25 -0
  5. package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
  6. package/src/api/client-ip.ts +27 -0
  7. package/src/api/extra-route.ts +3 -0
  8. package/src/api/index.ts +2 -0
  9. package/src/api/server.ts +3 -1
  10. package/src/changes.json +48 -0
  11. package/src/db/queries/event-store-transfer.ts +73 -0
  12. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  13. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  14. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  15. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  16. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  17. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  18. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  19. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  20. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  21. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  22. package/src/engine/feature-ast/extractors/index.ts +4 -0
  23. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  24. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  25. package/src/engine/feature-ast/parse.ts +13 -20
  26. package/src/engine/steps/webhook-send.ts +4 -2
  27. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  28. package/src/event-store/index.ts +6 -0
  29. package/src/event-store/transfer.ts +84 -0
  30. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  31. package/src/schema-cli.ts +5 -1
  32. package/src/upgrade-cli.ts +58 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.320.0",
3
+ "version": "0.321.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>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.320.0",
206
- "@cosmicdrift/kumiko-types": "0.320.0",
205
+ "@cosmicdrift/kumiko-http": "0.321.0",
206
+ "@cosmicdrift/kumiko-types": "0.321.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.320.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.321.0",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
@@ -54,6 +54,17 @@ describe("runSchemaCli — no-DB paths", () => {
54
54
  expect(cap.err).toHaveLength(0);
55
55
  });
56
56
 
57
+ test.each(["--help", "-h", "help"])(
58
+ "%s prints the same usage as no subcommand and exits 0",
59
+ async (helpArg) => {
60
+ const cap = captureOut();
61
+ const code = await runSchemaCli([helpArg], appCwd, cap.out);
62
+ expect(code).toBe(0);
63
+ expect(cap.log.join("\n")).toContain("Subcommands:");
64
+ expect(cap.err).toHaveLength(0);
65
+ },
66
+ );
67
+
57
68
  test("unknown subcommand exits 1 and reports it on stderr", async () => {
58
69
  const cap = captureOut();
59
70
  const code = await runSchemaCli(["cutover"], appCwd, cap.out);
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import {
16
16
  findCodemodScriptsRoot,
17
- findCoreChangelogFile,
18
17
  findFeaturesDirs,
18
+ findPackageChangelogFiles,
19
19
  resolveCodemodScript,
20
20
  runUpgradeCli,
21
21
  type UpgradeCliOut,
@@ -92,6 +92,23 @@ async function runJson(cwd: string, from: string): Promise<{ pending: Array<{ ti
92
92
  return JSON.parse(spy.logs.join("\n"));
93
93
  }
94
94
 
95
+ describe("upgrade command — --help", () => {
96
+ test("--help prints usage and exits 0 without touching the filesystem", async () => {
97
+ const spy = makeSpyOutput();
98
+ const exit = await runUpgradeCli(["--help"], "/nonexistent-cwd", spy.out);
99
+ expect(exit).toBe(0);
100
+ expect(spy.logs.join("\n")).toContain("kumiko-upgrade");
101
+ expect(spy.errs).toEqual([]);
102
+ });
103
+
104
+ test("-h behaves the same as --help", async () => {
105
+ const spy = makeSpyOutput();
106
+ const exit = await runUpgradeCli(["-h"], "/nonexistent-cwd", spy.out);
107
+ expect(exit).toBe(0);
108
+ expect(spy.logs.join("\n")).toContain("--apply");
109
+ });
110
+ });
111
+
95
112
  describe("upgrade command — framework core changelog", () => {
96
113
  test("collects core changes.json from the framework repo layout", async () => {
97
114
  const cwd = tmp({
@@ -198,6 +215,37 @@ describe("upgrade command — framework core changelog", () => {
198
215
  });
199
216
  });
200
217
 
218
+ describe("upgrade command — every package's changelog, not just framework core", () => {
219
+ const SERVER_RUNTIME_ENTRY = JSON.stringify([
220
+ { version: "0.168.0", type: "breaking", title: "server-runtime breaking change" },
221
+ ]);
222
+
223
+ test("a hoisted non-framework, non-bundled-features package's changes.json shows up in pending", async () => {
224
+ const cwd = tmp({
225
+ "node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
226
+ "apps/web/package.json": "{}",
227
+ });
228
+
229
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
230
+
231
+ expect(result.pending.map((e) => e.title)).toEqual(["server-runtime breaking change"]);
232
+ });
233
+
234
+ test("bundled-features is never double-counted alongside findFeaturesDirs's own collection", async () => {
235
+ const cwd = tmp({
236
+ "node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
237
+ "node_modules/@cosmicdrift/kumiko-bundled-features/src/user/changes.json": FEATURE_ENTRY,
238
+ "apps/web/package.json": "{}",
239
+ });
240
+
241
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
242
+
243
+ expect(result.pending.map((e) => e.title).sort()).toEqual(
244
+ ["feature fix", "server-runtime breaking change"].sort(),
245
+ );
246
+ });
247
+ });
248
+
201
249
  describe("upgrade command — enterprise package layout", () => {
202
250
  // Layout is detected by presence of changes.json, not an "ai-" name
203
251
  // prefix — the old heuristic silently dropped every enterprise package
@@ -641,9 +689,8 @@ describe("changes.json codemod fields resolve to real published scripts", () =>
641
689
  }
642
690
 
643
691
  test("every codemod field is a scripts/codemod/*.ts path that resolves to an existing published script", () => {
644
- const coreChangelog = findCoreChangelogFile(REAL_REPO_ROOT);
645
692
  const changesJsonFiles = [
646
- ...(coreChangelog ? [coreChangelog] : []),
693
+ ...findPackageChangelogFiles(REAL_REPO_ROOT),
647
694
  ...findFeaturesDirs(REAL_REPO_ROOT).flatMap((dir) => findChangesJsonFiles(dir)),
648
695
  ];
649
696
  expect(changesJsonFiles.length).toBeGreaterThan(0);
@@ -9,6 +9,7 @@ import {
9
9
  assertValidTrustedProxyHops,
10
10
  type ClientIpHeaderSource,
11
11
  createClientIpResolver,
12
+ parseTrustedProxyHopsEnv,
12
13
  } from "../client-ip";
13
14
 
14
15
  function sourceOf(headers: Record<string, string>, socketAddress?: string): ClientIpHeaderSource {
@@ -137,3 +138,27 @@ describe("createClientIpResolver", () => {
137
138
  expect(() => createClientIpResolver(-1, "test")).toThrow(/non-negative integer/);
138
139
  });
139
140
  });
141
+
142
+ describe("parseTrustedProxyHopsEnv", () => {
143
+ test("undefined -> undefined (no env set)", () => {
144
+ expect(parseTrustedProxyHopsEnv(undefined, "test")).toBeUndefined();
145
+ });
146
+
147
+ test("valid digits-only string -> parsed integer", () => {
148
+ expect(parseTrustedProxyHopsEnv("1", "test")).toBe(1);
149
+ expect(parseTrustedProxyHopsEnv("0", "test")).toBe(0);
150
+ expect(parseTrustedProxyHopsEnv("42", "test")).toBe(42);
151
+ });
152
+
153
+ test("hex/exponent/negative notation throws instead of silently coercing", () => {
154
+ expect(() => parseTrustedProxyHopsEnv("0x10", "test")).toThrow(/non-negative integer/);
155
+ expect(() => parseTrustedProxyHopsEnv("1e3", "test")).toThrow(/non-negative integer/);
156
+ expect(() => parseTrustedProxyHopsEnv("-1", "test")).toThrow(/non-negative integer/);
157
+ });
158
+
159
+ test("error message carries the context prefix and the raw value", () => {
160
+ expect(() => parseTrustedProxyHopsEnv("x", "runProdApp")).toThrow(
161
+ 'runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "x".',
162
+ );
163
+ });
164
+ });
@@ -585,6 +585,48 @@ describe("extraRoutes: entry:signature", () => {
585
585
  });
586
586
  });
587
587
 
588
+ describe("extraRoutes: entry:anonymous deps.clientIp", () => {
589
+ const clientIpRoute: AnonymousExtraRoute = {
590
+ method: "GET",
591
+ path: "/public/client-ip-probe",
592
+ entry: "anonymous",
593
+ handler: async (c, deps) => c.json({ clientIp: deps.clientIp }),
594
+ };
595
+
596
+ test("default trustedProxyHops (0): a spoofed X-Forwarded-For is ignored, socket address wins", async () => {
597
+ const stack = await setupTestStack({
598
+ features: [probeFeature],
599
+ extraRoutes: [clientIpRoute],
600
+ });
601
+ try {
602
+ const res = await stack.app.request(
603
+ "/public/client-ip-probe",
604
+ { headers: { "x-forwarded-for": "203.0.113.10" } },
605
+ "198.51.100.9",
606
+ );
607
+ expect(await res.json()).toEqual({ clientIp: "198.51.100.9" });
608
+ } finally {
609
+ await stack.cleanup();
610
+ }
611
+ });
612
+
613
+ test("trustedProxyHops: 1 — the rightmost X-Forwarded-For entry is trusted", async () => {
614
+ const stack = await setupTestStack({
615
+ features: [probeFeature],
616
+ extraRoutes: [clientIpRoute],
617
+ trustedProxyHops: 1,
618
+ });
619
+ try {
620
+ const res = await stack.app.request("/public/client-ip-probe", {
621
+ headers: { "x-forwarded-for": "203.0.113.10" },
622
+ });
623
+ expect(await res.json()).toEqual({ clientIp: "203.0.113.10" });
624
+ } finally {
625
+ await stack.cleanup();
626
+ }
627
+ });
628
+ });
629
+
588
630
  // Type-level contract for ExtraRouteDefinition (kumiko-framework#3050): the
589
631
  // bodies below are never invoked — tsc checks them, bun:test does not run
590
632
  // them. Each `@ts-expect-error` turns into an "unused directive" compile
@@ -44,6 +44,33 @@ export function assertValidTrustedProxyHops(value: number | undefined, context:
44
44
  }
45
45
  }
46
46
 
47
+ export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
48
+
49
+ // Fail loud on a garbage env value rather than silently coercing to NaN:
50
+ // a resolver built from NaN treats it like "always short chain" and returns
51
+ // "unknown" for every request, which collapses an IP-keyed rate limiter
52
+ // into one shared bucket for the whole deployment — a self-inflicted DoS,
53
+ // worse than staying on the default. Digits-only (not parseInt) —
54
+ // parseInt("0x10")/("1e3")/("2x") would silently coerce instead of failing.
55
+ export function parseTrustedProxyHopsEnv(
56
+ raw: string | undefined,
57
+ context: string,
58
+ ): number | undefined {
59
+ if (raw === undefined) return undefined;
60
+ if (!/^\d+$/.test(raw)) {
61
+ throw new Error(
62
+ `${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
63
+ );
64
+ }
65
+ const parsed = Number.parseInt(raw, 10);
66
+ if (!Number.isInteger(parsed) || parsed < 0) {
67
+ throw new Error(
68
+ `${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
69
+ );
70
+ }
71
+ return parsed;
72
+ }
73
+
47
74
  // Instance-scoped, NOT a module singleton: the warn-once flag must reset
48
75
  // per server boot (and per test), otherwise the first test to trigger it
49
76
  // consumes the warning for the whole process and later tests can't observe
@@ -32,6 +32,9 @@ export type AnonymousExtraRouteDeps = {
32
32
  * user, same request-resolved tenant (never overridable), only under
33
33
  * "/api/" (the only path that populates a session user). */
34
34
  readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
35
+ /** Resolved by the server's single trustedProxyHops-aware resolver —
36
+ * never read X-Forwarded-For yourself. */
37
+ readonly clientIp: string;
35
38
  };
36
39
 
37
40
  export type AnonymousExtraRoute = {
package/src/api/index.ts CHANGED
@@ -34,6 +34,8 @@ export {
34
34
  clientIpSourceFromHonoContext,
35
35
  createClientIpResolver,
36
36
  extractSocketAddress,
37
+ parseTrustedProxyHopsEnv,
38
+ TRUSTED_PROXY_HOPS_ENV,
37
39
  UNKNOWN_CLIENT_IP,
38
40
  } from "./client-ip";
39
41
  export type {
package/src/api/server.ts CHANGED
@@ -617,6 +617,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
617
617
  userId: event.metadata.userId,
618
618
  ...(mspOwner && { callerFeature: mspOwner }),
619
619
  ...(mspFiles && { files: mspFiles }),
620
+ ...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
620
621
  ...(mspFiles && {
621
622
  derivatives: createDerivativesContext({
622
623
  files: mspFiles,
@@ -1260,7 +1261,7 @@ type ExtraRouteHonoHandlerDeps = {
1260
1261
  readonly secrets: import("../secrets").SecretsContext | undefined;
1261
1262
  readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
1262
1263
  readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
1263
- readonly clientIpResolver?: ClientIpResolver;
1264
+ readonly clientIpResolver: ClientIpResolver;
1264
1265
  };
1265
1266
 
1266
1267
  function buildExtraRouteHonoHandler(
@@ -1276,6 +1277,7 @@ function buildExtraRouteHonoHandler(
1276
1277
  registry: shared.registry,
1277
1278
  systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1278
1279
  write: makeAnonymousWrite(c, shared.dispatcher),
1280
+ clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
1279
1281
  });
1280
1282
  case ExtraRouteEntries.user:
1281
1283
  return async (c) => {
package/src/changes.json CHANGED
@@ -1,4 +1,52 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "kumiko-schema gained --help/-h/help",
6
+ "detail": "Prints usage listing the available subcommands and exits 0.",
7
+ "migration": "No action needed."
8
+ },
9
+ {
10
+ "version": "0.321.0",
11
+ "type": "improvement",
12
+ "title": "parseTrustedProxyHopsEnv/TRUSTED_PROXY_HOPS_ENV factored out of runProdApp for runDevApp and consumer tooling to share",
13
+ "detail": "`packages/framework/src/api/client-ip.ts` gained\n`parseTrustedProxyHopsEnv(raw, context)` and the\n`TRUSTED_PROXY_HOPS_ENV = \"KUMIKO_TRUSTED_PROXY_HOPS\"` constant (both\nexported via the `api` barrel), moved out of `runProdApp`'s inline\nparsing. Same validation and error message as before.",
14
+ "migration": "No action needed; runProdApp's behavior and error message are unchanged."
15
+ },
16
+ {
17
+ "version": "0.321.0",
18
+ "type": "improvement",
19
+ "title": "AnonymousExtraRouteDeps gained clientIp, resolved by the server's trustedProxyHops-aware resolver",
20
+ "detail": "`AnonymousExtraRouteDeps.clientIp: string`, populated the same way as the\nhttpRoute path via `shared.clientIpResolver.resolve\n(clientIpSourceFromHonoContext(c))`. `ExtraRouteHonoHandlerDeps\n.clientIpResolver` is now required (its only caller always supplied it).",
21
+ "migration": "Anonymous extra routes that need the caller's IP should read\n`deps.clientIp` instead of reading `X-Forwarded-For` (or any other\nheader) themselves — the latter is attacker-controlled unless you also\nknow the exact trusted-hop count. Tests that build an\n`AnonymousExtraRouteDeps` literal by hand must add a `clientIp` string."
22
+ },
23
+ {
24
+ "version": "0.321.0",
25
+ "type": "improvement",
26
+ "title": "feature-ast resolves imported constants and registrar wrappers without building a TypeScript program",
27
+ "detail": "Imported `const` initializers (named imports, aliases, `export { X as Y }`,\nre-export chains, `export *`) and imported registrar-wrapper functions are\nresolved through `ts.resolveModuleName` plus a syntactic export walk.\nSame-file lookups walk enclosing block scopes instead of the file top level. Parse\noutput is unchanged; parsing a feature file drops from hundreds of\nmilliseconds to a few milliseconds."
28
+ },
29
+ {
30
+ "version": "0.321.0",
31
+ "type": "breaking",
32
+ "title": "webhook.send step auth secretRef renamed to secret; MultiStreamApplyContext gained an optional secrets field",
33
+ "detail": "`r.step.webhook.send`'s `auth` union renamed `secretRef` → `secret` (see\nthe step-dispatcher entry for the full rationale). `MultiStreamApplyContext`\n(and `createMultiStreamApplyContext`'s deps) gained an optional\n`secrets?: SecretsContext` field, mirroring `files`/`derivatives` — present\nwhen the app booted with the secrets feature, letting a saga/process-\nmanager MSP apply read a tenant secret with its own audit context. The\nserver's MSP consumer wiring now threads the boot-time `AppContext.secrets`\nthrough automatically; `rebuildMultiStreamProjection`'s rebuild context\ndeliberately does not carry `secrets` (rebuild must stay side-effect-free).",
34
+ "migration": "Rename `auth.secretRef` → `auth.secret` at every `r.step.webhook.send`\ncall site. No action needed for `MultiStreamApplyContext` consumers that\ndon't read `ctx.secrets` — the field is optional and additive."
35
+ },
36
+ {
37
+ "version": "0.321.0",
38
+ "type": "improvement",
39
+ "title": "New event-store primitive transferAggregateStreams for tenant-to-tenant aggregate moves",
40
+ "detail": "`transferAggregateStreams(db, { sourceTenantId, destinationTenantId,\naggregateType, aggregateIds, transferredBy })` (event-store/transfer.ts)\nmoves an aggregate's events, drops its snapshot, and carries its archive\nmarker from one tenant to another, then appends one\n`kumiko:system:aggregate.transferred` system event per moved aggregate\n(own fresh stream, destination tenant, payload carries aggregateType/\naggregateId/sourceTenantId/destinationTenantId). An id with no events\nunder the given tenant/aggregateType is left untouched and gets no event.\nBacked by db/queries/event-store-transfer.ts.",
41
+ "migration": "No action for existing consumers — this is a new, additive primitive."
42
+ },
43
+ {
44
+ "version": "0.321.0",
45
+ "type": "fix",
46
+ "title": "upgrade-cli now collects every package's changes.json, not just framework's",
47
+ "detail": "`findCoreChangelogFile(cwd): string | null` is replaced by\n`findPackageChangelogFiles(cwd): string[]`. In the framework repo itself\nit collects every `packages/<dir>/src/changes.json` except\n`packages/bundled-features` (bundled features are already collected per\nfeature dir). In a consumer repo it walks up to 10 levels looking for\n`node_modules/@cosmicdrift/*/src/changes.json`, skipping\n`kumiko-bundled-features`, deduping by package name (nearest\n`node_modules` wins) and by realpath (symlinked workspace copies).",
48
+ "migration": "No action needed — `kumiko upgrade` now surfaces more relevant breaking\nchanges than before, it never hides ones it previously showed."
49
+ },
2
50
  {
3
51
  "version": "0.320.0",
4
52
  "type": "breaking",
@@ -0,0 +1,73 @@
1
+ // Raw SQL backing event-store/transfer.ts — the framework's own tenant-transfer
2
+ // primitive for streams. Kept in db/queries alongside the rest of the
3
+ // event-store's raw statements (see event-store.ts, event-store-admin.ts,
4
+ // backfill-pii.ts): the one place in the repo allowed to write tenant_id
5
+ // directly onto kumiko_events / kumiko_snapshots / kumiko_archived_streams.
6
+
7
+ import type { AnyDb } from "../query";
8
+ import { asRawClient } from "../query";
9
+
10
+ export type TransferEventRowsParams = {
11
+ readonly sourceTenantId: string;
12
+ readonly destinationTenantId: string;
13
+ readonly aggregateType: string;
14
+ readonly aggregateIds: readonly string[];
15
+ };
16
+
17
+ // Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
18
+ // per-event-row hits in-process instead of relying on invalid SQL.
19
+ export async function transferEventRows(
20
+ db: AnyDb,
21
+ params: TransferEventRowsParams,
22
+ ): Promise<readonly string[]> {
23
+ const rows = (await asRawClient(db).unsafe(
24
+ `UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
25
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
26
+ `RETURNING "aggregate_id" AS id`,
27
+ [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
28
+ )) as ReadonlyArray<{ id: string }>;
29
+ return [...new Set(rows.map((row) => row.id))];
30
+ }
31
+
32
+ export type DeleteSnapshotsForAggregatesParams = {
33
+ readonly tenantId: string;
34
+ readonly aggregateIds: readonly string[];
35
+ };
36
+
37
+ // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
38
+ // dropped rather than rewritten, forcing a full replay from the just-moved
39
+ // events on next read instead of carrying the snapshot's own generation
40
+ // bookkeeping across the tenant boundary.
41
+ export async function deleteSnapshotsForAggregates(
42
+ db: AnyDb,
43
+ params: DeleteSnapshotsForAggregatesParams,
44
+ ): Promise<void> {
45
+ await asRawClient(db).unsafe(
46
+ `DELETE FROM "kumiko_snapshots" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2)`,
47
+ [params.tenantId, params.aggregateIds],
48
+ );
49
+ }
50
+
51
+ export type TransferArchivedStreamRowsParams = {
52
+ readonly sourceTenantId: string;
53
+ readonly destinationTenantId: string;
54
+ readonly aggregateType: string;
55
+ readonly aggregateIds: readonly string[];
56
+ };
57
+
58
+ // An archived stream's marker must move with it — leaving it behind would
59
+ // make the stream writable again the instant it lands in the destination
60
+ // tenant (archive.ts's isStreamArchived check is itself tenant-scoped).
61
+ // `aggregate_type` is part of the match, same as transferEventRows — an
62
+ // archived marker for a DIFFERENT aggregate type that happens to share the
63
+ // moved aggregate's id must stay in the source tenant.
64
+ export async function transferArchivedStreamRows(
65
+ db: AnyDb,
66
+ params: TransferArchivedStreamRowsParams,
67
+ ): Promise<void> {
68
+ await asRawClient(db).unsafe(
69
+ `UPDATE "kumiko_archived_streams" SET "tenant_id" = $1 ` +
70
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4)`,
71
+ [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
72
+ );
73
+ }
@@ -39,7 +39,7 @@ describe("buildWebhookSendStep", () => {
39
39
  method: "PUT",
40
40
  headers: { "X-Custom": "val" },
41
41
  body: { event: "test" },
42
- auth: { kind: "bearer", secretRef: "MY_SECRET" },
42
+ auth: { kind: "bearer", secret: "MY_SECRET" },
43
43
  retry: { times: 5, backoff: "linear" },
44
44
  mode: "deferred",
45
45
  });
@@ -125,7 +125,7 @@ describe("webhook.send run", () => {
125
125
 
126
126
  it("passes auth config through when provided", async () => {
127
127
  const stepDef = getStep("webhook.send");
128
- const auth = { kind: "bearer" as const, secretRef: "WEBHOOK_TOKEN" };
128
+ const auth = { kind: "bearer" as const, secret: "WEBHOOK_TOKEN" };
129
129
 
130
130
  await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
131
131
 
@@ -0,0 +1 @@
1
+ export { ORIGIN_VALUE as ALIASED } from "./origin";
@@ -0,0 +1 @@
1
+ export * from "./cycle-b";
@@ -0,0 +1 @@
1
+ export * from "./cycle-a";
@@ -0,0 +1,3 @@
1
+ import { ALIASED as RENAMED } from "./star-barrel";
2
+
3
+ export const marker: string = RENAMED;
@@ -0,0 +1,3 @@
1
+ import { ORIGIN_VALUE } from "./origin";
2
+
3
+ export { ORIGIN_VALUE as LOCAL_EXPORT };
@@ -0,0 +1 @@
1
+ export const ORIGIN_VALUE = "origin-value" as const;
@@ -0,0 +1 @@
1
+ export * from "./alias-reexport";
@@ -2799,10 +2799,10 @@ defineFeature("showpony", (r) => {
2799
2799
 
2800
2800
  test("an imported registrar-wrapper stays invisible when its file can't be resolved (in-memory project, #1008)", () => {
2801
2801
  // parseInline uses an in-memory ts-morph Project — "./register/screens"
2802
- // isn't a real file, so getModuleSpecifierSourceFile() returns
2803
- // undefined and the wrapper is skipped like any other unresolvable
2804
- // call. Real cross-file resolution against on-disk files is covered by
2805
- // the parseFeatureFile fixture tests below.
2802
+ // isn't a real file, so resolveModuleFile() returns undefined and the
2803
+ // wrapper is skipped like any other unresolvable call. Real cross-file
2804
+ // resolution against on-disk files is covered by the parseFeatureFile
2805
+ // fixture tests below.
2806
2806
  expect(result.patterns.some((p) => p.kind === "screen" || p.kind === "nav")).toBe(false);
2807
2807
  });
2808
2808
  });
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { Project, type SourceFile } from "ts-morph";
4
+ import {
5
+ findImportBindingForLocalName,
6
+ resolveExportedVariable,
7
+ resolveModuleFile,
8
+ } from "../extractors/resolve-import";
9
+ import { parseFeatureFile, parseSourceFile } from "../parse";
10
+
11
+ function loadFixture(relPath: string): SourceFile {
12
+ const project = new Project({
13
+ skipAddingFilesFromTsConfig: true,
14
+ skipFileDependencyResolution: true,
15
+ });
16
+ return project.addSourceFileAtPath(resolve(__dirname, relPath));
17
+ }
18
+
19
+ describe("resolveModuleFile", () => {
20
+ test("resolves a relative specifier to the real source file", () => {
21
+ const entry = loadFixture("fixtures/resolve-import/entry.ts");
22
+ const resolved = resolveModuleFile(entry, "./star-barrel");
23
+ expect(String(resolved?.getFilePath())).toBe(
24
+ resolve(__dirname, "fixtures/resolve-import/star-barrel.ts"),
25
+ );
26
+ });
27
+
28
+ test("returns undefined for an unresolvable package", () => {
29
+ const entry = loadFixture("fixtures/resolve-import/entry.ts");
30
+ const resolved = resolveModuleFile(entry, "@totally-unresolvable/not-a-real-package");
31
+ expect(resolved).toBeUndefined();
32
+ });
33
+ });
34
+
35
+ describe("findImportBindingForLocalName", () => {
36
+ test("follows an aliased named import to the module's original export name", () => {
37
+ const entry = loadFixture("fixtures/resolve-import/entry.ts");
38
+ const binding = findImportBindingForLocalName(entry, "RENAMED");
39
+ expect(binding).toEqual({ moduleSpecifier: "./star-barrel", importedName: "ALIASED" });
40
+ });
41
+
42
+ test("returns undefined for a name with no import declaration", () => {
43
+ const entry = loadFixture("fixtures/resolve-import/entry.ts");
44
+ expect(findImportBindingForLocalName(entry, "NOT_IMPORTED")).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("resolveExportedVariable", () => {
49
+ test("follows a cross-file aliased re-export (export { X as Y } from ...)", () => {
50
+ const aliasFile = loadFixture("fixtures/resolve-import/alias-reexport.ts");
51
+ const decl = resolveExportedVariable(aliasFile, "ALIASED");
52
+ expect(decl?.getName()).toBe("ORIGIN_VALUE");
53
+ expect(decl?.getInitializer()?.getText()).toContain("origin-value");
54
+ });
55
+
56
+ test("follows an export * barrel to a nested aliased re-export", () => {
57
+ const barrelFile = loadFixture("fixtures/resolve-import/star-barrel.ts");
58
+ const decl = resolveExportedVariable(barrelFile, "ALIASED");
59
+ expect(decl?.getName()).toBe("ORIGIN_VALUE");
60
+ });
61
+
62
+ test("follows a local re-export of an imported binding (import + export {} with no from)", () => {
63
+ const localRebindFile = loadFixture("fixtures/resolve-import/local-rebind.ts");
64
+ const decl = resolveExportedVariable(localRebindFile, "LOCAL_EXPORT");
65
+ expect(decl?.getName()).toBe("ORIGIN_VALUE");
66
+ expect(String(decl?.getSourceFile().getFilePath())).toBe(
67
+ resolve(__dirname, "fixtures/resolve-import/origin.ts"),
68
+ );
69
+ });
70
+
71
+ test("terminates instead of looping forever on a mutual export * cycle", () => {
72
+ const cycleFile = loadFixture("fixtures/resolve-import/cycle-a.ts");
73
+ const decl = resolveExportedVariable(cycleFile, "NOTHING_EXPORTED_HERE");
74
+ expect(decl).toBeUndefined();
75
+ });
76
+
77
+ test("returns undefined for a name that is never exported anywhere in the chain", () => {
78
+ const barrelFile = loadFixture("fixtures/resolve-import/star-barrel.ts");
79
+ expect(resolveExportedVariable(barrelFile, "DOES_NOT_EXIST")).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ describe("same-file shadowing (regression for the getSymbol()/getDefinitionNodes() removal)", () => {
84
+ function makeSourceFile(content: string): SourceFile {
85
+ const project = new Project({
86
+ skipAddingFilesFromTsConfig: true,
87
+ skipFileDependencyResolution: true,
88
+ useInMemoryFileSystem: true,
89
+ });
90
+ return project.createSourceFile("shadow.ts", content);
91
+ }
92
+
93
+ test("an inner const declared inside setup() shadows a same-named outer const", () => {
94
+ const sf = makeSourceFile(`
95
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
96
+
97
+ const NAME = "outer";
98
+
99
+ defineFeature("f", (r) => {
100
+ const NAME = "inner";
101
+ r.extendsRegistrar(NAME, {});
102
+ });
103
+ `);
104
+ const result = parseSourceFile(sf);
105
+ expect(result.errors).toEqual([]);
106
+ expect(result.patterns).toMatchObject([{ kind: "extendsRegistrar", extensionName: "inner" }]);
107
+ });
108
+
109
+ test("a function parameter shadowing an outer const yields an unresolvable reference, not the outer value", () => {
110
+ const sf = makeSourceFile(`
111
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
112
+
113
+ const NAME = "outer";
114
+
115
+ defineFeature("f", (r) => {
116
+ function wrap(NAME) {
117
+ r.extendsRegistrar(NAME, {});
118
+ }
119
+ wrap("shadowed-by-param");
120
+ });
121
+ `);
122
+ const result = parseSourceFile(sf);
123
+ expect(result.patterns).toEqual([]);
124
+ expect(result.errors).toEqual([expect.objectContaining({ methodName: "extendsRegistrar" })]);
125
+ });
126
+ });
127
+
128
+ describe("parseFeatureFile resolves cross-file constants without a Program", () => {
129
+ test("sessions' imported event-name constant resolves to its real value", () => {
130
+ const result = parseFeatureFile(
131
+ resolve(__dirname, "../../../../../bundled-features/src/sessions/feature.ts"),
132
+ );
133
+ const defineEvent = result.patterns.find((p) => p.kind === "defineEvent");
134
+ // SESSION_REVOKED_EVENT_SHORT is imported from ./session-revoked-event —
135
+ // only resolves through the cross-file resolver this change replaces.
136
+ expect(defineEvent).toMatchObject({ eventName: "session-revoked" });
137
+ });
138
+ });
@@ -35,6 +35,10 @@ export {
35
35
  readApplyBodies,
36
36
  readScreenStatic,
37
37
  } from "./projections-screens";
38
+ export {
39
+ findImportBindingForLocalName,
40
+ resolveModuleFile,
41
+ } from "./resolve-import";
38
42
  export {
39
43
  extractDescribe,
40
44
  extractOptionalRequires,