@ontrails/core 1.0.0-beta.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 (216) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +179 -0
  6. package/dist/adapters.d.ts +39 -0
  7. package/dist/adapters.d.ts.map +1 -0
  8. package/dist/adapters.js +2 -0
  9. package/dist/adapters.js.map +1 -0
  10. package/dist/blob-ref.d.ts +20 -0
  11. package/dist/blob-ref.d.ts.map +1 -0
  12. package/dist/blob-ref.js +22 -0
  13. package/dist/blob-ref.js.map +1 -0
  14. package/dist/branded.d.ts +36 -0
  15. package/dist/branded.d.ts.map +1 -0
  16. package/dist/branded.js +89 -0
  17. package/dist/branded.js.map +1 -0
  18. package/dist/collections.d.ts +31 -0
  19. package/dist/collections.d.ts.map +1 -0
  20. package/dist/collections.js +60 -0
  21. package/dist/collections.js.map +1 -0
  22. package/dist/context.d.ts +10 -0
  23. package/dist/context.d.ts.map +1 -0
  24. package/dist/context.js +15 -0
  25. package/dist/context.js.map +1 -0
  26. package/dist/derive.d.ts +33 -0
  27. package/dist/derive.d.ts.map +1 -0
  28. package/dist/derive.js +122 -0
  29. package/dist/derive.js.map +1 -0
  30. package/dist/errors.d.ts +83 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +142 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/event.d.ts +45 -0
  35. package/dist/event.d.ts.map +1 -0
  36. package/dist/event.js +17 -0
  37. package/dist/event.js.map +1 -0
  38. package/dist/fetch.d.ts +15 -0
  39. package/dist/fetch.d.ts.map +1 -0
  40. package/dist/fetch.js +102 -0
  41. package/dist/fetch.js.map +1 -0
  42. package/dist/guards.d.ts +17 -0
  43. package/dist/guards.d.ts.map +1 -0
  44. package/dist/guards.js +25 -0
  45. package/dist/guards.js.map +1 -0
  46. package/dist/health.d.ts +18 -0
  47. package/dist/health.d.ts.map +1 -0
  48. package/dist/health.js +5 -0
  49. package/dist/health.js.map +1 -0
  50. package/dist/hike.d.ts +36 -0
  51. package/dist/hike.d.ts.map +1 -0
  52. package/dist/hike.js +20 -0
  53. package/dist/hike.js.map +1 -0
  54. package/dist/index.d.ts +34 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +38 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/job.d.ts +24 -0
  59. package/dist/job.d.ts.map +1 -0
  60. package/dist/job.js +17 -0
  61. package/dist/job.js.map +1 -0
  62. package/dist/layer.d.ts +17 -0
  63. package/dist/layer.d.ts.map +1 -0
  64. package/dist/layer.js +21 -0
  65. package/dist/layer.js.map +1 -0
  66. package/dist/path-security.d.ts +28 -0
  67. package/dist/path-security.d.ts.map +1 -0
  68. package/dist/path-security.js +63 -0
  69. package/dist/path-security.js.map +1 -0
  70. package/dist/patterns/bulk.d.ts +15 -0
  71. package/dist/patterns/bulk.d.ts.map +1 -0
  72. package/dist/patterns/bulk.js +14 -0
  73. package/dist/patterns/bulk.js.map +1 -0
  74. package/dist/patterns/change.d.ts +10 -0
  75. package/dist/patterns/change.d.ts.map +1 -0
  76. package/dist/patterns/change.js +10 -0
  77. package/dist/patterns/change.js.map +1 -0
  78. package/dist/patterns/date-range.d.ts +10 -0
  79. package/dist/patterns/date-range.d.ts.map +1 -0
  80. package/dist/patterns/date-range.js +10 -0
  81. package/dist/patterns/date-range.js.map +1 -0
  82. package/dist/patterns/index.d.ts +9 -0
  83. package/dist/patterns/index.d.ts.map +1 -0
  84. package/dist/patterns/index.js +9 -0
  85. package/dist/patterns/index.js.map +1 -0
  86. package/dist/patterns/pagination.d.ts +18 -0
  87. package/dist/patterns/pagination.d.ts.map +1 -0
  88. package/dist/patterns/pagination.js +18 -0
  89. package/dist/patterns/pagination.js.map +1 -0
  90. package/dist/patterns/progress.d.ts +11 -0
  91. package/dist/patterns/progress.d.ts.map +1 -0
  92. package/dist/patterns/progress.js +11 -0
  93. package/dist/patterns/progress.js.map +1 -0
  94. package/dist/patterns/sorting.d.ts +13 -0
  95. package/dist/patterns/sorting.d.ts.map +1 -0
  96. package/dist/patterns/sorting.js +10 -0
  97. package/dist/patterns/sorting.js.map +1 -0
  98. package/dist/patterns/status.d.ts +15 -0
  99. package/dist/patterns/status.d.ts.map +1 -0
  100. package/dist/patterns/status.js +9 -0
  101. package/dist/patterns/status.js.map +1 -0
  102. package/dist/patterns/timestamps.d.ts +10 -0
  103. package/dist/patterns/timestamps.d.ts.map +1 -0
  104. package/dist/patterns/timestamps.js +10 -0
  105. package/dist/patterns/timestamps.js.map +1 -0
  106. package/dist/redaction/index.d.ts +4 -0
  107. package/dist/redaction/index.d.ts.map +1 -0
  108. package/dist/redaction/index.js +3 -0
  109. package/dist/redaction/index.js.map +1 -0
  110. package/dist/redaction/patterns.d.ts +9 -0
  111. package/dist/redaction/patterns.d.ts.map +1 -0
  112. package/dist/redaction/patterns.js +39 -0
  113. package/dist/redaction/patterns.js.map +1 -0
  114. package/dist/redaction/redactor.d.ts +27 -0
  115. package/dist/redaction/redactor.d.ts.map +1 -0
  116. package/dist/redaction/redactor.js +89 -0
  117. package/dist/redaction/redactor.js.map +1 -0
  118. package/dist/resilience.d.ts +34 -0
  119. package/dist/resilience.d.ts.map +1 -0
  120. package/dist/resilience.js +164 -0
  121. package/dist/resilience.js.map +1 -0
  122. package/dist/result.d.ts +57 -0
  123. package/dist/result.d.ts.map +1 -0
  124. package/dist/result.js +145 -0
  125. package/dist/result.js.map +1 -0
  126. package/dist/serialization.d.ts +27 -0
  127. package/dist/serialization.d.ts.map +1 -0
  128. package/dist/serialization.js +115 -0
  129. package/dist/serialization.js.map +1 -0
  130. package/dist/topo.d.ts +18 -0
  131. package/dist/topo.d.ts.map +1 -0
  132. package/dist/topo.js +74 -0
  133. package/dist/topo.js.map +1 -0
  134. package/dist/trail.d.ts +83 -0
  135. package/dist/trail.d.ts.map +1 -0
  136. package/dist/trail.js +16 -0
  137. package/dist/trail.js.map +1 -0
  138. package/dist/types.d.ts +46 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +2 -0
  141. package/dist/types.js.map +1 -0
  142. package/dist/validate-topo.d.ts +24 -0
  143. package/dist/validate-topo.d.ts.map +1 -0
  144. package/dist/validate-topo.js +108 -0
  145. package/dist/validate-topo.js.map +1 -0
  146. package/dist/validation.d.ts +27 -0
  147. package/dist/validation.d.ts.map +1 -0
  148. package/dist/validation.js +134 -0
  149. package/dist/validation.js.map +1 -0
  150. package/dist/workspace.d.ts +25 -0
  151. package/dist/workspace.d.ts.map +1 -0
  152. package/dist/workspace.js +57 -0
  153. package/dist/workspace.js.map +1 -0
  154. package/package.json +21 -0
  155. package/src/__tests__/blob-ref.test.ts +103 -0
  156. package/src/__tests__/branded.test.ts +148 -0
  157. package/src/__tests__/collections.test.ts +126 -0
  158. package/src/__tests__/context.test.ts +66 -0
  159. package/src/__tests__/derive.test.ts +159 -0
  160. package/src/__tests__/errors.test.ts +309 -0
  161. package/src/__tests__/event.test.ts +82 -0
  162. package/src/__tests__/fetch.test.ts +217 -0
  163. package/src/__tests__/guards.test.ts +102 -0
  164. package/src/__tests__/hike.test.ts +117 -0
  165. package/src/__tests__/job.test.ts +98 -0
  166. package/src/__tests__/layer.test.ts +224 -0
  167. package/src/__tests__/path-security.test.ts +114 -0
  168. package/src/__tests__/patterns.test.ts +273 -0
  169. package/src/__tests__/redaction.test.ts +244 -0
  170. package/src/__tests__/resilience.test.ts +246 -0
  171. package/src/__tests__/result.test.ts +155 -0
  172. package/src/__tests__/serialization.test.ts +236 -0
  173. package/src/__tests__/topo.test.ts +184 -0
  174. package/src/__tests__/trail.test.ts +179 -0
  175. package/src/__tests__/validate-topo.test.ts +201 -0
  176. package/src/__tests__/validation.test.ts +283 -0
  177. package/src/__tests__/workspace.test.ts +183 -0
  178. package/src/adapters.ts +68 -0
  179. package/src/blob-ref.ts +39 -0
  180. package/src/branded.ts +135 -0
  181. package/src/collections.ts +99 -0
  182. package/src/context.ts +18 -0
  183. package/src/derive.ts +223 -0
  184. package/src/errors.ts +196 -0
  185. package/src/event.ts +77 -0
  186. package/src/fetch.ts +138 -0
  187. package/src/guards.ts +37 -0
  188. package/src/health.ts +23 -0
  189. package/src/hike.ts +77 -0
  190. package/src/index.ts +158 -0
  191. package/src/job.ts +20 -0
  192. package/src/layer.ts +44 -0
  193. package/src/path-security.ts +90 -0
  194. package/src/patterns/bulk.ts +16 -0
  195. package/src/patterns/change.ts +12 -0
  196. package/src/patterns/date-range.ts +12 -0
  197. package/src/patterns/index.ts +8 -0
  198. package/src/patterns/pagination.ts +22 -0
  199. package/src/patterns/progress.ts +13 -0
  200. package/src/patterns/sorting.ts +14 -0
  201. package/src/patterns/status.ts +11 -0
  202. package/src/patterns/timestamps.ts +12 -0
  203. package/src/redaction/index.ts +3 -0
  204. package/src/redaction/patterns.ts +47 -0
  205. package/src/redaction/redactor.ts +178 -0
  206. package/src/resilience.ts +234 -0
  207. package/src/result.ts +180 -0
  208. package/src/serialization.ts +183 -0
  209. package/src/topo.ts +123 -0
  210. package/src/trail.ts +130 -0
  211. package/src/types.ts +58 -0
  212. package/src/validate-topo.ts +151 -0
  213. package/src/validation.ts +182 -0
  214. package/src/workspace.ts +77 -0
  215. package/tsconfig.json +9 -0
  216. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,82 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import { event } from '../event';
6
+
7
+ const payloadSchema = z.object({
8
+ action: z.string(),
9
+ userId: z.string(),
10
+ });
11
+
12
+ const userAction = event('user.action', {
13
+ description: 'A user performed an action',
14
+ from: ['auth.login', 'auth.signup'],
15
+ markers: { domain: 'auth', priority: 1 },
16
+ payload: payloadSchema,
17
+ });
18
+
19
+ describe('event() basics', () => {
20
+ test("returns kind 'event'", () => {
21
+ expect(userAction.kind).toBe('event');
22
+ });
23
+
24
+ test('returns correct id', () => {
25
+ expect(userAction.id).toBe('user.action');
26
+ });
27
+
28
+ test('preserves payload schema', () => {
29
+ const parsed = userAction.payload.safeParse({
30
+ action: 'click',
31
+ userId: 'u-1',
32
+ });
33
+ expect(parsed.success).toBe(true);
34
+
35
+ const bad = userAction.payload.safeParse({ userId: 42 });
36
+ expect(bad.success).toBe(false);
37
+ });
38
+
39
+ test('preserves description', () => {
40
+ expect(userAction.description).toBe('A user performed an action');
41
+ });
42
+
43
+ test('result object is frozen', () => {
44
+ expect(Object.isFrozen(userAction)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('event() from and markers', () => {
49
+ test('preserves from', () => {
50
+ expect(userAction.from).toEqual(['auth.login', 'auth.signup']);
51
+ });
52
+
53
+ test('from array is frozen', () => {
54
+ expect(Object.isFrozen(userAction.from)).toBe(true);
55
+ });
56
+
57
+ test('preserves markers', () => {
58
+ expect(userAction.markers).toEqual({ domain: 'auth', priority: 1 });
59
+ });
60
+
61
+ test('optional fields default to undefined', () => {
62
+ const minimal = event('minimal', {
63
+ payload: z.string(),
64
+ });
65
+ expect(minimal.description).toBeUndefined();
66
+ expect(minimal.from).toBeUndefined();
67
+ expect(minimal.markers).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe('event() single-object overload', () => {
72
+ test('accepts spec with id property', () => {
73
+ const e = event({
74
+ from: ['entity.add', 'entity.delete'],
75
+ id: 'entity.updated',
76
+ payload: z.object({ entityId: z.string() }),
77
+ });
78
+ expect(e.id).toBe('entity.updated');
79
+ expect(e.kind).toBe('event');
80
+ expect(e.from).toEqual(['entity.add', 'entity.delete']);
81
+ });
82
+ });
@@ -0,0 +1,217 @@
1
+ /* oxlint-disable require-await -- test mocks satisfy async interface without awaiting */
2
+ import { describe, test, expect, afterEach } from 'bun:test';
3
+
4
+ import {
5
+ AuthError,
6
+ CancelledError,
7
+ InternalError,
8
+ NetworkError,
9
+ NotFoundError,
10
+ PermissionError,
11
+ ValidationError,
12
+ RateLimitError,
13
+ TimeoutError,
14
+ } from '../errors.js';
15
+ import { Result } from '../result.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Mock fetch
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const originalFetch = globalThis.fetch;
22
+
23
+ const mockFetch = (impl: () => Promise<Response>) => {
24
+ globalThis.fetch = impl as unknown as typeof globalThis.fetch;
25
+ };
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = originalFetch;
29
+ });
30
+
31
+ const fakeResponse = (
32
+ status: number,
33
+ options?: { headers?: Record<string, string> }
34
+ ): Response => {
35
+ const init: ResponseInit = { status };
36
+ if (options?.headers) {
37
+ init.headers = options.headers;
38
+ }
39
+ return new Response(null, init);
40
+ };
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Successful responses
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe('Result.fromFetch — success', () => {
47
+ test('returns Ok with Response for 200', async () => {
48
+ mockFetch(async () => fakeResponse(200));
49
+ const result = await Result.fromFetch('https://example.com/api');
50
+ expect(result.isOk()).toBe(true);
51
+ expect(result.unwrap().status).toBe(200);
52
+ });
53
+
54
+ test('returns Ok for 201', async () => {
55
+ mockFetch(async () => fakeResponse(201));
56
+ const result = await Result.fromFetch('https://example.com/api');
57
+ expect(result.isOk()).toBe(true);
58
+ });
59
+
60
+ test('returns Ok for 204', async () => {
61
+ mockFetch(async () => fakeResponse(204));
62
+ const result = await Result.fromFetch('https://example.com/api');
63
+ expect(result.isOk()).toBe(true);
64
+ });
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // HTTP error status mapping
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('Result.fromFetch — status code mapping', () => {
72
+ test('401 → AuthError', async () => {
73
+ mockFetch(async () => fakeResponse(401));
74
+ const result = await Result.fromFetch('https://example.com/api');
75
+ expect(result.isErr()).toBe(true);
76
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
77
+ AuthError
78
+ );
79
+ });
80
+
81
+ test('403 → PermissionError', async () => {
82
+ mockFetch(async () => fakeResponse(403));
83
+ const result = await Result.fromFetch('https://example.com/api');
84
+ expect(result.isErr()).toBe(true);
85
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
86
+ PermissionError
87
+ );
88
+ });
89
+
90
+ test('404 → NotFoundError', async () => {
91
+ mockFetch(async () => fakeResponse(404));
92
+ const result = await Result.fromFetch('https://example.com/api');
93
+ expect(result.isErr()).toBe(true);
94
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
95
+ NotFoundError
96
+ );
97
+ });
98
+
99
+ test('429 → RateLimitError', async () => {
100
+ mockFetch(async () => fakeResponse(429));
101
+ const result = await Result.fromFetch('https://example.com/api');
102
+ expect(result.isErr()).toBe(true);
103
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
104
+ RateLimitError
105
+ );
106
+ });
107
+
108
+ test('429 with retry-after header', async () => {
109
+ mockFetch(async () =>
110
+ fakeResponse(429, { headers: { 'retry-after': '30' } })
111
+ );
112
+ const result = await Result.fromFetch('https://example.com/api');
113
+ expect(result.isErr()).toBe(true);
114
+ const err = (result as unknown as { error: RateLimitError }).error;
115
+ expect(err).toBeInstanceOf(RateLimitError);
116
+ expect(err.retryAfter).toBe(30);
117
+ });
118
+
119
+ test('500 → InternalError', async () => {
120
+ mockFetch(async () => fakeResponse(500));
121
+ const result = await Result.fromFetch('https://example.com/api');
122
+ expect(result.isErr()).toBe(true);
123
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
124
+ InternalError
125
+ );
126
+ });
127
+
128
+ test('502 → NetworkError', async () => {
129
+ mockFetch(async () => fakeResponse(502));
130
+ const result = await Result.fromFetch('https://example.com/api');
131
+ expect(result.isErr()).toBe(true);
132
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
133
+ NetworkError
134
+ );
135
+ });
136
+
137
+ test('504 → TimeoutError', async () => {
138
+ mockFetch(async () => fakeResponse(504));
139
+ const result = await Result.fromFetch('https://example.com/api');
140
+ expect(result.isErr()).toBe(true);
141
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
142
+ TimeoutError
143
+ );
144
+ });
145
+
146
+ test('503 → InternalError (generic 5xx)', async () => {
147
+ mockFetch(async () => fakeResponse(503));
148
+ const result = await Result.fromFetch('https://example.com/api');
149
+ expect(result.isErr()).toBe(true);
150
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
151
+ InternalError
152
+ );
153
+ });
154
+
155
+ test('400 → ValidationError', async () => {
156
+ mockFetch(async () => fakeResponse(400));
157
+ const result = await Result.fromFetch('https://example.com/api');
158
+ expect(result.isErr()).toBe(true);
159
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
160
+ ValidationError
161
+ );
162
+ });
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Network / abort errors
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe('Result.fromFetch — network errors', () => {
170
+ test('TypeError → NetworkError', async () => {
171
+ mockFetch(async () => {
172
+ throw new TypeError('Failed to fetch');
173
+ });
174
+ const result = await Result.fromFetch('https://example.com/api');
175
+ expect(result.isErr()).toBe(true);
176
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
177
+ NetworkError
178
+ );
179
+ });
180
+
181
+ test('AbortError → CancelledError', async () => {
182
+ mockFetch(async () => {
183
+ throw new DOMException('The operation was aborted', 'AbortError');
184
+ });
185
+ const result = await Result.fromFetch('https://example.com/api');
186
+ expect(result.isErr()).toBe(true);
187
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
188
+ CancelledError
189
+ );
190
+ });
191
+
192
+ test('unknown thrown value → NetworkError', async () => {
193
+ mockFetch(async () => {
194
+ // oxlint-disable-next-line no-throw-literal -- intentionally testing non-Error rejection handling
195
+ throw 'string error';
196
+ });
197
+ const result = await Result.fromFetch('https://example.com/api');
198
+ expect(result.isErr()).toBe(true);
199
+ expect((result as unknown as { error: Error }).error).toBeInstanceOf(
200
+ NetworkError
201
+ );
202
+ });
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Error context
207
+ // ---------------------------------------------------------------------------
208
+
209
+ describe('Result.fromFetch — error context', () => {
210
+ test('includes status in error context', async () => {
211
+ mockFetch(async () => fakeResponse(404));
212
+ const result = await Result.fromFetch('https://example.com/api');
213
+ expect(result.isErr()).toBe(true);
214
+ const err = (result as unknown as { error: NotFoundError }).error;
215
+ expect(err.context?.['status']).toBe(404);
216
+ });
217
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+
3
+ import {
4
+ isDefined,
5
+ isNonEmptyString,
6
+ isPlainObject,
7
+ hasProperty,
8
+ assertNever,
9
+ } from '../guards';
10
+
11
+ describe('guards', () => {
12
+ describe('isDefined()', () => {
13
+ test('returns true for non-nullish values', () => {
14
+ expect(isDefined(0)).toBe(true);
15
+ expect(isDefined('')).toBe(true);
16
+ expect(isDefined(false)).toBe(true);
17
+ expect(isDefined([])).toBe(true);
18
+ });
19
+
20
+ test('returns false for null', () => {
21
+ expect(isDefined(null)).toBe(false);
22
+ });
23
+
24
+ test('returns false for undefined', () => {
25
+ expect(isDefined()).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('isNonEmptyString()', () => {
30
+ test('returns true for non-empty strings', () => {
31
+ expect(isNonEmptyString('hello')).toBe(true);
32
+ expect(isNonEmptyString(' ')).toBe(true);
33
+ });
34
+
35
+ test('returns false for empty string', () => {
36
+ expect(isNonEmptyString('')).toBe(false);
37
+ });
38
+
39
+ test('returns false for non-string types', () => {
40
+ expect(isNonEmptyString(42)).toBe(false);
41
+ expect(isNonEmptyString(null)).toBe(false);
42
+ expect(isNonEmptyString()).toBe(false);
43
+ expect(isNonEmptyString([])).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe('isPlainObject()', () => {
48
+ test('returns true for plain objects', () => {
49
+ expect(isPlainObject({})).toBe(true);
50
+ expect(isPlainObject({ a: 1 })).toBe(true);
51
+ expect(isPlainObject(Object.create(null))).toBe(true);
52
+ });
53
+
54
+ test('returns false for arrays', () => {
55
+ expect(isPlainObject([])).toBe(false);
56
+ });
57
+
58
+ test('returns false for class instances', () => {
59
+ expect(isPlainObject(new Date())).toBe(false);
60
+ expect(isPlainObject(new Map())).toBe(false);
61
+ });
62
+
63
+ test('returns false for null', () => {
64
+ expect(isPlainObject(null)).toBe(false);
65
+ });
66
+
67
+ test('returns false for primitives', () => {
68
+ expect(isPlainObject('string')).toBe(false);
69
+ expect(isPlainObject(42)).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe('hasProperty()', () => {
74
+ test('returns true when key exists', () => {
75
+ expect(hasProperty({ name: 'test' }, 'name')).toBe(true);
76
+ });
77
+
78
+ test('returns true for keys with undefined values', () => {
79
+ expect(hasProperty({ x: undefined }, 'x')).toBe(true);
80
+ });
81
+
82
+ test('returns false when key is missing', () => {
83
+ expect(hasProperty({}, 'missing')).toBe(false);
84
+ });
85
+
86
+ test('returns false for null', () => {
87
+ expect(hasProperty(null, 'key')).toBe(false);
88
+ });
89
+
90
+ test('returns false for primitives', () => {
91
+ expect(hasProperty(42, 'key')).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('assertNever()', () => {
96
+ test('throws with a descriptive message', () => {
97
+ expect(() => assertNever('unexpected' as never)).toThrow(
98
+ 'Unexpected value: unexpected'
99
+ );
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import { Result } from '../result';
6
+ import { hike } from '../hike';
7
+ import type { TrailContext } from '../types';
8
+
9
+ const stubCtx: TrailContext = {
10
+ requestId: 'test-123',
11
+ signal: AbortSignal.timeout(5000),
12
+ };
13
+
14
+ describe('hike()', () => {
15
+ const inputSchema = z.object({ userId: z.string() });
16
+ const outputSchema = z.object({ profile: z.string() });
17
+
18
+ const fetchProfile = hike('fetch-profile', {
19
+ description: 'Fetch a user profile',
20
+ follows: ['authenticate', 'validate-session'],
21
+ implementation: (input) =>
22
+ Result.ok({ profile: `Profile for ${input.userId}` }),
23
+ input: inputSchema,
24
+ output: outputSchema,
25
+ });
26
+
27
+ describe('basics', () => {
28
+ test("returns kind 'hike'", () => {
29
+ expect(fetchProfile.kind).toBe('hike');
30
+ });
31
+
32
+ test('returns correct id', () => {
33
+ expect(fetchProfile.id).toBe('fetch-profile');
34
+ });
35
+
36
+ test('preserves follows array', () => {
37
+ expect(fetchProfile.follows).toEqual([
38
+ 'authenticate',
39
+ 'validate-session',
40
+ ]);
41
+ });
42
+
43
+ test('follows array is frozen', () => {
44
+ expect(Object.isFrozen(fetchProfile.follows)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('trail compatibility', () => {
49
+ test('extends Trail — has input schema', () => {
50
+ const parsed = fetchProfile.input.safeParse({ userId: 'u-1' });
51
+ expect(parsed.success).toBe(true);
52
+ });
53
+
54
+ test('extends Trail — has output schema', () => {
55
+ expect(fetchProfile.output).toBeDefined();
56
+ });
57
+
58
+ test('extends Trail — implementation is callable', async () => {
59
+ const result = await fetchProfile.implementation(
60
+ { userId: 'u-1' },
61
+ stubCtx
62
+ );
63
+ expect(result.isOk()).toBe(true);
64
+ expect(result.unwrap()).toEqual({ profile: 'Profile for u-1' });
65
+ });
66
+
67
+ test('preserves description', () => {
68
+ expect(fetchProfile.description).toBe('Fetch a user profile');
69
+ });
70
+
71
+ test('result object is frozen', () => {
72
+ expect(Object.isFrozen(fetchProfile)).toBe(true);
73
+ });
74
+ });
75
+
76
+ test('markers are preserved', () => {
77
+ const withMarkers = hike('tagged-hike', {
78
+ follows: ['setup'],
79
+ implementation: () => Result.ok(),
80
+ input: z.object({}),
81
+ markers: { domain: 'auth' },
82
+ });
83
+ expect(withMarkers.markers).toEqual({ domain: 'auth' });
84
+ });
85
+
86
+ describe('single-object overload', () => {
87
+ test('accepts spec with id property', () => {
88
+ const r = hike({
89
+ follows: ['entity.add', 'entity.relate'],
90
+ id: 'entity.onboard',
91
+ implementation: () => Result.ok(),
92
+ input: z.object({}),
93
+ });
94
+ expect(r.id).toBe('entity.onboard');
95
+ expect(r.kind).toBe('hike');
96
+ expect(r.follows).toEqual(['entity.add', 'entity.relate']);
97
+ });
98
+
99
+ test('sync implementations are normalized to an awaitable runtime function', async () => {
100
+ const r = hike({
101
+ follows: ['entity.add'],
102
+ id: 'entity.check',
103
+ implementation: (input: { userId: string }) =>
104
+ Result.ok({ profile: input.userId }),
105
+ input: inputSchema,
106
+ output: outputSchema,
107
+ });
108
+
109
+ const promise = r.implementation({ userId: 'u-2' }, stubCtx);
110
+ expect(promise).toBeInstanceOf(Promise);
111
+
112
+ const result = await promise;
113
+ expect(result.isOk()).toBe(true);
114
+ expect(result.unwrap()).toEqual({ profile: 'u-2' });
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import { jobOutputSchema } from '../job';
6
+
7
+ describe('jobOutputSchema', () => {
8
+ test('parses a valid completed job output', () => {
9
+ const input = {
10
+ completedAt: '2026-03-25T00:01:00Z',
11
+ current: 10,
12
+ jobId: 'job-123',
13
+ percentage: 100,
14
+ result: { rows: 42 },
15
+ startedAt: '2026-03-25T00:00:00Z',
16
+ status: 'completed',
17
+ total: 10,
18
+ };
19
+
20
+ const parsed = jobOutputSchema.parse(input);
21
+
22
+ expect(parsed.jobId).toBe('job-123');
23
+ expect(parsed.status).toBe('completed');
24
+ expect(parsed.current).toBe(10);
25
+ expect(parsed.total).toBe(10);
26
+ });
27
+
28
+ test('parses a minimal pending job output', () => {
29
+ const input = {
30
+ current: 0,
31
+ jobId: 'job-456',
32
+ status: 'pending',
33
+ total: 100,
34
+ };
35
+
36
+ const parsed = jobOutputSchema.parse(input);
37
+
38
+ expect(parsed.jobId).toBe('job-456');
39
+ expect(parsed.status).toBe('pending');
40
+ expect(parsed.result).toBeUndefined();
41
+ expect(parsed.error).toBeUndefined();
42
+ });
43
+
44
+ test('parses a failed job with error', () => {
45
+ const input = {
46
+ current: 3,
47
+ error: 'connection reset',
48
+ jobId: 'job-789',
49
+ status: 'failed',
50
+ total: 10,
51
+ };
52
+
53
+ const parsed = jobOutputSchema.parse(input);
54
+
55
+ expect(parsed.status).toBe('failed');
56
+ expect(parsed.error).toBe('connection reset');
57
+ });
58
+
59
+ test('rejects when jobId is missing', () => {
60
+ const input = {
61
+ current: 1,
62
+ status: 'running',
63
+ total: 5,
64
+ };
65
+
66
+ expect(() => jobOutputSchema.parse(input)).toThrow();
67
+ });
68
+
69
+ test('rejects an invalid status value', () => {
70
+ const input = {
71
+ current: 0,
72
+ jobId: 'job-bad',
73
+ status: 'unknown',
74
+ total: 0,
75
+ };
76
+
77
+ expect(() => jobOutputSchema.parse(input)).toThrow();
78
+ });
79
+
80
+ test('composes statusFields and progressFields correctly', () => {
81
+ const { shape } = jobOutputSchema;
82
+
83
+ // Status field from statusFields()
84
+ expect(shape.status).toBeInstanceOf(z.ZodEnum);
85
+
86
+ // Progress fields from progressFields()
87
+ expect(shape.current).toBeInstanceOf(z.ZodNumber);
88
+ expect(shape.total).toBeInstanceOf(z.ZodNumber);
89
+ expect(shape.percentage).toBeDefined();
90
+
91
+ // Job-specific fields
92
+ expect(shape.jobId).toBeInstanceOf(z.ZodString);
93
+ expect(shape.error).toBeDefined();
94
+ expect(shape.result).toBeDefined();
95
+ expect(shape.startedAt).toBeDefined();
96
+ expect(shape.completedAt).toBeDefined();
97
+ });
98
+ });