dopespec 0.0.1

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 (179) hide show
  1. package/README.md +195 -0
  2. package/bin/dopespec.mjs +26 -0
  3. package/dist/__tests__/codegen.test.d.ts +2 -0
  4. package/dist/__tests__/codegen.test.d.ts.map +1 -0
  5. package/dist/__tests__/codegen.test.js +713 -0
  6. package/dist/__tests__/codegen.test.js.map +1 -0
  7. package/dist/__tests__/e2e-proof.test.d.ts +2 -0
  8. package/dist/__tests__/e2e-proof.test.d.ts.map +1 -0
  9. package/dist/__tests__/e2e-proof.test.js +153 -0
  10. package/dist/__tests__/e2e-proof.test.js.map +1 -0
  11. package/dist/__tests__/schema.test.d.ts +2 -0
  12. package/dist/__tests__/schema.test.d.ts.map +1 -0
  13. package/dist/__tests__/schema.test.js +822 -0
  14. package/dist/__tests__/schema.test.js.map +1 -0
  15. package/dist/__tests__/type-errors.d.ts +2 -0
  16. package/dist/__tests__/type-errors.d.ts.map +1 -0
  17. package/dist/__tests__/type-errors.js +128 -0
  18. package/dist/__tests__/type-errors.js.map +1 -0
  19. package/dist/cli/generate.d.ts +2 -0
  20. package/dist/cli/generate.d.ts.map +1 -0
  21. package/dist/cli/generate.js +250 -0
  22. package/dist/cli/generate.js.map +1 -0
  23. package/dist/codegen/commands.d.ts +4 -0
  24. package/dist/codegen/commands.d.ts.map +1 -0
  25. package/dist/codegen/commands.js +25 -0
  26. package/dist/codegen/commands.js.map +1 -0
  27. package/dist/codegen/decisions-evaluate.d.ts +3 -0
  28. package/dist/codegen/decisions-evaluate.d.ts.map +1 -0
  29. package/dist/codegen/decisions-evaluate.js +50 -0
  30. package/dist/codegen/decisions-evaluate.js.map +1 -0
  31. package/dist/codegen/decisions-table.d.ts +3 -0
  32. package/dist/codegen/decisions-table.d.ts.map +1 -0
  33. package/dist/codegen/decisions-table.js +27 -0
  34. package/dist/codegen/decisions-table.js.map +1 -0
  35. package/dist/codegen/decisions-tests.d.ts +3 -0
  36. package/dist/codegen/decisions-tests.d.ts.map +1 -0
  37. package/dist/codegen/decisions-tests.js +43 -0
  38. package/dist/codegen/decisions-tests.js.map +1 -0
  39. package/dist/codegen/e2e-stubs.d.ts +4 -0
  40. package/dist/codegen/e2e-stubs.d.ts.map +1 -0
  41. package/dist/codegen/e2e-stubs.js +21 -0
  42. package/dist/codegen/e2e-stubs.js.map +1 -0
  43. package/dist/codegen/events.d.ts +8 -0
  44. package/dist/codegen/events.d.ts.map +1 -0
  45. package/dist/codegen/events.js +36 -0
  46. package/dist/codegen/events.js.map +1 -0
  47. package/dist/codegen/index.d.ts +18 -0
  48. package/dist/codegen/index.d.ts.map +1 -0
  49. package/dist/codegen/index.js +18 -0
  50. package/dist/codegen/index.js.map +1 -0
  51. package/dist/codegen/invariants.d.ts +4 -0
  52. package/dist/codegen/invariants.d.ts.map +1 -0
  53. package/dist/codegen/invariants.js +53 -0
  54. package/dist/codegen/invariants.js.map +1 -0
  55. package/dist/codegen/mermaid.d.ts +4 -0
  56. package/dist/codegen/mermaid.d.ts.map +1 -0
  57. package/dist/codegen/mermaid.js +21 -0
  58. package/dist/codegen/mermaid.js.map +1 -0
  59. package/dist/codegen/orchestrators.d.ts +7 -0
  60. package/dist/codegen/orchestrators.d.ts.map +1 -0
  61. package/dist/codegen/orchestrators.js +32 -0
  62. package/dist/codegen/orchestrators.js.map +1 -0
  63. package/dist/codegen/policy-index.d.ts +7 -0
  64. package/dist/codegen/policy-index.d.ts.map +1 -0
  65. package/dist/codegen/policy-index.js +40 -0
  66. package/dist/codegen/policy-index.js.map +1 -0
  67. package/dist/codegen/policy-mermaid.d.ts +7 -0
  68. package/dist/codegen/policy-mermaid.d.ts.map +1 -0
  69. package/dist/codegen/policy-mermaid.js +30 -0
  70. package/dist/codegen/policy-mermaid.js.map +1 -0
  71. package/dist/codegen/policy-tests.d.ts +8 -0
  72. package/dist/codegen/policy-tests.d.ts.map +1 -0
  73. package/dist/codegen/policy-tests.js +167 -0
  74. package/dist/codegen/policy-tests.js.map +1 -0
  75. package/dist/codegen/policy-validator.d.ts +8 -0
  76. package/dist/codegen/policy-validator.d.ts.map +1 -0
  77. package/dist/codegen/policy-validator.js +69 -0
  78. package/dist/codegen/policy-validator.js.map +1 -0
  79. package/dist/codegen/tests.d.ts +4 -0
  80. package/dist/codegen/tests.d.ts.map +1 -0
  81. package/dist/codegen/tests.js +125 -0
  82. package/dist/codegen/tests.js.map +1 -0
  83. package/dist/codegen/transitions.d.ts +4 -0
  84. package/dist/codegen/transitions.d.ts.map +1 -0
  85. package/dist/codegen/transitions.js +41 -0
  86. package/dist/codegen/transitions.js.map +1 -0
  87. package/dist/codegen/types.d.ts +4 -0
  88. package/dist/codegen/types.d.ts.map +1 -0
  89. package/dist/codegen/types.js +48 -0
  90. package/dist/codegen/types.js.map +1 -0
  91. package/dist/codegen/utils.d.ts +85 -0
  92. package/dist/codegen/utils.d.ts.map +1 -0
  93. package/dist/codegen/utils.js +357 -0
  94. package/dist/codegen/utils.js.map +1 -0
  95. package/dist/codegen/zod.d.ts +4 -0
  96. package/dist/codegen/zod.d.ts.map +1 -0
  97. package/dist/codegen/zod.js +32 -0
  98. package/dist/codegen/zod.js.map +1 -0
  99. package/dist/examples/generated/customer.types.d.ts +7 -0
  100. package/dist/examples/generated/customer.types.d.ts.map +1 -0
  101. package/dist/examples/generated/customer.types.js +2 -0
  102. package/dist/examples/generated/customer.types.js.map +1 -0
  103. package/dist/examples/generated/order.types.d.ts +9 -0
  104. package/dist/examples/generated/order.types.d.ts.map +1 -0
  105. package/dist/examples/generated/order.types.js +2 -0
  106. package/dist/examples/generated/order.types.js.map +1 -0
  107. package/dist/examples/generated/pet.types.d.ts +11 -0
  108. package/dist/examples/generated/pet.types.d.ts.map +1 -0
  109. package/dist/examples/generated/pet.types.js +2 -0
  110. package/dist/examples/generated/pet.types.js.map +1 -0
  111. package/dist/examples/pet-store.d.ts +137 -0
  112. package/dist/examples/pet-store.d.ts.map +1 -0
  113. package/dist/examples/pet-store.js +139 -0
  114. package/dist/examples/pet-store.js.map +1 -0
  115. package/dist/examples/src/customer.e2e.d.ts +2 -0
  116. package/dist/examples/src/customer.e2e.d.ts.map +1 -0
  117. package/dist/examples/src/customer.e2e.js +17 -0
  118. package/dist/examples/src/customer.e2e.js.map +1 -0
  119. package/dist/examples/src/customer.orchestrators.d.ts +5 -0
  120. package/dist/examples/src/customer.orchestrators.d.ts.map +1 -0
  121. package/dist/examples/src/customer.orchestrators.js +5 -0
  122. package/dist/examples/src/customer.orchestrators.js.map +1 -0
  123. package/dist/examples/src/order.e2e.d.ts +2 -0
  124. package/dist/examples/src/order.e2e.d.ts.map +1 -0
  125. package/dist/examples/src/order.e2e.js +22 -0
  126. package/dist/examples/src/order.e2e.js.map +1 -0
  127. package/dist/examples/src/order.orchestrators.d.ts +9 -0
  128. package/dist/examples/src/order.orchestrators.d.ts.map +1 -0
  129. package/dist/examples/src/order.orchestrators.js +10 -0
  130. package/dist/examples/src/order.orchestrators.js.map +1 -0
  131. package/dist/examples/src/pet.e2e.d.ts +2 -0
  132. package/dist/examples/src/pet.e2e.d.ts.map +1 -0
  133. package/dist/examples/src/pet.e2e.js +17 -0
  134. package/dist/examples/src/pet.e2e.js.map +1 -0
  135. package/dist/examples/src/pet.orchestrators.d.ts +8 -0
  136. package/dist/examples/src/pet.orchestrators.d.ts.map +1 -0
  137. package/dist/examples/src/pet.orchestrators.js +9 -0
  138. package/dist/examples/src/pet.orchestrators.js.map +1 -0
  139. package/dist/schema/actions.d.ts +37 -0
  140. package/dist/schema/actions.d.ts.map +1 -0
  141. package/dist/schema/actions.js +35 -0
  142. package/dist/schema/actions.js.map +1 -0
  143. package/dist/schema/constraints.d.ts +20 -0
  144. package/dist/schema/constraints.d.ts.map +1 -0
  145. package/dist/schema/constraints.js +15 -0
  146. package/dist/schema/constraints.js.map +1 -0
  147. package/dist/schema/decisions.d.ts +29 -0
  148. package/dist/schema/decisions.d.ts.map +1 -0
  149. package/dist/schema/decisions.js +56 -0
  150. package/dist/schema/decisions.js.map +1 -0
  151. package/dist/schema/index.d.ts +17 -0
  152. package/dist/schema/index.d.ts.map +1 -0
  153. package/dist/schema/index.js +8 -0
  154. package/dist/schema/index.js.map +1 -0
  155. package/dist/schema/model.d.ts +65 -0
  156. package/dist/schema/model.d.ts.map +1 -0
  157. package/dist/schema/model.js +30 -0
  158. package/dist/schema/model.js.map +1 -0
  159. package/dist/schema/policy.d.ts +25 -0
  160. package/dist/schema/policy.d.ts.map +1 -0
  161. package/dist/schema/policy.js +53 -0
  162. package/dist/schema/policy.js.map +1 -0
  163. package/dist/schema/props.d.ts +57 -0
  164. package/dist/schema/props.d.ts.map +1 -0
  165. package/dist/schema/props.js +53 -0
  166. package/dist/schema/props.js.map +1 -0
  167. package/dist/schema/relations.d.ts +9 -0
  168. package/dist/schema/relations.d.ts.map +1 -0
  169. package/dist/schema/relations.js +9 -0
  170. package/dist/schema/relations.js.map +1 -0
  171. package/dist/schema/transitions.d.ts +34 -0
  172. package/dist/schema/transitions.d.ts.map +1 -0
  173. package/dist/schema/transitions.js +25 -0
  174. package/dist/schema/transitions.js.map +1 -0
  175. package/dist/schema/types.d.ts +18 -0
  176. package/dist/schema/types.d.ts.map +1 -0
  177. package/dist/schema/types.js +11 -0
  178. package/dist/schema/types.js.map +1 -0
  179. package/package.json +55 -0
@@ -0,0 +1,822 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { action } from "../schema/actions.js";
3
+ import { rule } from "../schema/constraints.js";
4
+ import { decisions } from "../schema/decisions.js";
5
+ import { model } from "../schema/model.js";
6
+ import { policy } from "../schema/policy.js";
7
+ import { boolean, date, lifecycle, number, oneOf, optional, OPTIONAL_BRAND, string, } from "../schema/props.js";
8
+ import { belongsTo, hasMany } from "../schema/relations.js";
9
+ import { from } from "../schema/transitions.js";
10
+ import { ref } from "../schema/types.js";
11
+ describe("props", () => {
12
+ it("string() returns string prop", () => {
13
+ expect(string()).toEqual({ kind: "string", values: null });
14
+ });
15
+ it("number() returns number prop", () => {
16
+ expect(number()).toEqual({ kind: "number", values: null });
17
+ });
18
+ it("boolean() returns boolean prop", () => {
19
+ expect(boolean()).toEqual({ kind: "boolean", values: null });
20
+ });
21
+ it("date() returns date prop", () => {
22
+ expect(date()).toEqual({ kind: "date", values: null });
23
+ });
24
+ it("oneOf() captures values", () => {
25
+ const states = ["a", "b", "c"];
26
+ const prop = oneOf(states);
27
+ expect(prop.kind).toBe("oneOf");
28
+ expect(prop.values).toEqual(["a", "b", "c"]);
29
+ });
30
+ // as const arrays tested for backward compat; lifecycle.states() is the preferred API
31
+ it("lifecycle() captures values", () => {
32
+ const states = ["pending", "done"];
33
+ const prop = lifecycle(states);
34
+ expect(prop.kind).toBe("lifecycle");
35
+ expect(prop.values).toEqual(["pending", "done"]);
36
+ });
37
+ it("lifecycle.states() creates named state object", () => {
38
+ const states = lifecycle.states("pending", "paid", "shipped");
39
+ expect(states.pending).toBe("pending");
40
+ expect(states.paid).toBe("paid");
41
+ expect(states.shipped).toBe("shipped");
42
+ });
43
+ it("lifecycle.states() throws on duplicate names", () => {
44
+ expect(() => lifecycle.states("a", "b", "a")).toThrow("lifecycle.states() received duplicate name 'a'");
45
+ });
46
+ it("lifecycle.states() result is frozen", () => {
47
+ const states = lifecycle.states("a", "b");
48
+ expect(Object.isFrozen(states)).toBe(true);
49
+ });
50
+ it("lifecycle() accepts lifecycle.states() result", () => {
51
+ const states = lifecycle.states("pending", "done");
52
+ const prop = lifecycle(states);
53
+ expect(prop.kind).toBe("lifecycle");
54
+ expect(prop.values).toEqual(["pending", "done"]);
55
+ });
56
+ it("optional() wraps a prop with optional flag", () => {
57
+ const prop = optional(string());
58
+ expect(prop.kind).toBe("string");
59
+ expect(prop.values).toBe(null);
60
+ expect(OPTIONAL_BRAND in prop).toBe(true);
61
+ });
62
+ it("optional() preserves oneOf values", () => {
63
+ const prop = optional(oneOf(["a", "b"]));
64
+ expect(prop.kind).toBe("oneOf");
65
+ expect(prop.values).toEqual(["a", "b"]);
66
+ expect(OPTIONAL_BRAND in prop).toBe(true);
67
+ });
68
+ it("optional() preserves number prop", () => {
69
+ const prop = optional(number());
70
+ expect(prop.kind).toBe("number");
71
+ expect(OPTIONAL_BRAND in prop).toBe(true);
72
+ });
73
+ it("optional() preserves boolean prop", () => {
74
+ const prop = optional(boolean());
75
+ expect(prop.kind).toBe("boolean");
76
+ expect(prop.values).toBe(null);
77
+ expect(OPTIONAL_BRAND in prop).toBe(true);
78
+ });
79
+ it("optional() preserves date prop", () => {
80
+ const prop = optional(date());
81
+ expect(prop.kind).toBe("date");
82
+ expect(prop.values).toBe(null);
83
+ expect(OPTIONAL_BRAND in prop).toBe(true);
84
+ });
85
+ it("optional(lifecycle()) is a compile-time error", () => {
86
+ const states = lifecycle.states("a", "b");
87
+ // @ts-expect-error — lifecycle props cannot be optional
88
+ expect(() => optional(lifecycle(states))).toThrow("lifecycle props cannot be optional");
89
+ });
90
+ it("optional(lifecycle() as any) throws at runtime", () => {
91
+ const states = lifecycle.states("a", "b");
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- testing runtime guard
93
+ expect(() => optional(lifecycle(states))).toThrow("lifecycle props cannot be optional");
94
+ });
95
+ it("InferContext makes optional props optional and required props required", () => {
96
+ // Required keys are present
97
+ const _checkRequired = { age: 1, name: "a" };
98
+ // Optional key is accepted
99
+ const _checkOptional = { age: 1, name: "a", phone: "555" };
100
+ // Suppress unused warnings
101
+ expect(_checkRequired).toBeDefined();
102
+ expect(_checkOptional).toBeDefined();
103
+ });
104
+ });
105
+ describe("transitions (standalone)", () => {
106
+ it("from().to() creates transition with correct states", () => {
107
+ const t = from("pending").to("paid");
108
+ expect(t.kind).toBe("transition");
109
+ expect(t.from).toBe("pending");
110
+ expect(t.to).toBe("paid");
111
+ expect(t.guard).toBeNull();
112
+ expect(t.scenarios).toEqual([]);
113
+ });
114
+ it(".when() stores guard function", () => {
115
+ const guard = () => true;
116
+ const t = from("pending").to("paid").when(guard);
117
+ expect(t.guard).toBe(guard);
118
+ });
119
+ it(".scenario() appends to scenarios", () => {
120
+ const t = from("pending")
121
+ .to("paid")
122
+ .scenario({ total: 100 }, "paid")
123
+ .scenario({ total: 0 }, "pending");
124
+ expect(t.scenarios).toHaveLength(2);
125
+ expect(t.scenarios[0]).toEqual({ expected: "paid", given: { total: 100 } });
126
+ expect(t.scenarios[1]).toEqual({
127
+ expected: "pending",
128
+ given: { total: 0 },
129
+ });
130
+ });
131
+ it("chaining is immutable", () => {
132
+ const t1 = from("a").to("b");
133
+ const t2 = t1.when(() => true);
134
+ const t3 = t1.scenario({ x: 1 }, "b");
135
+ expect(t1.guard).toBeNull();
136
+ expect(t1.scenarios).toHaveLength(0);
137
+ expect(t2.guard).not.toBeNull();
138
+ expect(t3.scenarios).toHaveLength(1);
139
+ });
140
+ it(".when().scenario() together", () => {
141
+ const guard = () => true;
142
+ const t = from("a").to("b").when(guard).scenario({ x: 1 }, "b");
143
+ expect(t.guard).toBe(guard);
144
+ expect(t.scenarios).toHaveLength(1);
145
+ });
146
+ it(".scenario().when() — reverse order", () => {
147
+ const guard = () => false;
148
+ const t = from("a").to("b").scenario({ x: 1 }, "b").when(guard);
149
+ expect(t.guard).toBe(guard);
150
+ expect(t.scenarios).toHaveLength(1);
151
+ });
152
+ it("multiple .when() — last wins", () => {
153
+ const first = () => true;
154
+ const second = () => false;
155
+ const t = from("a").to("b").when(first).when(second);
156
+ expect(t.guard).toBe(second);
157
+ });
158
+ it("multiple .scenario() — accumulates", () => {
159
+ const t = from("a")
160
+ .to("b")
161
+ .scenario({ x: 1 }, "a")
162
+ .scenario({ x: 2 }, "b")
163
+ .scenario({ x: 3 }, "a");
164
+ expect(t.scenarios).toHaveLength(3);
165
+ expect(t.scenarios[0]?.expected).toBe("a");
166
+ expect(t.scenarios[1]?.expected).toBe("b");
167
+ expect(t.scenarios[2]?.expected).toBe("a");
168
+ });
169
+ });
170
+ describe("constraints (standalone)", () => {
171
+ it("rule() creates empty constraint", () => {
172
+ const c = rule();
173
+ expect(c.kind).toBe("constraint");
174
+ expect(c.guard).toBeNull();
175
+ expect(c.preventedAction).toBeNull();
176
+ });
177
+ it(".when().prevent() stores guard and action", () => {
178
+ const guard = () => true;
179
+ const c = rule().when(guard).prevent("pay");
180
+ expect(c.guard).toBe(guard);
181
+ expect(c.preventedAction).toBe("pay");
182
+ });
183
+ it("chaining is immutable", () => {
184
+ const c1 = rule();
185
+ const c2 = c1.when(() => true);
186
+ const c3 = c1.prevent("x");
187
+ expect(c1.guard).toBeNull();
188
+ expect(c1.preventedAction).toBeNull();
189
+ expect(c2.guard).not.toBeNull();
190
+ expect(c3.preventedAction).toBe("x");
191
+ });
192
+ it(".prevent().when() — reverse order", () => {
193
+ const guard = () => true;
194
+ const c = rule().prevent("x").when(guard);
195
+ expect(c.preventedAction).toBe("x");
196
+ expect(c.guard).toBe(guard);
197
+ });
198
+ it("multiple .prevent() — last wins", () => {
199
+ const c = rule().prevent("first").prevent("second");
200
+ expect(c.preventedAction).toBe("second");
201
+ });
202
+ it("multiple .when() — last wins", () => {
203
+ const first = () => true;
204
+ const second = () => false;
205
+ const c = rule().when(first).when(second);
206
+ expect(c.guard).toBe(second);
207
+ });
208
+ });
209
+ describe("guard execution", () => {
210
+ it("transition guard can be called and returns expected value", () => {
211
+ const states = ["off", "on"];
212
+ const m = model("Light", {
213
+ props: { brightness: number(), status: lifecycle(states) },
214
+ transitions: ({ from }) => ({
215
+ turnOn: from(states[0])
216
+ .to(states[1])
217
+ .when((ctx) => ctx.brightness > 0),
218
+ }),
219
+ });
220
+ const guard = m.transitions?.turnOn.guard;
221
+ expect(guard).not.toBeNull();
222
+ expect(guard?.({ brightness: 100, status: "off" })).toBe(true);
223
+ expect(guard?.({ brightness: 0, status: "off" })).toBe(false);
224
+ });
225
+ it("constraint guard can be called and returns expected value", () => {
226
+ const states = ["active", "inactive"];
227
+ const m = model("Switch", {
228
+ actions: { toggle: action() },
229
+ constraints: ({ rule }) => ({
230
+ noToggleWhenLocked: rule()
231
+ .when((ctx) => ctx.locked === true)
232
+ .prevent("toggle"),
233
+ }),
234
+ props: { locked: boolean(), status: lifecycle(states) },
235
+ });
236
+ const guard = m.constraints?.noToggleWhenLocked.guard;
237
+ expect(guard).not.toBeNull();
238
+ expect(guard?.({ locked: true, status: "active" })).toBe(true);
239
+ expect(guard?.({ locked: false, status: "active" })).toBe(false);
240
+ });
241
+ it("guard on optional prop handles undefined and present values", () => {
242
+ const states = ["draft", "published"];
243
+ const m = model("Article", {
244
+ actions: { publish: action() },
245
+ constraints: ({ rule }) => ({
246
+ subtitleTooLong: rule()
247
+ .when((ctx) => ctx.subtitle !== undefined && ctx.subtitle.length > 50)
248
+ .prevent("publish"),
249
+ }),
250
+ props: {
251
+ status: lifecycle(states),
252
+ subtitle: optional(string()),
253
+ title: string(),
254
+ },
255
+ });
256
+ const guard = m.constraints?.subtitleTooLong.guard;
257
+ expect(guard).not.toBeNull();
258
+ // undefined — guard does not fire
259
+ expect(guard?.({ status: "draft", title: "hi" })).toBe(false);
260
+ // short subtitle — guard does not fire
261
+ expect(guard?.({ status: "draft", subtitle: "short", title: "hi" })).toBe(false);
262
+ // long subtitle — guard fires
263
+ expect(guard?.({
264
+ status: "draft",
265
+ subtitle: "a".repeat(51),
266
+ title: "hi",
267
+ })).toBe(true);
268
+ });
269
+ });
270
+ describe("actions", () => {
271
+ it("action() returns action def", () => {
272
+ const a = action();
273
+ expect(a.kind).toBe("action");
274
+ });
275
+ });
276
+ describe("relations", () => {
277
+ it("hasMany() returns hasMany relation with model ref", () => {
278
+ const target = model("Other", {});
279
+ const r = hasMany(target);
280
+ expect(r.kind).toBe("hasMany");
281
+ expect(r.target.name).toBe("Other");
282
+ });
283
+ it("belongsTo() returns belongsTo relation with model ref", () => {
284
+ const target = model("Other", {});
285
+ const r = belongsTo(target);
286
+ expect(r.kind).toBe("belongsTo");
287
+ expect(r.target.name).toBe("Other");
288
+ });
289
+ it("ref() creates a forward reference", () => {
290
+ const r = hasMany(ref("FutureModel"));
291
+ expect(r.kind).toBe("hasMany");
292
+ expect(r.target.name).toBe("FutureModel");
293
+ });
294
+ it("ref() trims whitespace", () => {
295
+ const r = ref(" Padded ");
296
+ expect(r.name).toBe("Padded");
297
+ });
298
+ it('ref("") throws', () => {
299
+ expect(() => ref("")).toThrow("ref() requires a non-empty model name");
300
+ });
301
+ it('ref(" ") throws for whitespace-only', () => {
302
+ expect(() => ref(" ")).toThrow("ref() requires a non-empty model name");
303
+ });
304
+ });
305
+ describe("model", () => {
306
+ it("assembles all fields with typed callbacks", () => {
307
+ const states = ["pending", "paid"];
308
+ const m = model("Order", {
309
+ actions: {
310
+ addItem: action(),
311
+ },
312
+ constraints: ({ rule }) => ({
313
+ noop: rule()
314
+ .when((ctx) => ctx.total === 0)
315
+ .prevent("addItem"),
316
+ }),
317
+ props: {
318
+ status: lifecycle(states),
319
+ total: number(),
320
+ },
321
+ transitions: ({ from }) => ({
322
+ pay: from(states[0])
323
+ .to(states[1])
324
+ .when((ctx) => ctx.total > 0)
325
+ .scenario({ total: 100 }, states[1])
326
+ .scenario({ total: 0 }, states[0]),
327
+ }),
328
+ });
329
+ expect(m.kind).toBe("model");
330
+ expect(m.name).toBe("Order");
331
+ expect(m.props?.total.kind).toBe("number");
332
+ expect(m.props?.status.kind).toBe("lifecycle");
333
+ expect(m.transitions?.pay.from).toBe("pending");
334
+ expect(m.transitions?.pay.to).toBe("paid");
335
+ expect(m.transitions?.pay.scenarios).toHaveLength(2);
336
+ expect(m.constraints?.noop.preventedAction).toBe("addItem");
337
+ expect(m.actions?.addItem.kind).toBe("action");
338
+ });
339
+ it("works with relations", () => {
340
+ const target = model("Item", {});
341
+ const m = model("Order", {
342
+ relations: {
343
+ items: hasMany(target),
344
+ parent: belongsTo(target),
345
+ },
346
+ });
347
+ expect(m.relations?.items.kind).toBe("hasMany");
348
+ expect(m.relations?.items.target.name).toBe("Item");
349
+ expect(m.relations?.parent.kind).toBe("belongsTo");
350
+ });
351
+ it("works with minimal config", () => {
352
+ const m = model("Empty", {});
353
+ expect(m.kind).toBe("model");
354
+ expect(m.name).toBe("Empty");
355
+ });
356
+ it("trims model name", () => {
357
+ const m = model(" Padded ", {});
358
+ expect(m.name).toBe("Padded");
359
+ });
360
+ it('model("", {}) throws', () => {
361
+ expect(() => model("", {})).toThrow("model() requires a non-empty name");
362
+ });
363
+ it('model(" ", {}) throws for whitespace-only', () => {
364
+ expect(() => model(" ", {})).toThrow("model() requires a non-empty name");
365
+ });
366
+ it("typed from() constrains states from lifecycle()", () => {
367
+ const states = ["a", "b"];
368
+ const m = model("Test", {
369
+ props: { status: lifecycle(states) },
370
+ transitions: ({ from }) => ({
371
+ move: from(states[0]).to(states[1]),
372
+ }),
373
+ });
374
+ expect(m.transitions?.move.from).toBe("a");
375
+ expect(m.transitions?.move.to).toBe("b");
376
+ });
377
+ it("typed from() works with lifecycle.states() named references", () => {
378
+ const states = lifecycle.states("pending", "paid", "shipped");
379
+ const m = model("Order", {
380
+ props: { status: lifecycle(states), total: number() },
381
+ transitions: ({ from }) => ({
382
+ pay: from(states.pending)
383
+ .to(states.paid)
384
+ .when((ctx) => ctx.total > 0)
385
+ .scenario({ total: 100 }, states.paid)
386
+ .scenario({ total: 0 }, states.pending),
387
+ ship: from(states.paid).to(states.shipped),
388
+ }),
389
+ });
390
+ expect(m.transitions?.pay.from).toBe("pending");
391
+ expect(m.transitions?.pay.to).toBe("paid");
392
+ expect(m.transitions?.ship.from).toBe("paid");
393
+ expect(m.transitions?.ship.to).toBe("shipped");
394
+ expect(m.transitions?.pay.scenarios).toHaveLength(2);
395
+ });
396
+ it("typed prevent constrains to action keys", () => {
397
+ const states = ["open", "closed"];
398
+ const m = model("Gate", {
399
+ actions: {
400
+ lock: action(),
401
+ unlock: action(),
402
+ },
403
+ constraints: ({ rule }) => ({
404
+ noLockWhenOpen: rule()
405
+ .when((ctx) => ctx.status === states[0])
406
+ .prevent("lock"),
407
+ }),
408
+ props: { status: lifecycle(states) },
409
+ });
410
+ expect(m.constraints?.noLockWhenOpen.preventedAction).toBe("lock");
411
+ });
412
+ });
413
+ describe("scenario given: partial vs full", () => {
414
+ it("accepts partial given (subset of props)", () => {
415
+ const states = ["off", "on"];
416
+ const m = model("Device", {
417
+ props: {
418
+ label: string(),
419
+ status: lifecycle(states),
420
+ voltage: number(),
421
+ },
422
+ transitions: ({ from }) => ({
423
+ turnOn: from(states[0])
424
+ .to(states[1])
425
+ .scenario({ voltage: 120 }, states[1]),
426
+ }),
427
+ });
428
+ expect(m.transitions?.turnOn.scenarios[0]).toEqual({
429
+ expected: "on",
430
+ given: { voltage: 120 },
431
+ });
432
+ });
433
+ it("accepts full given (all props specified)", () => {
434
+ const states = ["off", "on"];
435
+ const m = model("Device", {
436
+ props: {
437
+ label: string(),
438
+ status: lifecycle(states),
439
+ voltage: number(),
440
+ },
441
+ transitions: ({ from }) => ({
442
+ turnOn: from(states[0])
443
+ .to(states[1])
444
+ .scenario({ label: "main", status: states[1], voltage: 120 }, states[1]),
445
+ }),
446
+ });
447
+ expect(m.transitions?.turnOn.scenarios[0]?.given).toEqual({
448
+ label: "main",
449
+ status: "on",
450
+ voltage: 120,
451
+ });
452
+ });
453
+ it("accepts empty given {}", () => {
454
+ const states = ["a", "b"];
455
+ const m = model("Minimal", {
456
+ props: { status: lifecycle(states) },
457
+ transitions: ({ from }) => ({
458
+ go: from(states[0]).to(states[1]).scenario({}, states[1]),
459
+ }),
460
+ });
461
+ expect(m.transitions?.go.scenarios[0]?.given).toEqual({});
462
+ });
463
+ });
464
+ describe("edge cases", () => {
465
+ it("oneOf with single value", () => {
466
+ const prop = oneOf(["only"]);
467
+ expect(prop.values).toEqual(["only"]);
468
+ });
469
+ it("lifecycle with single value", () => {
470
+ const states = ["sole"];
471
+ const m = model("Single", {
472
+ props: { status: lifecycle(states) },
473
+ transitions: ({ from }) => ({
474
+ loop: from(states[0]).to(states[0]),
475
+ }),
476
+ });
477
+ expect(m.transitions?.loop.from).toBe("sole");
478
+ expect(m.transitions?.loop.to).toBe("sole");
479
+ });
480
+ it("model with multiple oneOf props — they do not leak into lifecycle states", () => {
481
+ const states = ["active", "inactive"];
482
+ const priorities = ["low", "medium", "high"];
483
+ const m = model("Task", {
484
+ props: {
485
+ priority: oneOf(priorities),
486
+ status: lifecycle(states),
487
+ },
488
+ transitions: ({ from }) => ({
489
+ deactivate: from(states[0]).to(states[1]),
490
+ }),
491
+ });
492
+ // Only lifecycle values are valid — oneOf 'low'/'medium'/'high' cannot be used in from/to
493
+ expect(m.transitions?.deactivate.from).toBe("active");
494
+ expect(m.transitions?.deactivate.to).toBe("inactive");
495
+ });
496
+ });
497
+ // --- decisions() ---
498
+ describe("decisions", () => {
499
+ it("creates a valid DecisionDef", () => {
500
+ const d = decisions("Tier", {
501
+ inputs: { score: number() },
502
+ outputs: { level: string() },
503
+ rules: [{ then: { level: "gold" }, when: { score: 100 } }],
504
+ });
505
+ expect(d.kind).toBe("decision");
506
+ expect(d.name).toBe("Tier");
507
+ expect(d.inputs.score.kind).toBe("number");
508
+ expect(d.outputs.level.kind).toBe("string");
509
+ expect(d.rules).toHaveLength(1);
510
+ expect(d.rules[0]?.when).toEqual({ score: 100 });
511
+ expect(d.rules[0]?.then).toEqual({ level: "gold" });
512
+ });
513
+ it("supports multiple rules", () => {
514
+ const d = decisions("Discount", {
515
+ inputs: { tier: string() },
516
+ outputs: { percent: number() },
517
+ rules: [
518
+ { then: { percent: 10 }, when: { tier: "silver" } },
519
+ { then: { percent: 20 }, when: { tier: "gold" } },
520
+ { then: { percent: 30 }, when: { tier: "platinum" } },
521
+ ],
522
+ });
523
+ expect(d.rules).toHaveLength(3);
524
+ });
525
+ it("supports partial when (not all inputs matched)", () => {
526
+ const d = decisions("Route", {
527
+ inputs: { method: string(), path: string() },
528
+ outputs: { handler: string() },
529
+ rules: [{ then: { handler: "healthCheck" }, when: { path: "/health" } }],
530
+ });
531
+ expect(d.rules[0]?.when).toEqual({ path: "/health" });
532
+ });
533
+ it("supports empty when (catch-all)", () => {
534
+ const d = decisions("Default", {
535
+ inputs: { x: number() },
536
+ outputs: { y: number() },
537
+ rules: [{ then: { y: 0 }, when: {} }],
538
+ });
539
+ expect(d.rules[0]?.when).toEqual({});
540
+ });
541
+ it("supports oneOf inputs", () => {
542
+ const d = decisions("Access", {
543
+ inputs: { role: oneOf(["admin", "user"]) },
544
+ outputs: { canDelete: boolean() },
545
+ rules: [
546
+ { then: { canDelete: true }, when: { role: "admin" } },
547
+ { then: { canDelete: false }, when: { role: "user" } },
548
+ ],
549
+ });
550
+ expect(d.rules).toHaveLength(2);
551
+ });
552
+ it("supports multiple outputs", () => {
553
+ const d = decisions("Pricing", {
554
+ inputs: { plan: string() },
555
+ outputs: { maxUsers: number(), price: number() },
556
+ rules: [
557
+ { then: { maxUsers: 5, price: 0 }, when: { plan: "free" } },
558
+ { then: { maxUsers: 100, price: 49 }, when: { plan: "pro" } },
559
+ ],
560
+ });
561
+ expect(d.rules[0]?.then).toEqual({ maxUsers: 5, price: 0 });
562
+ });
563
+ it("trims whitespace from name", () => {
564
+ const d = decisions(" Spaced ", {
565
+ inputs: { x: number() },
566
+ outputs: { y: number() },
567
+ rules: [{ then: { y: 2 }, when: { x: 1 } }],
568
+ });
569
+ expect(d.name).toBe("Spaced");
570
+ });
571
+ it("throws on empty name", () => {
572
+ expect(() => decisions("", {
573
+ inputs: { x: number() },
574
+ outputs: { y: number() },
575
+ rules: [{ then: { y: 2 }, when: { x: 1 } }],
576
+ })).toThrow("non-empty name");
577
+ });
578
+ it("throws on no inputs", () => {
579
+ expect(() => decisions("Bad", {
580
+ inputs: {},
581
+ outputs: { y: number() },
582
+ rules: [{ then: { y: 0 }, when: {} }],
583
+ })).toThrow("at least one input");
584
+ });
585
+ it("throws on no outputs", () => {
586
+ expect(() => decisions("Bad", {
587
+ inputs: { x: number() },
588
+ outputs: {},
589
+ rules: [{ then: {}, when: { x: 1 } }],
590
+ })).toThrow("at least one output");
591
+ });
592
+ it("throws on no rules", () => {
593
+ expect(() => decisions("Bad", {
594
+ inputs: { x: number() },
595
+ outputs: { y: number() },
596
+ rules: [],
597
+ })).toThrow("at least one rule");
598
+ });
599
+ it("throws on lifecycle input", () => {
600
+ expect(() => decisions("Bad", {
601
+ inputs: { status: lifecycle(["a", "b"]) },
602
+ outputs: { y: number() },
603
+ rules: [{ then: { y: 1 }, when: { status: "a" } }],
604
+ })).toThrow("lifecycle");
605
+ });
606
+ it("throws on lifecycle output", () => {
607
+ expect(() => decisions("Bad", {
608
+ inputs: { x: number() },
609
+ outputs: { status: lifecycle(["a", "b"]) },
610
+ rules: [{ then: { status: "a" }, when: { x: 1 } }],
611
+ })).toThrow("lifecycle");
612
+ });
613
+ it("throws on optional input", () => {
614
+ expect(() => decisions("Bad", {
615
+ inputs: { x: optional(number()) },
616
+ outputs: { y: number() },
617
+ rules: [{ then: { y: 2 }, when: { x: 1 } }],
618
+ })).toThrow("optional");
619
+ });
620
+ it("throws on optional output", () => {
621
+ expect(() => decisions("Bad", {
622
+ inputs: { x: number() },
623
+ outputs: { y: optional(number()) },
624
+ rules: [{ then: { y: 2 }, when: { x: 1 } }],
625
+ })).toThrow("optional");
626
+ });
627
+ it("throws on unknown when key", () => {
628
+ expect(() => decisions("Bad", {
629
+ inputs: { x: number() },
630
+ outputs: { y: number() },
631
+ // @ts-expect-error -- intentionally passing invalid when key
632
+ rules: [{ then: { y: 2 }, when: { z: 1 } }],
633
+ })).toThrow('when key "z" is not a defined input');
634
+ });
635
+ it("throws on unknown then key", () => {
636
+ expect(() => decisions("Bad", {
637
+ inputs: { x: number() },
638
+ outputs: { y: number() },
639
+ // @ts-expect-error -- intentionally passing invalid then key
640
+ rules: [{ then: { z: 2 }, when: { x: 1 } }],
641
+ })).toThrow('then key "z" is not a defined output');
642
+ });
643
+ it("throws on missing then key", () => {
644
+ expect(() => decisions("Bad", {
645
+ inputs: { x: number() },
646
+ outputs: { a: number(), b: number() },
647
+ // @ts-expect-error -- intentionally missing output key
648
+ rules: [{ then: { a: 1 }, when: { x: 1 } }],
649
+ })).toThrow('missing output key "b"');
650
+ });
651
+ });
652
+ // --- policy() ---
653
+ describe("policy", () => {
654
+ const targetModel = model("Order", {
655
+ actions: { addItem: action() },
656
+ props: { status: lifecycle(["pending", "paid"]), total: number() },
657
+ });
658
+ const relatedModel = model("Customer", {
659
+ props: {
660
+ name: string(),
661
+ status: lifecycle(["active", "suspended"]),
662
+ },
663
+ });
664
+ it("creates a valid PolicyDef", () => {
665
+ const p = policy("NoBadCustomers", {
666
+ on: { action: "addItem", model: targetModel },
667
+ requires: { customer: belongsTo(relatedModel) },
668
+ rules: [
669
+ {
670
+ effect: "prevent",
671
+ when: (ctx) => ctx.customer.status === "suspended",
672
+ },
673
+ ],
674
+ });
675
+ expect(p.kind).toBe("policy");
676
+ expect(p.name).toBe("NoBadCustomers");
677
+ expect(p.on.model.name).toBe("Order");
678
+ expect(p.on.action).toBe("addItem");
679
+ expect(Object.keys(p.requires)).toEqual(["customer"]);
680
+ expect(p.rules).toHaveLength(1);
681
+ expect(p.rules[0]?.effect).toBe("prevent");
682
+ });
683
+ it("supports warn effect", () => {
684
+ const p = policy("WarnOnSuspended", {
685
+ on: { action: "addItem", model: targetModel },
686
+ requires: { customer: belongsTo(relatedModel) },
687
+ rules: [
688
+ { effect: "warn", when: (ctx) => ctx.customer.status === "suspended" },
689
+ ],
690
+ });
691
+ expect(p.rules[0]?.effect).toBe("warn");
692
+ });
693
+ it("supports hasMany in requires (collections)", () => {
694
+ const p = policy("LimitItems", {
695
+ on: { action: "addItem", model: targetModel },
696
+ requires: { items: hasMany(relatedModel) },
697
+ rules: [{ effect: "prevent", when: (ctx) => ctx.items.length > 10 }],
698
+ });
699
+ expect(p.requires["items"]?.kind).toBe("hasMany");
700
+ });
701
+ it("supports multiple rules", () => {
702
+ const p = policy("MultiRule", {
703
+ on: { action: "addItem", model: targetModel },
704
+ requires: { customer: belongsTo(relatedModel) },
705
+ rules: [
706
+ {
707
+ effect: "prevent",
708
+ when: (ctx) => ctx.customer.status === "suspended",
709
+ },
710
+ { effect: "warn", when: (ctx) => ctx.customer.name === "" },
711
+ ],
712
+ });
713
+ expect(p.rules).toHaveLength(2);
714
+ expect(p.rules[0]?.effect).toBe("prevent");
715
+ expect(p.rules[1]?.effect).toBe("warn");
716
+ });
717
+ it("trims whitespace from name", () => {
718
+ const p = policy(" Spaced ", {
719
+ on: { action: "addItem", model: targetModel },
720
+ requires: { customer: belongsTo(relatedModel) },
721
+ rules: [
722
+ { effect: "prevent", when: (ctx) => ctx.customer.status === "x" },
723
+ ],
724
+ });
725
+ expect(p.name).toBe("Spaced");
726
+ });
727
+ it("throws on empty name", () => {
728
+ expect(() => policy("", {
729
+ on: { action: "addItem", model: targetModel },
730
+ requires: { customer: belongsTo(relatedModel) },
731
+ rules: [{ effect: "prevent", when: () => true }],
732
+ })).toThrow("non-empty name");
733
+ });
734
+ it("throws on empty action", () => {
735
+ expect(() => policy("Bad", {
736
+ on: { action: "", model: targetModel },
737
+ requires: { customer: belongsTo(relatedModel) },
738
+ rules: [{ effect: "prevent", when: () => true }],
739
+ })).toThrow("non-empty string");
740
+ });
741
+ it("throws on action not defined on model", () => {
742
+ expect(() => policy("Bad", {
743
+ on: { action: "nonExistent", model: targetModel },
744
+ requires: { customer: belongsTo(relatedModel) },
745
+ rules: [{ effect: "prevent", when: () => true }],
746
+ })).toThrow('"nonExistent" is not a defined action');
747
+ });
748
+ it("skips action validation for ref() models", () => {
749
+ // ref() models don't have actions at definition time — no error
750
+ const p = policy("WithRef", {
751
+ on: { action: "anything", model: ref("FutureModel") },
752
+ requires: { customer: belongsTo(relatedModel) },
753
+ rules: [
754
+ {
755
+ effect: "prevent",
756
+ when: (ctx) => ctx.customer.status === "suspended",
757
+ },
758
+ ],
759
+ });
760
+ expect(p.on.action).toBe("anything");
761
+ });
762
+ it("throws on invalid model ref", () => {
763
+ expect(() => policy("Bad", {
764
+ // @ts-expect-error -- intentionally passing invalid model
765
+ on: { action: "x", model: { name: "Fake" } },
766
+ requires: { customer: belongsTo(relatedModel) },
767
+ rules: [{ effect: "prevent", when: () => true }],
768
+ })).toThrow("model() or ref()");
769
+ });
770
+ it("throws on empty requires", () => {
771
+ expect(() => policy("Bad", {
772
+ on: { action: "addItem", model: targetModel },
773
+ requires: {},
774
+ rules: [{ effect: "prevent", when: () => true }],
775
+ })).toThrow("at least one entry");
776
+ });
777
+ it("throws on no rules", () => {
778
+ expect(() => policy("Bad", {
779
+ on: { action: "addItem", model: targetModel },
780
+ requires: { customer: belongsTo(relatedModel) },
781
+ rules: [],
782
+ })).toThrow("at least one rule");
783
+ });
784
+ it("throws on invalid effect", () => {
785
+ expect(() => policy("Bad", {
786
+ on: { action: "addItem", model: targetModel },
787
+ requires: { customer: belongsTo(relatedModel) },
788
+ // @ts-expect-error -- intentionally passing invalid effect
789
+ rules: [{ effect: "block", when: () => true }],
790
+ })).toThrow('"prevent" or "warn"');
791
+ });
792
+ it("throws on non-function when", () => {
793
+ expect(() => policy("Bad", {
794
+ on: { action: "addItem", model: targetModel },
795
+ requires: { customer: belongsTo(relatedModel) },
796
+ // @ts-expect-error -- intentionally passing invalid when
797
+ rules: [{ effect: "prevent", when: "not a fn" }],
798
+ })).toThrow("must be a function");
799
+ });
800
+ it("throws on invalid requires entry", () => {
801
+ expect(() => policy("Bad", {
802
+ on: { action: "addItem", model: targetModel },
803
+ // @ts-expect-error -- intentionally passing invalid requires
804
+ requires: { customer: { kind: "invalid" } },
805
+ rules: [{ effect: "prevent", when: () => true }],
806
+ })).toThrow("belongsTo() or hasMany()");
807
+ });
808
+ it("accepts ref() in on.model", () => {
809
+ const p = policy("WithRef", {
810
+ on: { action: "create", model: ref("FutureModel") },
811
+ requires: { customer: belongsTo(relatedModel) },
812
+ rules: [
813
+ {
814
+ effect: "prevent",
815
+ when: (ctx) => ctx.customer.status === "suspended",
816
+ },
817
+ ],
818
+ });
819
+ expect(p.on.model.name).toBe("FutureModel");
820
+ });
821
+ });
822
+ //# sourceMappingURL=schema.test.js.map