@cosmicdrift/kumiko-dev-server 0.1.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/bin/kumiko-build.ts +85 -0
- package/bin/kumiko-dev.ts +90 -0
- package/package.json +45 -0
- package/src/__tests__/build-prod-bundle.integration.ts +265 -0
- package/src/__tests__/build-prod-bundle.test.ts +262 -0
- package/src/__tests__/cache-headers.test.ts +70 -0
- package/src/__tests__/classify-change.test.ts +87 -0
- package/src/__tests__/compose-features-wiring.integration.ts +352 -0
- package/src/__tests__/compose-features.test.ts +81 -0
- package/src/__tests__/crash-tracker.test.ts +89 -0
- package/src/__tests__/create-kumiko-server.integration.ts +286 -0
- package/src/__tests__/few-shot-corpus.test.ts +311 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/resolve-stylesheet.test.ts +90 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.ts +535 -0
- package/src/__tests__/scaffold-feature.test.ts +143 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/build-prod-bundle.ts +587 -0
- package/src/build-server-bundle.ts +308 -0
- package/src/build.ts +28 -0
- package/src/codegen/__tests__/run-codegen.test.ts +494 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
- package/src/codegen/__tests__/watch.test.ts +186 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/render.ts +225 -0
- package/src/codegen/run-codegen.ts +157 -0
- package/src/codegen/scan-events.ts +574 -0
- package/src/codegen/watch.ts +127 -0
- package/src/compose-features.ts +128 -0
- package/src/crash-tracker.ts +56 -0
- package/src/create-kumiko-server.ts +1010 -0
- package/src/drizzle-config.ts +44 -0
- package/src/drizzle-tables-auth-mode.ts +32 -0
- package/src/drizzle-tables-minimal.ts +22 -0
- package/src/few-shot-corpus.ts +369 -0
- package/src/index.ts +57 -0
- package/src/inject-schema.ts +24 -0
- package/src/resolve-tailwind-cli.ts +28 -0
- package/src/run-dev-app.ts +290 -0
- package/src/run-prod-app.ts +892 -0
- package/src/scaffold-feature.ts +226 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// Regression-Guard für die EIGENTLICHE Behauptung der Codegen-Pipeline:
|
|
2
|
+
// `ctx.appendEvent` wird via Lokal-Wrapper STRICT typgeprüft.
|
|
3
|
+
//
|
|
4
|
+
// Ohne diesen Test verifizieren die anderen 12 Codegen-Tests nur, dass
|
|
5
|
+
// die richtigen Strings ins File geschrieben werden. Wenn jemand später
|
|
6
|
+
// das `export *`-Shadowing kaputt macht, den `KumikoEventTypeMap`-
|
|
7
|
+
// Re-Export aus `engine/index.ts` entfernt, oder TS-Verhalten in einer
|
|
8
|
+
// neuen Version subtil bricht — die anderen Tests bleiben grün und der
|
|
9
|
+
// strict-mode stirbt schweigend. Genau das wäre der Fall den dieser
|
|
10
|
+
// Test fängt.
|
|
11
|
+
//
|
|
12
|
+
// Ablauf pro Test-Case:
|
|
13
|
+
// 1. tmp-App mit feature.ts + events.ts + bin/main.ts erzeugen.
|
|
14
|
+
// 2. `runCodegen` auf die tmp-App fahren — schreibt
|
|
15
|
+
// `.kumiko/types.generated.d.ts` + `define.ts`.
|
|
16
|
+
// 3. Eine synthetische Test-Datei mit den gewünschten Aufrufen schreiben.
|
|
17
|
+
// 4. ts.createProgram über die App + paths-alias zur framework-source.
|
|
18
|
+
// 5. Diagnostics auswerten — auf konkrete TS-Codes prüfen.
|
|
19
|
+
//
|
|
20
|
+
// `paths` zeigt direkt auf die framework-source (`packages/framework/src`),
|
|
21
|
+
// damit der TS-Type-Checker die Augmentation als Teil DESSELBEN Compiles
|
|
22
|
+
// sieht (Use-Site-Substitution funktioniert nur so — siehe
|
|
23
|
+
// project_x1_typemap_findings memory).
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import * as ts from "typescript";
|
|
28
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
29
|
+
import { runCodegen } from "../run-codegen";
|
|
30
|
+
|
|
31
|
+
const REPO_ROOT = join(__dirname, "../../../../..");
|
|
32
|
+
const FRAMEWORK_SRC = join(REPO_ROOT, "packages/framework/src");
|
|
33
|
+
|
|
34
|
+
// Test-Apps werden IM Repo-Tree angelegt (gitignored), damit Node's
|
|
35
|
+
// natürliches `node_modules`-Hochsuchen 'zod' & Co finden kann. tmpdir
|
|
36
|
+
// liegt außerhalb des Repo-Trees → keine node_modules-Sicht.
|
|
37
|
+
const TEST_FIXTURE_DIR = join(__dirname, ".tmp-fixtures");
|
|
38
|
+
const createdDirs: string[] = [];
|
|
39
|
+
|
|
40
|
+
function makeAppDir(): string {
|
|
41
|
+
mkdirSync(TEST_FIXTURE_DIR, { recursive: true });
|
|
42
|
+
const dir = mkdtempSync(join(TEST_FIXTURE_DIR, "app-"));
|
|
43
|
+
createdDirs.push(dir);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
for (const d of createdDirs) {
|
|
49
|
+
try {
|
|
50
|
+
rmSync(d, { recursive: true, force: true });
|
|
51
|
+
} catch {
|
|
52
|
+
// best-effort cleanup
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
rmSync(TEST_FIXTURE_DIR, { recursive: true, force: true });
|
|
57
|
+
} catch {
|
|
58
|
+
// ditto
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function write(dir: string, relPath: string, content: string): string {
|
|
63
|
+
const full = join(dir, relPath);
|
|
64
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
65
|
+
writeFileSync(full, content, "utf-8");
|
|
66
|
+
return full;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Baut ein TS-Program über die App + framework-source, gibt die
|
|
71
|
+
* semantischen Diagnostics zurück. Lib-files werden vom installierten
|
|
72
|
+
* typescript-Package geholt; sonst meckert TS über fehlende DOM-types.
|
|
73
|
+
*/
|
|
74
|
+
function compileApp(appRoot: string): readonly ts.Diagnostic[] {
|
|
75
|
+
// Wir lassen ts node_modules vom REPO_ROOT auflösen (tmp-Dir hat kein
|
|
76
|
+
// eigenes node_modules). `baseUrl` zeigt auf repo, `paths` mappt
|
|
77
|
+
// framework + tmp-app explizit; rest fällt auf node_modules-Lookup
|
|
78
|
+
// im repo-Tree zurück.
|
|
79
|
+
const compilerOptions: ts.CompilerOptions = {
|
|
80
|
+
target: ts.ScriptTarget.ESNext,
|
|
81
|
+
module: ts.ModuleKind.ESNext,
|
|
82
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
83
|
+
strict: true,
|
|
84
|
+
skipLibCheck: true,
|
|
85
|
+
esModuleInterop: true,
|
|
86
|
+
noEmit: true,
|
|
87
|
+
baseUrl: REPO_ROOT,
|
|
88
|
+
paths: {
|
|
89
|
+
"@cosmicdrift/kumiko-framework/*": [join(FRAMEWORK_SRC, "*/index.ts")],
|
|
90
|
+
},
|
|
91
|
+
types: [],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Sammle alle .ts-Files unter src/ + .kumiko/, plus die framework-
|
|
95
|
+
// source-tree die wir via paths erreichen wollen.
|
|
96
|
+
const program = ts.createProgram({
|
|
97
|
+
rootNames: collectFiles(appRoot),
|
|
98
|
+
options: compilerOptions,
|
|
99
|
+
});
|
|
100
|
+
return ts.getPreEmitDiagnostics(program);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collectFiles(dir: string): string[] {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const fs = require("node:fs");
|
|
106
|
+
const walk = (d: string) => {
|
|
107
|
+
let entries: string[];
|
|
108
|
+
try {
|
|
109
|
+
entries = fs.readdirSync(d);
|
|
110
|
+
} catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
if (e.startsWith(".") && e !== ".kumiko") continue;
|
|
115
|
+
if (e === "node_modules") continue;
|
|
116
|
+
const full = join(d, e);
|
|
117
|
+
const stat = fs.statSync(full);
|
|
118
|
+
if (stat.isDirectory()) walk(full);
|
|
119
|
+
else if (stat.isFile() && (e.endsWith(".ts") || e.endsWith(".tsx")) && !e.endsWith(".d.ts"))
|
|
120
|
+
out.push(full);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk(join(dir, "src"));
|
|
124
|
+
walk(join(dir, ".kumiko"));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Default-shape feature: setup callback registers `placed` and returns
|
|
129
|
+
// nothing. Used by tests that exercise the standard `ctx.appendEvent({
|
|
130
|
+
// type: "orders:event:placed", ... })` literal-string path.
|
|
131
|
+
function setupApp(): string {
|
|
132
|
+
const appRoot = makeAppDir();
|
|
133
|
+
writeOrderPlacedSchema(appRoot);
|
|
134
|
+
write(
|
|
135
|
+
appRoot,
|
|
136
|
+
"src/feature/feature.ts",
|
|
137
|
+
`import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
138
|
+
import { orderPlacedSchema } from "./events";
|
|
139
|
+
|
|
140
|
+
export const ordersFeature = defineFeature("orders", (r) => {
|
|
141
|
+
r.defineEvent("placed", orderPlacedSchema);
|
|
142
|
+
});
|
|
143
|
+
`,
|
|
144
|
+
);
|
|
145
|
+
return appRoot;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Exports-shape feature: setup callback returns `{ placed }` so handler
|
|
149
|
+
// modules can do `ordersFeature.exports.placed.name` and pick up the
|
|
150
|
+
// literal type. Used by the eventDef.name pattern test.
|
|
151
|
+
function setupAppWithExports(): string {
|
|
152
|
+
const appRoot = makeAppDir();
|
|
153
|
+
writeOrderPlacedSchema(appRoot);
|
|
154
|
+
write(
|
|
155
|
+
appRoot,
|
|
156
|
+
"src/feature/feature.ts",
|
|
157
|
+
`import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
158
|
+
import { orderPlacedSchema } from "./events";
|
|
159
|
+
|
|
160
|
+
export const ordersFeature = defineFeature("orders", (r) => ({
|
|
161
|
+
placed: r.defineEvent("placed", orderPlacedSchema),
|
|
162
|
+
}));
|
|
163
|
+
`,
|
|
164
|
+
);
|
|
165
|
+
return appRoot;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function writeOrderPlacedSchema(appRoot: string): void {
|
|
169
|
+
write(
|
|
170
|
+
appRoot,
|
|
171
|
+
"src/feature/events.ts",
|
|
172
|
+
`import { z } from "zod";
|
|
173
|
+
export const orderPlacedSchema = z.object({
|
|
174
|
+
orderId: z.string(),
|
|
175
|
+
customerId: z.string(),
|
|
176
|
+
amount: z.number(),
|
|
177
|
+
});
|
|
178
|
+
`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Each test compiles a tmp-app via ts.createProgram, which traverses
|
|
183
|
+
// the framework-source via paths-alias (~700 files). Lokal ~1.7s pro
|
|
184
|
+
// test; CI auf cdgs-runner (Hetzner CAX21 ARM64) braucht teilweise
|
|
185
|
+
// >60s — vermutlich kleinere Cores + weniger RAM-cache als M-Series-
|
|
186
|
+
// Mac. 120s gibt genug Puffer für den langsamsten Run-Path
|
|
187
|
+
// (eventDef.name pattern lädt full augmented map) ohne echte Hänger
|
|
188
|
+
// zu maskieren.
|
|
189
|
+
const STRICT_MODE_TIMEOUT_MS = 120_000;
|
|
190
|
+
|
|
191
|
+
describe("strict-mode diagnostics — the actual contract of the codegen", () => {
|
|
192
|
+
test("good ctx.appendEvent compiles cleanly", { timeout: STRICT_MODE_TIMEOUT_MS }, () => {
|
|
193
|
+
const appRoot = setupApp();
|
|
194
|
+
runCodegen({ appRoot });
|
|
195
|
+
|
|
196
|
+
write(
|
|
197
|
+
appRoot,
|
|
198
|
+
"src/feature/handler.ts",
|
|
199
|
+
`import { defineWriteHandler } from "../../.kumiko/define";
|
|
200
|
+
import { z } from "zod";
|
|
201
|
+
|
|
202
|
+
export const placeOrder = defineWriteHandler({
|
|
203
|
+
name: "orders.placeOrder",
|
|
204
|
+
schema: z.object({}),
|
|
205
|
+
access: { roles: ["Admin"] },
|
|
206
|
+
handler: async (_event, ctx) => {
|
|
207
|
+
await ctx.appendEvent({
|
|
208
|
+
aggregateId: "x",
|
|
209
|
+
aggregateType: "order",
|
|
210
|
+
type: "orders:event:placed",
|
|
211
|
+
payload: { orderId: "o1", customerId: "c1", amount: 99 },
|
|
212
|
+
});
|
|
213
|
+
return { isSuccess: true as const, data: { id: "o1" } };
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const diagnostics = compileApp(appRoot);
|
|
220
|
+
const handlerErrors = diagnostics.filter((d) =>
|
|
221
|
+
d.file?.fileName.endsWith("/feature/handler.ts"),
|
|
222
|
+
);
|
|
223
|
+
expect(handlerErrors).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("unknown event-type triggers TS2322 with augmented map in error message", {
|
|
227
|
+
timeout: STRICT_MODE_TIMEOUT_MS,
|
|
228
|
+
}, () => {
|
|
229
|
+
const appRoot = setupApp();
|
|
230
|
+
runCodegen({ appRoot });
|
|
231
|
+
|
|
232
|
+
write(
|
|
233
|
+
appRoot,
|
|
234
|
+
"src/feature/handler.ts",
|
|
235
|
+
`import { defineWriteHandler } from "../../.kumiko/define";
|
|
236
|
+
import { z } from "zod";
|
|
237
|
+
|
|
238
|
+
export const placeOrder = defineWriteHandler({
|
|
239
|
+
name: "orders.placeOrder",
|
|
240
|
+
schema: z.object({}),
|
|
241
|
+
access: { roles: ["Admin"] },
|
|
242
|
+
handler: async (_event, ctx) => {
|
|
243
|
+
await ctx.appendEvent({
|
|
244
|
+
aggregateId: "x",
|
|
245
|
+
aggregateType: "order",
|
|
246
|
+
type: "totally:made:up",
|
|
247
|
+
payload: { whatever: 1 },
|
|
248
|
+
});
|
|
249
|
+
return { isSuccess: true as const, data: { id: "x" } };
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
`,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const diagnostics = compileApp(appRoot);
|
|
256
|
+
const handlerErrors = diagnostics.filter((d) =>
|
|
257
|
+
d.file?.fileName.endsWith("/feature/handler.ts"),
|
|
258
|
+
);
|
|
259
|
+
// We expect at least one TS2322 ("not assignable") for the bogus
|
|
260
|
+
// type-string. The exact column may move with TS versions; the code
|
|
261
|
+
// + the type-name are the stable contract.
|
|
262
|
+
const ts2322 = handlerErrors.filter((d) => d.code === 2322);
|
|
263
|
+
expect(ts2322.length).toBeGreaterThan(0);
|
|
264
|
+
const flattened = ts2322
|
|
265
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
|
|
266
|
+
.join("\n");
|
|
267
|
+
expect(flattened).toMatch(/keyof KumikoEventTypeMap|"orders:event:placed"/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("payload-shape mismatch triggers a property-error", {
|
|
271
|
+
timeout: STRICT_MODE_TIMEOUT_MS,
|
|
272
|
+
}, () => {
|
|
273
|
+
const appRoot = setupApp();
|
|
274
|
+
runCodegen({ appRoot });
|
|
275
|
+
|
|
276
|
+
write(
|
|
277
|
+
appRoot,
|
|
278
|
+
"src/feature/handler.ts",
|
|
279
|
+
`import { defineWriteHandler } from "../../.kumiko/define";
|
|
280
|
+
import { z } from "zod";
|
|
281
|
+
|
|
282
|
+
export const placeOrder = defineWriteHandler({
|
|
283
|
+
name: "orders.placeOrder",
|
|
284
|
+
schema: z.object({}),
|
|
285
|
+
access: { roles: ["Admin"] },
|
|
286
|
+
handler: async (_event, ctx) => {
|
|
287
|
+
await ctx.appendEvent({
|
|
288
|
+
aggregateId: "x",
|
|
289
|
+
aggregateType: "order",
|
|
290
|
+
type: "orders:event:placed",
|
|
291
|
+
payload: { orderId: "o1", customerId: "c1", amount: 99, bogus: "extra" },
|
|
292
|
+
});
|
|
293
|
+
return { isSuccess: true as const, data: { id: "o1" } };
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
`,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const diagnostics = compileApp(appRoot);
|
|
300
|
+
const handlerErrors = diagnostics.filter((d) =>
|
|
301
|
+
d.file?.fileName.endsWith("/feature/handler.ts"),
|
|
302
|
+
);
|
|
303
|
+
// TS2353 = "Object literal may only specify known properties, and
|
|
304
|
+
// 'bogus' does not exist in type". This is the property-level
|
|
305
|
+
// strict-check we promised.
|
|
306
|
+
const propErrors = handlerErrors.filter((d) => d.code === 2353);
|
|
307
|
+
expect(propErrors.length).toBeGreaterThan(0);
|
|
308
|
+
const flattened = propErrors
|
|
309
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
|
|
310
|
+
.join("\n");
|
|
311
|
+
expect(flattened).toMatch(/'bogus'/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("direct framework-import + augmentation-included compiles strict too", {
|
|
315
|
+
timeout: STRICT_MODE_TIMEOUT_MS,
|
|
316
|
+
}, () => {
|
|
317
|
+
// Sanity-Check: in einem isolated app-tsc (tmp-fixture mit paths-
|
|
318
|
+
// mapping zur framework-source UND .kumiko/types.generated.d.ts im
|
|
319
|
+
// include-Glob) greift strict-mode auch beim direct framework-import.
|
|
320
|
+
// Generic-function-inference nimmt die augmentation am use-site wahr.
|
|
321
|
+
//
|
|
322
|
+
// Konsequenz: der Wrapper ist NICHT der einzige Weg zu strict —
|
|
323
|
+
// aber er ist DER ROBUSTE Weg. Er importiert `types.generated`
|
|
324
|
+
// explicit als side-effect, sodass die Augmentation auch in
|
|
325
|
+
// partial-builds / IDE-Sprachserver-stati garantiert visible ist.
|
|
326
|
+
// Direkter Import setzt voraus, dass das tsconfig-Setup stimmt.
|
|
327
|
+
//
|
|
328
|
+
// Die alte "K=never"-Beobachtung aus den 13 Probes war im
|
|
329
|
+
// bundled-features-Compile, wo das `.kumiko/`-Output nicht im
|
|
330
|
+
// include-Glob lag — die Augmentation aus inline `declare module`
|
|
331
|
+
// hatte einen anderen Resolution-Pfad. Der Wrapper bleibt der
|
|
332
|
+
// empfohlene Pfad für Apps, weil er diese Setup-Sensibilität wegabstrahiert.
|
|
333
|
+
const appRoot = setupApp();
|
|
334
|
+
runCodegen({ appRoot });
|
|
335
|
+
|
|
336
|
+
write(
|
|
337
|
+
appRoot,
|
|
338
|
+
"src/feature/handler-direct.ts",
|
|
339
|
+
`import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
340
|
+
import { z } from "zod";
|
|
341
|
+
|
|
342
|
+
export const placeOrder = defineWriteHandler({
|
|
343
|
+
name: "orders.placeOrder",
|
|
344
|
+
schema: z.object({}),
|
|
345
|
+
access: { roles: ["Admin"] },
|
|
346
|
+
handler: async (_event, ctx) => {
|
|
347
|
+
await ctx.appendEvent({
|
|
348
|
+
aggregateId: "x",
|
|
349
|
+
aggregateType: "order",
|
|
350
|
+
type: "orders:event:placed",
|
|
351
|
+
payload: { orderId: "o1", customerId: "c1", amount: 99 },
|
|
352
|
+
});
|
|
353
|
+
return { isSuccess: true as const, data: { id: "o1" } };
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
`,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const diagnostics = compileApp(appRoot);
|
|
360
|
+
const handlerErrors = diagnostics.filter((d) =>
|
|
361
|
+
d.file?.fileName.endsWith("/feature/handler-direct.ts"),
|
|
362
|
+
);
|
|
363
|
+
// Good call should compile — augmentation is visible via include of
|
|
364
|
+
// `.kumiko/types.generated.d.ts`.
|
|
365
|
+
expect(handlerErrors).toHaveLength(0);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("eventDef.name pattern: literal-typed name resolves to correct payload-shape", {
|
|
369
|
+
timeout: STRICT_MODE_TIMEOUT_MS,
|
|
370
|
+
}, () => {
|
|
371
|
+
// Marten pattern: `const placed = r.defineEvent(...)`, then
|
|
372
|
+
// `type: placed.name` in appendEvent. This requires `EventDef.name`
|
|
373
|
+
// to be LITERAL-typed (`"orders:event:placed"`, NOT `string`) —
|
|
374
|
+
// otherwise the lookup collapses to `string` and the strict check
|
|
375
|
+
// silently disappears.
|
|
376
|
+
//
|
|
377
|
+
// This test catches regressions in `EventDef<TPayload, TName>` and
|
|
378
|
+
// the `<const TInner>` inference in `defineFeature`/`defineEvent`
|
|
379
|
+
// — both have to cooperate so that `placed.name` resolves as a
|
|
380
|
+
// literal into the `KumikoEventTypeMap` key.
|
|
381
|
+
//
|
|
382
|
+
// Setup: `setupAppWithExports` returns `{ placed }` from the
|
|
383
|
+
// defineFeature callback so handler modules can read it as
|
|
384
|
+
// `ordersFeature.exports.placed.name`.
|
|
385
|
+
const appRoot = setupAppWithExports();
|
|
386
|
+
runCodegen({ appRoot });
|
|
387
|
+
|
|
388
|
+
write(
|
|
389
|
+
appRoot,
|
|
390
|
+
"src/feature/handler-byname.ts",
|
|
391
|
+
`import { defineWriteHandler } from "../../.kumiko/define";
|
|
392
|
+
import { z } from "zod";
|
|
393
|
+
import { ordersFeature } from "./feature";
|
|
394
|
+
|
|
395
|
+
const { placed } = ordersFeature.exports;
|
|
396
|
+
|
|
397
|
+
export const placeOrder = defineWriteHandler({
|
|
398
|
+
name: "orders.placeOrder",
|
|
399
|
+
schema: z.object({}),
|
|
400
|
+
access: { roles: ["Admin"] },
|
|
401
|
+
handler: async (_event, ctx) => {
|
|
402
|
+
await ctx.appendEvent({
|
|
403
|
+
aggregateId: "x",
|
|
404
|
+
aggregateType: "order",
|
|
405
|
+
type: placed.name,
|
|
406
|
+
payload: { orderId: "o1", customerId: "c1", amount: 99 },
|
|
407
|
+
});
|
|
408
|
+
return { isSuccess: true as const, data: { id: "o1" } };
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
`,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const goodDiagnostics = compileApp(appRoot);
|
|
415
|
+
const goodErrors = goodDiagnostics.filter((d) =>
|
|
416
|
+
d.file?.fileName.endsWith("/feature/handler-byname.ts"),
|
|
417
|
+
);
|
|
418
|
+
if (goodErrors.length > 0) {
|
|
419
|
+
const msgs = goodErrors
|
|
420
|
+
.map((d) => ` TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`)
|
|
421
|
+
.join("\n");
|
|
422
|
+
throw new Error(`expected handler-byname.ts to compile cleanly, got:\n${msgs}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Negative-Case: bad payload (extra property) → TS2353. Der
|
|
426
|
+
// entscheidende Punkt — wenn `placed.name` zu `string` kollabiert
|
|
427
|
+
// wäre, würde TS hier eine `Record<string, unknown>` annehmen und
|
|
428
|
+
// die extra property NICHT melden. TS2353 hier beweist die
|
|
429
|
+
// literal-typed Auflösung über `.name`.
|
|
430
|
+
write(
|
|
431
|
+
appRoot,
|
|
432
|
+
"src/feature/handler-byname.ts",
|
|
433
|
+
`import { defineWriteHandler } from "../../.kumiko/define";
|
|
434
|
+
import { z } from "zod";
|
|
435
|
+
import { ordersFeature } from "./feature";
|
|
436
|
+
|
|
437
|
+
const { placed } = ordersFeature.exports;
|
|
438
|
+
|
|
439
|
+
export const placeOrder = defineWriteHandler({
|
|
440
|
+
name: "orders.placeOrder",
|
|
441
|
+
schema: z.object({}),
|
|
442
|
+
access: { roles: ["Admin"] },
|
|
443
|
+
handler: async (_event, ctx) => {
|
|
444
|
+
await ctx.appendEvent({
|
|
445
|
+
aggregateId: "x",
|
|
446
|
+
aggregateType: "order",
|
|
447
|
+
type: placed.name,
|
|
448
|
+
payload: { orderId: "o1", customerId: "c1", amount: 99, bogus: "extra" },
|
|
449
|
+
});
|
|
450
|
+
return { isSuccess: true as const, data: { id: "o1" } };
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const badDiagnostics = compileApp(appRoot);
|
|
457
|
+
const badErrors = badDiagnostics.filter((d) =>
|
|
458
|
+
d.file?.fileName.endsWith("/feature/handler-byname.ts"),
|
|
459
|
+
);
|
|
460
|
+
const propErrors = badErrors.filter((d) => d.code === 2353);
|
|
461
|
+
expect(propErrors.length).toBeGreaterThan(0);
|
|
462
|
+
const flattened = propErrors
|
|
463
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
|
|
464
|
+
.join("\n");
|
|
465
|
+
expect(flattened).toMatch(/'bogus'/);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// watchAndRegenerate — file-watcher-Tests. Verifiziert: initial-Pass
|
|
2
|
+
// läuft synchron, file-changes triggern einen erneuten Pass mit Debounce,
|
|
3
|
+
// close() ist idempotent.
|
|
4
|
+
//
|
|
5
|
+
// Fixtures liegen wie bei strict-mode-diagnostics.test.ts unter
|
|
6
|
+
// `__tests__/.tmp-fixtures/` (gitignored), damit Node's natürliches
|
|
7
|
+
// `node_modules`-Hochsuchen 'zod' findet — auch wenn watch-Tests
|
|
8
|
+
// 'zod' nicht direct nutzen, runCodegen scant feature-files die
|
|
9
|
+
// `import { z } from "zod"` haben.
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
+
import type { CodegenResult } from "../run-codegen";
|
|
15
|
+
import { watchAndRegenerate } from "../watch";
|
|
16
|
+
|
|
17
|
+
const TEST_FIXTURE_DIR = join(__dirname, ".tmp-fixtures");
|
|
18
|
+
const createdDirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
function makeAppDir(): string {
|
|
21
|
+
mkdirSync(TEST_FIXTURE_DIR, { recursive: true });
|
|
22
|
+
const dir = mkdtempSync(join(TEST_FIXTURE_DIR, "watch-"));
|
|
23
|
+
createdDirs.push(dir);
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeFile(dir: string, relPath: string, content: string): string {
|
|
28
|
+
const full = join(dir, relPath);
|
|
29
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
30
|
+
writeFileSync(full, content, "utf-8");
|
|
31
|
+
return full;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
for (const d of createdDirs) {
|
|
36
|
+
try {
|
|
37
|
+
rmSync(d, { recursive: true, force: true });
|
|
38
|
+
} catch {
|
|
39
|
+
/* best-effort */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Polls a predicate at `interval` ms until it returns true, or rejects
|
|
46
|
+
* after `timeout`. Replaces fixed `setTimeout(...)` waits — those
|
|
47
|
+
* implicitly assume "this many ms is enough", which is brittle on
|
|
48
|
+
* loaded CI runners. The polling form converges as fast as the system
|
|
49
|
+
* allows AND fails loudly with a useful message if the event never lands.
|
|
50
|
+
*/
|
|
51
|
+
async function waitFor(
|
|
52
|
+
predicate: () => boolean,
|
|
53
|
+
opts: { timeout?: number; interval?: number; label?: string } = {},
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const timeout = opts.timeout ?? 2000;
|
|
56
|
+
const interval = opts.interval ?? 25;
|
|
57
|
+
const deadline = Date.now() + timeout;
|
|
58
|
+
while (!predicate()) {
|
|
59
|
+
if (Date.now() > deadline) {
|
|
60
|
+
throw new Error(`waitFor: ${opts.label ?? "predicate"} not satisfied within ${timeout}ms`);
|
|
61
|
+
}
|
|
62
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const FEATURE_TEMPLATE = (featureName: string, eventName: string) => `
|
|
67
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
68
|
+
import { z } from "zod";
|
|
69
|
+
|
|
70
|
+
export default defineFeature("${featureName}", (r) => {
|
|
71
|
+
r.defineEvent("${eventName}", z.object({ id: z.string() }));
|
|
72
|
+
});
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
describe("watchAndRegenerate", () => {
|
|
76
|
+
test("initial run produces output synchronously", () => {
|
|
77
|
+
const appRoot = makeAppDir();
|
|
78
|
+
writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("billing", "first-event"));
|
|
79
|
+
|
|
80
|
+
const results: CodegenResult[] = [];
|
|
81
|
+
const handle = watchAndRegenerate({
|
|
82
|
+
appRoot,
|
|
83
|
+
onResult: (r) => results.push(r),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(results).toHaveLength(1);
|
|
87
|
+
expect(results[0]?.eventCount).toBe(1);
|
|
88
|
+
handle.close();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("file change triggers a re-run after debounce", async () => {
|
|
92
|
+
const appRoot = makeAppDir();
|
|
93
|
+
writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("orders", "first"));
|
|
94
|
+
|
|
95
|
+
const results: CodegenResult[] = [];
|
|
96
|
+
const handle = watchAndRegenerate({
|
|
97
|
+
appRoot,
|
|
98
|
+
debounceMs: 30,
|
|
99
|
+
onResult: (r) => results.push(r),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(results).toHaveLength(1);
|
|
103
|
+
expect(results[0]?.eventCount).toBe(1);
|
|
104
|
+
|
|
105
|
+
// Add a second event-definition by rewriting the feature.
|
|
106
|
+
writeFile(
|
|
107
|
+
appRoot,
|
|
108
|
+
"src/feature.ts",
|
|
109
|
+
`
|
|
110
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
111
|
+
import { z } from "zod";
|
|
112
|
+
|
|
113
|
+
export default defineFeature("orders", (r) => {
|
|
114
|
+
r.defineEvent("first", z.object({ id: z.string() }));
|
|
115
|
+
r.defineEvent("second", z.object({ tag: z.string() }));
|
|
116
|
+
});
|
|
117
|
+
`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Poll until the watcher's debounced re-run has landed. fs.watch
|
|
121
|
+
// events on macOS arrive in 1-5ms; CI runners can stretch that —
|
|
122
|
+
// polling adapts to the actual schedule instead of guessing a fixed
|
|
123
|
+
// sleep.
|
|
124
|
+
await waitFor(() => results.length >= 2, {
|
|
125
|
+
timeout: 2000,
|
|
126
|
+
label: "second codegen result",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(results.at(-1)?.eventCount).toBe(2);
|
|
130
|
+
handle.close();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("close() is idempotent", () => {
|
|
134
|
+
const appRoot = makeAppDir();
|
|
135
|
+
writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("nope", "evt"));
|
|
136
|
+
const handle = watchAndRegenerate({ appRoot, onResult: () => {} });
|
|
137
|
+
handle.close();
|
|
138
|
+
handle.close(); // must not throw
|
|
139
|
+
expect(true).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("non-ts file changes do not trigger codegen", async () => {
|
|
143
|
+
// Negative-assertion shape: prove that .css/.md changes do NOT add
|
|
144
|
+
// a codegen result. Naïve "sleep N ms then assert length stayed"
|
|
145
|
+
// is racy on macOS, where fs.watch can deliver stale events from
|
|
146
|
+
// pre-watcher writes after the watcher is attached. We sidestep
|
|
147
|
+
// that by anchoring on a POSITIVE control: a known-triggering .ts
|
|
148
|
+
// change at the end. waitFor proves the watcher is alive — so the
|
|
149
|
+
// pre-trigger count is trustworthy.
|
|
150
|
+
const appRoot = makeAppDir();
|
|
151
|
+
writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "evt"));
|
|
152
|
+
|
|
153
|
+
const results: CodegenResult[] = [];
|
|
154
|
+
const handle = watchAndRegenerate({
|
|
155
|
+
appRoot,
|
|
156
|
+
debounceMs: 30,
|
|
157
|
+
onResult: (r) => results.push(r),
|
|
158
|
+
});
|
|
159
|
+
expect(results).toHaveLength(1);
|
|
160
|
+
|
|
161
|
+
// Drain any stale events from the pre-watcher feature.ts write —
|
|
162
|
+
// some platforms deliver these to a watcher attached after the
|
|
163
|
+
// write. Long enough to outlast debounce + scheduler jitter.
|
|
164
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
165
|
+
const baseline = results.length;
|
|
166
|
+
|
|
167
|
+
// Non-ts writes — the regression we want to catch.
|
|
168
|
+
writeFile(appRoot, "src/styles.css", `body { color: red; }`);
|
|
169
|
+
writeFile(appRoot, "src/README.md", `# hi`);
|
|
170
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
171
|
+
const afterNonTs = results.length;
|
|
172
|
+
|
|
173
|
+
// Positive control: a .ts change MUST trigger. waitFor exits as
|
|
174
|
+
// soon as the new result lands, confirming the watcher is alive.
|
|
175
|
+
writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "after"));
|
|
176
|
+
await waitFor(() => results.length > afterNonTs, {
|
|
177
|
+
timeout: 2000,
|
|
178
|
+
label: "ts-change result after non-ts noise",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// The non-ts writes should not have advanced the count past the
|
|
182
|
+
// baseline. If they did, the watcher's filter is broken.
|
|
183
|
+
expect(afterNonTs).toBe(baseline);
|
|
184
|
+
handle.close();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
renderDefineFile,
|
|
3
|
+
renderInlineSchemasFile,
|
|
4
|
+
renderTypesAugmentation,
|
|
5
|
+
} from "./render";
|
|
6
|
+
export { type CodegenOptions, type CodegenResult, runCodegen } from "./run-codegen";
|
|
7
|
+
export {
|
|
8
|
+
qualifiedNameToConstName,
|
|
9
|
+
rewriteImportPath,
|
|
10
|
+
type ScannedEvent,
|
|
11
|
+
type ScanOptions,
|
|
12
|
+
type ScanResult,
|
|
13
|
+
type ScanWarning,
|
|
14
|
+
type SchemaSource,
|
|
15
|
+
scanEvents,
|
|
16
|
+
} from "./scan-events";
|
|
17
|
+
export { type WatchHandle, type WatchOptions, watchAndRegenerate } from "./watch";
|