@cosmicdrift/kumiko-framework 0.158.2 → 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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -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)} }`;
@@ -2,6 +2,7 @@ import type { ZodType, z } from "zod";
2
2
  import { LifecycleHookTypes } from "./constants";
3
3
  import type {
4
4
  AuthClaimsFn,
5
+ BootCheckFn,
5
6
  ClaimKeyDefinition,
6
7
  ConfigKeyDefinition,
7
8
  ConfigSeedDef,
@@ -29,6 +30,7 @@ import type {
29
30
  SearchPayloadContributorFn,
30
31
  SecretKeyDefinition,
31
32
  StoreTableEntry,
33
+ StreamHandlerDef,
32
34
  TranslationKeys,
33
35
  TreeActionDef,
34
36
  UiHints,
@@ -56,6 +58,7 @@ export type FeatureBuilderState = {
56
58
  relations: Record<string, Record<string, RelationDefinition>>;
57
59
  writeHandlers: Record<string, WriteHandlerDef>;
58
60
  queryHandlers: Record<string, QueryHandlerDef>;
61
+ streamHandlers: Record<string, StreamHandlerDef>;
59
62
  validationHooks: Record<string, ValidationHookFn>;
60
63
  lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>>;
61
64
  phasedLifecycleHooks: Record<
@@ -79,6 +82,7 @@ export type FeatureBuilderState = {
79
82
  extensionSelectors: ExtensionSelectorDef[];
80
83
  exposedApis: Set<string>;
81
84
  usedApis: Set<string>;
85
+ bootChecks: BootCheckFn[];
82
86
  referenceData: ReferenceDataDef[];
83
87
  handlerEntityMappings: Record<string, string>;
84
88
  metrics: Record<string, FeatureMetricDef>;
@@ -117,6 +121,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
117
121
  relations: {},
118
122
  writeHandlers: {},
119
123
  queryHandlers: {},
124
+ streamHandlers: {},
120
125
  validationHooks: {},
121
126
  lifecycleHooks,
122
127
  phasedLifecycleHooks: { postSave: {}, preDelete: {}, postDelete: {} },
@@ -137,6 +142,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
137
142
  extensionSelectors: [],
138
143
  exposedApis: new Set(),
139
144
  usedApis: new Set(),
145
+ bootChecks: [],
140
146
  referenceData: [],
141
147
  handlerEntityMappings: {},
142
148
  metrics: {},
@@ -1,5 +1,6 @@
1
1
  import { ZodObject, type ZodType, type z } from "zod";
2
2
  import type { FeatureBuilderState } from "./feature-builder-state";
3
+ import { resolveName } from "./handler-helpers";
3
4
  import { splitNamedDefinition, unwrapArrayForm } from "./object-form";
4
5
  import { QnTypes, qn, toKebab } from "./qualified-name";
5
6
  import type {
@@ -26,7 +27,6 @@ import type {
26
27
  SecretOptions,
27
28
  TranslationsDef,
28
29
  } from "./types";
29
- import { resolveName } from "./types/handlers";
30
30
 
31
31
  // Builds config/secrets/claims/events/jobs/notifications registrar methods.
32
32
  export function buildConfigEventsJobsMethods<TName extends string>(
@@ -1,9 +1,14 @@
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";
11
+ import { resolveName } from "./handler-helpers";
7
12
  import { splitNamedDefinition } from "./object-form";
8
13
  import type {
9
14
  AccessRule,
@@ -14,9 +19,9 @@ import type {
14
19
  QueryHandlerFn,
15
20
  RateLimitOption,
16
21
  RelationDefinition,
22
+ StreamHandlerFn,
17
23
  WriteHandlerFn,
18
24
  } from "./types";
19
- import { resolveName } from "./types/handlers";
20
25
  import type { PipelineDef } from "./types/step";
21
26
 
22
27
  const CRUD_VERBS = new Set(["create", "update", "delete"]);
@@ -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,
@@ -1,8 +1,10 @@
1
1
  import type { EntityTableMeta } from "../db/entity-table-meta";
2
2
  import { LifecycleHookTypes } from "./constants";
3
3
  import type { FeatureBuilderState } from "./feature-builder-state";
4
+ import { resolveName } from "./handler-helpers";
4
5
  import { isKebabSegment, toKebab } from "./qualified-name";
5
6
  import type {
7
+ BootCheckFn,
6
8
  EntityProjectionExtension,
7
9
  HookPhase,
8
10
  LifecycleHookFn,
@@ -22,7 +24,6 @@ import type {
22
24
  ValidationHookFn,
23
25
  } from "./types";
24
26
  import { HookPhases } from "./types";
25
- import { resolveName } from "./types/handlers";
26
27
  import type { HttpRouteDefinition } from "./types/http-route";
27
28
  import type { NavDefinition } from "./types/nav";
28
29
  import type { ScreenDefinition } from "./types/screen";
@@ -255,6 +256,9 @@ export function buildUiExtensionsMethods<TName extends string>(
255
256
  usesApi(apiName: string): void {
256
257
  state.usedApis.add(apiName);
257
258
  },
259
+ bootCheck(fn: BootCheckFn): void {
260
+ state.bootChecks.push(fn);
261
+ },
258
262
  projection(definition: ProjectionDefinition): void {
259
263
  // Reject names that would blow up at registry-boot when we qualify them.
260
264
  // Catch it at the registration site so the stack trace points at the
@@ -0,0 +1,31 @@
1
+ import type { AnyFileFieldDef, FieldDefinition } from "./types/fields";
2
+
3
+ // --- Currency ---
4
+
5
+ export const DEFAULT_CURRENCIES = [
6
+ "EUR",
7
+ "USD",
8
+ "GBP",
9
+ "CHF",
10
+ "JPY",
11
+ "SEK",
12
+ "NOK",
13
+ "DKK",
14
+ "PLN",
15
+ "CZK",
16
+ "CAD",
17
+ "AUD",
18
+ "NZD",
19
+ "CNY",
20
+ "INR",
21
+ ] as const;
22
+
23
+ export function isFileField(field: FieldDefinition | undefined): field is AnyFileFieldDef {
24
+ if (!field) return false;
25
+ return (
26
+ field.type === "file" ||
27
+ field.type === "image" ||
28
+ field.type === "files" ||
29
+ field.type === "images"
30
+ );
31
+ }
@@ -0,0 +1,26 @@
1
+ import type { NameOrRef, WriteResult } from "./types/handlers";
2
+
3
+ /**
4
+ * Override the success-side `data` of a WriteResult while forwarding the
5
+ * failure half untouched. Useful for handlers that delegate to the
6
+ * event-store executor (which returns a SaveContext / DeleteContext
7
+ * envelope) but want to keep their own response shape — caller contract
8
+ * stays flat instead of leaking the executor's internals.
9
+ *
10
+ * ```ts
11
+ * const result = await executor.delete({ id }, user, db);
12
+ * return withResponseData(result, { userId, tenantId });
13
+ * ```
14
+ *
15
+ * On failure the same WriteFailure instance is returned — the error
16
+ * object round-trips without any wrapping, so the dispatcher / HTTP layer
17
+ * still read the original error code + httpStatus + i18nKey.
18
+ */
19
+ export function withResponseData<T>(result: WriteResult<unknown>, data: T): WriteResult<T> {
20
+ if (!result.isSuccess) return result;
21
+ return { isSuccess: true, data };
22
+ }
23
+
24
+ export function resolveName(ref: NameOrRef): string {
25
+ return typeof ref === "string" ? ref : ref.name;
26
+ }
@@ -0,0 +1,14 @@
1
+ // --- Hook Phases ---
2
+ //
3
+ // inTransaction: Hook runs inside the DB transaction. Failures roll back
4
+ // the entire write. Use for: DB-based side-effects (counter updates,
5
+ // dependent entity writes).
6
+ //
7
+ // afterCommit (default): Hook runs after the transaction commits. Failures
8
+ // are logged but don't affect the write. Use for: external systems
9
+ // (SSE broadcast, search index, email, webhooks).
10
+
11
+ export const HookPhases = {
12
+ inTransaction: "inTransaction",
13
+ afterCommit: "afterCommit",
14
+ } as const;