@thomasfosterau/effect-forms 0.0.0 → 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.
package/README.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  Framework-neutral, **Effect-native** form validation and state — schema-driven validation, nested errors, posted-value retention, status messages, and native input constraints, built on **Effect Schema** with no framework dependency. The Svelte 5 bindings live in [`@thomasfosterau/effect-forms-svelte`](../svelte).
4
4
 
5
- ## Why
5
+ ## Install
6
6
 
7
- The app validates everything with Effect Schema (Zod is gone). The progressive form libraries in the ecosystem are excellent, but they speak Standard Schema / Zod and bake in a framework. This package keeps the ergonomics — one schema drives validation, errors nest by field, posted values survive a failed submit, native input constraints come for free — while staying on Effect Schema and free of any framework, so the core is testable in isolation and reusable from a Worker, the CLI, or a Svelte route.
7
+ ```sh
8
+ pnpm add @thomasfosterau/effect-forms effect
9
+ ```
10
+
11
+ `effect` is a peer dependency; the package never bundles its own copy.
8
12
 
9
- ## The shape
13
+ ## Usage
10
14
 
11
15
  ```ts
12
16
  import * as Schema from "effect/Schema";
@@ -24,15 +28,17 @@ const signupForm = Form.make(Signup, {
24
28
  });
25
29
 
26
30
  // In a server action / load:
31
+ declare const request: Request;
27
32
  const state = await Effect.runPromise(signupForm.validate(request));
28
33
  // state: { id, valid, posted, data, errors, constraints, message? }
29
34
  ```
30
35
 
31
- ## Building a form incrementally
36
+ ### Building a form incrementally
32
37
 
33
38
  `Form.make` binds a hand-written `Schema.Struct`. When you'd rather compose a form field by field — reusing field groups, merging them, and attaching whole-form (cross-field) rules — reach for `FormBuilder`:
34
39
 
35
40
  ```ts
41
+ import * as Effect from "effect/Effect";
36
42
  import * as Schema from "effect/Schema";
37
43
  import { Field, Form, FormBuilder } from "@thomasfosterau/effect-forms";
38
44
 
@@ -47,6 +53,7 @@ const signup = FormBuilder.empty
47
53
 
48
54
  // Bind it exactly like a schema (defaults are derived when omitted):
49
55
  const form = Form.fromBuilder(signup, { id: "signup" });
56
+ declare const request: Request; // the incoming submission
50
57
  const state = await Effect.runPromise(form.validate(request));
51
58
  ```
52
59
 
@@ -57,19 +64,20 @@ Modules are exposed as Effect-style namespaces (`Form`, `State`, `Result`, `Erro
57
64
  - **`validate(input)`** accepts a `FormData`, `URLSearchParams`, `Request`, or a plain object. It parses the body (nested `author.name` / `tags[0]` / `tags[]` notation), decodes it through the schema collecting **every** field error (`errors: "all"`), and returns a serialisable [`FormState`](src/State.ts) — valid with the decoded data, or invalid with the nested [`FormErrors`](src/Errors.ts) tree and the **posted values preserved** so the form re-renders what the user typed. It is an `Effect`, so a schema that requires decoding services composes naturally; it never fails on the error channel (validation failures live in the returned state).
58
65
  - **`empty()`** seeds a blank, unposted form from the defaults — what a `load` returns to render a create form.
59
66
 
60
- ## What's in the box
67
+ ## API
61
68
 
62
69
  | Module | Purpose |
63
70
  | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64
71
  | [`Form`](src/Form.ts) | `make` — bind a schema to a form (validate + empty); `fromBuilder` — bind a `FormBuilder`. |
65
72
  | [`FormBuilder`](src/FormBuilder.ts) | Compose a form field by field: `addField` / `merge` / `refine` / `refineEffect` / `buildSchema`. |
66
73
  | [`Field`](src/Field.ts) | `makeField` / `makeArrayField` field defs + guards, and blank-default derivation from a schema — `defaultValue` / `defaultEncodedValues` for a builder field, `schemaAt` / `defaultItemAt` for any nested path. |
67
- | [`State`](src/State.ts) | `FormState`, `makeFormState`, `setMessage`, `withError`, `FormStateSchema` (transport). |
68
- | [`Errors`](src/Errors.ts) | The nested `FormErrors` tree + `issuesToErrors` / `flattenErrors` / `getError` / `setError` / `mergeErrors` / `remapIndices`; `FieldValidators` per-source error tracking via `replaceAt` / `setSourceAt` / `clearSourceAt` / `mergeSourced`. |
74
+ | [`State`](src/State.ts) | `FormState`, `make`, `withMessage`, `withError`, `isValid`, `FormStateSchema` (transport). |
75
+ | [`Errors`](src/Errors.ts) | The nested `FormErrors` tree + `issuesToErrors` / `flattenErrors` / `getError` / `setError` / `mergeErrors` / `remapIndices`; `SourcedErrors` per-source tracking via `replaceAt` / `setSourceAt` / `clearSourceAt` / `mergeSourced`. |
69
76
  | [`Issues`](src/Issues.ts) | `FormIssue` + the Effect Schema issue-tree walker. |
70
77
  | [`FormData`](src/FormData.ts) | `formDataToObject` / `parseFieldPath` — structured parsing of an HTML body. |
71
78
  | [`Path`](src/Path.ts) | `getAt` / `setAt` (immutable) along a `FormPath`, `toString` / `fromString` notation conversion, the type-level `FieldPath<T>` / `PathValue<T, P>` deep-key projections a typed field handle is built on, and the `IndexMap` constructors (`removeAt` / `insertAt` / `swapAt` / `moveAt` / `replaceAt` / `clearAll`) + `remapIndex` / `remapKeyedRecord` for keeping per-index state in sync with an array mutation. |
72
79
  | [`Coerce`](src/Coerce.ts) | Tri-state (value / `null` / `undefined`) coercers for the form boundary. |
80
+ | [`Command`](src/Command.ts) | Derive form-input schemas, blank defaults and tri-state create / update adapters from a command-struct pair: `plan`, `formInputFields`, `blankDefaults`, `createAdapter`, `updateAdapter`, `jsonRowsFromField`. |
73
81
  | [`Constraints`](src/Constraints.ts) | `constraintsFromSchema` — native input attributes (`required`, `minlength`/`maxlength`, `min`/`max`, `pattern`, `step`) derived from the schema AST, including into array items; `constraintAt` reads one field's leaf out of the derived tree. |
74
82
  | [`Mode`](src/Mode.ts) | `FormMode` + `parse` — when a form validates (`onSubmit` / `onBlur` / `onChange`), whether it auto-submits, and when a posted form revalidates (`revalidate`). |
75
83
  | [`Result`](src/Result.ts) | `FormActionResult` — the framework-neutral submission outcome (success / failure / redirect / error). |
@@ -79,22 +87,10 @@ Modules are exposed as Effect-style namespaces (`Form`, `State`, `Result`, `Erro
79
87
 
80
88
  - **Coercion is the schema's job.** An HTML body is all strings; build the schema from coercing leaves (`Schema.FiniteFromString`, `Schema.BooleanFromString`, the `Coerce` helpers) so the parsed object decodes cleanly. The parser never guesses types.
81
89
  - **Errors nest by field.** Leaf fields are `string[]`; nested objects / array indices nest; a branch's own (cross-field) message lives under `_errors`. The tree is plain JSON — it crosses the SSR boundary unchanged.
82
- - **Update semantics.** The `Coerce` helpers honour the value / `null` / `undefined` contract (set / clear / carry-forward) the app uses for partial updates.
83
- - **Constraint derivation reads schema checks, not just optionality.** `constraintsFromSchema` walks each property's `.check()`s for the `meta` annotation Effect Schema's built-in checks carry (`isMinLength`, `isLengthBetween`, `isPattern`, `isGreaterThanOrEqualTo`, `isBetween`, `isMultipleOf`, `isInt`, …), unwraps `optional` / `NullOr` unions, and descends into struct and array-item shapes. A check with no recognised `meta` tag (a bare `Schema.filter` predicate) derives nothing — there's no attribute to guess at. Anything the walk can't (or shouldn't) infer goes through the `overrides` argument, which always wins on conflict.
90
+ - **Update semantics.** The `Coerce` helpers honour the value / `null` / `undefined` contract (set / clear / carry-forward) for partial updates.
91
+ - **Constraint derivation reads schema checks, not just optionality.** `constraintsFromSchema` walks each property's `.check()`s for the `meta` annotation Effect Schema's built-in checks carry (`isMinLength`, `isBetweenLength`, `isPattern`, `isGreaterThanOrEqualTo`, `isBetween`, `isMultipleOf`, `isInt`, …), unwraps `optional` / `NullOr` unions, and descends into struct and array-item shapes. A check with no recognised `meta` tag (a bare `Schema.filter` predicate) derives nothing — there's no attribute to guess at. Anything the walk can't (or shouldn't) infer goes through the `overrides` argument, which always wins on conflict.
84
92
  - **Errors have a source.** `Errors.SourcedErrors` keeps a `client` tree (browser-side validation) parallel to a `server` tree (the last submission), merged for display by `mergeSourced`. `Errors.replaceAt` / `setSourceAt` / `clearSourceAt` update one path in one source without disturbing the rest — what a reactive store needs so a server-reported failure survives a client revalidation of some _other_ field, but clears once its own field changes.
85
93
 
86
- ## File naming
87
-
88
- Source modules use PascalCase file names, matching Effect's own convention (`effect/Schema`, `effect/SchemaParser`). The package has a single entry point: every module is re-exported from the `.` barrel, so there are no subpaths to import from.
89
-
90
- ## Development
91
-
92
- From the repository root:
93
-
94
- ```sh
95
- vp run -r build # build every package with tsdown
96
- vp check # format (Oxfmt), lint (Oxlint), type-check (tsgolint)
97
- vp run -r test # unit tests
98
- ```
94
+ ## License
99
95
 
100
- Or from this directory: `vp test run` / `vp test run --coverage`.
96
+ [MIT](LICENSE)
package/dist/index.d.ts CHANGED
@@ -7,8 +7,6 @@ declare namespace Coerce_d_exports {
7
7
  export { booleanFromCheckbox, emptyToNull, emptyToUndefined, numberFromField, trimToNull, trimToUndefined };
8
8
  }
9
9
  /**
10
- * @copyright Thomas Foster
11
- *
12
10
  * Tri-state coercers for the HTML-form boundary.
13
11
  *
14
12
  * An HTML form submits every value as a string, and an absent optional field
@@ -22,6 +20,7 @@ declare namespace Coerce_d_exports {
22
20
  * framework's `error()` helper (validation failures belong on the schema, not
23
21
  * in a coercer).
24
22
  *
23
+ * @copyright Thomas Foster
25
24
  * @module effect-forms/Coerce
26
25
  */
27
26
  /** Empty string / `undefined` → `undefined`; otherwise the value (skip semantics). */
@@ -143,7 +142,7 @@ declare const createAdapter: (commandPlan: CommandPlan, overrides?: AdaptOverrid
143
142
  */
144
143
  declare const updateAdapter: (commandPlan: CommandPlan, overrides?: AdaptOverrides) => (input: FormInputRecord) => Record<string, unknown>;
145
144
  declare namespace Issues_d_exports {
146
- export { FormIssue, FormPath, LeafFormatter, defaultLeafFormatter, filterOutputToFormIssues, schemaIssuesToFormIssues };
145
+ export { FormIssue, FormPath, LeafFormatter, defaultLeafFormatter, filterOutputToFormIssues$1 as filterOutputToFormIssues, schemaIssuesToFormIssues };
147
146
  }
148
147
  /**
149
148
  * A path into the form's data. String segments address object keys; number
@@ -157,13 +156,19 @@ interface FormIssue {
157
156
  readonly message: string;
158
157
  }
159
158
  /**
160
- * Render one terminal issue leaf into a human-readable message. The default
161
- * defers to the issue's own `toString`, which Effect Schema renders into a
162
- * readable sentence (e.g. `Expected string, got 1`). Override it to localise
163
- * or to map specific leaf `_tag`s to custom copy.
159
+ * Render one terminal issue into a human-readable message. The default is
160
+ * Effect Schema's own formatter (`SchemaIssue.makeFormatterDefault`), which
161
+ * renders a sentence such as `Expected string`. (An issue's `toString` is no
162
+ * longer a readable rendering as of Effect rc.118: it prints
163
+ * `[object Object]`.) Override it to localise or to map specific `_tag`s to
164
+ * custom copy.
165
+ *
166
+ * A failed schema check reaches the formatter as the `Filter` node that
167
+ * wraps its leaf, because the check's own message (e.g. `Expected a value
168
+ * with a length of at least 2`) lives on the `Filter`, not on the leaf.
164
169
  */
165
170
  type LeafFormatter = (issue: SchemaIssue.Issue) => string;
166
- /** The default leaf formatter — Effect Schema's own rendering of the leaf. */
171
+ /** The default leaf formatter — Effect Schema's own rendering of the issue. */
167
172
  declare const defaultLeafFormatter: LeafFormatter;
168
173
  /**
169
174
  * Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
@@ -180,7 +185,7 @@ declare function schemaIssuesToFormIssues(issue: SchemaIssue.Issue, format?: Lea
180
185
  * ({@link module:effect-forms/Validate}) so both fold into the same nested
181
186
  * error tree as schema-decode failures.
182
187
  */
183
- declare function filterOutputToFormIssues(output: Schema.FilterOutput): FormIssue[];
188
+ declare function filterOutputToFormIssues$1(output: Schema.FilterOutput): FormIssue[];
184
189
  declare namespace Constraints_d_exports {
185
190
  export { InputConstraint, InputConstraints, constraintAt, constraintsFromSchema, isLeafConstraint };
186
191
  }
@@ -521,6 +526,14 @@ declare const empty: FormBuilder<Record<never, never>, never>;
521
526
  declare function buildSchema<TFields extends FieldsRecord, R>(self: FormBuilder<TFields, R>): Schema.Codec<DecodedFromFields<TFields>, EncodedFromFields<TFields>, R>;
522
527
  /** The asynchronous refinements carried on a builder, as bare predicates. */
523
528
  declare function asyncRefinements<TFields extends FieldsRecord, R>(self: FormBuilder<TFields, R>): ReadonlyArray<(values: DecodedFromFields<TFields>) => Effect.Effect<Schema.FilterOutput, never, R>>;
529
+ /**
530
+ * Normalise a filter predicate's `Schema.FilterOutput` into a flat list of
531
+ * {@link FormIssue}s (empty on success).
532
+ *
533
+ * @deprecated Use `Issues.filterOutputToFormIssues`; this re-export remains
534
+ * only for callers that imported it from `FormBuilder`.
535
+ */
536
+ declare const filterOutputToFormIssues: typeof filterOutputToFormIssues$1;
524
537
  /**
525
538
  * Run every asynchronous refinement on `self` against `values` and collect
526
539
  * their failures as {@link FormIssue}s (empty when all pass). Runs them
@@ -555,11 +568,17 @@ interface FormState<A, Message = unknown> {
555
568
  }
556
569
  /** Options for {@link make}. */
557
570
  interface MakeFormStateOptions<Message> {
571
+ /** The form's id, distinguishing several forms on one page. Defaults to `"default"`. */
558
572
  readonly id?: string;
573
+ /** Whether the state is valid. Defaults to `true` exactly when `errors` is empty. */
559
574
  readonly valid?: boolean;
575
+ /** Whether the state comes from a submission. Defaults to `false`. */
560
576
  readonly posted?: boolean;
577
+ /** The nested error tree. Defaults to no errors. */
561
578
  readonly errors?: FormErrors;
579
+ /** A status message to render with the form. Omitted by default. */
562
580
  readonly message?: Message;
581
+ /** Native input constraints. Omitted by default. */
563
582
  readonly constraints?: InputConstraints;
564
583
  }
565
584
  /** Build a {@link FormState} from data plus overrides. `valid` defaults to "no errors". */
@@ -618,8 +637,11 @@ interface Form<S extends Schema.Top, Message = unknown> {
618
637
  validate(input: FormInput): Effect.Effect<FormState<S["Type"], Message>, never, S["DecodingServices"]>;
619
638
  }
620
639
  /**
621
- * Bind `schema` to a form. See the module docs for the contract. The returned
622
- * {@link Form} is stateless and reusable across requests.
640
+ * Bind `schema` to a form: `validate(input)` decodes a submission into a
641
+ * {@link FormState} (it never fails — validation failures live in the state;
642
+ * its requirements are the schema's decoding services) and `empty()` seeds a
643
+ * blank form from `defaults`. The returned {@link Form} is stateless and
644
+ * reusable across requests.
623
645
  */
624
646
  declare function make<S extends Schema.Top, Message = unknown>(schema: S, options: MakeFormOptions<S>): Form<S, Message>;
625
647
  /** Options for {@link fromBuilder}. Like {@link MakeFormOptions} but `defaults` is optional. */
@@ -647,8 +669,6 @@ declare namespace FormData_d_exports {
647
669
  export { FormBodySource, formDataToObject, parseFieldPath, requestToObject };
648
670
  }
649
671
  /**
650
- * @copyright Thomas Foster
651
- *
652
672
  * Structured parsing of an HTML form body into a plain object the schema can
653
673
  * decode.
654
674
  *
@@ -664,6 +684,7 @@ declare namespace FormData_d_exports {
664
684
  * `File` values pass through untouched; repeated bare keys collapse into an
665
685
  * array. The parser is total and never throws.
666
686
  *
687
+ * @copyright Thomas Foster
667
688
  * @module effect-forms/FormData
668
689
  */
669
690
  /** Anything that can be parsed into a structured form object. */
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import * as Schema from "effect/Schema";
3
3
  import * as SchemaAST from "effect/SchemaAST";
4
4
  import * as Effect from "effect/Effect";
5
5
  import * as SchemaParser from "effect/SchemaParser";
6
- import "effect/SchemaIssue";
6
+ import * as SchemaIssue from "effect/SchemaIssue";
7
7
  import * as Duration from "effect/Duration";
8
8
  //#region src/Coerce.ts
9
9
  var Coerce_exports = /* @__PURE__ */ __exportAll({
@@ -15,8 +15,6 @@ var Coerce_exports = /* @__PURE__ */ __exportAll({
15
15
  trimToUndefined: () => trimToUndefined
16
16
  });
17
17
  /**
18
- * @copyright Thomas Foster
19
- *
20
18
  * Tri-state coercers for the HTML-form boundary.
21
19
  *
22
20
  * An HTML form submits every value as a string, and an absent optional field
@@ -30,6 +28,7 @@ var Coerce_exports = /* @__PURE__ */ __exportAll({
30
28
  * framework's `error()` helper (validation failures belong on the schema, not
31
29
  * in a coercer).
32
30
  *
31
+ * @copyright Thomas Foster
33
32
  * @module effect-forms/Coerce
34
33
  */
35
34
  /** Empty string / `undefined` → `undefined`; otherwise the value (skip semantics). */
@@ -73,8 +72,6 @@ function booleanFromCheckbox(raw) {
73
72
  //#endregion
74
73
  //#region src/Command.ts
75
74
  /**
76
- * @copyright Thomas Foster
77
- *
78
75
  * Command-struct introspection and form derivation.
79
76
  *
80
77
  * A host app's write surface is a pair of flat command `Schema.Struct`s per
@@ -102,6 +99,7 @@ function booleanFromCheckbox(raw) {
102
99
  * (a create-time default, an asymmetric coercion) stay expressible via the
103
100
  * per-field {@link AdaptOverrides} escape hatch instead of forcing ejection.
104
101
  *
102
+ * @copyright Thomas Foster
105
103
  * @module effect-forms/Command
106
104
  */
107
105
  var Command_exports = /* @__PURE__ */ __exportAll({
@@ -441,7 +439,7 @@ function flattenChecks(checks, out) {
441
439
  const payloadPatches = {
442
440
  "effect/schema/isMinLength": (p) => ({ minlength: p.minLength }),
443
441
  "effect/schema/isMaxLength": (p) => ({ maxlength: p.maxLength }),
444
- "effect/schema/isLengthBetween": (p) => ({
442
+ "effect/schema/isBetweenLength": (p) => ({
445
443
  minlength: p.minimum,
446
444
  maxlength: p.maximum
447
445
  }),
@@ -621,9 +619,10 @@ const isIndexToken = (token) => token === "" || /^\d+$/.test(token);
621
619
  /** Create the container a token should live in: array for index/append tokens. */
622
620
  const makeContainer = (nextToken) => isIndexToken(nextToken) ? [] : {};
623
621
  const setOnContainer = (container, token, value) => {
624
- if (Array.isArray(container)) if (token === "") container.push(value);
625
- else container[Number(token)] = value;
626
- else container[token] = value;
622
+ if (Array.isArray(container)) {
623
+ if (token === "") container.push(value);
624
+ else container[Number(token)] = value;
625
+ } else container[token] = value;
627
626
  };
628
627
  const getFromContainer = (container, token) => Array.isArray(container) ? container[Number(token)] : container[token];
629
628
  /** Assign `value` deep into `root` along `tokens`, creating containers as needed. */
@@ -681,8 +680,6 @@ async function requestToObject(request) {
681
680
  //#endregion
682
681
  //#region src/Path.ts
683
682
  /**
684
- * @copyright Thomas Foster
685
- *
686
683
  * Nested get / set along a {@link FormPath}.
687
684
  *
688
685
  * A form's data is a tree, and the reactive bindings address it by path:
@@ -700,6 +697,7 @@ async function requestToObject(request) {
700
697
  * convert to and from the flat `author.name` / `tags[0]` notation the HTML
701
698
  * body parser ({@link module:effect-forms/FormData}) uses.
702
699
  *
700
+ * @copyright Thomas Foster
703
701
  * @module effect-forms/Path
704
702
  */
705
703
  var Path_exports = /* @__PURE__ */ __exportAll({
@@ -1170,11 +1168,11 @@ function defaultEncodedValues(fields) {
1170
1168
  //#region src/Issues.ts
1171
1169
  var Issues_exports = /* @__PURE__ */ __exportAll({
1172
1170
  defaultLeafFormatter: () => defaultLeafFormatter,
1173
- filterOutputToFormIssues: () => filterOutputToFormIssues,
1171
+ filterOutputToFormIssues: () => filterOutputToFormIssues$1,
1174
1172
  schemaIssuesToFormIssues: () => schemaIssuesToFormIssues
1175
1173
  });
1176
- /** The default leaf formatter — Effect Schema's own rendering of the leaf. */
1177
- const defaultLeafFormatter = (issue) => String(issue);
1174
+ /** The default leaf formatter — Effect Schema's own rendering of the issue. */
1175
+ const defaultLeafFormatter = SchemaIssue.makeFormatterDefault();
1178
1176
  /**
1179
1177
  * Normalise a `Pointer.path` segment into a {@link FormPath} segment: integer
1180
1178
  * keys become numbers (array indices), everything else becomes a string.
@@ -1184,6 +1182,17 @@ const normaliseSegment = (segment) => {
1184
1182
  if (typeof segment === "symbol") return segment.toString();
1185
1183
  return /^\d+$/.test(segment) ? Number(segment) : segment;
1186
1184
  };
1185
+ /** Whether `issue` is a terminal leaf: it wraps no further issues. */
1186
+ const isTerminal = (issue) => {
1187
+ switch (issue._tag) {
1188
+ case "Pointer":
1189
+ case "Composite":
1190
+ case "Filter":
1191
+ case "Encoding": return false;
1192
+ case "AnyOf": return issue.issues.length === 0;
1193
+ default: return true;
1194
+ }
1195
+ };
1187
1196
  /**
1188
1197
  * Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
1189
1198
  * failure. Path segments accumulate through `Pointer` nodes; `Composite` and
@@ -1212,6 +1221,15 @@ function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
1212
1221
  for (const child of current.issues) walk(child, path);
1213
1222
  return;
1214
1223
  case "Filter":
1224
+ if (isTerminal(current.issue)) {
1225
+ out.push({
1226
+ path,
1227
+ message: format(current)
1228
+ });
1229
+ return;
1230
+ }
1231
+ walk(current.issue, path);
1232
+ return;
1215
1233
  case "Encoding":
1216
1234
  walk(current.issue, path);
1217
1235
  return;
@@ -1233,7 +1251,7 @@ function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
1233
1251
  * ({@link module:effect-forms/Validate}) so both fold into the same nested
1234
1252
  * error tree as schema-decode failures.
1235
1253
  */
1236
- function filterOutputToFormIssues(output) {
1254
+ function filterOutputToFormIssues$1(output) {
1237
1255
  if (output === void 0 || output === true) return [];
1238
1256
  if (output === false) return [{
1239
1257
  path: [],
@@ -1262,8 +1280,6 @@ function filterIssueToFormIssues(issue) {
1262
1280
  //#endregion
1263
1281
  //#region src/FormBuilder.ts
1264
1282
  /**
1265
- * @copyright Thomas Foster
1266
- *
1267
1283
  * Compose a form schema one field at a time, with cross-field refinements.
1268
1284
  *
1269
1285
  * {@link make} (aliased {@link empty}) starts an empty builder;
@@ -1284,6 +1300,7 @@ function filterIssueToFormIssues(issue) {
1284
1300
  * path — {@link asyncRefinements} exposes them and {@link runAsyncRefinements}
1285
1301
  * runs them into a {@link FormIssue} list.
1286
1302
  *
1303
+ * @copyright Thomas Foster
1287
1304
  * @module effect-forms/FormBuilder
1288
1305
  */
1289
1306
  var FormBuilder_exports = /* @__PURE__ */ __exportAll({
@@ -1356,6 +1373,14 @@ function asyncRefinements(self) {
1356
1373
  return self.refinements.filter((r) => r._tag === "async").map((r) => r.fn);
1357
1374
  }
1358
1375
  /**
1376
+ * Normalise a filter predicate's `Schema.FilterOutput` into a flat list of
1377
+ * {@link FormIssue}s (empty on success).
1378
+ *
1379
+ * @deprecated Use `Issues.filterOutputToFormIssues`; this re-export remains
1380
+ * only for callers that imported it from `FormBuilder`.
1381
+ */
1382
+ const filterOutputToFormIssues = filterOutputToFormIssues$1;
1383
+ /**
1359
1384
  * Run every asynchronous refinement on `self` against `values` and collect
1360
1385
  * their failures as {@link FormIssue}s (empty when all pass). Runs them
1361
1386
  * concurrently; requirements propagate on the `R` channel.
@@ -1363,13 +1388,11 @@ function asyncRefinements(self) {
1363
1388
  function runAsyncRefinements(self, values) {
1364
1389
  const refinements = asyncRefinements(self);
1365
1390
  if (refinements.length === 0) return Effect.succeed([]);
1366
- return Effect.forEach(refinements, (fn) => fn(values), { concurrency: "unbounded" }).pipe(Effect.map((outputs) => outputs.flatMap(filterOutputToFormIssues)));
1391
+ return Effect.forEach(refinements, (fn) => fn(values), { concurrency: "unbounded" }).pipe(Effect.map((outputs) => outputs.flatMap(filterOutputToFormIssues$1)));
1367
1392
  }
1368
1393
  //#endregion
1369
1394
  //#region src/State.ts
1370
1395
  /**
1371
- * @copyright Thomas Foster
1372
- *
1373
1396
  * {@link FormState} — the serialisable snapshot of a form.
1374
1397
  *
1375
1398
  * This is the single object a
@@ -1378,6 +1401,7 @@ function runAsyncRefinements(self, values) {
1378
1401
  * boundary, and {@link FormStateSchema} gives it an Effect Schema so it can be
1379
1402
  * decoded back on arrival.
1380
1403
  *
1404
+ * @copyright Thomas Foster
1381
1405
  * @module effect-forms/State
1382
1406
  */
1383
1407
  var State_exports = /* @__PURE__ */ __exportAll({
@@ -1436,8 +1460,6 @@ function FormStateSchema(data) {
1436
1460
  //#endregion
1437
1461
  //#region src/Form.ts
1438
1462
  /**
1439
- * @copyright Thomas Foster
1440
- *
1441
1463
  * {@link make} — the core entry point: bind an Effect Schema to a form and
1442
1464
  * get back a validator and an empty-state factory.
1443
1465
  *
@@ -1448,9 +1470,8 @@ function FormStateSchema(data) {
1448
1470
  * it through the schema, returning a {@link FormState} — valid with the
1449
1471
  * decoded data, or invalid with the nested {@link FormErrors} and the posted
1450
1472
  * values preserved so the form re-renders what the user typed. It is an
1451
- * `Effect`, so a schema requiring decoding services composes naturally and
1452
- * the caller stays on the typed error channel — there is none; validation
1453
- * failures live in the returned state, not the error channel.
1473
+ * `Effect`, so a schema requiring decoding services composes naturally; it
1474
+ * has no error channel — validation failures live in the returned state.
1454
1475
  * - `empty()` seeds an unposted, invalid-by-default form from the supplied
1455
1476
  * defaults — what a `load` returns to render a blank create form.
1456
1477
  *
@@ -1459,6 +1480,7 @@ function FormStateSchema(data) {
1459
1480
  * {@link module:effect-forms/Coerce} helpers, …) so a multipart body decodes
1460
1481
  * cleanly.
1461
1482
  *
1483
+ * @copyright Thomas Foster
1462
1484
  * @module effect-forms/Form
1463
1485
  */
1464
1486
  var Form_exports = /* @__PURE__ */ __exportAll({
@@ -1472,8 +1494,11 @@ const toStructured = (input) => {
1472
1494
  return Effect.succeed(input);
1473
1495
  };
1474
1496
  /**
1475
- * Bind `schema` to a form. See the module docs for the contract. The returned
1476
- * {@link Form} is stateless and reusable across requests.
1497
+ * Bind `schema` to a form: `validate(input)` decodes a submission into a
1498
+ * {@link FormState} (it never fails — validation failures live in the state;
1499
+ * its requirements are the schema's decoding services) and `empty()` seeds a
1500
+ * blank form from `defaults`. The returned {@link Form} is stateless and
1501
+ * reusable across requests.
1477
1502
  */
1478
1503
  function make(schema, options) {
1479
1504
  const id = options.id ?? "default";
@@ -1569,8 +1594,6 @@ function fromBuilder(builder, options = {}) {
1569
1594
  //#endregion
1570
1595
  //#region src/Mode.ts
1571
1596
  /**
1572
- * @copyright Thomas Foster
1573
- *
1574
1597
  * When a form validates (and, optionally, auto-submits).
1575
1598
  *
1576
1599
  * A reactive form has to decide *when* to run validation as the user types.
@@ -1595,6 +1618,7 @@ function fromBuilder(builder, options = {}) {
1595
1618
  * once the form has been posted at least once; `"never"` keeps the classic
1596
1619
  * frozen-until-resubmit behaviour.
1597
1620
  *
1621
+ * @copyright Thomas Foster
1598
1622
  * @module effect-forms/Mode
1599
1623
  */
1600
1624
  var Mode_exports = /* @__PURE__ */ __exportAll({ parse: () => parse });
@@ -1662,8 +1686,6 @@ const errorResult = (message, status = 500) => ({
1662
1686
  //#endregion
1663
1687
  //#region src/Validate.ts
1664
1688
  /**
1665
- * @copyright Thomas Foster
1666
- *
1667
1689
  * Low-level validation helpers — decode a value through a schema and get back
1668
1690
  * the {@link FormErrors} tree (or the list of {@link FormIssue}s) instead of a
1669
1691
  * raised failure. `Form.make` is the high-level entry; these are for ad
@@ -1680,6 +1702,7 @@ const errorResult = (message, status = 500) => ({
1680
1702
  * and interrupting the previous fiber on every new run (see that function's
1681
1703
  * docs for why that alone gives debounce + "latest wins" cancellation).
1682
1704
  *
1705
+ * @copyright Thomas Foster
1683
1706
  * @module effect-forms/Validate
1684
1707
  */
1685
1708
  var Validate_exports = /* @__PURE__ */ __exportAll({
@@ -1725,7 +1748,7 @@ function validateValueSync(schema, input, format) {
1725
1748
  * cannot fail the Effect error channel.
1726
1749
  */
1727
1750
  function runSyncFieldValidator(validator, value) {
1728
- return filterOutputToFormIssues(validator(value));
1751
+ return filterOutputToFormIssues$1(validator(value));
1729
1752
  }
1730
1753
  /**
1731
1754
  * Build the Effect an {@link AsyncFieldValidator} run is: sleep `debounceMs`
@@ -1745,7 +1768,7 @@ function runSyncFieldValidator(validator, value) {
1745
1768
  function runAsyncFieldValidator(validator, value, debounceMs = 0) {
1746
1769
  return Effect.gen(function* () {
1747
1770
  if (debounceMs > 0) yield* Effect.sleep(Duration.millis(debounceMs));
1748
- return filterOutputToFormIssues(yield* validator(value));
1771
+ return filterOutputToFormIssues$1(yield* validator(value));
1749
1772
  });
1750
1773
  }
1751
1774
  /**