@cosmicdrift/kumiko-framework 0.2.3 → 0.4.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 (166) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +44 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +93 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/index.ts +11 -1
  116. package/src/engine/types/step.ts +334 -0
  117. package/src/engine/types/target-ref.ts +21 -0
  118. package/src/engine/types/tree-node.ts +132 -0
  119. package/src/engine/types/workspace.ts +7 -0
  120. package/src/engine/validate-projection-allowlist.ts +161 -0
  121. package/src/event-store/snapshot.ts +1 -1
  122. package/src/event-store/upcaster-dead-letter.ts +1 -1
  123. package/src/event-store/upcaster.ts +1 -1
  124. package/src/files/file-routes.ts +1 -1
  125. package/src/files/types.ts +2 -2
  126. package/src/jobs/job-runner.ts +10 -10
  127. package/src/lifecycle/lifecycle.ts +0 -3
  128. package/src/logging/index.ts +1 -0
  129. package/src/logging/pino-logger.ts +11 -7
  130. package/src/logging/utils.ts +24 -0
  131. package/src/observability/prometheus-meter.ts +7 -5
  132. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  133. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  134. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  135. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  136. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  137. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  138. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  139. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  140. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  141. package/src/pipeline/append-event-core.ts +22 -6
  142. package/src/pipeline/dispatcher-utils.ts +188 -0
  143. package/src/pipeline/dispatcher.ts +63 -283
  144. package/src/pipeline/distributed-lock.ts +1 -1
  145. package/src/pipeline/entity-cache.ts +2 -2
  146. package/src/pipeline/event-consumer-state.ts +0 -13
  147. package/src/pipeline/event-dispatcher.ts +4 -4
  148. package/src/pipeline/index.ts +0 -2
  149. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  150. package/src/pipeline/msp-rebuild.ts +5 -5
  151. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  152. package/src/pipeline/projection-rebuild.ts +2 -2
  153. package/src/pipeline/projection-state.ts +0 -12
  154. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  155. package/src/rate-limit/resolver.ts +1 -1
  156. package/src/search/in-memory-adapter.ts +1 -1
  157. package/src/search/meilisearch-adapter.ts +3 -3
  158. package/src/search/types.ts +1 -1
  159. package/src/secrets/leak-guard.ts +2 -2
  160. package/src/stack/request-helper.ts +9 -5
  161. package/src/stack/test-stack.ts +1 -1
  162. package/src/testing/handler-context.ts +4 -4
  163. package/src/testing/http-cookies.ts +1 -1
  164. package/src/time/tz-context.ts +1 -2
  165. package/src/ui-types/index.ts +4 -0
  166. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -0,0 +1,115 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getStep } from "../define-step";
3
+ import { buildAggregateAppendEventStep } from "../steps/aggregate-append-event";
4
+ import type { PipelineCtx } from "../types/step";
5
+
6
+ const mockUnsafeAppendEvent = vi.fn();
7
+
8
+ const mockCtx = {
9
+ unsafeAppendEvent: mockUnsafeAppendEvent,
10
+ event: { type: "test", payload: {} },
11
+ steps: {},
12
+ scope: {},
13
+ } as unknown as PipelineCtx;
14
+
15
+ describe("buildAggregateAppendEventStep", () => {
16
+ it("returns a StepInstance with kind aggregate.appendEvent", () => {
17
+ const step = buildAggregateAppendEventStep({
18
+ aggregateId: "abc-123",
19
+ aggregateType: "widget",
20
+ type: "widget:event:custom",
21
+ payload: { note: "test" },
22
+ });
23
+ expect(step.kind).toBe("aggregate.appendEvent");
24
+ });
25
+
26
+ it("accepts optional headers", () => {
27
+ const step = buildAggregateAppendEventStep({
28
+ aggregateId: "abc",
29
+ aggregateType: "widget",
30
+ type: "widget:event:custom",
31
+ payload: {},
32
+ headers: { correlationId: "corr-1" },
33
+ });
34
+ expect((step.args as { headers: Record<string, string> }).headers).toEqual({
35
+ correlationId: "corr-1",
36
+ });
37
+ });
38
+ });
39
+
40
+ describe("aggregate.appendEvent run", () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ it("calls ctx.unsafeAppendEvent with resolved aggregateId and payload", async () => {
46
+ const stepDef = getStep("aggregate.appendEvent");
47
+ expect(stepDef).toBeDefined();
48
+
49
+ await stepDef!.run(
50
+ {
51
+ aggregateId: "abc-123",
52
+ aggregateType: "widget",
53
+ type: "widget:event:custom",
54
+ payload: { note: "hello" },
55
+ },
56
+ mockCtx,
57
+ );
58
+
59
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
60
+ const call = mockUnsafeAppendEvent.mock.calls[0]![0];
61
+ expect(call).toMatchObject({
62
+ aggregateId: "abc-123",
63
+ aggregateType: "widget",
64
+ type: "widget:event:custom",
65
+ payload: { note: "hello" },
66
+ });
67
+ });
68
+
69
+ it("resolves function resolvers before calling unsafeAppendEvent", async () => {
70
+ const stepDef = getStep("aggregate.appendEvent");
71
+ const idFn = vi.fn(() => "dynamic-id");
72
+ const payloadFn = vi.fn(() => ({ note: "dynamic" }));
73
+ const headersFn = vi.fn(() => ({ key: "val" }));
74
+
75
+ await stepDef!.run(
76
+ {
77
+ aggregateId: idFn,
78
+ aggregateType: "widget",
79
+ type: "widget:event:custom",
80
+ payload: payloadFn,
81
+ headers: headersFn,
82
+ },
83
+ mockCtx,
84
+ );
85
+
86
+ expect(idFn).toHaveBeenCalledWith(mockCtx);
87
+ expect(payloadFn).toHaveBeenCalledWith(mockCtx);
88
+ expect(headersFn).toHaveBeenCalledWith(mockCtx);
89
+
90
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledWith(
91
+ expect.objectContaining({
92
+ aggregateId: "dynamic-id",
93
+ payload: { note: "dynamic" },
94
+ headers: { key: "val" },
95
+ }),
96
+ );
97
+ });
98
+
99
+ it("omits headers from the event when headers resolver is undefined", async () => {
100
+ const stepDef = getStep("aggregate.appendEvent");
101
+
102
+ await stepDef!.run(
103
+ {
104
+ aggregateId: "abc",
105
+ aggregateType: "widget",
106
+ type: "widget:event:plain",
107
+ payload: { x: 1 },
108
+ },
109
+ mockCtx,
110
+ );
111
+
112
+ const call = mockUnsafeAppendEvent.mock.calls[0]![0];
113
+ expect(call.headers).toBeUndefined();
114
+ });
115
+ });
@@ -0,0 +1,92 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { EventStoreExecutor } from "../../db/event-store-executor";
3
+ import { getStep } from "../define-step";
4
+ import { buildAggregateCreateStep } from "../steps/aggregate-create";
5
+ import type { PipelineCtx } from "../types/step";
6
+
7
+ const mockCreate = vi.fn();
8
+ const mockExecutor = { create: mockCreate } as unknown as EventStoreExecutor & {
9
+ create: typeof mockCreate;
10
+ };
11
+ const mockDb = {};
12
+
13
+ const mockCtx = {
14
+ db: mockDb,
15
+ event: { type: "test", payload: { label: "test" }, user: { id: "u1" } },
16
+ steps: {},
17
+ scope: {},
18
+ } as unknown as PipelineCtx;
19
+
20
+ describe("buildAggregateCreateStep", () => {
21
+ it("returns a StepInstance with kind aggregate.create", () => {
22
+ const step = buildAggregateCreateStep("widget", {
23
+ executor: mockExecutor,
24
+ data: { label: "hello" },
25
+ });
26
+ expect(step.kind).toBe("aggregate.create");
27
+ expect((step.args as { name: string }).name).toBe("widget");
28
+ });
29
+
30
+ it("stores the result key from the name arg", () => {
31
+ const step = buildAggregateCreateStep("myResult", {
32
+ executor: mockExecutor,
33
+ data: { label: "hello" },
34
+ });
35
+ const def = getStep("aggregate.create");
36
+ expect(def?.resultKey?.(step.args as { name: string })).toBe("myResult");
37
+ });
38
+ });
39
+
40
+ describe("aggregate.create run", () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ it("resolves data and calls executor.create with user and db", async () => {
46
+ const stepDef = getStep("aggregate.create");
47
+ mockExecutor.create.mockResolvedValue({
48
+ isSuccess: true,
49
+ data: { id: "abc-123", label: "hello" },
50
+ });
51
+
52
+ const result = await stepDef!.run(
53
+ { name: "widget", executor: mockExecutor, data: { label: "hello" } },
54
+ mockCtx,
55
+ );
56
+
57
+ expect(mockExecutor.create).toHaveBeenCalledWith(
58
+ { label: "hello" },
59
+ mockCtx.event.user,
60
+ mockDb,
61
+ );
62
+ expect(result).toEqual({ id: "abc-123", label: "hello" });
63
+ });
64
+
65
+ it("resolves a function data resolver before calling executor.create", async () => {
66
+ const stepDef = getStep("aggregate.create");
67
+ const dataFn = vi.fn((ctx: PipelineCtx) => ({
68
+ label: (ctx.event.payload as { label: string }).label,
69
+ }));
70
+ mockExecutor.create.mockResolvedValue({
71
+ isSuccess: true,
72
+ data: { id: "abc", label: "test" },
73
+ });
74
+
75
+ await stepDef!.run({ name: "widget", executor: mockExecutor, data: dataFn }, mockCtx);
76
+
77
+ expect(dataFn).toHaveBeenCalledWith(mockCtx);
78
+ expect(mockExecutor.create).toHaveBeenCalledWith({ label: "test" }, mockCtx.event.user, mockDb);
79
+ });
80
+
81
+ it("re-throws executor WriteFailure as a KumikoError", async () => {
82
+ const stepDef = getStep("aggregate.create");
83
+ mockExecutor.create.mockResolvedValue({
84
+ isSuccess: false,
85
+ error: { code: "validation_error", message: "label is required" },
86
+ });
87
+
88
+ await expect(
89
+ stepDef!.run({ name: "widget", executor: mockExecutor, data: {} }, mockCtx),
90
+ ).rejects.toThrow(/label is required/);
91
+ });
92
+ });
@@ -0,0 +1,127 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { EventStoreExecutor } from "../../db/event-store-executor";
3
+ import { getStep } from "../define-step";
4
+ import { buildAggregateUpdateStep } from "../steps/aggregate-update";
5
+ import type { PipelineCtx } from "../types/step";
6
+
7
+ const mockUpdate = vi.fn();
8
+ const mockExecutor = { update: mockUpdate } as unknown as EventStoreExecutor & {
9
+ update: typeof mockUpdate;
10
+ };
11
+ const mockDb = {};
12
+
13
+ const mockCtx = {
14
+ db: mockDb,
15
+ event: { type: "test", payload: {}, user: { id: "u1" } },
16
+ steps: {},
17
+ scope: {},
18
+ } as unknown as PipelineCtx;
19
+
20
+ describe("buildAggregateUpdateStep", () => {
21
+ it("returns a StepInstance with kind aggregate.update", () => {
22
+ const step = buildAggregateUpdateStep("widget", {
23
+ executor: mockExecutor,
24
+ id: "abc-123",
25
+ changes: { label: "updated" },
26
+ });
27
+ expect(step.kind).toBe("aggregate.update");
28
+ expect((step.args as { name: string }).name).toBe("widget");
29
+ });
30
+
31
+ it("stores the result key from the name arg", () => {
32
+ const step = buildAggregateUpdateStep("myUpdate", {
33
+ executor: mockExecutor,
34
+ id: "abc",
35
+ changes: {},
36
+ });
37
+ const def = getStep("aggregate.update");
38
+ expect(def?.resultKey?.(step.args as { name: string })).toBe("myUpdate");
39
+ });
40
+
41
+ it("accepts an optional version resolver", () => {
42
+ const step = buildAggregateUpdateStep("widget", {
43
+ executor: mockExecutor,
44
+ id: "abc",
45
+ changes: {},
46
+ version: () => 1,
47
+ });
48
+ expect(typeof (step.args as { version: unknown }).version).toBe("function");
49
+ });
50
+
51
+ it("accepts skipOptimisticLock flag", () => {
52
+ const step = buildAggregateUpdateStep("widget", {
53
+ executor: mockExecutor,
54
+ id: "abc",
55
+ changes: {},
56
+ skipOptimisticLock: true,
57
+ });
58
+ expect((step.args as { skipOptimisticLock: boolean }).skipOptimisticLock).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("aggregate.update run", () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ });
66
+
67
+ it("resolves id, changes, version and calls executor.update", async () => {
68
+ const stepDef = getStep("aggregate.update");
69
+ mockUpdate.mockResolvedValue({
70
+ isSuccess: true,
71
+ data: { id: "abc", changes: { label: "updated" }, previous: { label: "old" } },
72
+ });
73
+
74
+ await stepDef!.run(
75
+ {
76
+ name: "widget",
77
+ executor: mockExecutor,
78
+ id: "abc-123",
79
+ changes: { label: "updated" },
80
+ },
81
+ mockCtx,
82
+ );
83
+
84
+ expect(mockExecutor.update).toHaveBeenCalled();
85
+ const [input, user, db] = mockUpdate.mock.calls[0]!;
86
+ expect(input).toMatchObject({ id: "abc-123", changes: { label: "updated" } });
87
+ expect(user).toBe(mockCtx.event.user);
88
+ expect(db).toBe(mockDb);
89
+ });
90
+
91
+ it("passes skipOptimisticLock when set", async () => {
92
+ const stepDef = getStep("aggregate.update");
93
+ mockUpdate.mockResolvedValue({
94
+ isSuccess: true,
95
+ data: { id: "abc", changes: {} },
96
+ });
97
+
98
+ await stepDef!.run(
99
+ {
100
+ name: "widget",
101
+ executor: mockExecutor,
102
+ id: "abc",
103
+ changes: {},
104
+ skipOptimisticLock: true,
105
+ },
106
+ mockCtx,
107
+ );
108
+
109
+ const [, , , opts] = mockUpdate.mock.calls[0]!;
110
+ expect(opts).toEqual({ skipOptimisticLock: true });
111
+ });
112
+
113
+ it("re-throws executor WriteFailure as a KumikoError", async () => {
114
+ const stepDef = getStep("aggregate.update");
115
+ mockUpdate.mockResolvedValue({
116
+ isSuccess: false,
117
+ error: { code: "not_found", message: "aggregate not found" },
118
+ });
119
+
120
+ await expect(
121
+ stepDef!.run(
122
+ { name: "widget", executor: mockExecutor, id: "nonexistent", changes: {} },
123
+ mockCtx,
124
+ ),
125
+ ).rejects.toThrow(/aggregate not found/);
126
+ });
127
+ });
@@ -0,0 +1,123 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getStep } from "../define-step";
3
+ import { buildCallFeatureStep } from "../steps/call-feature";
4
+ import type { PipelineCtx } from "../types/step";
5
+
6
+ const mockWrite = vi.fn();
7
+ const mockWriteAs = vi.fn();
8
+
9
+ const mockCtx = {
10
+ write: mockWrite,
11
+ writeAs: mockWriteAs,
12
+ event: { type: "test", payload: { title: "test" } },
13
+ steps: {},
14
+ scope: {},
15
+ } as unknown as PipelineCtx;
16
+
17
+ describe("buildCallFeatureStep", () => {
18
+ it("returns a StepInstance with kind callFeature", () => {
19
+ const step = buildCallFeatureStep("inner", {
20
+ handler: "other-feature:write:handler",
21
+ payload: { key: "val" },
22
+ });
23
+ expect(step.kind).toBe("callFeature");
24
+ expect((step.args as { name: string }).name).toBe("inner");
25
+ expect((step.args as { handler: string }).handler).toBe("other-feature:write:handler");
26
+ });
27
+
28
+ it("stores the result key from the name arg", () => {
29
+ const step = buildCallFeatureStep("myCall", {
30
+ handler: "feat:write:h",
31
+ payload: {},
32
+ });
33
+ const def = getStep("callFeature");
34
+ expect(def?.resultKey?.(step.args as { name: string })).toBe("myCall");
35
+ });
36
+ });
37
+
38
+ describe("callFeature run", () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ it("calls ctx.write with the resolved payload and handler name", async () => {
44
+ const stepDef = getStep("callFeature");
45
+ mockWrite.mockResolvedValue({
46
+ isSuccess: true,
47
+ data: { id: "abc-123" },
48
+ });
49
+
50
+ const result = await stepDef!.run(
51
+ {
52
+ name: "inner",
53
+ handler: "other-feature:write:handler",
54
+ payload: { title: "hello" },
55
+ },
56
+ mockCtx,
57
+ );
58
+
59
+ expect(mockWrite).toHaveBeenCalledWith("other-feature:write:handler", {
60
+ title: "hello",
61
+ });
62
+ expect(result).toEqual({ id: "abc-123" });
63
+ });
64
+
65
+ it("resolves a function payload resolver before calling ctx.write", async () => {
66
+ const stepDef = getStep("callFeature");
67
+ const payloadFn = vi.fn((ctx: PipelineCtx) => ({
68
+ title: (ctx.event.payload as { title: string }).title,
69
+ }));
70
+ mockWrite.mockResolvedValue({
71
+ isSuccess: true,
72
+ data: { id: "abc" },
73
+ });
74
+
75
+ await stepDef!.run({ name: "inner", handler: "feat:write:h", payload: payloadFn }, mockCtx);
76
+
77
+ expect(payloadFn).toHaveBeenCalledWith(mockCtx);
78
+ expect(mockWrite).toHaveBeenCalledWith("feat:write:h", { title: "test" });
79
+ });
80
+
81
+ it("calls ctx.writeAs when opts.as is provided", async () => {
82
+ const stepDef = getStep("callFeature");
83
+ const adminUser = { id: "admin-id", tenantId: "t1", roles: ["Admin"] };
84
+ mockWriteAs.mockResolvedValue({
85
+ isSuccess: true,
86
+ data: { id: "abc" },
87
+ });
88
+
89
+ await stepDef!.run(
90
+ {
91
+ name: "inner",
92
+ handler: "feat:write:h",
93
+ payload: {},
94
+ as: adminUser,
95
+ },
96
+ mockCtx,
97
+ );
98
+
99
+ expect(mockWriteAs).toHaveBeenCalledWith(adminUser, "feat:write:h", {});
100
+ expect(mockWrite).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("preserves WriteFailure as error cause when the sub-handler returns failure", async () => {
104
+ const stepDef = getStep("callFeature");
105
+ const writeError = {
106
+ code: "validation_error",
107
+ message: "title is required",
108
+ details: [{ path: ["title"], message: "required" }],
109
+ };
110
+ mockWrite.mockResolvedValue({
111
+ isSuccess: false,
112
+ error: writeError,
113
+ });
114
+
115
+ try {
116
+ await stepDef!.run({ name: "inner", handler: "feat:write:h", payload: {} }, mockCtx);
117
+ expect.unreachable("should have thrown");
118
+ } catch (err) {
119
+ expect((err as Error).message).toMatch(/returned failure/);
120
+ expect((err as Error & { cause?: unknown }).cause).toEqual(writeError);
121
+ }
122
+ });
123
+ });
@@ -0,0 +1,136 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getStep } from "../define-step";
3
+ import {
4
+ STEP_DISPATCH_AGGREGATE_TYPE,
5
+ STEP_DISPATCH_REQUESTED_TYPE,
6
+ } from "../steps/_step-dispatch-constants";
7
+ import { buildMailSendStep } from "../steps/mail-send";
8
+ import type { PipelineCtx } from "../types/step";
9
+
10
+ const mockUnsafeAppendEvent = vi.fn();
11
+
12
+ const mockCtx = {
13
+ unsafeAppendEvent: mockUnsafeAppendEvent,
14
+ event: { type: "test", payload: {} },
15
+ steps: {},
16
+ scope: {},
17
+ } as unknown as PipelineCtx;
18
+
19
+ describe("buildMailSendStep", () => {
20
+ it("returns a StepInstance with kind mail.send", () => {
21
+ const step = buildMailSendStep({
22
+ to: "user@example.com",
23
+ subject: "Hello",
24
+ body: "World",
25
+ mode: "deferred",
26
+ });
27
+ expect(step.kind).toBe("mail.send");
28
+ });
29
+
30
+ it("requires mode to be deferred", () => {
31
+ const step = buildMailSendStep({
32
+ to: "user@example.com",
33
+ subject: "Hello",
34
+ body: "World",
35
+ mode: "deferred",
36
+ });
37
+ expect((step.args as { mode: string }).mode).toBe("deferred");
38
+ });
39
+
40
+ it("accepts an optional from address", () => {
41
+ const step = buildMailSendStep({
42
+ to: "user@example.com",
43
+ subject: "Hello",
44
+ body: "World",
45
+ from: "noreply@example.com",
46
+ mode: "deferred",
47
+ });
48
+ expect((step.args as { from: string }).from).toBe("noreply@example.com");
49
+ });
50
+
51
+ it("accepts string array for to", () => {
52
+ const step = buildMailSendStep({
53
+ to: ["a@example.com", "b@example.com"],
54
+ subject: "Hello",
55
+ body: "World",
56
+ mode: "deferred",
57
+ });
58
+ expect((step.args as { to: unknown }).to).toEqual(["a@example.com", "b@example.com"]);
59
+ });
60
+ });
61
+
62
+ describe("mail.send run", () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ });
66
+
67
+ it("appends a step.dispatch-requested system event with the mail spec", async () => {
68
+ const stepDef = getStep("mail.send");
69
+ expect(stepDef).toBeDefined();
70
+
71
+ await stepDef!.run(
72
+ {
73
+ to: "user@example.com",
74
+ subject: "Test",
75
+ body: "Body text",
76
+ mode: "deferred",
77
+ },
78
+ mockCtx,
79
+ );
80
+
81
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
82
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
83
+
84
+ expect(eventArg.aggregateType).toBe(STEP_DISPATCH_AGGREGATE_TYPE);
85
+ expect(eventArg.type).toBe(STEP_DISPATCH_REQUESTED_TYPE);
86
+ expect(eventArg.payload.stepKind).toBe("mail.send");
87
+ expect(eventArg.payload.spec).toMatchObject({
88
+ to: "user@example.com",
89
+ subject: "Test",
90
+ body: "Body text",
91
+ });
92
+ });
93
+
94
+ it("resolves function-based resolvers", async () => {
95
+ const stepDef = getStep("mail.send");
96
+ const toFn = vi.fn(() => "resolved@example.com");
97
+ const subjectFn = vi.fn(() => "Resolved Subject");
98
+ const bodyFn = vi.fn(() => "Resolved Body");
99
+
100
+ await stepDef!.run({ to: toFn, subject: subjectFn, body: bodyFn, mode: "deferred" }, mockCtx);
101
+
102
+ expect(toFn).toHaveBeenCalledWith(mockCtx);
103
+ expect(subjectFn).toHaveBeenCalledWith(mockCtx);
104
+ expect(bodyFn).toHaveBeenCalledWith(mockCtx);
105
+ });
106
+
107
+ it("includes from when provided", async () => {
108
+ const stepDef = getStep("mail.send");
109
+
110
+ await stepDef!.run(
111
+ {
112
+ to: "user@example.com",
113
+ subject: "Hi",
114
+ body: "Message",
115
+ from: "system@example.com",
116
+ mode: "deferred",
117
+ },
118
+ mockCtx,
119
+ );
120
+
121
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
122
+ expect(eventArg.payload.spec.from).toBe("system@example.com");
123
+ });
124
+
125
+ it("omits from from the spec when not provided", async () => {
126
+ const stepDef = getStep("mail.send");
127
+
128
+ await stepDef!.run(
129
+ { to: "user@example.com", subject: "Hi", body: "Message", mode: "deferred" },
130
+ mockCtx,
131
+ );
132
+
133
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
134
+ expect(eventArg.payload.spec.from).toBeUndefined();
135
+ });
136
+ });