@thomasfosterau/effect-forms 0.2.0 → 0.3.2

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