@cosmicdrift/kumiko-framework 0.154.0 → 0.154.2

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +1 -1
  3. package/src/api/__tests__/batch.integration.test.ts +9 -9
  4. package/src/engine/__tests__/engine.test.ts +16 -0
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  7. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  8. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  9. package/src/engine/define-feature.ts +22 -4
  10. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +15 -28
  11. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  12. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  13. package/src/engine/feature-ast/__tests__/parse.test.ts +55 -14
  14. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  15. package/src/engine/feature-ast/__tests__/patcher.test.ts +12 -22
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +247 -5
  17. package/src/engine/feature-ast/extractors/index.ts +0 -3
  18. package/src/engine/feature-ast/extractors/round4.ts +113 -287
  19. package/src/engine/feature-ast/index.ts +0 -4
  20. package/src/engine/feature-ast/parse.ts +0 -6
  21. package/src/engine/feature-ast/patch.ts +35 -45
  22. package/src/engine/feature-ast/patcher.ts +18 -40
  23. package/src/engine/feature-ast/patterns.ts +20 -41
  24. package/src/engine/feature-ast/render.ts +17 -27
  25. package/src/engine/feature-config-events-jobs.ts +150 -74
  26. package/src/engine/feature-entity-handlers.ts +24 -6
  27. package/src/engine/feature-ui-extensions.ts +116 -45
  28. package/src/engine/object-form.ts +12 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +0 -19
  30. package/src/engine/pattern-library/library.ts +0 -4
  31. package/src/engine/pattern-library/mixed-schemas.ts +9 -80
  32. package/src/engine/pattern-library/shared-fields.ts +1 -6
  33. package/src/engine/registry-ingest.ts +7 -2
  34. package/src/engine/registry-validate.ts +6 -6
  35. package/src/engine/types/feature.ts +76 -45
  36. package/src/engine/types/handlers.ts +6 -3
  37. package/src/engine/types/nav.ts +4 -0
  38. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  39. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  40. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  41. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  42. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  43. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  44. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  45. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  46. package/src/search/meilisearch-adapter.ts +13 -12
@@ -301,21 +301,16 @@ describe("patch coverage for the remaining pattern-kinds", () => {
301
301
  expect(parseSourceFile(sf).patterns.find((p) => p.kind === "authClaims")).toBeUndefined();
302
302
  });
303
303
 
304
- test("add + remove via PatternId for eventMigration (event+versions key)", () => {
304
+ test("add + remove via PatternId for defineEvent with migrations", () => {
305
305
  const sf = makeSourceFile(STARTER);
306
- createFeaturePatcher(sf).addEventMigration({
307
- event: "itemCreated",
308
- fromVersion: 1,
309
- toVersion: 2,
310
- transformSource: "(old) => old",
311
- });
312
- removePattern(sf, {
313
- kind: "eventMigration",
314
- eventName: "itemCreated",
315
- fromVersion: 1,
316
- toVersion: 2,
306
+ createFeaturePatcher(sf).addDefineEvent({
307
+ name: "itemCreated",
308
+ schemaSource: "z.object({ id: z.string() })",
309
+ version: 2,
310
+ migrations: { "1": "(old) => old" },
317
311
  });
318
- expect(parseSourceFile(sf).patterns.find((p) => p.kind === "eventMigration")).toBeUndefined();
312
+ removePattern(sf, { kind: "defineEvent", eventName: "itemCreated" });
313
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "defineEvent")).toBeUndefined();
319
314
  });
320
315
  });
321
316
 
@@ -157,48 +157,38 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
157
157
  });
158
158
  });
159
159
 
160
- test("addEntityHook routes type + entity correctly", () => {
160
+ test("addHook routes entity-wide { allOf } target correctly", () => {
161
161
  const sf = makeSourceFile(STARTER);
162
- createFeaturePatcher(sf).addEntityHook({
162
+ createFeaturePatcher(sf).addHook({
163
163
  type: "postDelete",
164
- entity: "task",
164
+ target: { allOf: "task" },
165
165
  handlerSource: "async (event, ctx) => { /* cleanup */ }",
166
166
  });
167
167
  const result = parseSourceFile(sf);
168
168
  expect(result.patterns[0]).toMatchObject({
169
- kind: "entityHook",
169
+ kind: "hook",
170
170
  hookType: "postDelete",
171
- entityName: "task",
171
+ target: { allOf: "task" },
172
172
  });
173
173
  });
174
174
 
175
- test("addDefineEvent + addEventMigration form a complete event-versioning chain", () => {
175
+ test("addDefineEvent with migrations forms a complete event-versioning chain", () => {
176
176
  const sf = makeSourceFile(STARTER);
177
177
  const p = createFeaturePatcher(sf);
178
178
  p.addDefineEvent({
179
179
  name: "stepCompleted",
180
180
  schemaSource: "z.object({ id: z.string() })",
181
181
  version: 2,
182
- });
183
- p.addEventMigration({
184
- event: "stepCompleted",
185
- fromVersion: 1,
186
- toVersion: 2,
187
- transformSource: '(old) => ({ id: old.id ?? "" })',
182
+ migrations: { "1": '(old) => ({ id: old.id ?? "" })' },
188
183
  });
189
184
  const result = parseSourceFile(sf);
190
185
  expect(result.errors).toEqual([]);
191
- expect(result.patterns).toHaveLength(2);
186
+ expect(result.patterns).toHaveLength(1);
192
187
  expect(result.patterns[0]).toMatchObject({
193
188
  kind: "defineEvent",
194
189
  eventName: "stepCompleted",
195
190
  version: 2,
196
- });
197
- expect(result.patterns[1]).toMatchObject({
198
- kind: "eventMigration",
199
- eventName: "stepCompleted",
200
- fromVersion: 1,
201
- toVersion: 2,
191
+ migrations: { "1": expect.anything() },
202
192
  });
203
193
  });
204
194
  });
@@ -452,9 +442,9 @@ defineFeature("tasks", (r) => {
452
442
  handlerSource: "async (q, ctx) => []",
453
443
  access: { openToAll: true },
454
444
  });
455
- p.addEntityHook({
445
+ p.addHook({
456
446
  type: "postDelete",
457
- entity: "task",
447
+ target: { allOf: "task" },
458
448
  handlerSource: "async (event, ctx) => { /* cascade-clean */ }",
459
449
  });
460
450
  const result = parseSourceFile(sf);
@@ -463,7 +453,7 @@ defineFeature("tasks", (r) => {
463
453
  "entity",
464
454
  "writeHandler",
465
455
  "queryHandler",
466
- "entityHook",
456
+ "hook",
467
457
  ]);
468
458
  });
469
459
  });
@@ -9,7 +9,8 @@
9
9
  // up at canonical positions). We compare every other field.
10
10
 
11
11
  import { describe, expect, test } from "bun:test";
12
- import { Project } from "ts-morph";
12
+ import * as path from "node:path";
13
+ import { Project, ts } from "ts-morph";
13
14
  import { parseSourceFile } from "../parse";
14
15
  import type { FeaturePattern } from "../patterns";
15
16
  import { renderFeatureFile, renderPattern } from "../render";
@@ -29,10 +30,10 @@ defineFeature("inventory", (r) => {
29
30
  },
30
31
  });
31
32
 
32
- r.relation("item", "supplier", { kind: "belongsTo", to: "user" });
33
+ r.relation("item", "supplier", { type: "belongsTo", target: "user", foreignKey: "supplierId" });
33
34
 
34
35
  r.metric("created", { type: "counter" });
35
- r.secret("apiKey", { description: "Stripe key" });
36
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
36
37
  r.claimKey("teamId", { type: "string" });
37
38
 
38
39
  r.referenceData(
@@ -44,7 +45,16 @@ defineFeature("inventory", (r) => {
44
45
  { upsertKey: "id" },
45
46
  );
46
47
 
47
- r.config({ keys: { maxRows: { type: "number", default: 50 } } });
48
+ r.config({
49
+ keys: {
50
+ maxRows: {
51
+ type: "number",
52
+ default: 50,
53
+ scope: "tenant",
54
+ access: { read: ["user"], write: ["admin"] },
55
+ },
56
+ },
57
+ });
48
58
  });
49
59
  `;
50
60
 
@@ -114,7 +124,7 @@ function stripLocations(value: unknown): unknown {
114
124
  out[k] = { raw: normalizeBodyRaw((v as { raw: string }).raw) };
115
125
  continue;
116
126
  }
117
- if (k === "opaqueProps" && v && typeof v === "object") {
127
+ if ((k === "opaqueProps" || k === "migrations") && v && typeof v === "object") {
118
128
  const op: Record<string, unknown> = {};
119
129
  for (const [pk, pv] of Object.entries(v as Record<string, unknown>)) {
120
130
  if (pv && typeof pv === "object" && "raw" in pv) {
@@ -359,6 +369,41 @@ describe("renderPattern — single-pattern shape", () => {
359
369
  });
360
370
  });
361
371
 
372
+ // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
373
+ // hit: a registrar-shape change must survive the Designer's parse→render→
374
+ // parse cycle, not just a TS compile of the framework itself. r.hook()'s
375
+ // `{ allOf: entity }` target (replacing r.entityHook) reuses HookPattern's
376
+ // already-generic target field, so this is the cheap case — but proving it
377
+ // beats assuming it.
378
+ const HOOK_ALL_OF_FEATURE = `
379
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
380
+
381
+ defineFeature("shop", (r) => {
382
+ r.entity("product", { fields: { name: { type: "text" } } });
383
+ r.hook("postSave", { allOf: "product" }, async (result) => {
384
+ console.log(result);
385
+ });
386
+ });
387
+ `;
388
+
389
+ describe("render → parse roundtrip — r.hook({ allOf }) entity-wide target", () => {
390
+ test("allOf target survives parse → render → parse unchanged", () => {
391
+ const initial = parse(HOOK_ALL_OF_FEATURE);
392
+ const rendered = renderFeatureFile({
393
+ featureName: initial.featureName ?? "",
394
+ patterns: initial.patterns,
395
+ });
396
+ const reparsed = parse(rendered);
397
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
398
+
399
+ const hookPattern = reparsed.patterns.find((p) => p.kind === "hook");
400
+ expect(hookPattern?.kind).toBe("hook");
401
+ if (hookPattern?.kind === "hook") {
402
+ expect(hookPattern.target).toEqual({ allOf: "product" });
403
+ }
404
+ });
405
+ });
406
+
362
407
  // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
363
408
  // hit: a registrar-shape change (there, removing a method; here, adding a
364
409
  // nested optional field) must survive the Designer's parse→render→parse
@@ -400,3 +445,200 @@ describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () =>
400
445
  }
401
446
  });
402
447
  });
448
+
449
+ const DEFINE_EVENT_WITH_MIGRATIONS_FEATURE = `
450
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
451
+
452
+ defineFeature("billing", (r) => {
453
+ r.defineEvent("invoicePaid", z.object({ totalCents: z.number() }), {
454
+ version: 2,
455
+ migrations: [
456
+ {
457
+ fromVersion: 1,
458
+ toVersion: 2,
459
+ transform: (payload) => ({ totalCents: Math.round(payload.total * 100) }),
460
+ },
461
+ ],
462
+ });
463
+ });
464
+ `;
465
+
466
+ describe("render → parse roundtrip — r.defineEvent({ migrations }) fold", () => {
467
+ test("migrations array survives parse → render → parse unchanged", () => {
468
+ const initial = parse(DEFINE_EVENT_WITH_MIGRATIONS_FEATURE);
469
+ const rendered = renderFeatureFile({
470
+ featureName: initial.featureName ?? "",
471
+ patterns: initial.patterns,
472
+ });
473
+ const reparsed = parse(rendered);
474
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
475
+
476
+ const eventPattern = reparsed.patterns.find((p) => p.kind === "defineEvent");
477
+ expect(eventPattern?.kind).toBe("defineEvent");
478
+ if (eventPattern?.kind === "defineEvent") {
479
+ expect(eventPattern.version).toBe(2);
480
+ expect(Object.keys(eventPattern.migrations ?? {})).toEqual(["1"]);
481
+ }
482
+ });
483
+ });
484
+
485
+ // --- Real-compile check ---------------------------------------------------
486
+ //
487
+ // Everything above proves parse(render(patterns)) is structurally faithful.
488
+ // It does NOT prove the rendered code actually compiles against the real
489
+ // FeatureRegistrar type — parse() runs against a bare in-memory Project with
490
+ // no lib/module resolution, so a rendered call can be well-formed AST and
491
+ // still be a type error against the real registrar (e.g. a removed method).
492
+ // That gap let the r.usesApi removal (folded into r.requires({apis})) pass
493
+ // the full 4960-test suite silently; only the visual Designer would have hit
494
+ // the broken shape.
495
+ //
496
+ // This Project resolves `@cosmicdrift/kumiko-framework/*` against the real
497
+ // framework source (unlike parse()'s Project, dependency resolution is NOT
498
+ // skipped), so `r` in a compiled fixture is the real FeatureRegistrar, not a
499
+ // structural stand-in.
500
+ const FRAMEWORK_TSCONFIG_PATH = path.join(import.meta.dir, "../../../../tsconfig.json");
501
+
502
+ const compileProject = new Project({
503
+ tsConfigFilePath: FRAMEWORK_TSCONFIG_PATH,
504
+ skipAddingFilesFromTsConfig: true,
505
+ });
506
+
507
+ let compileCheckCounter = 0;
508
+
509
+ function compileAgainstRegistrar(source: string): readonly string[] {
510
+ compileCheckCounter += 1;
511
+ const filePath = path.join(
512
+ import.meta.dir,
513
+ `__generated__/compile-check-${compileCheckCounter}.gen.ts`,
514
+ );
515
+ const sourceFile = compileProject.createSourceFile(filePath, source, { overwrite: true });
516
+ const diagnostics = sourceFile.getPreEmitDiagnostics().map((d) => {
517
+ const text = ts.flattenDiagnosticMessageText(d.compilerObject.messageText, "\n");
518
+ return `${d.getLineNumber()}: ${text}`;
519
+ });
520
+ sourceFile.forget();
521
+ return diagnostics;
522
+ }
523
+
524
+ function renderAndCompile(source: string): readonly string[] {
525
+ const { featureName, patterns } = parse(source);
526
+ const rendered = renderFeatureFile({ featureName: featureName ?? "", patterns });
527
+ return compileAgainstRegistrar(rendered);
528
+ }
529
+
530
+ describe("render → compiles against the real FeatureRegistrar type", () => {
531
+ test("compile check has teeth: an unknown registrar method is reported", () => {
532
+ const diagnostics = compileAgainstRegistrar(`
533
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
534
+
535
+ defineFeature("teeth-check", (r) => {
536
+ r.methodThatDoesNotExist({ nope: true });
537
+ });
538
+ `);
539
+ expect(diagnostics.length).toBeGreaterThan(0);
540
+ expect(diagnostics.join("\n")).toContain("methodThatDoesNotExist");
541
+ }, 20_000);
542
+
543
+ test("positive control: a minimal always-matched-the-real-shape fixture compiles clean", () => {
544
+ const diagnostics = compileAgainstRegistrar(`
545
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
546
+
547
+ defineFeature("teeth-check-positive", (r) => {
548
+ r.systemScope();
549
+ r.describe("Minimal fixture proving the harness returns [] on real success.");
550
+ r.toggleable({ default: true });
551
+ });
552
+ `);
553
+ expect(diagnostics).toEqual([]);
554
+ }, 20_000);
555
+
556
+ test("STATIC_FEATURE's rendered header-data patterns compile clean", () => {
557
+ expect(renderAndCompile(STATIC_FEATURE)).toEqual([]);
558
+ }, 20_000);
559
+ });
560
+
561
+ // One self-contained, compile-clean fixture per remaining pattern kind not
562
+ // already exercised above (STATIC_FEATURE / MIXED_FEATURE / HOOK_ALL_OF /
563
+ // SCREEN_WITH_NAV / DEFINE_EVENT_WITH_MIGRATIONS). Deliberately separate
564
+ // from the parse-roundtrip fixtures above: those were written to test
565
+ // parse/render symmetry, not to be type-correct (e.g. DEFINE_EVENT_WITH_
566
+ // MIGRATIONS_FEATURE uses `z` without importing it) — reusing them here
567
+ // would produce diagnostics unrelated to the registrar shape and defeat the
568
+ // point of an exact-match assertion.
569
+ //
570
+ // RAW_REF_FEATURE and the "unknown" pattern kind are excluded on purpose:
571
+ // raw-ref sentinels reference symbols that don't exist by design, and
572
+ // "unknown" is the parser's catch-all bucket for unrecognized r.calls, not
573
+ // a real user-authored pattern.
574
+ const MANIFEST_AND_CONFIG_FEATURE = `
575
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
576
+ import { z } from "zod";
577
+
578
+ defineFeature("catalog", (r) => {
579
+ r.systemScope();
580
+ r.describe("Product catalog and pricing.");
581
+ r.optionalRequires("promotions");
582
+ r.uiHints({ displayLabel: "Catalog", category: "Commerce", recommended: true });
583
+ r.readsConfig("auth.smtpHost");
584
+ r.translations({ keys: { en: { title: "Catalog" } } });
585
+ r.useExtension("audit-log", "item");
586
+ r.extendsRegistrar("audit-log", {});
587
+ r.envSchema(z.object({ CATALOG_API_KEY: z.string() }));
588
+ r.usesApi("compliance.forTenant");
589
+ r.exposesApi("catalog.pricingFor");
590
+ });
591
+ `;
592
+
593
+ // r.projection is deliberately excluded here: its `table` field (a Drizzle
594
+ // table reference) is dropped by the feature-ast renderer independent of
595
+ // the object-form/positional-form issue this fixture targets — a
596
+ // pre-existing, separately tracked gap (public-api-registrar-consolidation
597
+ // plan doc, kumiko-platform). multiStreamProjection below covers the
598
+ // `apply`-map shape without hitting it (its `table` is optional).
599
+ const INTEGRATION_FEATURE = `
600
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
601
+
602
+ defineFeature("fulfillment", (r) => {
603
+ r.job("reconcile-counts", { trigger: { manual: true } }, async (payload, context) => {
604
+ console.log(payload, context);
605
+ });
606
+
607
+ r.notification("itemLowStock", {
608
+ trigger: { on: "item:lowStock" },
609
+ recipient: () => null,
610
+ data: () => ({}),
611
+ });
612
+
613
+ r.authClaims(async () => ({}));
614
+
615
+ r.httpRoute({
616
+ method: "GET",
617
+ path: "/fulfillment/feed.xml",
618
+ handler: (c) => c.text("ok"),
619
+ });
620
+
621
+ r.multiStreamProjection({
622
+ name: "item-audit",
623
+ apply: {
624
+ "item.created": async (event, tx, ctx) => {
625
+ console.log(event, tx, ctx);
626
+ },
627
+ },
628
+ });
629
+
630
+ r.workspace({ id: "ops", label: "fulfillment:workspace.ops" });
631
+
632
+ r.treeActions({ list: {} });
633
+ });
634
+ `;
635
+
636
+ describe("render → compiles against the real FeatureRegistrar type — full pattern-kind coverage", () => {
637
+ test("manifest/config-only patterns compile clean", () => {
638
+ expect(renderAndCompile(MANIFEST_AND_CONFIG_FEATURE)).toEqual([]);
639
+ }, 20_000);
640
+
641
+ test("job/notification/httpRoute/projection/workspace patterns compile clean", () => {
642
+ expect(renderAndCompile(INTEGRATION_FEATURE)).toEqual([]);
643
+ }, 20_000);
644
+ });
@@ -29,8 +29,6 @@ export {
29
29
  collectScreenOpaqueProps,
30
30
  extractAuthClaims,
31
31
  extractDefineEvent,
32
- extractEntityHook,
33
- extractEventMigration,
34
32
  extractHook,
35
33
  extractHttpRoute,
36
34
  extractJob,
@@ -40,7 +38,6 @@ export {
40
38
  extractQueryHandler,
41
39
  extractScreen,
42
40
  extractWriteHandler,
43
- isEntityHookType,
44
41
  isHookType,
45
42
  isHttpRouteMethod,
46
43
  type ParsedHandlerCall,