@cosmicdrift/kumiko-framework 0.159.1 → 0.161.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 (90) 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/auth-middleware.ts +17 -44
  11. package/src/api/auth-routes.ts +6 -2
  12. package/src/api/index.ts +1 -0
  13. package/src/api/routes.ts +57 -0
  14. package/src/api/server.ts +5 -4
  15. package/src/bun-db/query.ts +12 -25
  16. package/src/crypto/kms-adapter.ts +2 -118
  17. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  18. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  19. package/src/db/cursor.ts +1 -18
  20. package/src/db/dialect.ts +8 -19
  21. package/src/db/entity-table-meta-types.ts +2 -92
  22. package/src/db/event-store-executor.ts +4 -96
  23. package/src/db/table-builder.ts +2 -19
  24. package/src/db/tenant-db.ts +6 -55
  25. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  26. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  27. package/src/engine/__tests__/engine.test.ts +28 -0
  28. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  29. package/src/engine/__tests__/registry.test.ts +40 -0
  30. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  31. package/src/engine/boot-validator/entity-handler.ts +10 -1
  32. package/src/engine/define-feature.ts +1 -0
  33. package/src/engine/define-handler.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  35. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  36. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  37. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  38. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  39. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  40. package/src/engine/feature-ast/extractors/index.ts +1 -0
  41. package/src/engine/feature-ast/index.ts +2 -0
  42. package/src/engine/feature-ast/parse.ts +3 -0
  43. package/src/engine/feature-ast/patch.ts +2 -0
  44. package/src/engine/feature-ast/patcher.ts +21 -0
  45. package/src/engine/feature-ast/patterns.ts +16 -0
  46. package/src/engine/feature-ast/render.ts +15 -0
  47. package/src/engine/feature-builder-state.ts +3 -0
  48. package/src/engine/feature-entity-handlers.ts +35 -1
  49. package/src/engine/index.ts +3 -0
  50. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  51. package/src/engine/pattern-library/library.ts +2 -0
  52. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  53. package/src/engine/registry-facade.ts +9 -0
  54. package/src/engine/registry-ingest.ts +10 -0
  55. package/src/engine/registry-state.ts +3 -0
  56. package/src/engine/types/config.ts +2 -497
  57. package/src/engine/types/define-handler.ts +2 -94
  58. package/src/engine/types/entity-handlers.ts +2 -30
  59. package/src/engine/types/feature.ts +2 -1021
  60. package/src/engine/types/fields.ts +2 -685
  61. package/src/engine/types/handlers.ts +2 -820
  62. package/src/engine/types/hooks.ts +2 -170
  63. package/src/engine/types/index.ts +44 -36
  64. package/src/engine/types/nav.ts +2 -67
  65. package/src/engine/types/ownership.ts +2 -83
  66. package/src/engine/types/projection.ts +2 -165
  67. package/src/engine/types/screen.ts +2 -747
  68. package/src/engine/types/step.ts +2 -334
  69. package/src/engine/types/workspace.ts +2 -42
  70. package/src/errors/write-error-info.ts +6 -22
  71. package/src/event-store/errors.ts +2 -35
  72. package/src/event-store/event-store.ts +2 -21
  73. package/src/event-store/snapshot.ts +11 -35
  74. package/src/event-store/types.ts +2 -22
  75. package/src/files/provider-resolver.ts +3 -5
  76. package/src/files/types.ts +5 -54
  77. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  78. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  79. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  80. package/src/pipeline/dispatch-shared.ts +39 -1
  81. package/src/pipeline/dispatch-stream.ts +74 -0
  82. package/src/pipeline/dispatcher-utils.ts +1 -1
  83. package/src/pipeline/dispatcher.ts +7 -0
  84. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  85. package/src/rate-limit/resolver.ts +10 -30
  86. package/src/secrets/envelope-cipher.ts +4 -6
  87. package/src/secrets/types.ts +2 -177
  88. package/src/stack/request-helper.ts +19 -2
  89. package/src/stack/test-stack.ts +33 -14
  90. package/src/time/tz-context.ts +9 -56
@@ -384,3 +384,171 @@ defineFeature("legacy", (r) => {
384
384
  expect(text).not.toContain('r.entity("item"');
385
385
  });
386
386
  });
387
+
388
+ describe("addPattern — error paths", () => {
389
+ test("throws when no defineFeature call is present", () => {
390
+ const sf = makeSourceFile("export const x = 1;");
391
+ expect(() => addPattern(sf, newSecretPattern)).toThrow(/no defineFeature/);
392
+ });
393
+ });
394
+
395
+ describe("callMatchesId — positional and object-form lookups", () => {
396
+ const LEGACY = `
397
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
398
+
399
+ defineFeature("legacy", (r) => {
400
+ r.relation("task", "owner", { kind: "belongsTo", to: "user" });
401
+
402
+ r.hook("postSave", "task", () => {});
403
+
404
+ r.hook("preDelete", { allOf: "task" }, () => {});
405
+
406
+ r.job("nightly", { trigger: { cron: "0 0 * * *" } }, async () => {});
407
+
408
+ r.writeHandler("task:create", z.object({}), async () => ({}));
409
+
410
+ r.queryHandler("task:list", z.object({}), async () => []);
411
+
412
+ r.notification("taskCreated", {
413
+ trigger: { on: "task" },
414
+ recipient: () => ({}),
415
+ data: () => ({}),
416
+ });
417
+ });
418
+ `;
419
+
420
+ test("removePattern finds a positional relation by entity+name", () => {
421
+ const sf = makeSourceFile(LEGACY);
422
+ removePattern(sf, { kind: "relation", entityName: "task", relationName: "owner" });
423
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "relation")).toBeUndefined();
424
+ });
425
+
426
+ test("removePattern finds a positional hook by type+string target", () => {
427
+ const sf = makeSourceFile(LEGACY);
428
+ removePattern(sf, { kind: "hook", hookType: "postSave", target: "task" });
429
+ expect(parseSourceFile(sf).patterns.filter((p) => p.kind === "hook")).toHaveLength(1);
430
+ });
431
+
432
+ test("removePattern finds a positional hook with an allOf target", () => {
433
+ const sf = makeSourceFile(LEGACY);
434
+ removePattern(sf, { kind: "hook", hookType: "preDelete", target: { allOf: "task" } });
435
+ expect(parseSourceFile(sf).patterns.filter((p) => p.kind === "hook")).toHaveLength(1);
436
+ });
437
+
438
+ test("removePattern finds a positional job by name", () => {
439
+ const sf = makeSourceFile(LEGACY);
440
+ removePattern(sf, { kind: "job", jobName: "nightly" });
441
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "job")).toBeUndefined();
442
+ });
443
+
444
+ test("removePattern finds a writeHandler by positional name", () => {
445
+ const sf = makeSourceFile(LEGACY);
446
+ removePattern(sf, { kind: "writeHandler", handlerName: "task:create" });
447
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "writeHandler")).toBeUndefined();
448
+ });
449
+
450
+ test("removePattern finds a queryHandler by positional name", () => {
451
+ const sf = makeSourceFile(LEGACY);
452
+ removePattern(sf, { kind: "queryHandler", handlerName: "task:list" });
453
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "queryHandler")).toBeUndefined();
454
+ });
455
+
456
+ test("removePattern finds a positional notification by name", () => {
457
+ const sf = makeSourceFile(LEGACY);
458
+ removePattern(sf, { kind: "notification", notificationName: "taskCreated" });
459
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "notification")).toBeUndefined();
460
+ });
461
+ });
462
+
463
+ describe("removePattern — blank-line collapse", () => {
464
+ test("removing an appended pattern collapses the preceding blank line", () => {
465
+ const sf = makeSourceFile(STARTER);
466
+ addPattern(sf, newSecretPattern);
467
+ const linesBefore = sf.getFullText().split("\n").length;
468
+ removePattern(sf, { kind: "secret", shortName: "stripeKey" });
469
+ const linesAfter = sf.getFullText().split("\n").length;
470
+ expect(linesAfter).toBeLessThan(linesBefore);
471
+ expect(parseSourceFile(sf).patterns.map((p) => p.kind)).toEqual(["entity", "metric"]);
472
+ });
473
+ });
474
+
475
+ describe("replacePattern — object-form hook with allOf target", () => {
476
+ test("replaces an object-form hook registered with an allOf target", () => {
477
+ const file = `
478
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
479
+
480
+ defineFeature("hooks", (r) => {
481
+ r.hook({ type: "postSave", target: { allOf: "task" }, handler: () => {} });
482
+ });
483
+ `;
484
+ const sf = makeSourceFile(file);
485
+ replacePattern(
486
+ sf,
487
+ { kind: "hook", hookType: "postSave", target: { allOf: "task" } },
488
+ {
489
+ kind: "hook",
490
+ source: SAMPLE_LOC,
491
+ hookType: "postSave",
492
+ target: { allOf: "task" },
493
+ fnBody: { ...SAMPLE_LOC, raw: "() => { /* updated */ }" },
494
+ },
495
+ );
496
+ expect(sf.getFullText()).toContain("/* updated */");
497
+ });
498
+ });
499
+ describe("callMatchesId — object-form lookups", () => {
500
+ const OBJECT_FORM = `
501
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
502
+
503
+ defineFeature("object", (r) => {
504
+ r.relation({ entity: "task", name: "owner", kind: "belongsTo", to: "user" });
505
+
506
+ r.job({ name: "nightly", trigger: { cron: "0 0 * * *" }, handler: async () => {} });
507
+
508
+ r.useExtension({ name: "audit", entity: "task" });
509
+
510
+ r.writeHandler({
511
+ name: "task:create",
512
+ schema: z.object({}),
513
+ handler: async () => ({}),
514
+ });
515
+
516
+ r.notification({
517
+ name: "taskCreated",
518
+ trigger: { on: "task" },
519
+ recipient: () => ({}),
520
+ data: () => ({}),
521
+ });
522
+ });
523
+ `;
524
+
525
+ test("removePattern finds an object-form relation", () => {
526
+ const sf = makeSourceFile(OBJECT_FORM);
527
+ removePattern(sf, { kind: "relation", entityName: "task", relationName: "owner" });
528
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "relation")).toBeUndefined();
529
+ });
530
+
531
+ test("removePattern finds an object-form job", () => {
532
+ const sf = makeSourceFile(OBJECT_FORM);
533
+ removePattern(sf, { kind: "job", jobName: "nightly" });
534
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "job")).toBeUndefined();
535
+ });
536
+
537
+ test("removePattern finds an object-form useExtension", () => {
538
+ const sf = makeSourceFile(OBJECT_FORM);
539
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "task" });
540
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "useExtension")).toBeUndefined();
541
+ });
542
+
543
+ test("removePattern finds an object-form writeHandler", () => {
544
+ const sf = makeSourceFile(OBJECT_FORM);
545
+ removePattern(sf, { kind: "writeHandler", handlerName: "task:create" });
546
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "writeHandler")).toBeUndefined();
547
+ });
548
+
549
+ test("removePattern finds an object-form notification", () => {
550
+ const sf = makeSourceFile(OBJECT_FORM);
551
+ removePattern(sf, { kind: "notification", notificationName: "taskCreated" });
552
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "notification")).toBeUndefined();
553
+ });
554
+ });
@@ -442,6 +442,12 @@ defineFeature("tasks", (r) => {
442
442
  handlerSource: "async (q, ctx) => []",
443
443
  access: { openToAll: true },
444
444
  });
445
+ p.addStreamHandler({
446
+ name: "task:stream",
447
+ schemaSource: "z.object({ prompt: z.string() })",
448
+ handlerSource: 'async function* (input, ctx) { yield "token"; }',
449
+ access: { roles: ["user"] },
450
+ });
445
451
  p.addHook({
446
452
  type: "postDelete",
447
453
  target: { allOf: "task" },
@@ -453,6 +459,7 @@ defineFeature("tasks", (r) => {
453
459
  "entity",
454
460
  "writeHandler",
455
461
  "queryHandler",
462
+ "streamHandler",
456
463
  "hook",
457
464
  ]);
458
465
  });
@@ -206,6 +206,15 @@ defineFeature("workflow", (r) => {
206
206
  { access: { openToAll: true } },
207
207
  );
208
208
 
209
+ r.streamHandler(
210
+ "step:stream",
211
+ z.object({ prompt: z.string() }),
212
+ async function* (input, ctx) {
213
+ yield "token";
214
+ },
215
+ { access: { roles: ["user"] } },
216
+ );
217
+
209
218
  r.hook("postSave", "step", async (event, ctx) => {
210
219
  console.log("step saved");
211
220
  });
@@ -1,7 +1,7 @@
1
1
  import type { CallExpression, Node, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
3
  import type { AccessRule, RateLimitOption } from "../../types/handlers";
4
- import type { QueryHandlerPattern, WriteHandlerPattern } from "../patterns";
4
+ import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
5
5
  import type { SourceLocation } from "../source-location";
6
6
  import { sourceLocationFromNode } from "../source-location";
7
7
  import { readOptionalAccessRule, readOptionalRateLimit } from "./hooks";
@@ -44,7 +44,7 @@ function resolveObjectLiteralArg(node: Node) {
44
44
  export function parseHandlerCall(
45
45
  call: CallExpression,
46
46
  sourceFile: SourceFile,
47
- methodName: "writeHandler" | "queryHandler",
47
+ methodName: "writeHandler" | "queryHandler" | "streamHandler",
48
48
  ): ExtractOutput<ParsedHandlerCall> {
49
49
  const args = call.getArguments();
50
50
  const first = args[0];
@@ -220,3 +220,20 @@ export function extractQueryHandler(
220
220
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
221
221
  });
222
222
  }
223
+
224
+ export function extractStreamHandler(
225
+ call: CallExpression,
226
+ sourceFile: SourceFile,
227
+ ): ExtractOutput<StreamHandlerPattern> {
228
+ const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
229
+ if (parsed.kind === "error") return parsed;
230
+ return ok({
231
+ kind: "streamHandler",
232
+ source: parsed.pattern.source,
233
+ handlerName: parsed.pattern.handlerName,
234
+ schemaSource: parsed.pattern.schemaSource,
235
+ handlerBody: parsed.pattern.handlerBody,
236
+ ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
237
+ ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
238
+ });
239
+ }
@@ -4,6 +4,7 @@ export {
4
4
  } from "./events";
5
5
  export {
6
6
  extractQueryHandler,
7
+ extractStreamHandler,
7
8
  extractWriteHandler,
8
9
  type ParsedHandlerCall,
9
10
  parseHandlerCall,
@@ -43,6 +43,7 @@ export type {
43
43
  AddRequiresArgs,
44
44
  AddScreenArgs,
45
45
  AddSecretArgs,
46
+ AddStreamHandlerArgs,
46
47
  AddToggleableArgs,
47
48
  AddTranslationsArgs,
48
49
  AddUseExtensionArgs,
@@ -79,6 +80,7 @@ export type {
79
80
  // Mixed patterns
80
81
  ScreenPattern,
81
82
  SecretPattern,
83
+ StreamHandlerPattern,
82
84
  SystemScopePattern,
83
85
  ToggleablePattern,
84
86
  TranslationsPattern,
@@ -58,6 +58,7 @@ import {
58
58
  extractScreen,
59
59
  extractSecret,
60
60
  extractStoreTable,
61
+ extractStreamHandler,
61
62
  extractSystemScope,
62
63
  extractToggleable,
63
64
  extractTranslations,
@@ -447,6 +448,8 @@ function dispatchExtractor(
447
448
  return extractWriteHandler(call, sourceFile);
448
449
  case "queryHandler":
449
450
  return extractQueryHandler(call, sourceFile);
451
+ case "streamHandler":
452
+ return extractStreamHandler(call, sourceFile);
450
453
  case "job":
451
454
  return extractJob(call, sourceFile);
452
455
  case "httpRoute":
@@ -53,6 +53,7 @@ export type PatternId =
53
53
  | { readonly kind: "screen"; readonly id: string }
54
54
  | { readonly kind: "writeHandler"; readonly handlerName: string }
55
55
  | { readonly kind: "queryHandler"; readonly handlerName: string }
56
+ | { readonly kind: "streamHandler"; readonly handlerName: string }
56
57
  | {
57
58
  readonly kind: "hook";
58
59
  readonly hookType: string;
@@ -353,6 +354,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
353
354
  return matchObjectProperty(call, "id", id.id);
354
355
  case "writeHandler":
355
356
  case "queryHandler":
357
+ case "streamHandler":
356
358
  return (
357
359
  matchFirstArgString(call, id.handlerName) ||
358
360
  matchObjectProperty(call, "name", id.handlerName)
@@ -104,6 +104,14 @@ export type AddQueryHandlerArgs = {
104
104
  readonly rateLimit?: RateLimitOption;
105
105
  };
106
106
 
107
+ export type AddStreamHandlerArgs = {
108
+ readonly name: string;
109
+ readonly schemaSource: string;
110
+ readonly handlerSource: string;
111
+ readonly access?: AccessRule;
112
+ readonly rateLimit?: RateLimitOption;
113
+ };
114
+
107
115
  export type AddHookArgs = {
108
116
  readonly type: LifecycleHookType | "validation";
109
117
  // `{ allOf: entity }` — entity-wide target, fires for every write/query
@@ -233,6 +241,7 @@ export type FeaturePatcher = {
233
241
  readonly addScreen: (args: AddScreenArgs) => void;
234
242
  readonly addWriteHandler: (args: AddWriteHandlerArgs) => void;
235
243
  readonly addQueryHandler: (args: AddQueryHandlerArgs) => void;
244
+ readonly addStreamHandler: (args: AddStreamHandlerArgs) => void;
236
245
  readonly addHook: (args: AddHookArgs) => void;
237
246
  readonly addJob: (args: AddJobArgs) => void;
238
247
  readonly addNotification: (args: AddNotificationArgs) => void;
@@ -392,6 +401,18 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
392
401
  });
393
402
  },
394
403
 
404
+ addStreamHandler({ name, schemaSource, handlerSource, access, rateLimit }) {
405
+ add({
406
+ kind: "streamHandler",
407
+ source: SYNTHETIC_LOC,
408
+ handlerName: name,
409
+ schemaSource: rawLoc(schemaSource),
410
+ handlerBody: rawLoc(handlerSource),
411
+ ...(access !== undefined && { access }),
412
+ ...(rateLimit !== undefined && { rateLimit }),
413
+ });
414
+ },
415
+
395
416
  addHook({ type, target, handlerSource, phase }) {
396
417
  add({
397
418
  kind: "hook",
@@ -374,6 +374,20 @@ export type QueryHandlerPattern = {
374
374
  readonly rateLimit?: RateLimitOption;
375
375
  };
376
376
 
377
+ // `r.streamHandler(...)` — registers a streaming read handler: name, Zod
378
+ // input schema, async-generator handler closure, plus optional `access` and
379
+ // `rateLimit` rules. Same header/body split and opaque single-reference
380
+ // case as QueryHandlerPattern.
381
+ export type StreamHandlerPattern = {
382
+ readonly kind: "streamHandler";
383
+ readonly source: SourceLocation;
384
+ readonly handlerName?: string;
385
+ readonly schemaSource?: SourceLocation;
386
+ readonly handlerBody?: SourceLocation;
387
+ readonly access?: AccessRule;
388
+ readonly rateLimit?: RateLimitOption;
389
+ };
390
+
377
391
  // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
378
392
  // (`validation`, `preSave`, `postSave`, `preDelete`, `postDelete`,
379
393
  // `preQuery`, `postQuery`) to one or more target handlers, or — for
@@ -592,6 +606,7 @@ export type FeaturePattern =
592
606
  | ScreenPattern
593
607
  | WriteHandlerPattern
594
608
  | QueryHandlerPattern
609
+ | StreamHandlerPattern
595
610
  | HookPattern
596
611
  | JobPattern
597
612
  | NotificationPattern
@@ -647,6 +662,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
647
662
  case "screen":
648
663
  case "writeHandler":
649
664
  case "queryHandler":
665
+ case "streamHandler":
650
666
  case "hook":
651
667
  case "job":
652
668
  case "notification":
@@ -44,6 +44,7 @@ import type {
44
44
  RequiresPattern,
45
45
  ScreenPattern,
46
46
  SecretPattern,
47
+ StreamHandlerPattern,
47
48
  SystemScopePattern,
48
49
  ToggleablePattern,
49
50
  TranslationsPattern,
@@ -109,6 +110,8 @@ export function renderPattern(pattern: FeaturePattern): string {
109
110
  return renderWriteHandler(pattern);
110
111
  case "queryHandler":
111
112
  return renderQueryHandler(pattern);
113
+ case "streamHandler":
114
+ return renderStreamHandler(pattern);
112
115
  case "hook":
113
116
  return renderHook(pattern);
114
117
  case "job":
@@ -395,6 +398,18 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
395
398
  return lines.join("\n");
396
399
  }
397
400
 
401
+ function renderStreamHandler(p: StreamHandlerPattern): string {
402
+ if (p.handlerName === undefined) return p.source.raw;
403
+ const lines: string[] = ["r.streamHandler({"];
404
+ lines.push(` name: ${JSON.stringify(p.handlerName)},`);
405
+ lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
406
+ lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
407
+ if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
408
+ if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
409
+ lines.push("});");
410
+ return lines.join("\n");
411
+ }
412
+
398
413
  function renderHookTarget(target: HookPattern["target"]): string {
399
414
  if (typeof target === "string") return renderValue(target);
400
415
  if ("allOf" in target) return `{ allOf: ${JSON.stringify(target.allOf)} }`;
@@ -30,6 +30,7 @@ import type {
30
30
  SearchPayloadContributorFn,
31
31
  SecretKeyDefinition,
32
32
  StoreTableEntry,
33
+ StreamHandlerDef,
33
34
  TranslationKeys,
34
35
  TreeActionDef,
35
36
  UiHints,
@@ -57,6 +58,7 @@ export type FeatureBuilderState = {
57
58
  relations: Record<string, Record<string, RelationDefinition>>;
58
59
  writeHandlers: Record<string, WriteHandlerDef>;
59
60
  queryHandlers: Record<string, QueryHandlerDef>;
61
+ streamHandlers: Record<string, StreamHandlerDef>;
60
62
  validationHooks: Record<string, ValidationHookFn>;
61
63
  lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>>;
62
64
  phasedLifecycleHooks: Record<
@@ -119,6 +121,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
119
121
  relations: {},
120
122
  writeHandlers: {},
121
123
  queryHandlers: {},
124
+ streamHandlers: {},
122
125
  validationHooks: {},
123
126
  lifecycleHooks,
124
127
  phasedLifecycleHooks: { postSave: {}, preDelete: {}, postDelete: {} },
@@ -1,6 +1,10 @@
1
1
  import type { ZodType, z } from "zod";
2
2
  import { toTableName } from "../db/table-builder";
3
- import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
3
+ import type {
4
+ QueryHandlerDefinition,
5
+ StreamHandlerDefinition,
6
+ WriteHandlerDefinition,
7
+ } from "./define-handler";
4
8
  import type { RegisterEntityCrudOptions } from "./entity-handlers";
5
9
  import { registerEntityCrud } from "./entity-handlers";
6
10
  import type { FeatureBuilderState } from "./feature-builder-state";
@@ -15,6 +19,7 @@ import type {
15
19
  QueryHandlerFn,
16
20
  RateLimitOption,
17
21
  RelationDefinition,
22
+ StreamHandlerFn,
18
23
  WriteHandlerFn,
19
24
  } from "./types";
20
25
  import type { PipelineDef } from "./types/step";
@@ -164,6 +169,35 @@ export function buildEntityHandlerMethods<TName extends string>(
164
169
  tryMapEntity(state, name, nameOrDef);
165
170
  return { name: nameOrDef };
166
171
  },
172
+ streamHandler<TName extends string, TSchema extends ZodType>(
173
+ nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
174
+ schema?: TSchema,
175
+ handler?: StreamHandlerFn<z.infer<TSchema>>,
176
+ options?: { access?: AccessRule; rateLimit?: RateLimitOption },
177
+ ): HandlerRef {
178
+ if (typeof nameOrDef === "object") {
179
+ const def = nameOrDef;
180
+ state.streamHandlers[def.name] = {
181
+ name: def.name,
182
+ schema: def.schema,
183
+ // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
184
+ handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
185
+ ...(def.access && { access: def.access }),
186
+ ...(def.rateLimit && { rateLimit: def.rateLimit }),
187
+ };
188
+ return { name: def.name };
189
+ }
190
+ if (!schema || !handler)
191
+ throw new Error("streamHandler inline form requires schema + handler");
192
+ state.streamHandlers[nameOrDef] = {
193
+ name: nameOrDef,
194
+ schema,
195
+ handler: handler as StreamHandlerFn, // @cast-boundary engine-bridge
196
+ ...(options?.access && { access: options.access }),
197
+ ...(options?.rateLimit && { rateLimit: options.rateLimit }),
198
+ };
199
+ return { name: nameOrDef };
200
+ },
167
201
  };
168
202
  return {
169
203
  ...methods,
@@ -42,6 +42,7 @@ export { crossTenantOverrideDenied } from "./cross-tenant";
42
42
  export { defineFeature } from "./define-feature";
43
43
  export type {
44
44
  QueryHandlerDefinition,
45
+ StreamHandlerDefinition,
45
46
  WriteHandlerDefinition,
46
47
  WriteHandlerInput,
47
48
  } from "./define-handler";
@@ -364,6 +365,8 @@ export type {
364
365
  SecretKeyHandle,
365
366
  SelectFieldDef,
366
367
  SessionUser,
368
+ StreamHandlerDef,
369
+ StreamHandlerFn,
367
370
  Subscribe,
368
371
  TargetRef,
369
372
  TenantId,
@@ -46,6 +46,7 @@ const ALL_KINDS: FeaturePatternKind[] = [
46
46
  "screen",
47
47
  "writeHandler",
48
48
  "queryHandler",
49
+ "streamHandler",
49
50
  "hook",
50
51
  "job",
51
52
  "notification",
@@ -270,6 +271,14 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
270
271
  schemaSource: PLACEHOLDER_BODY_LOC,
271
272
  handlerBody: PLACEHOLDER_BODY_LOC,
272
273
  };
274
+ case "streamHandler":
275
+ return {
276
+ kind,
277
+ source: PLACEHOLDER_LOC,
278
+ handlerName: "x",
279
+ schemaSource: PLACEHOLDER_BODY_LOC,
280
+ handlerBody: PLACEHOLDER_BODY_LOC,
281
+ };
273
282
  case "hook":
274
283
  return {
275
284
  kind,
@@ -25,6 +25,7 @@ import {
25
25
  projectionSchema,
26
26
  queryHandlerSchema,
27
27
  screenSchema,
28
+ streamHandlerSchema,
28
29
  writeHandlerSchema,
29
30
  } from "./mixed-schemas";
30
31
  import {
@@ -79,6 +80,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
79
80
  screen: screenSchema,
80
81
  writeHandler: writeHandlerSchema,
81
82
  queryHandler: queryHandlerSchema,
83
+ streamHandler: streamHandlerSchema,
82
84
  hook: hookSchema,
83
85
  job: jobSchema,
84
86
  notification: notificationSchema,
@@ -125,6 +125,43 @@ export const queryHandlerSchema: PatternFormSchema = {
125
125
  ],
126
126
  };
127
127
 
128
+ export const streamHandlerSchema: PatternFormSchema = {
129
+ kind: "streamHandler",
130
+ label: { en: "Stream handler", de: "Stream-Handler" },
131
+ summary: { en: "Streaming read endpoint (SSE) with Zod schema + async-generator body." },
132
+ category: "behaviour",
133
+ editability: "mixed",
134
+ fields: [
135
+ {
136
+ path: "handlerName",
137
+ label: { en: "Name", de: "Name" },
138
+ input: "text",
139
+ required: true,
140
+ placeholder: "chat:complete",
141
+ },
142
+ {
143
+ path: "schemaSource",
144
+ label: { en: "Zod schema (source)", de: "Zod-Schema (Source)" },
145
+ input: "code-block",
146
+ language: "zod",
147
+ readOnly: true,
148
+ },
149
+ {
150
+ path: "handlerBody",
151
+ label: { en: "Handler body (source)", de: "Handler-Body (Source)" },
152
+ input: "code-block",
153
+ language: "typescript",
154
+ readOnly: true,
155
+ },
156
+ accessRuleField,
157
+ {
158
+ path: "rateLimit",
159
+ label: { en: "Rate limit", de: "Rate-Limit" },
160
+ input: "json-readonly",
161
+ },
162
+ ],
163
+ };
164
+
128
165
  export const hookSchema: PatternFormSchema = {
129
166
  kind: "hook",
130
167
  label: { en: "Lifecycle hook", de: "Lifecycle-Hook" },
@@ -30,6 +30,7 @@ import type {
30
30
  SearchPayloadContributorFn,
31
31
  SecretKeyDefinition,
32
32
  StoreTableDef,
33
+ StreamHandlerDef,
33
34
  TranslationKeys,
34
35
  TreeActionDef,
35
36
  WorkspaceDefinition,
@@ -72,6 +73,14 @@ export function buildRegistryFacade(state: RegistryState): Registry {
72
73
  return state.queryHandlerMap;
73
74
  },
74
75
 
76
+ getStreamHandler(name: string): StreamHandlerDef | undefined {
77
+ return state.streamHandlerMap.get(name);
78
+ },
79
+
80
+ getAllStreamHandlers(): ReadonlyMap<string, StreamHandlerDef> {
81
+ return state.streamHandlerMap;
82
+ },
83
+
75
84
  getSearchableFields(entityName: string): readonly string[] {
76
85
  return state.searchableFieldsCache.get(entityName) ?? [];
77
86
  },
@@ -80,6 +80,16 @@ export function populateHandlers(state: RegistryState, feature: FeatureDefinitio
80
80
  state.queryHandlerMap.set(qualified, { ...handler, name: qualified });
81
81
  state.handlerFeatureMap.set(qualified, feature.name);
82
82
  }
83
+
84
+ // Stream handlers: scope:stream:name
85
+ for (const [name, handler] of Object.entries(feature.streamHandlers ?? {})) {
86
+ const qualified = qualify(feature.name, "stream", name);
87
+ if (state.streamHandlerMap.has(qualified)) {
88
+ throw new Error(`Duplicate stream handler: "${qualified}" (registered by multiple features)`);
89
+ }
90
+ state.streamHandlerMap.set(qualified, { ...handler, name: qualified });
91
+ state.handlerFeatureMap.set(qualified, feature.name);
92
+ }
83
93
  }
84
94
 
85
95
  // Config keys: scope:config:name, duplicate-guarded.
@@ -37,6 +37,7 @@ import type {
37
37
  SearchPayloadContributorFn,
38
38
  SecretKeyDefinition,
39
39
  StoreTableDef,
40
+ StreamHandlerDef,
40
41
  TreeActionDef,
41
42
  WorkspaceDefinition,
42
43
  WriteHandlerDef,
@@ -165,6 +166,7 @@ export type RegistryState = {
165
166
  relationMap: Map<string, Record<string, RelationDefinition>>;
166
167
  writeHandlerMap: Map<string, WriteHandlerDef>;
167
168
  queryHandlerMap: Map<string, QueryHandlerDef>;
169
+ streamHandlerMap: Map<string, StreamHandlerDef>;
168
170
  preSaveHooks: Map<string, OwnedFn<PreSaveHookFn>[]>;
169
171
  postSaveHooks: Map<string, PhasedHook<PostSaveHookFn>[]>;
170
172
  preDeleteHooks: Map<string, PhasedHook<PreDeleteHookFn>[]>;
@@ -229,6 +231,7 @@ export function createInitialState(): RegistryState {
229
231
  relationMap: new Map(),
230
232
  writeHandlerMap: new Map(),
231
233
  queryHandlerMap: new Map(),
234
+ streamHandlerMap: new Map(),
232
235
  preSaveHooks: new Map(),
233
236
  postSaveHooks: new Map(),
234
237
  preDeleteHooks: new Map(),