@thomasfosterau/effect-forms 0.1.0 → 0.3.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.
package/dist/index.js CHANGED
@@ -1,9 +1,15 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
+ import * as Arr from "effect/Array";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Match from "effect/Match";
5
+ import * as Predicate from "effect/Predicate";
6
+ import * as Rec from "effect/Record";
2
7
  import * as Schema from "effect/Schema";
3
8
  import * as SchemaAST from "effect/SchemaAST";
4
- import * as Effect from "effect/Effect";
5
9
  import * as SchemaParser from "effect/SchemaParser";
6
- import "effect/SchemaIssue";
10
+ import * as Option from "effect/Option";
11
+ import * as Effect from "effect/Effect";
12
+ import * as SchemaIssue from "effect/SchemaIssue";
7
13
  import * as Duration from "effect/Duration";
8
14
  //#region src/Coerce.ts
9
15
  var Coerce_exports = /* @__PURE__ */ __exportAll({
@@ -15,8 +21,6 @@ var Coerce_exports = /* @__PURE__ */ __exportAll({
15
21
  trimToUndefined: () => trimToUndefined
16
22
  });
17
23
  /**
18
- * @copyright Thomas Foster
19
- *
20
24
  * Tri-state coercers for the HTML-form boundary.
21
25
  *
22
26
  * An HTML form submits every value as a string, and an absent optional field
@@ -30,6 +34,7 @@ var Coerce_exports = /* @__PURE__ */ __exportAll({
30
34
  * framework's `error()` helper (validation failures belong on the schema, not
31
35
  * in a coercer).
32
36
  *
37
+ * @copyright Thomas Foster
33
38
  * @module effect-forms/Coerce
34
39
  */
35
40
  /** Empty string / `undefined` → `undefined`; otherwise the value (skip semantics). */
@@ -56,7 +61,7 @@ function trimToNull(value) {
56
61
  }
57
62
  function numberFromField(raw, { nullable }) {
58
63
  const coerced = nullable ? emptyToNull(raw) : emptyToUndefined(raw);
59
- return typeof coerced === "string" ? Number(coerced) : coerced;
64
+ return coerced === void 0 || coerced === null ? coerced : Number(coerced);
60
65
  }
61
66
  /**
62
67
  * Interpret a checkbox field. An HTML checkbox submits its `value` (default
@@ -73,8 +78,6 @@ function booleanFromCheckbox(raw) {
73
78
  //#endregion
74
79
  //#region src/Command.ts
75
80
  /**
76
- * @copyright Thomas Foster
77
- *
78
81
  * Command-struct introspection and form derivation.
79
82
  *
80
83
  * A host app's write surface is a pair of flat command `Schema.Struct`s per
@@ -102,6 +105,7 @@ function booleanFromCheckbox(raw) {
102
105
  * (a create-time default, an asymmetric coercion) stay expressible via the
103
106
  * per-field {@link AdaptOverrides} escape hatch instead of forcing ejection.
104
107
  *
108
+ * @copyright Thomas Foster
105
109
  * @module effect-forms/Command
106
110
  */
107
111
  var Command_exports = /* @__PURE__ */ __exportAll({
@@ -112,23 +116,21 @@ var Command_exports = /* @__PURE__ */ __exportAll({
112
116
  plan: () => plan,
113
117
  updateAdapter: () => updateAdapter
114
118
  });
119
+ /** A command field {@link plan} cannot classify into any {@link FieldKind}. */
120
+ var UnclassifiableFieldError = class extends Schema.TaggedError()("UnclassifiableFieldError", { message: Schema.String }) {};
121
+ /** A malformed `jsonArray` field payload, as {@link jsonRowsFromField} rejects it. */
122
+ var InvalidJsonRowsError = class extends Schema.TaggedError()("InvalidJsonRowsError", { message: Schema.String }) {};
115
123
  /**
116
124
  * Run one decode probe: whether the schema accepts the input, plus the decoded
117
125
  * value for instance checks (the date probe needs to see a `Date` come out,
118
126
  * not just a successful decode).
119
127
  */
120
128
  const probe = (schema, input) => {
121
- try {
122
- return {
123
- ok: true,
124
- value: Schema.decodeUnknownSync(schema)(input)
125
- };
126
- } catch {
127
- return { ok: false };
128
- }
129
+ const decoder = schema;
130
+ return SchemaParser.decodeUnknownExit(decoder)(input);
129
131
  };
130
- /** True when a schema decodes the given input without throwing. */
131
- const decodes = (schema, input) => probe(schema, input).ok;
132
+ /** True when a schema decodes the given input without failing. */
133
+ const decodes = (schema, input) => Exit.isSuccess(probe(schema, input));
132
134
  /** A syntactically valid UUID no other field kind accepts as non-text. */
133
135
  const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
134
136
  /**
@@ -143,13 +145,10 @@ const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
143
145
  * `enumOptions` below needs the full literal set, not just one branch of it.
144
146
  */
145
147
  function unwrapWrapperUnion(ast) {
146
- let current = ast;
147
- while (SchemaAST.isUnion(current)) {
148
- const real = current.types.filter((t) => !SchemaAST.isUndefined(t) && !SchemaAST.isNull(t));
149
- if (real.length !== 1 || real.length === current.types.length) break;
150
- current = real[0];
151
- }
152
- return current;
148
+ if (!SchemaAST.isUnion(ast)) return ast;
149
+ const real = Arr.filter(ast.types, (t) => !SchemaAST.isUndefined(t) && !SchemaAST.isNull(t));
150
+ if (real.length !== 1 || real.length === ast.types.length) return ast;
151
+ return unwrapWrapperUnion(real[0]);
153
152
  }
154
153
  /**
155
154
  * The literal option values of a field shaped like `Schema.Literal(...)` or
@@ -162,8 +161,8 @@ function enumOptions(ast) {
162
161
  const inner = unwrapWrapperUnion(ast);
163
162
  if (SchemaAST.isLiteral(inner)) return [String(inner.literal)];
164
163
  if (SchemaAST.isUnion(inner)) {
165
- const literals = inner.types.filter(SchemaAST.isLiteral);
166
- if (literals.length === inner.types.length && literals.length > 0) return literals.map((literal) => String(literal.literal));
164
+ const literals = Arr.filter(inner.types, SchemaAST.isLiteral);
165
+ if (Arr.isReadonlyArrayNonEmpty(literals) && literals.length === inner.types.length) return Arr.map(literals, (literal) => String(literal.literal));
167
166
  }
168
167
  }
169
168
  /**
@@ -189,7 +188,7 @@ const probeKind = (schema) => {
189
188
  if (decodes(schema, true)) return "boolean";
190
189
  if (decodes(schema, 42)) return "number";
191
190
  const date = probe(schema, "2024-01-02T03:04:05.000Z");
192
- if (date.ok && date.value instanceof Date) return "date";
191
+ if (Exit.isSuccess(date) && date.value instanceof Date) return "date";
193
192
  if (decodes(schema, PROBE_UUID) && !decodes(schema, "not-a-uuid")) return "id";
194
193
  if (decodes(schema, "plain text")) return "text";
195
194
  };
@@ -216,7 +215,7 @@ const classify = (key, schema) => {
216
215
  const probed = probeKind(schema);
217
216
  if (probed !== void 0) return { kind: probed };
218
217
  if (isStructured(schema.ast)) return { kind: "structured" };
219
- throw new Error(`Command.plan: field "${key}" matches no known kind (text/id/number/date/jsonArray/boolean/enum/structured) — a Schema.declare'd type needs an explicit form-level treatment before it can derive`);
218
+ throw new UnclassifiableFieldError({ message: `Command.plan: field "${key}" matches no known kind (text/id/number/date/jsonArray/boolean/enum/structured) — a Schema.declare'd type needs an explicit form-level treatment before it can derive` });
220
219
  };
221
220
  /**
222
221
  * Read a create/update command struct pair into a {@link CommandPlan}.
@@ -226,22 +225,23 @@ const classify = (key, schema) => {
226
225
  const plan = (create, update) => {
227
226
  const createFields = create.fields;
228
227
  const updateFields = update.fields;
229
- const keys = [];
230
- for (const key in updateFields) if (key !== "id") keys.push(key);
231
- for (const key in createFields) if (key !== "id" && !(key in updateFields)) keys.push(key);
228
+ const keys = [...Arr.filter(Rec.keys(updateFields), (key) => key !== "id"), ...Arr.filter(Rec.keys(createFields), (key) => key !== "id" && !(key in updateFields))];
232
229
  return {
233
- fields: keys.map((key) => {
230
+ fields: Arr.map(keys, (key) => {
234
231
  const createField = createFields[key];
235
232
  const updateField = updateFields[key];
236
233
  const { kind, options } = classify(key, createField ?? updateField);
237
- return {
234
+ const fieldPlan = {
238
235
  key,
239
236
  kind,
240
237
  inCreate: createField !== void 0,
241
238
  createRequired: createField !== void 0 && !decodes(createField, void 0),
242
239
  inUpdate: updateField !== void 0,
243
- updateClearable: updateField !== void 0 && decodes(updateField, null),
244
- ...options !== void 0 ? { options } : {}
240
+ updateClearable: updateField !== void 0 && decodes(updateField, null)
241
+ };
242
+ return options === void 0 ? fieldPlan : {
243
+ ...fieldPlan,
244
+ options
245
245
  };
246
246
  }),
247
247
  clientId: "id" in createFields
@@ -255,17 +255,16 @@ const plan = (create, update) => {
255
255
  * "create") })`, with `overrides` spread last for irregular fields.
256
256
  */
257
257
  const formInputFields = (commandPlan, mode, overrides = {}) => {
258
- const fields = {};
259
- if (mode === "create" && commandPlan.clientId) fields.id = Schema.optional(Schema.String);
260
- if (mode === "update") fields.id = Schema.String.check(Schema.isUUID());
261
- for (const field of commandPlan.fields) {
262
- if (mode === "create" ? !field.inCreate : !field.inUpdate) continue;
263
- fields[field.key] = mode === "create" && field.createRequired ? Schema.String.check(Schema.isMinLength(1)) : Schema.optional(Schema.String);
264
- }
265
- return {
266
- ...fields,
267
- ...overrides
268
- };
258
+ const idField = mode === "update" ? [["id", Schema.String.check(Schema.isUUID())]] : commandPlan.clientId ? [["id", Schema.optional(Schema.String)]] : [];
259
+ const planFields = Arr.flatMap(commandPlan.fields, (field) => {
260
+ if (mode === "create" ? !field.inCreate : !field.inUpdate) return [];
261
+ return [[field.key, mode === "create" && field.createRequired ? Schema.String.check(Schema.isMinLength(1)) : Schema.optional(Schema.String)]];
262
+ });
263
+ return Rec.fromEntries([
264
+ ...idField,
265
+ ...planFields,
266
+ ...Rec.toEntries(overrides)
267
+ ]);
269
268
  };
270
269
  /**
271
270
  * Blank string defaults for one mode's Form-Input shape. Client-id entities
@@ -273,13 +272,8 @@ const formInputFields = (commandPlan, mode, overrides = {}) => {
273
272
  * crypto.randomUUID() }`) so retries stay idempotent.
274
273
  */
275
274
  const blankDefaults = (commandPlan, mode) => {
276
- const defaults = {};
277
- if (mode === "update" || commandPlan.clientId) defaults.id = "";
278
- for (const field of commandPlan.fields) {
279
- if (mode === "create" ? !field.inCreate : !field.inUpdate) continue;
280
- defaults[field.key] = "";
281
- }
282
- return defaults;
275
+ const keys = [...mode === "update" || commandPlan.clientId ? ["id"] : [], ...Arr.flatMap(commandPlan.fields, (field) => (mode === "create" ? field.inCreate : field.inUpdate) ? [field.key] : [])];
276
+ return Rec.fromEntries(Arr.map(keys, (key) => [key, ""]));
283
277
  };
284
278
  /**
285
279
  * Parse a JSON-encoded row list (a contributor list) from a multipart form
@@ -291,25 +285,28 @@ const blankDefaults = (commandPlan, mode) => {
291
285
  */
292
286
  const jsonRowsFromField = (raw, label) => {
293
287
  if (raw === void 0 || raw.trim() === "") return void 0;
294
- let parsed;
295
- try {
296
- parsed = JSON.parse(raw);
297
- } catch {
298
- throw new Error(`Invalid ${label} payload — expected JSON array`);
299
- }
300
- if (!Array.isArray(parsed)) throw new Error(`Invalid ${label} payload — expected array`);
301
- return parsed.map((row, index) => {
302
- if (typeof row !== "object" || row === null) throw new Error(`Invalid ${label} entry at index ${index}`);
303
- const r = row;
304
- if (typeof r.agentId !== "string") throw new Error(`${label} entry at index ${index} missing agentId`);
288
+ const decoded = decodeJson(raw);
289
+ if (Exit.isFailure(decoded)) throw new InvalidJsonRowsError({ message: `Invalid ${label} payload — expected JSON array` });
290
+ const parsed = decoded.value;
291
+ if (!Arr.isArray(parsed)) throw new InvalidJsonRowsError({ message: `Invalid ${label} payload — expected array` });
292
+ return Arr.map(parsed, (row, index) => {
293
+ if (!Predicate.isObjectOrArray(row)) throw new InvalidJsonRowsError({ message: `Invalid ${label} entry at index ${index}` });
294
+ const propertyOf = (key) => Predicate.hasProperty(row, key) ? row[key] : void 0;
295
+ const agentId = propertyOf("agentId");
296
+ if (!Predicate.isString(agentId)) throw new InvalidJsonRowsError({ message: `${label} entry at index ${index} missing agentId` });
297
+ const id = propertyOf("id");
298
+ const creditedName = propertyOf("creditedName");
299
+ const orderingPosition = propertyOf("orderingPosition");
305
300
  return {
306
- id: typeof r.id === "string" ? r.id : void 0,
307
- agentId: r.agentId,
308
- creditedName: typeof r.creditedName === "string" ? r.creditedName : void 0,
309
- orderingPosition: typeof r.orderingPosition === "number" ? r.orderingPosition : index
301
+ id: Predicate.isString(id) ? id : void 0,
302
+ agentId,
303
+ creditedName: Predicate.isString(creditedName) ? creditedName : void 0,
304
+ orderingPosition: Predicate.isNumber(orderingPosition) ? orderingPosition : index
310
305
  };
311
306
  });
312
307
  };
308
+ /** Parse a JSON text (`JSON.parse` semantics) to a success or a failed `Exit`. */
309
+ const decodeJson = SchemaParser.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
313
310
  /** Text fields: trim always; required create fields never skip. */
314
311
  const coerceText = (ctx, raw) => ctx.required ? (raw ?? "").trim() : ctx.clearable ? trimToNull(raw) : trimToUndefined(raw);
315
312
  /** Id fields: no trimming (pickers emit `""` or a UUID); required create FKs pass through raw. */
@@ -358,49 +355,66 @@ const coerceContextFor = (field, mode) => ({
358
355
  /** The default coercion for one field in one mode. */
359
356
  const coerce = (field, mode, raw) => {
360
357
  const ctx = coerceContextFor(field, mode);
361
- switch (field.kind) {
362
- case "text":
363
- case "enum": return coerceText(ctx, raw);
364
- case "id": return coerceId(ctx, raw);
365
- case "number": return coerceNumber(ctx, raw);
366
- case "date": return coerceDate(ctx, raw);
367
- case "jsonArray": return jsonRowsFromField(raw, field.key);
368
- case "boolean": return coerceBoolean(ctx, raw);
369
- case "structured": return structuredFromField(raw, ctx.clearable);
370
- }
358
+ return Match.value(field.kind).pipe(Match.when(Match.is("text", "enum"), () => coerceText(ctx, raw)), Match.when("id", () => coerceId(ctx, raw)), Match.when("number", () => coerceNumber(ctx, raw)), Match.when("date", () => coerceDate(ctx, raw)), Match.when("jsonArray", () => jsonRowsFromField(raw, field.key)), Match.when("boolean", () => coerceBoolean(ctx, raw)), Match.when("structured", () => structuredFromField(raw, ctx.clearable)), Match.exhaustive);
371
359
  };
372
360
  /**
361
+ * Adapt every field of `commandPlan` present in `mode`, in plan order, to a
362
+ * `[key, value]` entry: through the field's override when it has one, else
363
+ * the default {@link coerce}.
364
+ */
365
+ const adaptFields = (commandPlan, overrides, mode, input) => Arr.flatMap(commandPlan.fields, (field) => {
366
+ if (mode === "create" ? !field.inCreate : !field.inUpdate) return [];
367
+ const override = overrides[field.key];
368
+ const raw = input[field.key];
369
+ return [[field.key, override ? override(raw) : coerce(field, mode, raw)]];
370
+ });
371
+ /**
373
372
  * Derive the create adapter: validated all-string input → encoded create
374
373
  * command payload. `overrides` replace the default per-field coercion for
375
374
  * genuinely irregular fields (e.g. a create-time fallback default).
376
375
  */
377
- const createAdapter = (commandPlan, overrides = {}) => (input) => {
378
- const out = {};
379
- if (commandPlan.clientId) out.id = trimToUndefined(input.id);
380
- for (const field of commandPlan.fields) {
381
- if (!field.inCreate) continue;
382
- const override = overrides[field.key];
383
- out[field.key] = override ? override(input[field.key]) : coerce(field, "create", input[field.key]);
384
- }
385
- return out;
386
- };
376
+ const createAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([...commandPlan.clientId ? [["id", trimToUndefined(input.id)]] : [], ...adaptFields(commandPlan, overrides, "create", input)]);
387
377
  /**
388
378
  * Derive the update adapter: validated all-string input → encoded update
389
379
  * command payload (value sets, `null` clears a clearable field, `undefined`
390
380
  * skips). The required `id` passes through as-is — the command's canonical
391
381
  * decode brands it.
392
382
  */
393
- const updateAdapter = (commandPlan, overrides = {}) => (input) => {
394
- const out = { id: input.id };
395
- for (const field of commandPlan.fields) {
396
- if (!field.inUpdate) continue;
397
- const override = overrides[field.key];
398
- out[field.key] = override ? override(input[field.key]) : coerce(field, "update", input[field.key]);
399
- }
400
- return out;
401
- };
383
+ const updateAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([["id", input.id], ...adaptFields(commandPlan, overrides, "update", input)]);
402
384
  //#endregion
403
385
  //#region src/Constraints.ts
386
+ /**
387
+ * HTML input constraints derived from an Effect Schema.
388
+ *
389
+ * Native validation attributes can be read (`required`, `minlength`, …)
390
+ * off the schema so a form can apply them to its inputs for free, layering
391
+ * browser-native validation under the schema-driven validation. This module
392
+ * does the framework-neutral half: walk the schema AST and emit an
393
+ * {@link InputConstraints} tree a UI can spread onto its `<input>`s.
394
+ *
395
+ * `required` comes from a struct field's optionality; `minlength` /
396
+ * `maxlength` / `min` / `max` / `pattern` / `step` are derived from the
397
+ * `representation` annotation Effect Schema's built-in checks carry
398
+ * (`isMinLength`, `isBetween`, `isPattern`, …), keyed by its stable
399
+ * `effect/schema/<check>` id. A check with no recognised representation id
400
+ * (a bare `Schema.filter` predicate, a custom `.check()`) contributes
401
+ * nothing; there is no attribute to guess at,
402
+ * so the derivation stays silent rather than wrong. The walk descends into
403
+ * nested structs and into a homogeneous array's item type (folding the
404
+ * array's own optionality into the item's `required`); a fixed-shape tuple
405
+ * with differently-typed elements has no single item schema to represent as
406
+ * one constraint, so it derives only `required`. Like
407
+ * {@link module:effect-forms/Field}'s `fromAst`, an `optional` / `NullOr`
408
+ * union is unwrapped by taking its first non-`null`/`undefined` member —
409
+ * scoped to that wrapping pattern, not general discriminated unions, whose
410
+ * branches can't be flattened into one constraint either. The `overrides`
411
+ * argument to {@link constraintsFromSchema} remains the seam for anything the
412
+ * walk cannot infer (or should not — a check that's semantically stronger
413
+ * than any HTML attribute).
414
+ *
415
+ * @copyright Thomas Foster
416
+ * @module effect-forms/Constraints
417
+ */
404
418
  var Constraints_exports = /* @__PURE__ */ __exportAll({
405
419
  constraintAt: () => constraintAt,
406
420
  constraintsFromSchema: () => constraintsFromSchema,
@@ -418,17 +432,15 @@ const isOptional = (type) => type.context?.isOptional === true;
418
432
  */
419
433
  function unwrapOptional(type) {
420
434
  if (!SchemaAST.isUnion(type)) return type;
421
- for (const member of type.types) {
422
- if (SchemaAST.isUndefined(member) || SchemaAST.isNull(member)) continue;
423
- return unwrapOptional(member);
424
- }
425
- return type;
435
+ return Arr.findFirst(type.types, (member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member)).pipe(Option.match({
436
+ onNone: () => type,
437
+ onSome: unwrapOptional
438
+ }));
426
439
  }
427
440
  /** Flatten a `Checks` array (recursing into `FilterGroup`s) into individual filters. */
428
- function flattenChecks(checks, out) {
429
- if (!checks) return;
430
- for (const check of checks) if (check._tag === "FilterGroup") flattenChecks(check.checks, out);
431
- else out.push(check);
441
+ function flattenChecks(checks) {
442
+ if (checks === void 0) return [];
443
+ return Arr.flatMap(checks, (check) => Predicate.isTagged(check, "FilterGroup") ? flattenChecks(check.checks) : [check]);
432
444
  }
433
445
  /**
434
446
  * One constraint patch per recognised representation id — the built-in
@@ -441,7 +453,7 @@ function flattenChecks(checks, out) {
441
453
  const payloadPatches = {
442
454
  "effect/schema/isMinLength": (p) => ({ minlength: p.minLength }),
443
455
  "effect/schema/isMaxLength": (p) => ({ maxlength: p.maxLength }),
444
- "effect/schema/isLengthBetween": (p) => ({
456
+ "effect/schema/isBetweenLength": (p) => ({
445
457
  minlength: p.minimum,
446
458
  maxlength: p.maximum
447
459
  }),
@@ -456,14 +468,18 @@ const payloadPatches = {
456
468
  }),
457
469
  "effect/schema/isMultipleOf": (p) => ({ step: p.divisor })
458
470
  };
471
+ /** Whether `id` names a check {@link payloadPatches} has a patch for (an own key, never an inherited one). */
472
+ const isPatchedCheckId = (id) => Rec.has(payloadPatches, id);
459
473
  /**
460
474
  * Look up and apply `payloadPatches[representation.id]`. The cast is sound:
461
475
  * each payload is authored by the same built-in check constructor its id
462
476
  * names, so an entry is never called with a mismatched payload shape.
463
477
  */
464
478
  function applyPayloadPatch(representation) {
465
- const patch = payloadPatches[representation.id];
466
- return patch?.(representation.payload);
479
+ const id = representation.id;
480
+ if (!isPatchedCheckId(id)) return void 0;
481
+ const patch = payloadPatches[id];
482
+ return patch(representation.payload);
467
483
  }
468
484
  /**
469
485
  * Derive an {@link InputConstraint}'s refinement attributes from the checks
@@ -474,25 +490,20 @@ function applyPayloadPatch(representation) {
474
490
  * filter) is skipped: there is no attribute to derive from it.
475
491
  */
476
492
  function constraintFromChecks(outer, inner) {
477
- const filters = [];
478
- flattenChecks(outer.checks, filters);
479
- if (inner !== outer) flattenChecks(inner.checks, filters);
480
- let out = {};
481
- let hasIntCheck = false;
482
- for (const filter of filters) {
493
+ const filters = inner === outer ? flattenChecks(outer.checks) : [...flattenChecks(outer.checks), ...flattenChecks(inner.checks)];
494
+ const representations = Arr.flatMap(filters, (filter) => {
483
495
  const representation = filter.annotations?.representation;
484
- if (!representation) continue;
485
- if (representation.id === "effect/schema/isInt") {
486
- hasIntCheck = true;
487
- continue;
488
- }
489
- out = {
490
- ...out,
491
- ...applyPayloadPatch(representation)
492
- };
493
- }
494
- if (hasIntCheck && out.step === void 0) out.step = 1;
495
- return out;
496
+ return representation === void 0 ? [] : [representation];
497
+ });
498
+ const isIntCheck = (representation) => representation.id === "effect/schema/isInt";
499
+ const patched = Arr.reduce(representations, {}, (out, representation) => isIntCheck(representation) ? out : {
500
+ ...out,
501
+ ...applyPayloadPatch(representation)
502
+ });
503
+ return Arr.some(representations, isIntCheck) && patched.step === void 0 ? {
504
+ ...patched,
505
+ step: 1
506
+ } : patched;
496
507
  }
497
508
  /**
498
509
  * Whether `ast` is a homogeneous array/list — `Schema.Array(item)` or
@@ -505,8 +516,10 @@ function constraintFromChecks(outer, inner) {
505
516
  function homogeneousItem(ast) {
506
517
  if (ast.rest.length !== 1) return void 0;
507
518
  const [item] = ast.rest;
508
- if (ast.elements.length === 0) return item;
509
- if (ast.elements.length === 1 && ast.elements[0] === item) return item;
519
+ return Arr.match(ast.elements, {
520
+ onEmpty: () => item,
521
+ onNonEmpty: (elements) => elements.length === 1 && elements[0] === item ? item : void 0
522
+ });
510
523
  }
511
524
  /**
512
525
  * Derive the constraint (or nested tree) for one property/item type: a
@@ -537,30 +550,43 @@ function constraintsForType(type) {
537
550
  }
538
551
  /** Build the constraint tree for one object AST node. */
539
552
  function constraintsForObjects(ast) {
540
- const out = {};
541
- for (const ps of ast.propertySignatures) out[String(ps.name)] = constraintsForType(ps.type);
542
- return out;
553
+ return Rec.fromEntries(Arr.map(ast.propertySignatures, (ps) => [String(ps.name), constraintsForType(ps.type)]));
543
554
  }
544
555
  /** Deep-merge constraint overrides over a derived tree. */
545
556
  function mergeConstraints(base, overrides) {
546
- const out = { ...base };
547
- for (const key of Object.keys(overrides)) {
557
+ const merged = Arr.flatMap(Rec.keys(overrides), (key) => {
548
558
  const override = overrides[key];
549
- if (override === void 0) continue;
550
- const existing = out[key];
551
- if (existing !== void 0 && !isLeafConstraint(existing) && !isLeafConstraint(override)) out[key] = mergeConstraints(existing, override);
552
- else if (existing !== void 0 && isLeafConstraint(existing) && isLeafConstraint(override)) out[key] = {
559
+ if (override === void 0) return [];
560
+ const existing = base[key];
561
+ if (existing !== void 0 && !isLeafConstraint(existing) && !isLeafConstraint(override)) return [[key, mergeConstraints(existing, override)]];
562
+ if (existing !== void 0 && isLeafConstraint(existing) && isLeafConstraint(override)) return [[key, {
553
563
  ...existing,
554
564
  ...override
555
- };
556
- else out[key] = override;
557
- }
558
- return out;
565
+ }]];
566
+ return [[key, override]];
567
+ });
568
+ return {
569
+ ...base,
570
+ ...Rec.fromEntries(merged)
571
+ };
559
572
  }
560
573
  /**
561
574
  * Derive the {@link InputConstraints} for a struct schema. Non-struct schemas
562
575
  * yield an empty tree. `overrides` (deep-merged over the derived tree, and
563
576
  * winning on conflict) is the seam for anything the AST walk does not infer.
577
+ *
578
+ * @example
579
+ * ```ts
580
+ * import * as Schema from "effect/Schema"
581
+ * import { Constraints } from "@thomasfosterau/effect-forms"
582
+ *
583
+ * const Signup = Schema.Struct({
584
+ * name: Schema.String.check(Schema.isMinLength(2)),
585
+ * nickname: Schema.optional(Schema.String),
586
+ * })
587
+ * console.log(Constraints.constraintsFromSchema(Signup))
588
+ * // { name: { required: true, minlength: 2 }, nickname: { required: false } }
589
+ * ```
564
590
  */
565
591
  function constraintsFromSchema(schema, overrides = {}) {
566
592
  const ast = schema.ast;
@@ -577,16 +603,34 @@ function constraintsFromSchema(schema, overrides = {}) {
577
603
  * skips the index and resolves straight to `label`'s derived constraint.
578
604
  */
579
605
  function constraintAt(constraints, path) {
580
- let node = constraints;
581
- for (const segment of path) {
582
- if (typeof segment === "number") continue;
583
- if (node === void 0 || isLeafConstraint(node)) return {};
584
- node = node[segment];
585
- }
606
+ const node = Arr.reduce(path, constraints, (current, segment) => {
607
+ if (Predicate.isNumber(segment)) return current;
608
+ if (current === void 0 || isLeafConstraint(current)) return void 0;
609
+ return current[segment];
610
+ });
586
611
  return node !== void 0 && isLeafConstraint(node) ? node : {};
587
612
  }
588
613
  //#endregion
589
614
  //#region src/FormData.ts
615
+ /**
616
+ * Structured parsing of an HTML form body into a plain object the schema can
617
+ * decode.
618
+ *
619
+ * Browsers flatten structured data into `name=value` pairs using bracket /
620
+ * dot path notation (`author.name`, `author[name]`, `tags[0]`, `tags[]`).
621
+ * {@link formDataToObject} reverses that into a nested object / array tree —
622
+ * the framework-neutral step that turns a form body into structured data, before handing it
623
+ * to a validator. Coercion (strings → numbers / booleans / dates) is then the
624
+ * schema's job: decode the result with a schema built from coercing leaves
625
+ * (`Schema.FiniteFromString`, `Schema.BooleanFromString`, the {@link
626
+ * module:effect-forms/Coerce} helpers, …).
627
+ *
628
+ * `File` values pass through untouched; repeated bare keys collapse into an
629
+ * array. The parser is total and never throws.
630
+ *
631
+ * @copyright Thomas Foster
632
+ * @module effect-forms/FormData
633
+ */
590
634
  var FormData_exports = /* @__PURE__ */ __exportAll({
591
635
  formDataToObject: () => formDataToObject,
592
636
  parseFieldPath: () => parseFieldPath,
@@ -599,47 +643,56 @@ var FormData_exports = /* @__PURE__ */ __exportAll({
599
643
  * `"a.b[]"` → `["a","b",""]`.
600
644
  */
601
645
  function parseFieldPath(name) {
602
- const tokens = [];
603
- let buffer = "";
604
- let inBracket = false;
605
- for (const char of name) if (char === "[") {
606
- if (buffer !== "") tokens.push(buffer);
607
- buffer = "";
608
- inBracket = true;
609
- } else if (char === "]") {
610
- tokens.push(buffer);
611
- buffer = "";
612
- inBracket = false;
613
- } else if (char === "." && !inBracket) {
614
- if (buffer !== "") tokens.push(buffer);
615
- buffer = "";
616
- } else buffer += char;
617
- if (buffer !== "") tokens.push(buffer);
646
+ const nonEmpty = (buffer) => buffer === "" ? [] : [buffer];
647
+ const scanned = Arr.reduce(Arr.fromIterable(name), {
648
+ tokens: Arr.empty(),
649
+ buffer: "",
650
+ inBracket: false
651
+ }, ({ tokens, buffer, inBracket }, char) => {
652
+ if (char === "[") return {
653
+ tokens: [...tokens, ...nonEmpty(buffer)],
654
+ buffer: "",
655
+ inBracket: true
656
+ };
657
+ if (char === "]") return {
658
+ tokens: [...tokens, buffer],
659
+ buffer: "",
660
+ inBracket: false
661
+ };
662
+ if (char === "." && !inBracket) return {
663
+ tokens: [...tokens, ...nonEmpty(buffer)],
664
+ buffer: "",
665
+ inBracket
666
+ };
667
+ return {
668
+ tokens,
669
+ buffer: buffer + char,
670
+ inBracket
671
+ };
672
+ });
673
+ const tokens = [...scanned.tokens, ...nonEmpty(scanned.buffer)];
618
674
  return tokens.length > 1 && tokens[0] === "" ? tokens.slice(1) : tokens;
619
675
  }
620
676
  const isIndexToken = (token) => token === "" || /^\d+$/.test(token);
621
677
  /** Create the container a token should live in: array for index/append tokens. */
622
678
  const makeContainer = (nextToken) => isIndexToken(nextToken) ? [] : {};
623
679
  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;
680
+ if (Arr.isArray(container)) {
681
+ if (token === "") container.push(value);
682
+ else container[Number(token)] = value;
683
+ } else container[token] = value;
627
684
  };
628
- const getFromContainer = (container, token) => Array.isArray(container) ? container[Number(token)] : container[token];
685
+ const getFromContainer = (container, token) => Arr.isArray(container) ? container[Number(token)] : container[token];
629
686
  /** Assign `value` deep into `root` along `tokens`, creating containers as needed. */
630
687
  function assign(root, tokens, value) {
631
- let node = root;
632
- for (let i = 0; i < tokens.length - 1; i++) {
633
- const token = tokens[i];
634
- const nextToken = tokens[i + 1];
635
- let child = token === "" ? void 0 : getFromContainer(node, token);
636
- if (child === void 0 || typeof child !== "object" || child === null) {
637
- child = makeContainer(nextToken);
638
- setOnContainer(node, token, child);
639
- }
640
- node = child;
641
- }
642
- const last = tokens[tokens.length - 1];
688
+ const last = Arr.lastNonEmpty(tokens);
689
+ const node = Arr.reduce(Arr.initNonEmpty(tokens), root, (parent, token, i) => {
690
+ const child = token === "" ? void 0 : getFromContainer(parent, token);
691
+ if (Predicate.isObjectOrArray(child)) return child;
692
+ const created = makeContainer(tokens[i + 1]);
693
+ setOnContainer(parent, token, created);
694
+ return created;
695
+ });
643
696
  if (last !== "" && !Array.isArray(node)) {
644
697
  const existing = getFromContainer(node, last);
645
698
  if (existing !== void 0) {
@@ -652,27 +705,35 @@ function assign(root, tokens, value) {
652
705
  setOnContainer(node, last, value);
653
706
  }
654
707
  /** Normalise a source into `[name, value]` entries. */
655
- function* entriesOf(source) {
656
- if (typeof source[Symbol.iterator] === "function" && !(source instanceof FormData) && !(source instanceof URLSearchParams)) {
657
- yield* source;
658
- return;
659
- }
660
- for (const entry of source.entries()) yield entry;
708
+ function entriesOf(source) {
709
+ if (source instanceof FormData || source instanceof URLSearchParams) return source.entries();
710
+ return source;
661
711
  }
662
712
  /**
663
713
  * Parse a form body into a nested object / array tree. Field names use dot or
664
714
  * bracket path notation (see {@link parseFieldPath}); repeated bare keys
665
715
  * become arrays; `File` values pass through. The result is ready to hand to
666
716
  * `Schema.decodeUnknown` with a schema whose leaves coerce the string inputs.
717
+ *
718
+ * @example
719
+ * ```ts
720
+ * import { FormData } from "@thomasfosterau/effect-forms"
721
+ *
722
+ * const body = new URLSearchParams([
723
+ * ["author.name", "Ada"],
724
+ * ["tags[]", "math"],
725
+ * ["tags[]", "engines"],
726
+ * ])
727
+ * console.log(FormData.formDataToObject(body))
728
+ * // { author: { name: "Ada" }, tags: ["math", "engines"] }
729
+ * ```
667
730
  */
668
731
  function formDataToObject(source) {
669
- const root = {};
670
- for (const [name, value] of entriesOf(source)) {
732
+ return Arr.reduce(entriesOf(source), Rec.empty(), (root, [name, value]) => {
671
733
  const tokens = parseFieldPath(name);
672
- if (tokens.length === 0) continue;
673
- assign(root, tokens, value);
674
- }
675
- return root;
734
+ if (Arr.isReadonlyArrayNonEmpty(tokens)) assign(root, tokens, value);
735
+ return root;
736
+ });
676
737
  }
677
738
  /** Read and parse a `Request`'s form body. Errors (e.g. wrong content-type) reject. */
678
739
  async function requestToObject(request) {
@@ -681,8 +742,6 @@ async function requestToObject(request) {
681
742
  //#endregion
682
743
  //#region src/Path.ts
683
744
  /**
684
- * @copyright Thomas Foster
685
- *
686
745
  * Nested get / set along a {@link FormPath}.
687
746
  *
688
747
  * A form's data is a tree, and the reactive bindings address it by path:
@@ -700,6 +759,7 @@ async function requestToObject(request) {
700
759
  * convert to and from the flat `author.name` / `tags[0]` notation the HTML
701
760
  * body parser ({@link module:effect-forms/FormData}) uses.
702
761
  *
762
+ * @copyright Thomas Foster
703
763
  * @module effect-forms/Path
704
764
  */
705
765
  var Path_exports = /* @__PURE__ */ __exportAll({
@@ -722,32 +782,41 @@ var Path_exports = /* @__PURE__ */ __exportAll({
722
782
  * traverses a non-object. The empty path returns the object itself.
723
783
  */
724
784
  function getAt(obj, path) {
725
- let current = obj;
726
- for (const segment of path) {
727
- if (current === null || typeof current !== "object") return void 0;
728
- current = current[segment];
729
- }
730
- return current;
785
+ return Arr.reduce(path, obj, (current, segment) => Predicate.isObjectOrArray(current) && Predicate.hasProperty(current, segment) ? current[segment] : void 0);
731
786
  }
732
787
  /**
733
788
  * Return a new tree with the value at `path` replaced by `value`. Objects and
734
789
  * arrays along the path are cloned (structural sharing elsewhere); missing
735
790
  * containers are created as arrays for numeric segments and objects otherwise.
736
791
  * The empty path returns `value`. Pure — `obj` is never mutated.
792
+ *
793
+ * @example
794
+ * ```ts
795
+ * import { Path } from "@thomasfosterau/effect-forms"
796
+ *
797
+ * const before = { authors: [{ name: "Ada" }] }
798
+ * const after = Path.setAt(before, ["authors", 0, "name"], "Grace")
799
+ * console.log(Path.getAt(after, ["authors", 0, "name"])) // "Grace"
800
+ * console.log(before.authors[0]?.name) // "Ada" — the input is untouched
801
+ * ```
737
802
  */
738
803
  function setAt(obj, path, value) {
739
- if (path.length === 0) return value;
740
- const [head, ...rest] = path;
741
- const clone = Array.isArray(obj) ? [...obj] : { ...obj };
742
- const child = clone[head];
743
- clone[head] = rest.length === 0 ? value : setAt(child === null || typeof child !== "object" ? typeof rest[0] === "number" ? [] : {} : child, rest, value);
744
- return clone;
804
+ return Arr.match(path, {
805
+ onEmpty: () => value,
806
+ onNonEmpty: ([head, ...rest]) => {
807
+ const clone = Arr.isArray(obj) ? [...obj] : { ...Object(obj) };
808
+ const child = clone[head];
809
+ clone[head] = Arr.match(rest, {
810
+ onEmpty: () => value,
811
+ onNonEmpty: (tail) => setAt(Predicate.isObjectOrArray(child) ? child : Predicate.isNumber(tail[0]) ? [] : {}, tail, value)
812
+ });
813
+ return clone;
814
+ }
815
+ });
745
816
  }
746
817
  /** Whether `path` is `root` or nested beneath it. */
747
818
  function isUnder(path, root) {
748
- if (path.length < root.length) return false;
749
- for (let i = 0; i < root.length; i++) if (path[i] !== root[i]) return false;
750
- return true;
819
+ return path.length >= root.length && Arr.every(root, (segment, i) => path[i] === segment);
751
820
  }
752
821
  /**
753
822
  * Render a {@link FormPath} as flat field-name notation: string segments join
@@ -755,10 +824,7 @@ function isUnder(path, root) {
755
824
  * The inverse of {@link fromString}.
756
825
  */
757
826
  function toString(path) {
758
- let out = "";
759
- for (const segment of path) if (typeof segment === "number") out += `[${segment}]`;
760
- else out += out === "" ? segment : `.${segment}`;
761
- return out;
827
+ return Arr.reduce(path, "", (out, segment) => Predicate.isNumber(segment) ? `${out}[${segment}]` : out === "" ? segment : `${out}.${segment}`);
762
828
  }
763
829
  /**
764
830
  * Parse flat field-name notation into a {@link FormPath}: integer tokens become
@@ -803,7 +869,7 @@ const clearAll = () => null;
803
869
  function remapIndex(path, arrayPath, map) {
804
870
  if (!isUnder(path, arrayPath) || path.length === arrayPath.length) return path;
805
871
  const segment = path[arrayPath.length];
806
- if (typeof segment !== "number") return path;
872
+ if (!Predicate.isNumber(segment)) return path;
807
873
  const mapped = map(segment);
808
874
  if (mapped === null) return null;
809
875
  if (mapped === segment) return path;
@@ -819,15 +885,34 @@ function remapIndex(path, arrayPath, map) {
819
885
  * maps to `null` are dropped. Pure.
820
886
  */
821
887
  function remapKeyedRecord(record, arrayPath, map) {
822
- const out = {};
823
- for (const key of Object.keys(record)) {
888
+ return Rec.fromEntries(Arr.flatMap(Rec.toEntries(record), ([key, value]) => {
824
889
  const remapped = remapIndex(fromString(key), arrayPath, map);
825
- if (remapped !== null) out[toString(remapped)] = record[key];
826
- }
827
- return out;
890
+ return remapped === null ? [] : [[toString(remapped), value]];
891
+ }));
828
892
  }
829
893
  //#endregion
830
894
  //#region src/Errors.ts
895
+ /**
896
+ * The nested form-error tree and its operations.
897
+ *
898
+ * {@link FormErrors} is a nested error tree so a UI can read
899
+ * `errors.author.name` or `errors.tags[0]` directly. The representation is:
900
+ *
901
+ * - a **leaf** field's messages are a `string[]` keyed by the field name
902
+ * (`{ title: ["Required"] }`);
903
+ * - a **branch** (nested object / array) is a nested `FormErrors`
904
+ * (`{ author: { name: ["Required"] } }`); array elements are keyed by their
905
+ * stringified index (`{ tags: { "0": ["Too short"] } }`);
906
+ * - a message attached to a **branch itself** (a cross-field or
907
+ * whole-array message) lives under that branch's `_errors` key.
908
+ *
909
+ * The tree is plain JSON — it serialises across the SSR boundary unchanged —
910
+ * and the operations here ({@link issuesToErrors}, {@link flattenErrors},
911
+ * {@link setError}, {@link mergeErrors}, …) are pure and return new trees.
912
+ *
913
+ * @copyright Thomas Foster
914
+ * @module effect-forms/Errors
915
+ */
831
916
  var Errors_exports = /* @__PURE__ */ __exportAll({
832
917
  ROOT_ERROR_KEY: () => ROOT_ERROR_KEY,
833
918
  clearSourceAt: () => clearSourceAt,
@@ -846,7 +931,7 @@ var Errors_exports = /* @__PURE__ */ __exportAll({
846
931
  });
847
932
  /** The root key under which a branch's own (non-field) messages are stored. */
848
933
  const ROOT_ERROR_KEY = "_errors";
849
- const isMessageList = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
934
+ const isMessageList = (value) => Arr.isArray(value) && Arr.every(value, Predicate.isString);
850
935
  const ownErrors = (node) => {
851
936
  const existing = node[ROOT_ERROR_KEY];
852
937
  if (isMessageList(existing)) return existing;
@@ -855,15 +940,26 @@ const ownErrors = (node) => {
855
940
  return created;
856
941
  };
857
942
  /**
858
- * If `node[key]` is a leaf's message list, convert it in place into a branch
859
- * carrying those same messages as its own {@link ROOT_ERROR_KEY} (a path used
860
- * as a leaf is now needed as a branch too) — a no-op when `node[key]` is
861
- * already a branch or absent. Shared by {@link insert} and {@link setLeaf}'s
862
- * path-descent, the one piece of their branch-walking that's identical.
943
+ * Return the branch at `node[key]`, making one in place if needed: an absent
944
+ * key gets a new empty branch, and a leaf's message list is converted into a
945
+ * branch carrying those same messages as its own {@link ROOT_ERROR_KEY} (a
946
+ * path used as a leaf is now needed as a branch too). Shared by {@link insert}
947
+ * and {@link setLeaf}'s path-descent, the one piece of their branch-walking
948
+ * that's identical.
863
949
  */
864
- const promoteLeafToBranch = (node, key) => {
950
+ const branchAt = (node, key) => {
865
951
  const existing = node[key];
866
- if (isMessageList(existing)) node[key] = { [ROOT_ERROR_KEY]: existing };
952
+ if (existing === void 0) {
953
+ const created = {};
954
+ node[key] = created;
955
+ return created;
956
+ }
957
+ if (isMessageList(existing)) {
958
+ const promoted = { [ROOT_ERROR_KEY]: existing };
959
+ node[key] = promoted;
960
+ return promoted;
961
+ }
962
+ return existing;
867
963
  };
868
964
  /**
869
965
  * Insert one message at `path` into a mutable error tree. A leaf becomes a
@@ -873,18 +969,12 @@ const promoteLeafToBranch = (node, key) => {
873
969
  * tree root's {@link ROOT_ERROR_KEY}.
874
970
  */
875
971
  const insert = (root, path, message) => {
876
- if (path.length === 0) {
972
+ if (!Arr.isReadonlyArrayNonEmpty(path)) {
877
973
  ownErrors(root).push(message);
878
974
  return;
879
975
  }
880
- let node = root;
881
- for (let i = 0; i < path.length - 1; i++) {
882
- const key = String(path[i]);
883
- if (node[key] === void 0) node[key] = {};
884
- else promoteLeafToBranch(node, key);
885
- node = node[key];
886
- }
887
- const last = String(path[path.length - 1]);
976
+ const node = Arr.reduce(Arr.initNonEmpty(path), root, (parent, segment) => branchAt(parent, String(segment)));
977
+ const last = String(Arr.lastNonEmpty(path));
888
978
  const existing = node[last];
889
979
  if (existing === void 0) node[last] = [message];
890
980
  else if (isMessageList(existing)) existing.push(message);
@@ -893,7 +983,7 @@ const insert = (root, path, message) => {
893
983
  /** Build a nested {@link FormErrors} tree from a flat list of {@link FormIssue}s. */
894
984
  function issuesToErrors(issues) {
895
985
  const root = {};
896
- for (const issue of issues) insert(root, issue.path, issue.message);
986
+ Arr.forEach(issues, (issue) => insert(root, issue.path, issue.message));
897
987
  return root;
898
988
  }
899
989
  /**
@@ -902,32 +992,25 @@ function issuesToErrors(issues) {
902
992
  * ordering within a single field).
903
993
  */
904
994
  function flattenErrors(errors) {
905
- const out = [];
906
- const walk = (node, path) => {
907
- for (const key of Object.keys(node)) {
908
- const value = node[key];
909
- if (value === void 0) continue;
910
- if (key === "_errors") {
911
- for (const message of value) out.push({
912
- path,
913
- message
914
- });
915
- continue;
916
- }
917
- const segment = /^\d+$/.test(key) ? Number(key) : key;
918
- if (isMessageList(value)) for (const message of value) out.push({
919
- path: [...path, segment],
920
- message
921
- });
922
- else walk(value, [...path, segment]);
923
- }
924
- };
925
- walk(errors, []);
926
- return out;
995
+ const walk = (node, path) => Arr.flatMap(Rec.keys(node), (key) => {
996
+ if (key === "_errors") return Arr.map(node["_errors"] ?? [], (message) => ({
997
+ path,
998
+ message
999
+ }));
1000
+ const value = node[key];
1001
+ if (value === void 0) return [];
1002
+ const segment = /^\d+$/.test(key) ? Number(key) : key;
1003
+ if (isMessageList(value)) return Arr.map(value, (message) => ({
1004
+ path: [...path, segment],
1005
+ message
1006
+ }));
1007
+ return walk(value, [...path, segment]);
1008
+ });
1009
+ return walk(errors, []);
927
1010
  }
928
1011
  /** Whether the tree carries any message anywhere. */
929
1012
  function hasErrors(errors) {
930
- return flattenErrors(errors).length > 0;
1013
+ return Arr.isReadonlyArrayNonEmpty(flattenErrors(errors));
931
1014
  }
932
1015
  /**
933
1016
  * Read the messages attached directly to `path` (a leaf's list, or a branch's
@@ -935,11 +1018,7 @@ function hasErrors(errors) {
935
1018
  * messages.
936
1019
  */
937
1020
  function getError(errors, path) {
938
- let node = errors;
939
- for (const segment of path) {
940
- if (node === void 0 || isMessageList(node)) return void 0;
941
- node = node[String(segment)];
942
- }
1021
+ const node = Arr.reduce(path, errors, (current, segment) => current === void 0 || isMessageList(current) ? void 0 : current[String(segment)]);
943
1022
  if (node === void 0) return void 0;
944
1023
  if (isMessageList(node)) return node;
945
1024
  return node[ROOT_ERROR_KEY];
@@ -963,15 +1042,13 @@ const noErrors = {};
963
1042
  * Messages whose index maps to `null` are dropped. Pure.
964
1043
  */
965
1044
  function remapIndices(errors, arrayPath, map) {
966
- const remapped = [];
967
- for (const issue of flattenErrors(errors)) {
1045
+ return issuesToErrors(Arr.flatMap(flattenErrors(errors), (issue) => {
968
1046
  const path = remapIndex(issue.path, arrayPath, map);
969
- if (path !== null) remapped.push({
1047
+ return path === null ? [] : [{
970
1048
  path,
971
1049
  message: issue.message
972
- });
973
- }
974
- return issuesToErrors(remapped);
1050
+ }];
1051
+ }));
975
1052
  }
976
1053
  /**
977
1054
  * Return a new tree with the messages *directly at* `path` replaced by
@@ -988,51 +1065,45 @@ function replaceAt(errors, path, messages) {
988
1065
  return next;
989
1066
  }
990
1067
  const setLeaf = (root, path, messages) => {
991
- if (path.length === 0) {
1068
+ if (!Arr.isReadonlyArrayNonEmpty(path)) {
992
1069
  setOwnMessages(root, messages);
993
1070
  return;
994
1071
  }
995
- const parent = descendClearing(root, path, messages);
1072
+ const parent = descendClearing(root, Arr.initNonEmpty(path), messages);
996
1073
  if (parent === void 0) return;
997
- setLeafMessages(parent, String(path[path.length - 1]), messages);
1074
+ setLeafMessages(parent, String(Arr.lastNonEmpty(path)), messages);
998
1075
  };
999
1076
  /** Set or clear a node's own {@link ROOT_ERROR_KEY} messages. */
1000
1077
  const setOwnMessages = (node, messages) => {
1001
- if (messages.length === 0) delete node[ROOT_ERROR_KEY];
1078
+ if (Arr.isReadonlyArrayEmpty(messages)) delete node[ROOT_ERROR_KEY];
1002
1079
  else node[ROOT_ERROR_KEY] = [...messages];
1003
1080
  };
1004
1081
  /**
1005
- * Walk to `path`'s parent branch, creating branches as needed. When
1082
+ * Walk down the `parents` segments (a path minus its last segment) to the
1083
+ * leaf's parent branch, creating branches as needed. When
1006
1084
  * `messages` is empty (a clear, not a set) a missing branch means there is
1007
1085
  * nothing to clear — descent stops early and returns `undefined` rather than
1008
1086
  * creating empty branches just to immediately no-op inside them.
1009
1087
  */
1010
- const descendClearing = (root, path, messages) => {
1011
- let node = root;
1012
- for (let i = 0; i < path.length - 1; i++) {
1013
- const key = String(path[i]);
1014
- if (node[key] === void 0) {
1015
- if (messages.length === 0) return void 0;
1016
- node[key] = {};
1017
- } else promoteLeafToBranch(node, key);
1018
- node = node[key];
1019
- }
1020
- return node;
1021
- };
1088
+ const descendClearing = (root, parents, messages) => Arr.reduce(parents, root, (node, segment) => {
1089
+ if (node === void 0) return void 0;
1090
+ const key = String(segment);
1091
+ if (node[key] === void 0 && Arr.isReadonlyArrayEmpty(messages)) return void 0;
1092
+ return branchAt(node, key);
1093
+ });
1022
1094
  /** Set or clear `node[key]`'s messages — a leaf's list, or (if it's already a branch) its own {@link ROOT_ERROR_KEY}. */
1023
1095
  const setLeafMessages = (node, key, messages) => {
1024
1096
  const existing = node[key];
1025
1097
  if (existing === void 0) {
1026
- if (messages.length > 0) node[key] = [...messages];
1098
+ if (Arr.isReadonlyArrayNonEmpty(messages)) node[key] = [...messages];
1027
1099
  return;
1028
1100
  }
1029
1101
  if (isMessageList(existing)) {
1030
- if (messages.length === 0) delete node[key];
1102
+ if (Arr.isReadonlyArrayEmpty(messages)) delete node[key];
1031
1103
  else node[key] = [...messages];
1032
1104
  return;
1033
1105
  }
1034
- if (messages.length === 0) delete existing[ROOT_ERROR_KEY];
1035
- else existing[ROOT_ERROR_KEY] = [...messages];
1106
+ setOwnMessages(existing, messages);
1036
1107
  };
1037
1108
  /** Both source trees empty. */
1038
1109
  const emptySourced = {
@@ -1059,6 +1130,26 @@ function clearSourceAt(sourced, source, path) {
1059
1130
  }
1060
1131
  //#endregion
1061
1132
  //#region src/Field.ts
1133
+ /**
1134
+ * Field definitions for the {@link module:effect-forms/FormBuilder}.
1135
+ *
1136
+ * A {@link FormBuilder} composes a form one field at a time. Each field is
1137
+ * either a **scalar** {@link FieldDef} (bound to a leaf schema) or an
1138
+ * **array** {@link ArrayFieldDef} (bound to an item schema — the field is a
1139
+ * list of that item). This module is the small vocabulary the builder is made
1140
+ * of: the two field shapes, their guards, the type-level projections that turn
1141
+ * a record of fields into the form's encoded / decoded data types, and the
1142
+ * default-value derivation used to seed a blank form (or a freshly-appended
1143
+ * array item).
1144
+ *
1145
+ * Defaults are *encoded* — an HTML form holds strings, so a scalar seeds to
1146
+ * `""`, an array to `[]`, and a nested struct recurses. Coercion back to the
1147
+ * decoded type is the schema's job at validate time (see
1148
+ * {@link module:effect-forms/Coerce}).
1149
+ *
1150
+ * @copyright Thomas Foster
1151
+ * @module effect-forms/Field
1152
+ */
1062
1153
  var Field_exports = /* @__PURE__ */ __exportAll({
1063
1154
  defaultEncodedValues: () => defaultEncodedValues,
1064
1155
  defaultItemAt: () => defaultItemAt,
@@ -1070,9 +1161,9 @@ var Field_exports = /* @__PURE__ */ __exportAll({
1070
1161
  schemaAt: () => schemaAt
1071
1162
  });
1072
1163
  /** Whether `def` is an {@link ArrayFieldDef}. */
1073
- const isArrayFieldDef = (def) => def._tag === "array";
1164
+ const isArrayFieldDef = (def) => Predicate.isTagged(def, "array");
1074
1165
  /** Whether `def` is a scalar {@link FieldDef}. */
1075
- const isFieldDef = (def) => def._tag === "field";
1166
+ const isFieldDef = (def) => Predicate.isTagged(def, "field");
1076
1167
  /** Construct a scalar {@link FieldDef}. */
1077
1168
  const makeField = (key, schema) => ({
1078
1169
  _tag: "field",
@@ -1102,19 +1193,14 @@ function defaultValue(schema) {
1102
1193
  */
1103
1194
  function unwrapToStructured(ast) {
1104
1195
  if (!SchemaAST.isUnion(ast)) return ast;
1105
- for (const member of ast.types) {
1196
+ return Arr.findFirst(ast.types, (member) => {
1106
1197
  const inner = unwrapToStructured(member);
1107
- if (SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner)) return inner;
1108
- }
1109
- return ast;
1198
+ return SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner) ? Option.some(inner) : Option.none();
1199
+ }).pipe(Option.getOrElse(() => ast));
1110
1200
  }
1111
1201
  function fromAst(ast) {
1112
1202
  const unwrapped = unwrapToStructured(ast);
1113
- if (SchemaAST.isObjects(unwrapped)) {
1114
- const out = {};
1115
- for (const ps of unwrapped.propertySignatures) out[String(ps.name)] = fromAst(ps.type);
1116
- return out;
1117
- }
1203
+ if (SchemaAST.isObjects(unwrapped)) return Rec.fromEntries(Arr.map(unwrapped.propertySignatures, (ps) => [String(ps.name), fromAst(ps.type)]));
1118
1204
  if (SchemaAST.isArrays(unwrapped)) return [];
1119
1205
  return "";
1120
1206
  }
@@ -1142,12 +1228,7 @@ function stepIntoStruct(ast, key) {
1142
1228
  * schemas built directly with `Form.make` (no builder involved).
1143
1229
  */
1144
1230
  function schemaAt(schema, path) {
1145
- let ast = unwrapToStructured(schema.ast);
1146
- for (const segment of path) {
1147
- if (ast === void 0) return void 0;
1148
- ast = typeof segment === "number" ? stepIntoArray(ast) : stepIntoStruct(ast, segment);
1149
- }
1150
- return ast;
1231
+ return Arr.reduce(path, unwrapToStructured(schema.ast), (ast, segment) => ast === void 0 ? void 0 : Predicate.isNumber(segment) ? stepIntoArray(ast) : stepIntoStruct(ast, segment));
1151
1232
  }
1152
1233
  /**
1153
1234
  * Derive a blank encoded item for the array field at `path` (any nesting
@@ -1156,34 +1237,52 @@ function schemaAt(schema, path) {
1156
1237
  */
1157
1238
  function defaultItemAt(schema, path) {
1158
1239
  const ast = schemaAt(schema, path);
1159
- if (ast === void 0 || !SchemaAST.isArrays(ast)) return "";
1160
- const item = arrayElementAst(ast);
1240
+ const item = ast !== void 0 && SchemaAST.isArrays(ast) ? arrayElementAst(ast) : void 0;
1161
1241
  return item === void 0 ? "" : fromAst(item);
1162
1242
  }
1163
1243
  /** Seed a blank encoded value for every field: `[]` for arrays, `""` otherwise. */
1164
1244
  function defaultEncodedValues(fields) {
1165
- const out = {};
1166
- for (const [key, def] of Object.entries(fields)) out[key] = isArrayFieldDef(def) ? [] : defaultValue(def.schema);
1167
- return out;
1245
+ return Rec.map(fields, (def) => isArrayFieldDef(def) ? [] : defaultValue(def.schema));
1168
1246
  }
1169
1247
  //#endregion
1170
1248
  //#region src/Issues.ts
1249
+ /**
1250
+ * Form issues — the flat, path-addressed representation of a single
1251
+ * validation failure, plus the walker that turns an Effect Schema issue
1252
+ * tree into a list of them.
1253
+ *
1254
+ * An Effect Schema decode failure is a recursive {@link SchemaIssue.Issue}
1255
+ * tree: `Pointer` nodes carry path segments, `Composite` / `AnyOf` group
1256
+ * sibling failures, `Filter` / `Encoding` wrap a single inner failure, and
1257
+ * the leaves (`InvalidType`, `InvalidValue`, `MissingKey`, …) describe the
1258
+ * actual problem. {@link schemaIssuesToFormIssues} flattens that tree into
1259
+ * one {@link FormIssue} per leaf, accumulating the `Pointer.path` segments
1260
+ * into a `FormPath`. This is the framework-neutral
1261
+ * flattened error list and the same walk the repo's JSON:API materialiser
1262
+ * performs — here it stays UI-shaped (`path` + `message`) rather than
1263
+ * JSON:API-pointer-shaped.
1264
+ *
1265
+ * @copyright Thomas Foster
1266
+ * @module effect-forms/Issues
1267
+ */
1171
1268
  var Issues_exports = /* @__PURE__ */ __exportAll({
1172
1269
  defaultLeafFormatter: () => defaultLeafFormatter,
1173
- filterOutputToFormIssues: () => filterOutputToFormIssues,
1270
+ filterOutputToFormIssues: () => filterOutputToFormIssues$1,
1174
1271
  schemaIssuesToFormIssues: () => schemaIssuesToFormIssues
1175
1272
  });
1176
- /** The default leaf formatter — Effect Schema's own rendering of the leaf. */
1177
- const defaultLeafFormatter = (issue) => String(issue);
1273
+ /** The default leaf formatter — Effect Schema's own rendering of the issue. */
1274
+ const defaultLeafFormatter = SchemaIssue.makeFormatterDefault();
1178
1275
  /**
1179
1276
  * Normalise a `Pointer.path` segment into a {@link FormPath} segment: integer
1180
1277
  * keys become numbers (array indices), everything else becomes a string.
1181
1278
  */
1182
1279
  const normaliseSegment = (segment) => {
1183
- if (typeof segment === "number") return segment;
1184
- if (typeof segment === "symbol") return segment.toString();
1280
+ if (Predicate.isNumber(segment)) return segment;
1281
+ if (Predicate.isSymbol(segment)) return segment.toString();
1185
1282
  return /^\d+$/.test(segment) ? Number(segment) : segment;
1186
1283
  };
1284
+ /** Whether `issue` is a terminal leaf: it wraps no further issues. */
1285
+ const isTerminal = Match.type().pipe(Match.tag("Pointer", "Composite", "Filter", "Encoding", () => false), Match.tag("AnyOf", (issue) => Arr.isReadonlyArrayEmpty(issue.issues)), Match.orElse(() => true));
1187
1286
  /**
1188
1287
  * Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
1189
1288
  * failure. Path segments accumulate through `Pointer` nodes; `Composite` and
@@ -1192,39 +1291,18 @@ const normaliseSegment = (segment) => {
1192
1291
  * mismatch) is itself a leaf.
1193
1292
  */
1194
1293
  function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
1195
- const out = [];
1196
1294
  const walk = (current, path) => {
1197
- switch (current._tag) {
1198
- case "Pointer":
1199
- walk(current.issue, [...path, ...current.path.map(normaliseSegment)]);
1200
- return;
1201
- case "Composite":
1202
- for (const child of current.issues) walk(child, path);
1203
- return;
1204
- case "AnyOf":
1205
- if (current.issues.length === 0) {
1206
- out.push({
1207
- path,
1208
- message: format(current)
1209
- });
1210
- return;
1211
- }
1212
- for (const child of current.issues) walk(child, path);
1213
- return;
1214
- case "Filter":
1215
- case "Encoding":
1216
- walk(current.issue, path);
1217
- return;
1218
- default:
1219
- out.push({
1220
- path,
1221
- message: format(current)
1222
- });
1223
- return;
1224
- }
1295
+ const leaf = (node) => [{
1296
+ path,
1297
+ message: format(node)
1298
+ }];
1299
+ const walkAll = (children) => Arr.flatMap(children, (child) => walk(child, path));
1300
+ return Match.value(current).pipe(Match.tag("Pointer", (pointer) => walk(pointer.issue, [...path, ...Arr.map(pointer.path, normaliseSegment)])), Match.tag("Composite", (composite) => walkAll(composite.issues)), Match.tag("AnyOf", (anyOf) => Arr.match(anyOf.issues, {
1301
+ onEmpty: () => leaf(anyOf),
1302
+ onNonEmpty: walkAll
1303
+ })), Match.tag("Filter", (filter) => isTerminal(filter.issue) ? leaf(filter) : walk(filter.issue, path)), Match.tag("Encoding", (encoding) => walk(encoding.issue, path)), Match.orElse(leaf));
1225
1304
  };
1226
- walk(issue, []);
1227
- return out;
1305
+ return walk(issue, []);
1228
1306
  }
1229
1307
  /**
1230
1308
  * Normalise a filter predicate's {@link Schema.FilterOutput} into a flat list of
@@ -1233,23 +1311,25 @@ function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
1233
1311
  * ({@link module:effect-forms/Validate}) so both fold into the same nested
1234
1312
  * error tree as schema-decode failures.
1235
1313
  */
1236
- function filterOutputToFormIssues(output) {
1314
+ function filterOutputToFormIssues$1(output) {
1237
1315
  if (output === void 0 || output === true) return [];
1238
1316
  if (output === false) return [{
1239
1317
  path: [],
1240
1318
  message: "Invalid value"
1241
1319
  }];
1242
- if (Array.isArray(output)) return output.flatMap(filterIssueToFormIssues);
1320
+ if (isFilterIssueList(output)) return Arr.flatMap(output, filterIssueToFormIssues);
1243
1321
  return filterIssueToFormIssues(output);
1244
1322
  }
1323
+ /** `Array.isArray` as a guard that also narrows away a `ReadonlyArray` member. */
1324
+ const isFilterIssueList = (output) => Arr.isArray(output);
1245
1325
  function filterIssueToFormIssues(issue) {
1246
- if (typeof issue === "string") return [{
1326
+ if (Predicate.isString(issue)) return [{
1247
1327
  path: [],
1248
1328
  message: issue
1249
1329
  }];
1250
1330
  if ("path" in issue && "issue" in issue) {
1251
- const prefix = issue.path.map((s) => typeof s === "symbol" ? s.toString() : s);
1252
- return (typeof issue.issue === "string" ? [{
1331
+ const prefix = Arr.map(issue.path, (s) => Predicate.isSymbol(s) ? s.toString() : s);
1332
+ return (Predicate.isString(issue.issue) ? [{
1253
1333
  path: [],
1254
1334
  message: issue.issue
1255
1335
  }] : schemaIssuesToFormIssues(issue.issue)).map((i) => ({
@@ -1262,8 +1342,6 @@ function filterIssueToFormIssues(issue) {
1262
1342
  //#endregion
1263
1343
  //#region src/FormBuilder.ts
1264
1344
  /**
1265
- * @copyright Thomas Foster
1266
- *
1267
1345
  * Compose a form schema one field at a time, with cross-field refinements.
1268
1346
  *
1269
1347
  * {@link make} (aliased {@link empty}) starts an empty builder;
@@ -1284,6 +1362,7 @@ function filterIssueToFormIssues(issue) {
1284
1362
  * path — {@link asyncRefinements} exposes them and {@link runAsyncRefinements}
1285
1363
  * runs them into a {@link FormIssue} list.
1286
1364
  *
1365
+ * @copyright Thomas Foster
1287
1366
  * @module effect-forms/FormBuilder
1288
1367
  */
1289
1368
  var FormBuilder_exports = /* @__PURE__ */ __exportAll({
@@ -1298,10 +1377,12 @@ var FormBuilder_exports = /* @__PURE__ */ __exportAll({
1298
1377
  });
1299
1378
  /** Brand for a {@link FormBuilder}. */
1300
1379
  const TypeId = Symbol.for("@thomasfosterau/effect-forms/FormBuilder");
1380
+ /** Whether `addField` was called with the `(key, schema)` overload — the only one whose first argument is a string. */
1381
+ const isKeyedArgs = (args) => Predicate.isString(args[0]);
1301
1382
  const proto = {
1302
1383
  [TypeId]: TypeId,
1303
- addField(keyOrField, schema) {
1304
- const field = typeof keyOrField === "string" ? makeField(keyOrField, schema) : keyOrField;
1384
+ addField(...args) {
1385
+ const field = isKeyedArgs(args) ? makeField(...args) : args[0];
1305
1386
  return build({
1306
1387
  ...this.fields,
1307
1388
  [field.key]: field
@@ -1333,7 +1414,7 @@ function build(fields, refinements) {
1333
1414
  return self;
1334
1415
  }
1335
1416
  /** Whether `u` is a {@link FormBuilder}. */
1336
- const isFormBuilder = (u) => typeof u === "object" && u !== null && TypeId in u;
1417
+ const isFormBuilder = (u) => Predicate.isObjectOrArray(u) && Predicate.hasProperty(u, TypeId);
1337
1418
  /** The empty builder — the starting point for {@link FormBuilder.addField}. */
1338
1419
  const make$2 = build({}, []);
1339
1420
  /** Alias of {@link make} — the empty builder. */
@@ -1344,32 +1425,43 @@ const empty = make$2;
1344
1425
  * applied as a struct-level check. Ready for {@link module:effect-forms/Form}.
1345
1426
  */
1346
1427
  function buildSchema(self) {
1347
- const structFields = {};
1348
- for (const [key, def] of Object.entries(self.fields)) if (isArrayFieldDef(def)) structFields[key] = Schema.Array(def.itemSchema);
1349
- else if (isFieldDef(def)) structFields[key] = def.schema;
1350
- let schema = Schema.Struct(structFields);
1351
- for (const refinement of self.refinements) if (refinement._tag === "sync") schema = schema.check(Schema.makeFilter(refinement.fn));
1352
- return schema;
1428
+ const structFields = Rec.fromEntries(Arr.flatMap(Rec.toEntries(self.fields), ([key, def]) => {
1429
+ if (isArrayFieldDef(def)) return [[key, Schema.Array(def.itemSchema)]];
1430
+ if (isFieldDef(def)) return [[key, def.schema]];
1431
+ return [];
1432
+ }));
1433
+ const struct = Schema.Struct(structFields);
1434
+ return Arr.reduce(self.refinements, struct, (refined, refinement) => Predicate.isTagged(refinement, "sync") ? refined.check(Schema.makeFilter(refinement.fn)) : refined);
1353
1435
  }
1354
1436
  /** The asynchronous refinements carried on a builder, as bare predicates. */
1355
1437
  function asyncRefinements(self) {
1356
- return self.refinements.filter((r) => r._tag === "async").map((r) => r.fn);
1438
+ return Arr.flatMap(self.refinements, (refinement) => {
1439
+ if (!Predicate.isTagged(refinement, "async")) return [];
1440
+ return [refinement.fn];
1441
+ });
1357
1442
  }
1358
1443
  /**
1444
+ * Normalise a filter predicate's `Schema.FilterOutput` into a flat list of
1445
+ * {@link FormIssue}s (empty on success).
1446
+ *
1447
+ * @deprecated Use `Issues.filterOutputToFormIssues`; this re-export remains
1448
+ * only for callers that imported it from `FormBuilder`.
1449
+ */
1450
+ const filterOutputToFormIssues = filterOutputToFormIssues$1;
1451
+ /**
1359
1452
  * Run every asynchronous refinement on `self` against `values` and collect
1360
1453
  * their failures as {@link FormIssue}s (empty when all pass). Runs them
1361
1454
  * concurrently; requirements propagate on the `R` channel.
1362
1455
  */
1363
1456
  function runAsyncRefinements(self, values) {
1364
- const refinements = asyncRefinements(self);
1365
- if (refinements.length === 0) return Effect.succeed([]);
1366
- return Effect.forEach(refinements, (fn) => fn(values), { concurrency: "unbounded" }).pipe(Effect.map((outputs) => outputs.flatMap(filterOutputToFormIssues)));
1457
+ return Arr.match(asyncRefinements(self), {
1458
+ onEmpty: () => Effect.succeed([]),
1459
+ onNonEmpty: (refinements) => Effect.forEach(refinements, (fn) => fn(values), { concurrency: "unbounded" }).pipe(Effect.map((outputs) => Arr.flatMap(outputs, filterOutputToFormIssues$1)))
1460
+ });
1367
1461
  }
1368
1462
  //#endregion
1369
1463
  //#region src/State.ts
1370
1464
  /**
1371
- * @copyright Thomas Foster
1372
- *
1373
1465
  * {@link FormState} — the serialisable snapshot of a form.
1374
1466
  *
1375
1467
  * This is the single object a
@@ -1378,6 +1470,7 @@ function runAsyncRefinements(self, values) {
1378
1470
  * boundary, and {@link FormStateSchema} gives it an Effect Schema so it can be
1379
1471
  * decoded back on arrival.
1380
1472
  *
1473
+ * @copyright Thomas Foster
1381
1474
  * @module effect-forms/State
1382
1475
  */
1383
1476
  var State_exports = /* @__PURE__ */ __exportAll({
@@ -1390,15 +1483,16 @@ var State_exports = /* @__PURE__ */ __exportAll({
1390
1483
  /** Build a {@link FormState} from data plus overrides. `valid` defaults to "no errors". */
1391
1484
  function make$1(data, options = {}) {
1392
1485
  const errors = options.errors ?? noErrors;
1393
- return {
1486
+ const state = {
1394
1487
  id: options.id ?? "default",
1395
1488
  valid: options.valid ?? !hasErrors(errors),
1396
1489
  posted: options.posted ?? false,
1397
1490
  data,
1398
- errors,
1399
- ...options.message !== void 0 ? { message: options.message } : {},
1400
- ...options.constraints !== void 0 ? { constraints: options.constraints } : {}
1491
+ errors
1401
1492
  };
1493
+ if (options.message !== void 0) state.message = options.message;
1494
+ if (options.constraints !== void 0) state.constraints = options.constraints;
1495
+ return state;
1402
1496
  }
1403
1497
  /** Return a new state carrying `message` (and not otherwise changed). */
1404
1498
  function withMessage(state, message) {
@@ -1436,8 +1530,6 @@ function FormStateSchema(data) {
1436
1530
  //#endregion
1437
1531
  //#region src/Form.ts
1438
1532
  /**
1439
- * @copyright Thomas Foster
1440
- *
1441
1533
  * {@link make} — the core entry point: bind an Effect Schema to a form and
1442
1534
  * get back a validator and an empty-state factory.
1443
1535
  *
@@ -1448,9 +1540,8 @@ function FormStateSchema(data) {
1448
1540
  * it through the schema, returning a {@link FormState} — valid with the
1449
1541
  * decoded data, or invalid with the nested {@link FormErrors} and the posted
1450
1542
  * 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.
1543
+ * `Effect`, so a schema requiring decoding services composes naturally; it
1544
+ * has no error channel — validation failures live in the returned state.
1454
1545
  * - `empty()` seeds an unposted, invalid-by-default form from the supplied
1455
1546
  * defaults — what a `load` returns to render a blank create form.
1456
1547
  *
@@ -1459,6 +1550,7 @@ function FormStateSchema(data) {
1459
1550
  * {@link module:effect-forms/Coerce} helpers, …) so a multipart body decodes
1460
1551
  * cleanly.
1461
1552
  *
1553
+ * @copyright Thomas Foster
1462
1554
  * @module effect-forms/Form
1463
1555
  */
1464
1556
  var Form_exports = /* @__PURE__ */ __exportAll({
@@ -1467,13 +1559,35 @@ var Form_exports = /* @__PURE__ */ __exportAll({
1467
1559
  });
1468
1560
  const toStructured = (input) => {
1469
1561
  if (input === null || input === void 0) return Effect.succeed(null);
1470
- if (input instanceof Request) return Effect.promise(() => requestToObject(input));
1562
+ if (input instanceof Request) return Effect.tryPromise({
1563
+ try: () => requestToObject(input),
1564
+ catch: (cause) => cause
1565
+ }).pipe(Effect.orDie);
1471
1566
  if (input instanceof FormData || input instanceof URLSearchParams) return Effect.succeed(formDataToObject(input));
1472
1567
  return Effect.succeed(input);
1473
1568
  };
1474
1569
  /**
1475
- * Bind `schema` to a form. See the module docs for the contract. The returned
1476
- * {@link Form} is stateless and reusable across requests.
1570
+ * Bind `schema` to a form: `validate(input)` decodes a submission into a
1571
+ * {@link FormState} (it never fails — validation failures live in the state;
1572
+ * its requirements are the schema's decoding services) and `empty()` seeds a
1573
+ * blank form from `defaults`. The returned {@link Form} is stateless and
1574
+ * reusable across requests.
1575
+ *
1576
+ * @example
1577
+ * ```ts
1578
+ * import * as Effect from "effect/Effect"
1579
+ * import * as Schema from "effect/Schema"
1580
+ * import { Form } from "@thomasfosterau/effect-forms"
1581
+ *
1582
+ * const signup = Form.make(
1583
+ * Schema.Struct({ name: Schema.String.check(Schema.isMinLength(2)) }),
1584
+ * { id: "signup", defaults: { name: "" } },
1585
+ * )
1586
+ *
1587
+ * const state = Effect.runSync(signup.validate(new URLSearchParams({ name: "A" })))
1588
+ * console.log(state.valid) // false
1589
+ * console.log(state.data) // { name: "A" } — the posted value, kept for re-render
1590
+ * ```
1477
1591
  */
1478
1592
  function make(schema, options) {
1479
1593
  const id = options.id ?? "default";
@@ -1497,16 +1611,19 @@ function make(schema, options) {
1497
1611
  posted: true,
1498
1612
  constraints
1499
1613
  })),
1500
- onFailure: (issue) => Effect.succeed(make$1({
1501
- ...defaults,
1502
- ...object
1503
- }, {
1504
- id,
1505
- valid: false,
1506
- posted: true,
1507
- constraints,
1508
- errors: issuesToErrors(schemaIssuesToFormIssues(issue, leafFormatter))
1509
- }))
1614
+ onFailure: (issue) => {
1615
+ const posted = {
1616
+ ...defaults,
1617
+ ...object
1618
+ };
1619
+ return Effect.succeed(make$1(posted, {
1620
+ id,
1621
+ valid: false,
1622
+ posted: true,
1623
+ constraints,
1624
+ errors: issuesToErrors(schemaIssuesToFormIssues(issue, leafFormatter))
1625
+ }));
1626
+ }
1510
1627
  }));
1511
1628
  }));
1512
1629
  return {
@@ -1540,17 +1657,20 @@ function fromBuilder(builder, options = {}) {
1540
1657
  ...object
1541
1658
  };
1542
1659
  return decode(object).pipe(Effect.matchEffect({
1543
- onSuccess: (data) => runAsyncRefinements(builder, data).pipe(Effect.map((issues) => issues.length === 0 ? make$1(data, {
1544
- id: base.id,
1545
- valid: true,
1546
- posted: true,
1547
- constraints: base.constraints
1548
- }) : make$1(posted, {
1549
- id: base.id,
1550
- valid: false,
1551
- posted: true,
1552
- constraints: base.constraints,
1553
- errors: issuesToErrors(issues)
1660
+ onSuccess: (data) => runAsyncRefinements(builder, data).pipe(Effect.map((issues) => Arr.match(issues, {
1661
+ onEmpty: () => make$1(data, {
1662
+ id: base.id,
1663
+ valid: true,
1664
+ posted: true,
1665
+ constraints: base.constraints
1666
+ }),
1667
+ onNonEmpty: (failures) => make$1(posted, {
1668
+ id: base.id,
1669
+ valid: false,
1670
+ posted: true,
1671
+ constraints: base.constraints,
1672
+ errors: issuesToErrors(failures)
1673
+ })
1554
1674
  }))),
1555
1675
  onFailure: (issue) => Effect.succeed(make$1(posted, {
1556
1676
  id: base.id,
@@ -1569,8 +1689,6 @@ function fromBuilder(builder, options = {}) {
1569
1689
  //#endregion
1570
1690
  //#region src/Mode.ts
1571
1691
  /**
1572
- * @copyright Thomas Foster
1573
- *
1574
1692
  * When a form validates (and, optionally, auto-submits).
1575
1693
  *
1576
1694
  * A reactive form has to decide *when* to run validation as the user types.
@@ -1595,6 +1713,7 @@ function fromBuilder(builder, options = {}) {
1595
1713
  * once the form has been posted at least once; `"never"` keeps the classic
1596
1714
  * frozen-until-resubmit behaviour.
1597
1715
  *
1716
+ * @copyright Thomas Foster
1598
1717
  * @module effect-forms/Mode
1599
1718
  */
1600
1719
  var Mode_exports = /* @__PURE__ */ __exportAll({ parse: () => parse });
@@ -1662,8 +1781,6 @@ const errorResult = (message, status = 500) => ({
1662
1781
  //#endregion
1663
1782
  //#region src/Validate.ts
1664
1783
  /**
1665
- * @copyright Thomas Foster
1666
- *
1667
1784
  * Low-level validation helpers — decode a value through a schema and get back
1668
1785
  * the {@link FormErrors} tree (or the list of {@link FormIssue}s) instead of a
1669
1786
  * raised failure. `Form.make` is the high-level entry; these are for ad
@@ -1680,6 +1797,7 @@ const errorResult = (message, status = 500) => ({
1680
1797
  * and interrupting the previous fiber on every new run (see that function's
1681
1798
  * docs for why that alone gives debounce + "latest wins" cancellation).
1682
1799
  *
1800
+ * @copyright Thomas Foster
1683
1801
  * @module effect-forms/Validate
1684
1802
  */
1685
1803
  var Validate_exports = /* @__PURE__ */ __exportAll({
@@ -1725,7 +1843,7 @@ function validateValueSync(schema, input, format) {
1725
1843
  * cannot fail the Effect error channel.
1726
1844
  */
1727
1845
  function runSyncFieldValidator(validator, value) {
1728
- return filterOutputToFormIssues(validator(value));
1846
+ return filterOutputToFormIssues$1(validator(value));
1729
1847
  }
1730
1848
  /**
1731
1849
  * Build the Effect an {@link AsyncFieldValidator} run is: sleep `debounceMs`
@@ -1745,7 +1863,7 @@ function runSyncFieldValidator(validator, value) {
1745
1863
  function runAsyncFieldValidator(validator, value, debounceMs = 0) {
1746
1864
  return Effect.gen(function* () {
1747
1865
  if (debounceMs > 0) yield* Effect.sleep(Duration.millis(debounceMs));
1748
- return filterOutputToFormIssues(yield* validator(value));
1866
+ return filterOutputToFormIssues$1(yield* validator(value));
1749
1867
  });
1750
1868
  }
1751
1869
  /**