@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,142 @@
1
+ import type { SQL } from "drizzle-orm";
2
+ import { pgTable, text, uuid } from "drizzle-orm/pg-core";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { getStep } from "../define-step";
5
+ import { buildReadFindManyStep } from "../steps/read-find-many";
6
+ import { buildReadFindOneStep } from "../steps/read-find-one";
7
+ import type { PipelineCtx } from "../types/step";
8
+
9
+ const testTable = pgTable("test_read", {
10
+ id: uuid("id").primaryKey().defaultRandom(),
11
+ tenantId: uuid("tenant_id").notNull(),
12
+ label: text("label"),
13
+ });
14
+
15
+ class MockQuery {
16
+ rows: Record<string, unknown>[] = [];
17
+ where = vi.fn(() => this);
18
+ limit = vi.fn(() => this);
19
+ // biome-ignore lint/suspicious/noThenProperty: mock query builder is intentionally thenable so await resolves rows
20
+ then: Promise<Record<string, unknown>[]>["then"];
21
+
22
+ constructor(rows?: Record<string, unknown>[]) {
23
+ if (rows) this.rows = rows;
24
+ const promise = Promise.resolve(this.rows);
25
+ // biome-ignore lint/suspicious/noThenProperty: see above
26
+ this.then = promise.then.bind(promise);
27
+ }
28
+ }
29
+
30
+ const mockDb = { select: vi.fn(() => ({ from: vi.fn(() => new MockQuery([])) })) };
31
+
32
+ const mockCtx = {
33
+ db: mockDb,
34
+ event: { type: "test", payload: {} },
35
+ steps: {},
36
+ scope: {},
37
+ } as unknown as PipelineCtx;
38
+
39
+ describe("buildReadFindOneStep", () => {
40
+ it("returns a StepInstance with kind read.findOne", () => {
41
+ const step = buildReadFindOneStep("myLookup", {
42
+ table: testTable,
43
+ where: undefined as unknown as SQL,
44
+ });
45
+ expect(step.kind).toBe("read.findOne");
46
+ expect((step.args as { name: string }).name).toBe("myLookup");
47
+ });
48
+
49
+ it("stores the result key from the name arg", () => {
50
+ const step = buildReadFindOneStep("lookupResult", {
51
+ table: testTable,
52
+ where: undefined as unknown as SQL,
53
+ });
54
+ const def = getStep("read.findOne");
55
+ expect(def?.resultKey?.(step.args as { name: string })).toBe("lookupResult");
56
+ });
57
+ });
58
+
59
+ describe("read.findOne run", () => {
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ });
63
+
64
+ it("returns null when no row is found", async () => {
65
+ const stepDef = getStep("read.findOne");
66
+ const query = new MockQuery([]);
67
+ mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue(query) });
68
+
69
+ const result = await stepDef!.run(
70
+ { name: "lookup", table: testTable, where: undefined as unknown as SQL },
71
+ mockCtx,
72
+ );
73
+
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ it("returns the first row when found", async () => {
78
+ const stepDef = getStep("read.findOne");
79
+ const row = { id: "abc", tenantId: "t1", label: "hello" };
80
+ const query = new MockQuery([row]);
81
+ mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue(query) });
82
+
83
+ const result = await stepDef!.run(
84
+ { name: "lookup", table: testTable, where: undefined as unknown as SQL },
85
+ mockCtx,
86
+ );
87
+
88
+ expect(result).toEqual(row);
89
+ });
90
+
91
+ it("resolves a function where-clause before querying", async () => {
92
+ const stepDef = getStep("read.findOne");
93
+ const whereFn = vi.fn(() => "dynamic where" as unknown as SQL);
94
+ const query = new MockQuery([]);
95
+ mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue(query) });
96
+
97
+ await stepDef!.run({ name: "lookup", table: testTable, where: whereFn }, mockCtx);
98
+
99
+ expect(whereFn).toHaveBeenCalledWith(mockCtx);
100
+ expect(query.where).toHaveBeenCalledWith("dynamic where");
101
+ });
102
+ });
103
+
104
+ describe("buildReadFindManyStep", () => {
105
+ it("returns a StepInstance with kind read.findMany", () => {
106
+ const step = buildReadFindManyStep("myList", { table: testTable });
107
+ expect(step.kind).toBe("read.findMany");
108
+ expect((step.args as { name: string }).name).toBe("myList");
109
+ });
110
+
111
+ it("accepts an optional limit", () => {
112
+ const step = buildReadFindManyStep("myList", { table: testTable, limit: 10 });
113
+ expect((step.args as { limit: number }).limit).toBe(10);
114
+ });
115
+ });
116
+
117
+ describe("read.findMany run", () => {
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+ });
121
+
122
+ it("returns an empty array when no rows exist", async () => {
123
+ const stepDef = getStep("read.findMany");
124
+ const query = new MockQuery([]);
125
+ mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue(query) });
126
+
127
+ const result = await stepDef!.run({ name: "list", table: testTable }, mockCtx);
128
+
129
+ expect(result).toEqual([]);
130
+ });
131
+
132
+ it("applies the limit when specified", async () => {
133
+ const stepDef = getStep("read.findMany");
134
+ const rows = [{ id: "a" }, { id: "b" }];
135
+ const query = new MockQuery(rows);
136
+ mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue(query) });
137
+
138
+ await stepDef!.run({ name: "list", table: testTable, limit: 2 }, mockCtx);
139
+
140
+ expect(query.limit).toHaveBeenCalledWith(2);
141
+ });
142
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { resolveOptional, resolveRequired } from "../steps/_resolver-utils";
3
+ import type { PipelineCtx } from "../types/step";
4
+
5
+ const dummyCtx = {} as unknown as PipelineCtx;
6
+
7
+ describe("resolveRequired", () => {
8
+ it("returns a static value as-is", () => {
9
+ expect(resolveRequired("hello", dummyCtx)).toBe("hello");
10
+ expect(resolveRequired(42, dummyCtx)).toBe(42);
11
+ expect(resolveRequired(null, dummyCtx)).toBeNull();
12
+ expect(resolveRequired({ key: "val" }, dummyCtx)).toEqual({ key: "val" });
13
+ });
14
+
15
+ it("calls a function resolver with the ctx and returns its result", () => {
16
+ const fn = vi.fn((_ctx: PipelineCtx) => "from-fn");
17
+ expect(resolveRequired(fn, dummyCtx)).toBe("from-fn");
18
+ expect(fn).toHaveBeenCalledOnce();
19
+ expect(fn).toHaveBeenCalledWith(dummyCtx);
20
+ });
21
+
22
+ it("passes the full ctx to the resolver function", () => {
23
+ const ctx = { event: { type: "test" }, steps: { x: 1 }, scope: {} } as unknown as PipelineCtx;
24
+ const fn = vi.fn((c: PipelineCtx) => c.event.type);
25
+ expect(resolveRequired(fn, ctx)).toBe("test");
26
+ });
27
+ });
28
+
29
+ describe("resolveOptional", () => {
30
+ it("returns the static value when defined", () => {
31
+ expect(resolveOptional("hello", dummyCtx)).toBe("hello");
32
+ expect(resolveOptional(0, dummyCtx)).toBe(0);
33
+ expect(resolveOptional(false, dummyCtx)).toBe(false);
34
+ expect(resolveOptional("", dummyCtx)).toBe("");
35
+ });
36
+
37
+ it("returns undefined when arg is undefined", () => {
38
+ expect(resolveOptional(undefined, dummyCtx)).toBeUndefined();
39
+ });
40
+
41
+ it("calls a function resolver when defined", () => {
42
+ const fn = vi.fn(() => "resolved");
43
+ expect(resolveOptional(fn, dummyCtx)).toBe("resolved");
44
+ expect(fn).toHaveBeenCalledOnce();
45
+ });
46
+
47
+ it("returns undefined for undefined function resolver", () => {
48
+ expect(resolveOptional(undefined, dummyCtx)).toBeUndefined();
49
+ });
50
+ });
@@ -0,0 +1,69 @@
1
+ import type { SQL } from "drizzle-orm";
2
+ import { pgTable, text, uuid } from "drizzle-orm/pg-core";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { getStep } from "../define-step";
5
+ import { buildUnsafeProjectionDeleteStep } from "../steps/unsafe-projection-delete";
6
+ import type { PipelineCtx } from "../types/step";
7
+
8
+ const testTable = pgTable("test_projection", {
9
+ id: uuid("id").primaryKey().defaultRandom(),
10
+ tenantId: uuid("tenant_id").notNull(),
11
+ label: text("label"),
12
+ });
13
+
14
+ const mockDb = { delete: vi.fn() };
15
+ const mockDeleteBuilder = { where: vi.fn() };
16
+
17
+ const mockCtx = {
18
+ db: mockDb,
19
+ event: { type: "test", payload: {} },
20
+ steps: {},
21
+ scope: {},
22
+ } as unknown as PipelineCtx;
23
+
24
+ describe("buildUnsafeProjectionDeleteStep", () => {
25
+ it("returns a StepInstance with kind unsafeProjectionDelete", () => {
26
+ const step = buildUnsafeProjectionDeleteStep({
27
+ table: testTable,
28
+ where: () => undefined as unknown as SQL,
29
+ });
30
+ expect(step.kind).toBe("unsafeProjectionDelete");
31
+ expect(step.args).toMatchObject({ table: testTable });
32
+ });
33
+
34
+ it("accepts a static SQL where clause", () => {
35
+ const step = buildUnsafeProjectionDeleteStep({
36
+ table: testTable,
37
+ where: undefined as unknown as SQL,
38
+ });
39
+ expect(step.args).toHaveProperty("table");
40
+ });
41
+ });
42
+
43
+ describe("unsafeProjectionDelete run", () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ mockDb.delete.mockReturnValue(mockDeleteBuilder);
47
+ });
48
+
49
+ it("calls db.delete().where() with the resolved where clause", async () => {
50
+ const stepDef = getStep("unsafeProjectionDelete");
51
+ expect(stepDef).toBeDefined();
52
+
53
+ const whereClause = "fake sql" as unknown as SQL;
54
+ await stepDef!.run({ table: testTable, where: whereClause }, mockCtx);
55
+
56
+ expect(mockDb.delete).toHaveBeenCalledOnce();
57
+ expect(mockDeleteBuilder.where).toHaveBeenCalledWith(whereClause);
58
+ });
59
+
60
+ it("resolves a function where-clause before calling delete", async () => {
61
+ const stepDef = getStep("unsafeProjectionDelete");
62
+
63
+ const whereFn = vi.fn(() => "dynamic where" as unknown as SQL);
64
+ await stepDef!.run({ table: testTable, where: whereFn }, mockCtx);
65
+
66
+ expect(whereFn).toHaveBeenCalledWith(mockCtx);
67
+ expect(mockDeleteBuilder.where).toHaveBeenCalledWith("dynamic where");
68
+ });
69
+ });
@@ -0,0 +1,117 @@
1
+ import { pgTable, text, uuid } from "drizzle-orm/pg-core";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { getStep } from "../define-step";
4
+ import { buildUnsafeProjectionUpsertStep } from "../steps/unsafe-projection-upsert";
5
+ import type { PipelineCtx } from "../types/step";
6
+
7
+ const testTable = pgTable("test_projection", {
8
+ id: uuid("id").primaryKey().defaultRandom(),
9
+ tenantId: uuid("tenant_id").notNull(),
10
+ externalId: text("external_id").notNull().unique(),
11
+ label: text("label"),
12
+ });
13
+
14
+ const mockConflictBuilder = { onConflictDoUpdate: vi.fn() };
15
+ const mockValuesBuilder = { values: vi.fn(() => mockConflictBuilder) };
16
+ const mockDb = { insert: vi.fn(() => mockValuesBuilder) };
17
+
18
+ const mockCtx = {
19
+ db: mockDb,
20
+ event: { type: "test", payload: {} },
21
+ steps: {},
22
+ scope: {},
23
+ } as unknown as PipelineCtx;
24
+
25
+ describe("buildUnsafeProjectionUpsertStep", () => {
26
+ it("returns a StepInstance with kind unsafeProjectionUpsert", () => {
27
+ const step = buildUnsafeProjectionUpsertStep({
28
+ table: testTable,
29
+ on: ["externalId"],
30
+ row: { tenantId: "t1", externalId: "e1", label: "hello" },
31
+ });
32
+ expect(step.kind).toBe("unsafeProjectionUpsert");
33
+ expect(step.args).toMatchObject({
34
+ table: testTable,
35
+ on: ["externalId"],
36
+ });
37
+ });
38
+
39
+ it("accepts a static row resolver", () => {
40
+ const step = buildUnsafeProjectionUpsertStep({
41
+ table: testTable,
42
+ on: ["externalId"],
43
+ row: { tenantId: "t1", externalId: "e1" },
44
+ });
45
+ expect((step.args as { row: unknown }).row).toEqual({ tenantId: "t1", externalId: "e1" });
46
+ });
47
+
48
+ it("accepts a function row resolver", () => {
49
+ const resolver = vi.fn(() => ({ tenantId: "t1", externalId: "e1" }));
50
+ const step = buildUnsafeProjectionUpsertStep({
51
+ table: testTable,
52
+ on: ["externalId"],
53
+ row: resolver,
54
+ });
55
+ expect(typeof (step.args as { row: unknown }).row).toBe("function");
56
+ });
57
+
58
+ it("accepts multiple conflict key columns", () => {
59
+ const step = buildUnsafeProjectionUpsertStep({
60
+ table: testTable,
61
+ on: ["tenantId", "externalId"],
62
+ row: { tenantId: "t1", externalId: "e1" },
63
+ });
64
+ expect((step.args as { on: string[] }).on).toEqual(["tenantId", "externalId"]);
65
+ });
66
+ });
67
+
68
+ describe("unsafeProjectionUpsert run", () => {
69
+ beforeEach(() => {
70
+ vi.clearAllMocks();
71
+ });
72
+
73
+ it("throws when a conflict-key column does not exist on the table", async () => {
74
+ const stepDef = getStep("unsafeProjectionUpsert");
75
+ expect(stepDef).toBeDefined();
76
+
77
+ await expect(
78
+ stepDef!.run(
79
+ {
80
+ table: testTable,
81
+ on: ["nonExistentColumn"],
82
+ row: { tenantId: "t1" },
83
+ },
84
+ mockCtx,
85
+ ),
86
+ ).rejects.toThrow(/column "nonExistentColumn" not found/);
87
+ });
88
+
89
+ it("builds conflict targets from the `on` keys and excludes them from updateSet", async () => {
90
+ const stepDef = getStep("unsafeProjectionUpsert");
91
+ const row = { tenantId: "t1", externalId: "e1", label: "hello" };
92
+
93
+ await stepDef!.run({ table: testTable, on: ["externalId"], row }, mockCtx);
94
+
95
+ expect(mockDb.insert).toHaveBeenCalledOnce();
96
+ expect(mockConflictBuilder.onConflictDoUpdate).toHaveBeenCalledOnce();
97
+
98
+ const conflictArgs = mockConflictBuilder.onConflictDoUpdate.mock.calls[0]![0]!;
99
+ expect(conflictArgs.set).toEqual({ tenantId: "t1", label: "hello" });
100
+ });
101
+
102
+ it("calls insert().onConflictDoUpdate with the resolved row and conflict targets", async () => {
103
+ const stepDef = getStep("unsafeProjectionUpsert");
104
+
105
+ await stepDef!.run(
106
+ {
107
+ table: testTable,
108
+ on: ["externalId"],
109
+ row: { tenantId: "t1", externalId: "e1", label: "hi" },
110
+ },
111
+ mockCtx,
112
+ );
113
+
114
+ expect(mockDb.insert).toHaveBeenCalled();
115
+ expect(mockConflictBuilder.onConflictDoUpdate).toHaveBeenCalled();
116
+ });
117
+ });
@@ -0,0 +1,135 @@
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 { buildWebhookSendStep } from "../steps/webhook-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: { url: "https://hooks.example/test" } },
15
+ steps: {},
16
+ scope: {},
17
+ } as unknown as PipelineCtx;
18
+
19
+ describe("buildWebhookSendStep", () => {
20
+ it("returns a StepInstance with kind webhook.send", () => {
21
+ const step = buildWebhookSendStep({
22
+ url: "https://hooks.example/test",
23
+ mode: "deferred",
24
+ });
25
+ expect(step.kind).toBe("webhook.send");
26
+ });
27
+
28
+ it("requires mode to be deferred", () => {
29
+ const step = buildWebhookSendStep({
30
+ url: "https://hooks.example/test",
31
+ mode: "deferred",
32
+ });
33
+ expect((step.args as { mode: string }).mode).toBe("deferred");
34
+ });
35
+
36
+ it("accepts optional method, headers, body, auth, retry", () => {
37
+ const step = buildWebhookSendStep({
38
+ url: "https://hooks.example/test",
39
+ method: "PUT",
40
+ headers: { "X-Custom": "val" },
41
+ body: { event: "test" },
42
+ auth: { kind: "bearer", secretRef: "MY_SECRET" },
43
+ retry: { times: 5, backoff: "linear" },
44
+ mode: "deferred",
45
+ });
46
+ expect((step.args as { method: string }).method).toBe("PUT");
47
+ expect((step.args as { retry: { times: number } }).retry.times).toBe(5);
48
+ });
49
+ });
50
+
51
+ describe("webhook.send run", () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ });
55
+
56
+ it("appends a step.dispatch-requested system event with the webhook spec", async () => {
57
+ const stepDef = getStep("webhook.send");
58
+ expect(stepDef).toBeDefined();
59
+
60
+ await stepDef!.run(
61
+ {
62
+ url: "https://hooks.example/test",
63
+ mode: "deferred",
64
+ method: "POST",
65
+ body: { event: "incident-opened", id: "abc" },
66
+ },
67
+ mockCtx,
68
+ );
69
+
70
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
71
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
72
+
73
+ expect(eventArg.aggregateType).toBe(STEP_DISPATCH_AGGREGATE_TYPE);
74
+ expect(eventArg.type).toBe(STEP_DISPATCH_REQUESTED_TYPE);
75
+ expect(eventArg.payload.stepKind).toBe("webhook.send");
76
+ expect(eventArg.payload.spec.url).toBe("https://hooks.example/test");
77
+ expect(eventArg.payload.spec.body).toEqual({ event: "incident-opened", id: "abc" });
78
+ });
79
+
80
+ it("resolves function-based url and body resolvers", async () => {
81
+ const stepDef = getStep("webhook.send");
82
+ const urlFn = vi.fn(() => "https://hooks.example/dynamic");
83
+ const bodyFn = vi.fn(() => ({ key: "value" }));
84
+
85
+ await stepDef!.run(
86
+ {
87
+ url: urlFn,
88
+ mode: "deferred",
89
+ body: bodyFn,
90
+ },
91
+ mockCtx,
92
+ );
93
+
94
+ expect(urlFn).toHaveBeenCalledWith(mockCtx);
95
+ expect(bodyFn).toHaveBeenCalledWith(mockCtx);
96
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ payload: expect.objectContaining({
99
+ spec: expect.objectContaining({
100
+ url: "https://hooks.example/dynamic",
101
+ body: { key: "value" },
102
+ }),
103
+ }),
104
+ }),
105
+ );
106
+ });
107
+
108
+ it("defaults method to POST when not specified", async () => {
109
+ const stepDef = getStep("webhook.send");
110
+
111
+ await stepDef!.run({ url: "https://hooks.example/test", mode: "deferred" }, mockCtx);
112
+
113
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
114
+ expect(eventArg.payload.spec.method).toBe("POST");
115
+ });
116
+
117
+ it("defaults retry to 3x exponential when not specified", async () => {
118
+ const stepDef = getStep("webhook.send");
119
+
120
+ await stepDef!.run({ url: "https://hooks.example/test", mode: "deferred" }, mockCtx);
121
+
122
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
123
+ expect(eventArg.payload.retry).toEqual({ times: 3, backoff: "exponential" });
124
+ });
125
+
126
+ it("passes auth config through when provided", async () => {
127
+ const stepDef = getStep("webhook.send");
128
+ const auth = { kind: "bearer" as const, secretRef: "WEBHOOK_TOKEN" };
129
+
130
+ await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
131
+
132
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
133
+ expect(eventArg.payload.spec.auth).toEqual(auth);
134
+ });
135
+ });