@onrails/result 0.1.4 → 0.2.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 (65) hide show
  1. package/DESIGN.md +2 -2
  2. package/README.md +0 -3
  3. package/dist/$.cjs.map +1 -1
  4. package/dist/$.js.map +1 -1
  5. package/dist/_.d.cts +1 -1
  6. package/dist/_.d.ts +1 -1
  7. package/dist/async-1fIhfzFu.d.ts +290 -0
  8. package/dist/async-u8U5P2EH.d.cts +290 -0
  9. package/dist/compat/neverthrow.cjs +234 -50
  10. package/dist/compat/neverthrow.cjs.map +1 -1
  11. package/dist/compat/neverthrow.d.cts +2 -2
  12. package/dist/compat/neverthrow.d.ts +2 -2
  13. package/dist/compat/neverthrow.js +231 -47
  14. package/dist/compat/neverthrow.js.map +1 -1
  15. package/dist/extra.cjs +1 -24
  16. package/dist/extra.cjs.map +1 -1
  17. package/dist/extra.d.cts +3 -9
  18. package/dist/extra.d.ts +3 -9
  19. package/dist/extra.js +2 -24
  20. package/dist/extra.js.map +1 -1
  21. package/dist/fluent.cjs +0 -2
  22. package/dist/fluent.cjs.map +1 -1
  23. package/dist/fluent.d.cts +2 -6
  24. package/dist/fluent.d.ts +2 -6
  25. package/dist/fluent.js +0 -2
  26. package/dist/fluent.js.map +1 -1
  27. package/dist/index.cjs +220 -47
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +174 -51
  30. package/dist/index.d.ts +174 -51
  31. package/dist/index.js +221 -44
  32. package/dist/index.js.map +1 -1
  33. package/dist/interop.cjs +203 -23
  34. package/dist/interop.cjs.map +1 -1
  35. package/dist/interop.d.cts +48 -8
  36. package/dist/interop.d.ts +48 -8
  37. package/dist/interop.js +203 -23
  38. package/dist/interop.js.map +1 -1
  39. package/dist/pipe.cjs.map +1 -1
  40. package/dist/pipe.d.cts +7 -5
  41. package/dist/pipe.d.ts +7 -5
  42. package/dist/pipe.js.map +1 -1
  43. package/dist/railway.cjs +203 -24
  44. package/dist/railway.cjs.map +1 -1
  45. package/dist/railway.d.cts +2 -2
  46. package/dist/railway.d.ts +2 -2
  47. package/dist/railway.js +203 -24
  48. package/dist/railway.js.map +1 -1
  49. package/dist/try-gen.cjs.map +1 -1
  50. package/dist/try-gen.d.cts +29 -3
  51. package/dist/try-gen.d.ts +29 -3
  52. package/dist/try-gen.js.map +1 -1
  53. package/dist/types-BQ9vv0nD.d.cts +55 -0
  54. package/dist/types-BQ9vv0nD.d.ts +55 -0
  55. package/dist/validation.cjs.map +1 -1
  56. package/dist/validation.d.cts +1 -1
  57. package/dist/validation.d.ts +1 -1
  58. package/dist/validation.js.map +1 -1
  59. package/package.json +5 -3
  60. package/skills/railway-do-notation/SKILL.md +436 -0
  61. package/skills/result-composition/SKILL.md +325 -0
  62. package/dist/async-CH8Cs8KE.d.cts +0 -161
  63. package/dist/async-DKISqIBc.d.ts +0 -161
  64. package/dist/types-C2Dp1d5J.d.cts +0 -21
  65. package/dist/types-C2Dp1d5J.d.ts +0 -21
@@ -0,0 +1,436 @@
1
+ ---
2
+ name: railway-do-notation
3
+ description: "Ergonomic railway composition for `@onrails/result`: when to use low-level Result/ResultAsync helpers, fluent `Railway.*` workflows, functional `railway(...)` reusable steps, or sync `tryGen`. Use when writing or refactoring Result-heavy TypeScript, Drizzle/Zod ETL workflows, nested `flatMapResult` chains, mixed sync/async railway code, or when a user asks about 'Railway', 'railway', 'do-notation', 'safe workflow', or 'expressive Result code'. Do NOT use workflow builders for tiny repository methods where `asyncAfter` or `.flatMap` is clearer."
4
+ ---
5
+
6
+ # Railway ergonomics in `@onrails/result`
7
+
8
+ This skill documents the intended ergonomic layers for `@onrails/result`.
9
+
10
+ The goal is expressive safe code without exposing plumbing at every composition site. Low-level `Result` and `ResultAsync` remain the foundation. Higher-level workflow syntax exists only where it makes complex service code easier to read.
11
+
12
+ > Below the workflow-builder layer — pipe/flow point-free composition, dual-form transforms, the closure ladder — see the [`result-composition`](../result-composition/SKILL.md) skill.
13
+
14
+ ## Decision Tree
15
+
16
+ Use the smallest layer that makes the code clear:
17
+
18
+ 1. **Low-level helpers** for small functions and library internals.
19
+ - Use `trySync`, `tryAsync`, `fromResult`, `asyncAfter`, `flatMapResult`, `.flatMap`, `.andThen`.
20
+ - Best for one or two steps, repository helpers, and package internals.
21
+
22
+ 2. **Fluent `Railway.*`** for one-off service workflows.
23
+ - Use when code is ETL-shaped: validate input, query Drizzle, require nullable rows, derive values, run parallel enrichment, return DTO.
24
+ - Best when there are 4+ named steps or mixed sync/async boundaries.
25
+
26
+ 3. **Functional `railway(...)`** for reusable step composition.
27
+ - Use when steps are shared across multiple workflows.
28
+ - Best for date-fns-style reusable transforms.
29
+
30
+ 4. **Sync `tryGen`** for dense synchronous `Result` chains only.
31
+ - Use sparingly when no workflow builder is warranted and nested sync `flatMapResult` is hard to read.
32
+ - Never use for `ResultAsync` or anything with `await`.
33
+
34
+ ## Layer 1 — Low-Level Helpers
35
+
36
+ Use low-level helpers when the operation is small and direct.
37
+
38
+ ```ts
39
+ return asyncAfter(
40
+ // Sync validation boundary. If Zod throws, map the thrown value to Error.
41
+ trySync(() => ArtifactSchema.parse(artifact), toError)(),
42
+
43
+ // Async IO boundary. Promise rejection becomes Err(Error).
44
+ (validated) =>
45
+ tryAsync(
46
+ getDb()
47
+ .insert(artifacts)
48
+ .values(validated)
49
+ .then(() => undefined),
50
+ ),
51
+ );
52
+ ```
53
+
54
+ This is already clear. Do not wrap it in a workflow builder just to be consistent.
55
+
56
+ Use this layer for:
57
+
58
+ - repository create/update/delete methods
59
+ - simple parse-then-query functions
60
+ - library internals
61
+ - functions with one sync boundary and one async boundary
62
+
63
+ ## Layer 2 — Fluent `Railway.*`
64
+
65
+ Use fluent `Railway.*` when the code is a named workflow. It carries a growing typed context object through each step.
66
+
67
+ ### Mental Model
68
+
69
+ ```ts
70
+ Railway
71
+ .fromSync("profileId", ...)
72
+ // context: { profileId }
73
+ .fromPromise("row", ...)
74
+ // context: { profileId, row }
75
+ .require("profile", "row", ...)
76
+ // context: { profileId, row, profile }
77
+ .derive("normalized", ...)
78
+ // context: { profileId, row, profile, normalized }
79
+ .parallel({ recent: ..., metrics: ... })
80
+ // context: { profileId, row, profile, normalized, recent, metrics }
81
+ .select(...)
82
+ ```
83
+
84
+ Each step either adds a named field to context or maps the final context to a result.
85
+
86
+ ### Sync vs Async Mode
87
+
88
+ `Railway` tracks whether the workflow has crossed an async boundary:
89
+
90
+ ```ts
91
+ type RailwayMode = "sync" | "async";
92
+
93
+ type RailwayOutput<T, E, M extends RailwayMode> =
94
+ M extends "async" ? ResultAsync<T, E> : Result<T, E>;
95
+ ```
96
+
97
+ Sync-only workflows return `Result`:
98
+
99
+ ```ts
100
+ const parsed = Railway
101
+ .fromSync("id", () => IdSchema.parse(raw), toError)
102
+ .derive("slug", ({ id }) => makeSlug(id))
103
+ .select(({ id, slug }) => ({ id, slug }));
104
+
105
+ // Result<{ id: Id; slug: string }, Error>
106
+ ```
107
+
108
+ Once a workflow uses `fromPromise`, `fromAsync`, or `parallel`, it upgrades to `ResultAsync`:
109
+
110
+ ```ts
111
+ const dto = Railway
112
+ .fromSync("id", () => IdSchema.parse(raw), toError)
113
+ .fromPromise("row", ({ id }) => db.query.users.findFirst(...), toError)
114
+ .require("user", "row", ({ id }) => new Error(`User not found: ${id}`))
115
+ .select(({ user }) => toUserDto(user));
116
+
117
+ // ResultAsync<UserDto, Error>
118
+ ```
119
+
120
+ ### Example — Drizzle Query + ETL + Parallel Enrichment
121
+
122
+ ```ts
123
+ function loadProfileSummary(id: string): ResultAsync<ProfileSummary, Error> {
124
+ return Railway
125
+ // Start sync. Zod parse failures become Err(Error).
126
+ .fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
127
+
128
+ // First async boundary. The workflow upgrades from Result to ResultAsync.
129
+ .fromPromise(
130
+ "row",
131
+ ({ profileId }) =>
132
+ db.query.profiles.findFirst({
133
+ where: eq(profiles.id, profileId),
134
+ with: { artifacts: true, jobs: true },
135
+ }),
136
+ toError,
137
+ )
138
+
139
+ // Convert nullable Drizzle output into a required profile.
140
+ // If row is null/undefined, short-circuit with this error.
141
+ .require("profile", "row", ({ profileId }) =>
142
+ new Error(`Profile not found: ${profileId}`),
143
+ )
144
+
145
+ // Pure sync derivation. Do not catch throws here.
146
+ // Use fromSync if the transform can throw.
147
+ .derive("normalized", ({ profile }) => normalizeProfile(profile))
148
+
149
+ // Sync Result-returning ETL step.
150
+ // Err from enrichProfileStats short-circuits the whole workflow.
151
+ .fromResult("stats", ({ normalized }) => enrichProfileStats(normalized))
152
+
153
+ // Independent async branches run from the same context.
154
+ // Their named successes merge back into context.
155
+ .parallel({
156
+ recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id),
157
+ jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id),
158
+ })
159
+
160
+ // Final projection. Output stays ResultAsync because async appeared above.
161
+ .select(({ normalized, stats, recentArtifacts, jobMetrics }) =>
162
+ toProfileSummary({
163
+ profile: normalized,
164
+ stats,
165
+ recentArtifacts,
166
+ jobMetrics,
167
+ }),
168
+ );
169
+ }
170
+ ```
171
+
172
+ Use fluent `Railway.*` when it removes manual bridges like:
173
+
174
+ - `fromResult(...)`
175
+ - nested `flatMapResult(...)`
176
+ - positional tuple destructuring for parallel work
177
+ - manual context-carrying objects after every step
178
+
179
+ Do not use fluent `Railway.*` when `asyncAfter(...)` is already clearer.
180
+
181
+ ## Layer 3 — Functional `railway(...)`
182
+
183
+ Use lowercase `railway(...)` when the steps themselves should be reusable values.
184
+
185
+ ```ts
186
+ const parseProfileId =
187
+ // Parse raw input and name the output. Name-first, like every other step
188
+ // below — `parseWith(ProfileIdSchema, toError).as("profileId")` is the
189
+ // equivalent fluent trailing-name form if you prefer it.
190
+ parseNamed("profileId", ProfileIdSchema, toError);
191
+
192
+ const loadProfileRow = fromPromiseNamed(
193
+ "row",
194
+ // This reusable step depends on prior named context.
195
+ ({ profileId }) =>
196
+ db.query.profiles.findFirst({
197
+ where: eq(profiles.id, profileId),
198
+ with: { artifacts: true, jobs: true },
199
+ }),
200
+ toError,
201
+ );
202
+
203
+ const requireProfile = requireNamed(
204
+ "profile",
205
+ "row",
206
+ // Error factories can read accumulated context.
207
+ ({ profileId }) => new Error(`Profile not found: ${profileId}`),
208
+ );
209
+
210
+ const normalize = deriveNamed("normalized", ({ profile }) =>
211
+ normalizeProfile(profile),
212
+ );
213
+
214
+ const loadSummaryInputs = parallelNamed({
215
+ recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id),
216
+ jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id),
217
+ });
218
+
219
+ const toSummary = select(
220
+ ({ normalized, recentArtifacts, jobMetrics }) =>
221
+ toProfileSummary({
222
+ profile: normalized,
223
+ recentArtifacts,
224
+ jobMetrics,
225
+ }),
226
+ );
227
+
228
+ const summary = railway(
229
+ id,
230
+ parseProfileId,
231
+ loadProfileRow,
232
+ requireProfile,
233
+ normalize,
234
+ loadSummaryInputs,
235
+ toSummary,
236
+ );
237
+ ```
238
+
239
+ Use functional `railway(...)` when:
240
+
241
+ - the same parse/query/require/derive steps appear in multiple workflows
242
+ - tests should exercise individual steps
243
+ - method chaining would hide the fact that the steps are shared building blocks
244
+
245
+ Prefer fluent `Railway.*` for one-off service functions.
246
+
247
+ ## Layer 4 — Sync `tryGen`
248
+
249
+ `tryGen` is a synchronous do-notation escape hatch. It is not the primary service-workflow API.
250
+
251
+ Use it only when:
252
+
253
+ - every step is sync
254
+ - the function body has nested `flatMapResult` / `mapResult`
255
+ - a full `Railway.*` workflow would be overkill
256
+
257
+ ```ts
258
+ import { ok, tryGen, yieldResult as $ } from "@onrails/result";
259
+
260
+ const bundleResult = tryGen<{ artifact: Artifact; chunkData: Chunk[] }, Error>(() => {
261
+ const artifact = $(parseArtifact(raw));
262
+ const chunkTexts = $(chunker.chunk(artifact.content));
263
+ const chunkData = $(parseChunks(artifact, chunkTexts));
264
+ return ok({ artifact, chunkData });
265
+ });
266
+ ```
267
+
268
+ `$(result)` unwraps the Ok value or short-circuits the whole block with the first Err. Its real edge over a `flatMap` chain is a **guard between two unwraps** — an early `return err(...)`:
269
+
270
+ ```ts
271
+ const authorizeEdit = (req: Request, postId: string) =>
272
+ tryGen(() => {
273
+ const user = $(authenticate(req));
274
+ const post = $(fetchPost(postId));
275
+ if (post.authorId !== user.id && !user.isAdmin) {
276
+ return err({ kind: "forbidden" as const }); // early exit, no nesting
277
+ }
278
+ return ok(post);
279
+ });
280
+ ```
281
+
282
+ …and a `Result`-returning loop body that must bail on first failure:
283
+
284
+ ```ts
285
+ const applyMigrations = (db: Db, steps: readonly Migration[]) =>
286
+ tryGen(() => {
287
+ let schema = $(currentSchema(db));
288
+ for (const step of steps) {
289
+ schema = $(applyMigration(schema, step)); // first Err aborts loop + block
290
+ }
291
+ return ok(schema);
292
+ });
293
+ ```
294
+
295
+ Use `yieldResult as $` for do-notation snippets in this repo. The exports are also re-published from the namesake subpath — `import { tryGen, $ } from "@onrails/result/$"` — when `$` reads better as the module's name. The alias is intentionally local to `tryGen` blocks and mirrors Rust's `?` ergonomics without changing the rest of the railway API.
296
+
297
+ Never use `tryGen` with `await` or `ResultAsync`.
298
+
299
+ ```ts
300
+ // Wrong: tryGen is sync-only.
301
+ tryGen(() => {
302
+ const id = yieldResult(parseId(raw));
303
+ const row = yieldResult(await loadRow(id)); // invalid shape
304
+ return ok(row);
305
+ });
306
+ ```
307
+
308
+ Async code stays on `ResultAsync.flatMap`, `.andThen`, `asyncAfter`, or a `Railway.*` workflow.
309
+
310
+ ## API Design Notes
311
+
312
+ ### Fluent Starters
313
+
314
+ `Railway` static methods should start workflows directly:
315
+
316
+ ```ts
317
+ Railway.empty()
318
+ Railway.context({ profileId })
319
+ Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
320
+ Railway.fromResult("settings", () => loadSettings())
321
+ Railway.fromPromise("row", () => db.query.users.findFirst(...), toError)
322
+ Railway.fromAsync("artifact", () => ingestArtifact(...))
323
+ ```
324
+
325
+ Avoid requiring:
326
+
327
+ ```ts
328
+ Railway.create().fromSync(...)
329
+ ```
330
+
331
+ The static starter form is easier to scan and avoids an empty builder call.
332
+
333
+ ### Instance Steps
334
+
335
+ Expected instance methods:
336
+
337
+ ```ts
338
+ .derive(key, fn) // pure sync transform
339
+ .fromResult(key, fn) // sync Result-returning step
340
+ .fromSync(key, fn) // throwing sync boundary
341
+ .fromPromise(key, fn) // Promise boundary, upgrades to async
342
+ .fromAsync(key, fn) // ResultAsync boundary, upgrades to async
343
+ .require(key, source, onMissing)
344
+ .parallel(record)
345
+ .select(fn)
346
+ .done()
347
+ ```
348
+
349
+ Recommended semantics:
350
+
351
+ - `derive` is pure and should not catch throws.
352
+ - Use `fromSync` for throwing sync transforms.
353
+ - `require` narrows nullable values to non-null fields.
354
+ - `parallel` always upgrades to async.
355
+ - `select` hides the internal context.
356
+ - `done` returns the accumulated context.
357
+
358
+ ## When Not To Use Workflow Syntax
359
+
360
+ Do not use `Railway.*` or `railway(...)` for:
361
+
362
+ - a single parse
363
+ - a single DB call
364
+ - one validation followed by one insert where `asyncAfter` is clearer
365
+ - small library combinators
366
+ - code where positional tuple output is genuinely clearer than named context
367
+
368
+ Bad:
369
+
370
+ ```ts
371
+ return Railway
372
+ .fromSync("artifact", () => ArtifactSchema.parse(artifact), toError)
373
+ .fromPromise("inserted", ({ artifact }) =>
374
+ getDb().insert(artifacts).values(artifact).then(() => undefined),
375
+ toError,
376
+ )
377
+ .select(() => undefined);
378
+ ```
379
+
380
+ Better:
381
+
382
+ ```ts
383
+ return asyncAfter(
384
+ trySync(() => ArtifactSchema.parse(artifact), toError)(),
385
+ (validated) =>
386
+ tryAsync(
387
+ getDb()
388
+ .insert(artifacts)
389
+ .values(validated)
390
+ .then(() => undefined),
391
+ ),
392
+ );
393
+ ```
394
+
395
+ The workflow builder earns its keep when it names several domain steps and removes real nesting.
396
+
397
+ ## Refactor Heuristics
398
+
399
+ Consider fluent `Railway.*` when at least two are true:
400
+
401
+ - more than three railway steps
402
+ - both sync and async boundaries appear
403
+ - nullable DB output must become a required value
404
+ - independent async branches can run in parallel
405
+ - comments explain dataflow because the code shape does not
406
+ - prior values must be carried forward through nested closures
407
+
408
+ Consider functional `railway(...)` when:
409
+
410
+ - two or more workflows share steps
411
+ - step-level tests would be useful
412
+ - the same parse/query/require sequence repeats
413
+
414
+ Consider `tryGen` when:
415
+
416
+ - all steps are sync
417
+ - the current code is a nested `flatMapResult` tree
418
+ - a workflow builder would introduce unnecessary named context
419
+
420
+ Stay low-level when:
421
+
422
+ - the function has one or two steps
423
+ - the code is library internals
424
+ - the operation is already readable with `asyncAfter`, `.flatMap`, or `mapResult`
425
+
426
+ ## Maintainer Workflow For Case Studies
427
+
428
+ Each case study should come from a real downstream PR.
429
+
430
+ 1. Identify the pain point: nested sync chain, mixed sync/async ETL, or reusable workflow steps.
431
+ 2. Write the low-level current version and the proposed `Railway.*` or `railway(...)` version.
432
+ 3. Add line comments explaining what each new step does.
433
+ 4. Confirm the proposed style removes plumbing without hiding important failure behavior.
434
+ 5. Add the before/after pair to this skill or to package docs.
435
+
436
+ Keep examples grounded in real code. Synthetic examples are fine for reference sections, but case studies should prove the API pays for itself in production-shaped code.