@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.
- package/package.json +4 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/client-ip.test.ts +25 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
- package/src/api/client-ip.ts +27 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +2 -0
- package/src/api/server.ts +3 -1
- package/src/changes.json +48 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/schema-cli.ts +5 -1
- 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.
|
|
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.
|
|
206
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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
|
-
...(
|
|
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
|
package/src/api/client-ip.ts
CHANGED
|
@@ -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
|
package/src/api/extra-route.ts
CHANGED
|
@@ -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
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
|
|
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",
|
|
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,
|
|
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 @@
|
|
|
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
|
|
2803
|
-
//
|
|
2804
|
-
//
|
|
2805
|
-
//
|
|
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
|
+
});
|