@cosmicdrift/kumiko-framework 0.159.1 → 0.160.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 (84) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +65 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  7. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  8. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  9. package/src/api/api-constants.ts +1 -0
  10. package/src/api/routes.ts +57 -0
  11. package/src/bun-db/query.ts +12 -25
  12. package/src/crypto/kms-adapter.ts +2 -118
  13. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  14. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  15. package/src/db/cursor.ts +1 -18
  16. package/src/db/dialect.ts +8 -19
  17. package/src/db/entity-table-meta-types.ts +2 -92
  18. package/src/db/event-store-executor.ts +4 -96
  19. package/src/db/table-builder.ts +2 -19
  20. package/src/db/tenant-db.ts +6 -55
  21. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  22. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  23. package/src/engine/__tests__/engine.test.ts +28 -0
  24. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  25. package/src/engine/__tests__/registry.test.ts +40 -0
  26. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  27. package/src/engine/boot-validator/entity-handler.ts +10 -1
  28. package/src/engine/define-feature.ts +1 -0
  29. package/src/engine/define-handler.ts +1 -0
  30. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  31. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  32. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  33. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  34. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  35. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  36. package/src/engine/feature-ast/extractors/index.ts +1 -0
  37. package/src/engine/feature-ast/index.ts +2 -0
  38. package/src/engine/feature-ast/parse.ts +3 -0
  39. package/src/engine/feature-ast/patch.ts +2 -0
  40. package/src/engine/feature-ast/patcher.ts +21 -0
  41. package/src/engine/feature-ast/patterns.ts +16 -0
  42. package/src/engine/feature-ast/render.ts +15 -0
  43. package/src/engine/feature-builder-state.ts +3 -0
  44. package/src/engine/feature-entity-handlers.ts +35 -1
  45. package/src/engine/index.ts +3 -0
  46. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  47. package/src/engine/pattern-library/library.ts +2 -0
  48. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  49. package/src/engine/registry-facade.ts +9 -0
  50. package/src/engine/registry-ingest.ts +10 -0
  51. package/src/engine/registry-state.ts +3 -0
  52. package/src/engine/types/config.ts +2 -497
  53. package/src/engine/types/define-handler.ts +2 -94
  54. package/src/engine/types/entity-handlers.ts +2 -30
  55. package/src/engine/types/feature.ts +2 -1021
  56. package/src/engine/types/fields.ts +2 -685
  57. package/src/engine/types/handlers.ts +2 -820
  58. package/src/engine/types/hooks.ts +2 -170
  59. package/src/engine/types/index.ts +44 -36
  60. package/src/engine/types/nav.ts +2 -67
  61. package/src/engine/types/ownership.ts +2 -83
  62. package/src/engine/types/projection.ts +2 -165
  63. package/src/engine/types/screen.ts +2 -747
  64. package/src/engine/types/step.ts +2 -334
  65. package/src/engine/types/workspace.ts +2 -42
  66. package/src/errors/write-error-info.ts +6 -22
  67. package/src/event-store/errors.ts +2 -35
  68. package/src/event-store/event-store.ts +2 -21
  69. package/src/event-store/snapshot.ts +11 -35
  70. package/src/event-store/types.ts +2 -22
  71. package/src/files/provider-resolver.ts +3 -5
  72. package/src/files/types.ts +5 -54
  73. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  74. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  75. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  76. package/src/pipeline/dispatch-shared.ts +39 -1
  77. package/src/pipeline/dispatch-stream.ts +74 -0
  78. package/src/pipeline/dispatcher-utils.ts +1 -1
  79. package/src/pipeline/dispatcher.ts +7 -0
  80. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  81. package/src/rate-limit/resolver.ts +10 -30
  82. package/src/secrets/envelope-cipher.ts +4 -6
  83. package/src/secrets/types.ts +2 -177
  84. package/src/time/tz-context.ts +9 -56
@@ -1,3 +1,5 @@
1
+ import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
+ import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
1
3
  import {
2
4
  asEntityTableMeta,
3
5
  asRawClient,
@@ -12,61 +14,10 @@ import {
12
14
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
13
15
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
14
16
  import type { DbRunner } from "./connection";
15
- import type { TableColumns } from "./dialect";
16
- import type { EntityTableMeta } from "./entity-table-meta";
17
- import type { NotExecutorOnly } from "./table-builder";
18
-
19
- // biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
20
- type Table = TableColumns<any>;
21
-
22
- // Method-form writes reject the executor-only brand exactly like the free-function
23
- // helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
24
- // directly — free-function OR method-form — drifts the row past its event stream and
25
- // a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
26
- // metas are not projections → writable); `& NotExecutorOnly` strips only branded
27
- // EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
28
- // the plain `Table` param.
29
- type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
30
-
31
- /**
32
- * TenantDb scope modes:
33
- *
34
- * - "tenant" (default): SELECT/UPDATE/DELETE filtered by tenantId + reference data (tenantId=SYSTEM_TENANT_ID).
35
- * INSERT forces tenantId — handler cannot override.
36
- *
37
- * - "system" (r.systemScope()): No tenant filter on reads/updates/deletes.
38
- * INSERT uses tenantId as default but handler can override.
39
- *
40
- * Tables without a tenantId column are always unfiltered regardless of mode.
41
- */
42
- export type TenantDbMode = "tenant" | "system";
43
-
44
- export type TenantDb = {
45
- readonly tenantId: TenantId;
46
- readonly mode: TenantDbMode;
47
- /**
48
- * Underlying DbRunner. Framework-internal use (event-store, migrations) —
49
- * bypasses tenant-filter. Feature code uses the typed helpers above so the
50
- * automatic scoping stays intact.
51
- */
52
- readonly raw: DbRunner;
53
- selectMany<T = Record<string, unknown>>(
54
- table: Table,
55
- where?: WhereObject,
56
- options?: SelectOptions,
57
- ): Promise<readonly T[]>;
58
- fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
59
- insertOne<T = Record<string, unknown>>(
60
- table: WritableTable,
61
- values: Record<string, unknown>,
62
- ): Promise<T | undefined>;
63
- updateMany<T = Record<string, unknown>>(
64
- table: WritableTable,
65
- set: Record<string, unknown>,
66
- where: WhereObject,
67
- ): Promise<readonly T[]>;
68
- deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
69
- };
17
+
18
+ type Table = SchemaTable;
19
+
20
+ export type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
70
21
 
71
22
  // @cast-boundary tenant-db-row
72
23
  export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
@@ -590,6 +590,52 @@ describe("boot-validator", () => {
590
590
  expect(() => validateBoot(features)).not.toThrow();
591
591
  });
592
592
 
593
+ test("throws when a stream handler has no access rule", () => {
594
+ const features = [
595
+ defineFeature("a", (r) => {
596
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
597
+ }),
598
+ ];
599
+ expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
600
+ });
601
+
602
+ test("accepts openToAll access rule on a stream handler", () => {
603
+ const features = [
604
+ defineFeature("a", (r) => {
605
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
606
+ access: { openToAll: true },
607
+ });
608
+ }),
609
+ ];
610
+ expect(() => validateBoot(features)).not.toThrow();
611
+ });
612
+
613
+ test("throws when an anonymous stream handler uses a user-bucketed rate limit", () => {
614
+ const features = [
615
+ defineFeature("a", (r) => {
616
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
617
+ access: { roles: ["anonymous"] },
618
+ rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
619
+ });
620
+ }),
621
+ ];
622
+ expect(() => validateBoot(features)).toThrow(
623
+ /stream handler "a:stream:chat:complete" allows anonymous callers/i,
624
+ );
625
+ });
626
+
627
+ test("accepts an anonymous stream handler with an ip-bucketed rate limit", () => {
628
+ const features = [
629
+ defineFeature("a", (r) => {
630
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
631
+ access: { roles: ["anonymous"] },
632
+ rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
633
+ });
634
+ }),
635
+ ];
636
+ expect(() => validateBoot(features)).not.toThrow();
637
+ });
638
+
593
639
  describe("config key bounds consistency", () => {
594
640
  test("accepts number key with consistent bounds + default", () => {
595
641
  const features = [
@@ -1,19 +1,32 @@
1
1
  import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- // Bun.Glob is only used by scanForCandidates / runCodemod, not by
6
- // analyzeFile or convertFile. Those higher-level functions are tested
7
- // in the integration suite; unit tests here focus on file-level
8
- // operations which don't need Glob. A stub class prevents import
9
- // errors in bun (which runs on Node, not Bun).
5
+ // Bun.Glob is only used by scanForCandidates / runCodemod. Emulate
6
+ // **/*.write.ts via node:fs so scan/convert paths stay testable.
7
+ function collectWriteTsRelPaths(rootDir: string, dir = rootDir, prefix = ""): string[] {
8
+ const out: string[] = [];
9
+ for (const name of readdirSync(dir)) {
10
+ const rel = prefix ? `${prefix}/${name}` : name;
11
+ const full = join(dir, name);
12
+ if (statSync(full).isDirectory()) {
13
+ out.push(...collectWriteTsRelPaths(rootDir, full, rel));
14
+ } else if (name.endsWith(".write.ts")) {
15
+ out.push(rel);
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
10
21
  mock.module("bun", () => {
11
- class StubGlob {
12
- scanSync(): never {
13
- throw new Error("Bun.Glob is not available in unit-test mode");
22
+ class FsGlob {
23
+ constructor(private readonly pattern: string) {}
24
+ scanSync(rootDir: string): string[] {
25
+ if (this.pattern !== "**/*.write.ts") return [];
26
+ return collectWriteTsRelPaths(rootDir).sort();
14
27
  }
15
28
  }
16
- return { Glob: StubGlob };
29
+ return { Glob: FsGlob };
17
30
  });
18
31
 
19
32
  import {
@@ -21,6 +34,8 @@ import {
21
34
  analyzeHandlerArrow,
22
35
  convertFile,
23
36
  generatePerformBlock,
37
+ runCodemod,
38
+ scanForCandidates,
24
39
  } from "../codemod/index";
25
40
 
26
41
  const tmpDir = join(__dirname, "__codemod_fixtures__");
@@ -31,6 +46,20 @@ function writeFixture(name: string, content: string): string {
31
46
  return p;
32
47
  }
33
48
 
49
+ function writeFixtureIn(dir: string, name: string, content: string): string {
50
+ const p = join(dir, name);
51
+ mkdirSync(join(p, ".."), { recursive: true });
52
+ writeFileSync(p, content, "utf8");
53
+ return p;
54
+ }
55
+
56
+ function freshScanRoot(name: string): string {
57
+ const dir = join(tmpDir, name);
58
+ rmSync(dir, { recursive: true, force: true });
59
+ mkdirSync(dir, { recursive: true });
60
+ return dir;
61
+ }
62
+
34
63
  beforeAll(() => {
35
64
  rmSync(tmpDir, { recursive: true, force: true });
36
65
  mkdirSync(tmpDir, { recursive: true });
@@ -399,6 +428,31 @@ export const h = defineWriteHandler({
399
428
  // ts-morph should handle this gracefully (parse error → no call found)
400
429
  expect(result.status).toBe("skipped");
401
430
  });
431
+
432
+ it("returns error when the path is not a readable file", async () => {
433
+ const dirPath = join(tmpDir, "dir-not-file.write.ts");
434
+ rmSync(dirPath, { recursive: true, force: true });
435
+ mkdirSync(dirPath, { recursive: true });
436
+ const result = await convertFile(dirPath);
437
+ expect(result.status).toBe("error");
438
+ expect(result.reason).toBeDefined();
439
+ });
440
+
441
+ it("verbose logs when skipping a non-trivial handler", async () => {
442
+ const p = writeFixture("verbose-skip-file.write.ts", complexContent);
443
+ const logs: string[] = [];
444
+ const origLog = console.log;
445
+ console.log = (...args: unknown[]) => {
446
+ logs.push(args.map(String).join(" "));
447
+ };
448
+ try {
449
+ const result = await convertFile(p, undefined, { verbose: true });
450
+ expect(result.status).toBe("skipped");
451
+ expect(logs.some((l) => l.includes("non-trivial handler"))).toBe(true);
452
+ } finally {
453
+ console.log = origLog;
454
+ }
455
+ });
402
456
  });
403
457
  });
404
458
 
@@ -509,6 +563,81 @@ describe("analyzeHandlerArrow", () => {
509
563
  expect(result.isGuardedCreate).toBe(true);
510
564
  expect(result.isSimpleExecutorCreate).toBe(false);
511
565
  });
566
+
567
+ it("returns non-convertible defaults for async ( without =>", () => {
568
+ const result = analyzeHandlerArrow("async (event, ctx) { return { isSuccess: true } }");
569
+ expect(result.isStaticReturn).toBe(false);
570
+ expect(result.isSimpleExecutorCreate).toBe(false);
571
+ expect(result.isSimpleExecutorUpdate).toBe(false);
572
+ expect(result.hasConditionalLogic).toBe(false);
573
+ expect(result.isExpressionBodyCreate).toBe(false);
574
+ expect(result.isGuardedCreate).toBe(false);
575
+ });
576
+ });
577
+
578
+ describe("scanForCandidates", () => {
579
+ it("returns empty list when no *.write.ts files exist", () => {
580
+ const root = freshScanRoot("scan-empty");
581
+ expect(scanForCandidates(root)).toEqual([]);
582
+ });
583
+
584
+ it("finds and classifies *.write.ts fixtures under rootDir", () => {
585
+ const root = freshScanRoot("scan-mixed");
586
+ writeFixtureIn(root, "freeform.write.ts", staticReturnContent);
587
+ writeFixtureIn(root, "already-pipeline.write.ts", alreadyPipelineContent);
588
+ writeFixtureIn(root, "nested/deep.write.ts", queryHandlerContent);
589
+
590
+ const results = scanForCandidates(root);
591
+ expect(results.map((r) => r.filePath).sort()).toEqual(
592
+ [
593
+ join(root, "already-pipeline.write.ts"),
594
+ join(root, "freeform.write.ts"),
595
+ join(root, "nested/deep.write.ts"),
596
+ ].sort(),
597
+ );
598
+ expect(results.find((r) => r.pattern === "free-form-write")?.convertible).toBe(true);
599
+ expect(results.find((r) => r.pattern === "pipeline-write")?.convertible).toBe(false);
600
+ expect(results.find((r) => r.pattern === "query-handler")?.convertible).toBe(false);
601
+ });
602
+ });
603
+
604
+ describe("runCodemod", () => {
605
+ it("returns empty report when scan finds no convertible handlers", async () => {
606
+ const root = freshScanRoot("run-empty");
607
+ const report = await runCodemod(root);
608
+ expect(report).toEqual({ results: [], converted: 0, skipped: 0, errors: 0 });
609
+ });
610
+
611
+ it("converts free-form write handlers discovered by scan", async () => {
612
+ const root = freshScanRoot("run-convert");
613
+ const p = writeFixtureIn(root, "to-convert.write.ts", staticReturnContent);
614
+ const report = await runCodemod(root);
615
+ expect(report.converted).toBe(1);
616
+ expect(report.skipped).toBe(0);
617
+ expect(report.errors).toBe(0);
618
+ expect(readFileSync(p, "utf8")).toContain("perform: stepsPipeline");
619
+ });
620
+
621
+ it("verbose logs per-file outcomes", async () => {
622
+ const root = freshScanRoot("run-verbose");
623
+ writeFixtureIn(root, "verbose-convert.write.ts", staticReturnContent);
624
+ writeFixtureIn(root, "verbose-skip.write.ts", complexContent);
625
+
626
+ const logs: string[] = [];
627
+ const origLog = console.log;
628
+ console.log = (...args: unknown[]) => {
629
+ logs.push(args.map(String).join(" "));
630
+ };
631
+ try {
632
+ const report = await runCodemod(root, { verbose: true });
633
+ expect(report.converted).toBe(1);
634
+ expect(report.skipped).toBe(1);
635
+ expect(logs.some((l) => l.includes("verbose-convert.write.ts"))).toBe(true);
636
+ expect(logs.some((l) => l.includes("verbose-skip.write.ts"))).toBe(true);
637
+ } finally {
638
+ console.log = origLog;
639
+ }
640
+ });
512
641
  });
513
642
 
514
643
  describe("generatePerformBlock", () => {
@@ -14,6 +14,7 @@ import {
14
14
  createTextField,
15
15
  } from "../factories";
16
16
  import { createApp, createRegistry, defineFeature } from "../index";
17
+ import { buildMinimalCtx } from "./_pipeline-test-utils";
17
18
 
18
19
  // --- defineFeature ---
19
20
 
@@ -118,6 +119,33 @@ describe("defineFeature", () => {
118
119
  expect(ref?.name).toBe("order:list");
119
120
  });
120
121
 
122
+ test("streamHandler registers on feature.streamHandlers with schema/access/handler intact", async () => {
123
+ let ref: { name: string } | undefined;
124
+ const feature = defineFeature("test", (r) => {
125
+ ref = r.streamHandler(
126
+ "chat:complete",
127
+ z.object({ prompt: z.string() }),
128
+ async function* (query) {
129
+ yield `echo:${query.payload.prompt}`;
130
+ },
131
+ { access: { openToAll: true } },
132
+ );
133
+ });
134
+ expect(ref?.name).toBe("chat:complete");
135
+
136
+ const stored = feature.streamHandlers["chat:complete"];
137
+ expect(stored?.access).toEqual({ openToAll: true });
138
+
139
+ const chunks: string[] = [];
140
+ for await (const chunk of stored!.handler(
141
+ { type: "chat:complete", payload: { prompt: "hi" }, user: createTestUser() },
142
+ buildMinimalCtx(),
143
+ )) {
144
+ chunks.push(chunk as string);
145
+ }
146
+ expect(chunks).toEqual(["echo:hi"]);
147
+ });
148
+
121
149
  test("r.defineEvent returns typed EventDef and registers on feature", () => {
122
150
  let eventRef: { name: string } | undefined;
123
151
  const feature = defineFeature("orders", (r) => {
@@ -0,0 +1,80 @@
1
+ // Dilution reclaim: integration lcov registers every facade getter as DA:N,0
2
+ // when the file loads, but unit only instruments getters that a test touches.
3
+ // Calling every getter once in unit raises LF+LH so the badge merge stops
4
+ // counting those one-liners as uncovered.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createRegistry } from "../registry";
8
+
9
+ describe("buildRegistryFacade — getter sweep", () => {
10
+ test("empty registry: every getter is callable without throw", () => {
11
+ const r = createRegistry([]);
12
+
13
+ expect(r.features.size).toBe(0);
14
+ expect(r.getFeature("x")).toBeUndefined();
15
+ expect(r.hasRateLimitedHandler()).toBe(false);
16
+ expect(r.getEntity("x")).toBeUndefined();
17
+ expect(r.getAllEntities().size).toBe(0);
18
+ expect(r.getWriteHandler("x")).toBeUndefined();
19
+ expect(r.getQueryHandler("x")).toBeUndefined();
20
+ expect(r.getAllQueryHandlers().size).toBe(0);
21
+ expect(r.getSearchableFields("x")).toEqual([]);
22
+ expect(r.getSortableFields("x")).toEqual([]);
23
+ expect(r.getRelations("x")).toEqual({});
24
+ expect(r.getSearchIncludes("x").size).toBe(0);
25
+ expect(r.getIncomingRelations("x")).toEqual([]);
26
+ expect(r.getPreSaveHooks("x")).toEqual([]);
27
+ expect(r.getPostSaveHooks("x")).toEqual([]);
28
+ expect(r.getPreDeleteHooks("x")).toEqual([]);
29
+ expect(r.getPostDeleteHooks("x")).toEqual([]);
30
+ expect(r.getPreQueryHooks("x")).toEqual([]);
31
+ expect(r.getPostQueryHooks("x")).toEqual([]);
32
+ expect(r.getEntityPostSaveHooks("x")).toEqual([]);
33
+ expect(r.getEntityPreDeleteHooks("x")).toEqual([]);
34
+ expect(r.getEntityPostDeleteHooks("x")).toEqual([]);
35
+ expect(r.getEntityPostQueryHooks("x")).toEqual([]);
36
+ expect(r.getSearchPayloadExtensions("x")).toEqual([]);
37
+ expect(r.getAllTranslations()).toBeDefined();
38
+ expect(r.getHandlerEntity("x")).toBeUndefined();
39
+ expect(r.isHandlerSystemScoped("x")).toBe(false);
40
+ expect(r.getHandlerFeature("x")).toBeUndefined();
41
+ expect(r.getAllMetrics().size).toBe(0);
42
+ expect(r.getAllSecretKeys().size).toBe(0);
43
+ expect(r.getSecretKey("x")).toBeUndefined();
44
+ expect(r.getConfigKey("x")).toBeUndefined();
45
+ expect(r.getAllConfigKeys().size).toBe(0);
46
+ expect(r.getJob("x")).toBeUndefined();
47
+ expect(r.getAllJobs().size).toBe(0);
48
+ expect(r.getEvent("x")).toBeUndefined();
49
+ expect(r.getEventUpcasters().size).toBe(0);
50
+ expect(r.getExtension("x")).toBeUndefined();
51
+ expect(r.getExtensionUsages("x")).toEqual([]);
52
+ expect(r.getAllExtensionSelectors().size).toBe(0);
53
+ expect(r.getAllNotifications().size).toBe(0);
54
+ expect(r.getAllReferenceData()).toEqual([]);
55
+ expect(r.getAllConfigSeeds()).toEqual([]);
56
+ expect(r.getProjectionsForSource("x")).toEqual([]);
57
+ expect(r.getAllProjections().size).toBe(0);
58
+ expect(r.getAllStoreTables().size).toBe(0);
59
+ expect(r.getAllMultiStreamProjections().size).toBe(0);
60
+ expect(r.getMultiStreamProjectionFeature("x")).toBeUndefined();
61
+ expect(r.getAuthClaimsHooks()).toEqual([]);
62
+ expect(r.getAllClaimKeys().size).toBe(0);
63
+ expect(r.getClaimKey("x")).toBeUndefined();
64
+ expect(r.getAllScreens().size).toBe(0);
65
+ expect(r.getScreen("x")).toBeUndefined();
66
+ expect(r.getScreenFeature("x")).toBeUndefined();
67
+ expect(r.getScreensByEntity("x")).toEqual([]);
68
+ expect(r.getAllNavs().size).toBe(0);
69
+ expect(r.getNav("x")).toBeUndefined();
70
+ expect(r.getNavFeature("x")).toBeUndefined();
71
+ expect(r.getNavsByParent("x")).toEqual([]);
72
+ expect(r.getTopLevelNavs()).toEqual([]);
73
+ expect(r.getAllWorkspaces().size).toBe(0);
74
+ expect(r.getWorkspace("x")).toBeUndefined();
75
+ expect(r.getWorkspaceFeature("x")).toBeUndefined();
76
+ expect(r.getWorkspaceNavs("x")).toEqual([]);
77
+ expect(r.getDefaultWorkspace()).toBeUndefined();
78
+ expect(r.getTreeActions("x")).toBeUndefined();
79
+ });
80
+ });
@@ -101,6 +101,46 @@ describe("getAllQueryHandlers", () => {
101
101
  });
102
102
  });
103
103
 
104
+ describe("getAllStreamHandlers", () => {
105
+ test("returns every registered stream handler, qualified, across multiple features", () => {
106
+ const aiFeature = defineFeature("registry-test-ai", (r) => {
107
+ r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
108
+ access: { openToAll: true },
109
+ });
110
+ });
111
+ const otherFeature = defineFeature("registry-test-other", (r) => {
112
+ r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
113
+ access: { openToAll: true },
114
+ });
115
+ });
116
+
117
+ const registry = createRegistry([aiFeature, otherFeature]);
118
+ const handlers = registry.getAllStreamHandlers();
119
+
120
+ expect(handlers.get("registry-test-ai:stream:chat:complete")).toBeDefined();
121
+ expect(handlers.get("registry-test-other:stream:chat:complete")).toBeDefined();
122
+ // Same Map instance getStreamHandler reads from, not a copy that can drift.
123
+ expect(handlers.get("registry-test-ai:stream:chat:complete")).toBe(
124
+ registry.getStreamHandler("registry-test-ai:stream:chat:complete"),
125
+ );
126
+ });
127
+
128
+ test("empty registry returns an empty map, not undefined", () => {
129
+ const registry = createRegistry([]);
130
+ expect(registry.getAllStreamHandlers().size).toBe(0);
131
+ });
132
+
133
+ test("duplicate stream-handler short-name across features qualifies independently, no collision", () => {
134
+ const aiFeature = defineFeature("registry-test-dup-a", (r) => {
135
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
136
+ });
137
+ const otherFeature = defineFeature("registry-test-dup-b", (r) => {
138
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
139
+ });
140
+ expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
141
+ });
142
+ });
143
+
104
144
  describe("extensionSelector boot-validation", () => {
105
145
  function foundationFeature() {
106
146
  return defineFeature("probe-foundation", (r) => {
@@ -5,7 +5,11 @@
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
7
  import { createEntity, defineFeature } from "../index";
8
- import { findTierResolverUsage, TENANT_TIER_RESOLVER_EXT } from "../tier-resolver-extension";
8
+ import {
9
+ findTierResolverUsage,
10
+ isTierResolverPlugin,
11
+ TENANT_TIER_RESOLVER_EXT,
12
+ } from "../tier-resolver-extension";
9
13
 
10
14
  function tierResolverFeature(name: string) {
11
15
  return defineFeature(name, (r) => {
@@ -49,3 +53,17 @@ describe("findTierResolverUsage", () => {
49
53
  expect(findTierResolverUsage([])).toBeUndefined();
50
54
  });
51
55
  });
56
+
57
+ describe("isTierResolverPlugin", () => {
58
+ test("true when build is a function", () => {
59
+ expect(isTierResolverPlugin({ build: async () => new Set() })).toBe(true);
60
+ });
61
+
62
+ test("false for null, non-objects, and missing/non-fn build", () => {
63
+ expect(isTierResolverPlugin(null)).toBe(false);
64
+ expect(isTierResolverPlugin(undefined)).toBe(false);
65
+ expect(isTierResolverPlugin("x")).toBe(false);
66
+ expect(isTierResolverPlugin({})).toBe(false);
67
+ expect(isTierResolverPlugin({ build: 1 })).toBe(false);
68
+ });
69
+ });
@@ -119,11 +119,20 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
119
119
  }
120
120
  validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
121
121
  }
122
+ for (const [name, handler] of Object.entries(feature.streamHandlers)) {
123
+ if (!handler.access) {
124
+ throw new Error(
125
+ `Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
126
+ `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
127
+ );
128
+ }
129
+ validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
130
+ }
122
131
  }
123
132
 
124
133
  export function validateAnonymousRateLimit(
125
134
  featureName: string,
126
- kind: "write" | "query",
135
+ kind: "write" | "query" | "stream",
127
136
  handlerName: string,
128
137
  access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
129
138
  rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
@@ -106,6 +106,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
106
106
  relations: state.relations,
107
107
  writeHandlers: state.writeHandlers,
108
108
  queryHandlers: state.queryHandlers,
109
+ streamHandlers: state.streamHandlers,
109
110
  translations: state.translations,
110
111
  hooks: {
111
112
  validation: state.validationHooks,
@@ -10,6 +10,7 @@ import type {
10
10
 
11
11
  export type {
12
12
  QueryHandlerDefinition,
13
+ StreamHandlerDefinition,
13
14
  WriteHandlerDefinition,
14
15
  WriteHandlerInput,
15
16
  } from "./types/define-handler";
@@ -78,6 +78,15 @@ defineFeature("todoList", (r) => {
78
78
  access: { openToAll: true },
79
79
  });
80
80
 
81
+ r.streamHandler({
82
+ name: "task:stream",
83
+ schema: z.object({ prompt: z.string() }),
84
+ handler: async function* (input, ctx) {
85
+ yield "token";
86
+ },
87
+ access: { roles: ["user"] },
88
+ });
89
+
81
90
  r.hook({
82
91
  type: "postSave",
83
92
  target: "task",
@@ -195,7 +204,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
195
204
  expect(unknowns).toEqual([]);
196
205
  });
197
206
 
198
- test("alle 28 Pattern-Kinds sind erfasst", () => {
207
+ test("alle Pattern-Kinds sind erfasst", () => {
199
208
  const kinds: ReadonlySet<string> = new Set(result.patterns.map((p) => p.kind));
200
209
  const expected = [
201
210
  // Static
@@ -219,6 +228,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
219
228
  "screen",
220
229
  "writeHandler",
221
230
  "queryHandler",
231
+ "streamHandler",
222
232
  "hook",
223
233
  "job",
224
234
  "notification",