@thomasfosterau/effect-forms 0.2.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,8 +1,14 @@
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";
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
14
  //#region src/Coerce.ts
@@ -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
@@ -110,23 +116,21 @@ var Command_exports = /* @__PURE__ */ __exportAll({
110
116
  plan: () => plan,
111
117
  updateAdapter: () => updateAdapter
112
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 }) {};
113
123
  /**
114
124
  * Run one decode probe: whether the schema accepts the input, plus the decoded
115
125
  * value for instance checks (the date probe needs to see a `Date` come out,
116
126
  * not just a successful decode).
117
127
  */
118
128
  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
- }
129
+ const decoder = schema;
130
+ return SchemaParser.decodeUnknownExit(decoder)(input);
127
131
  };
128
- /** True when a schema decodes the given input without throwing. */
129
- 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));
130
134
  /** A syntactically valid UUID no other field kind accepts as non-text. */
131
135
  const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
132
136
  /**
@@ -141,13 +145,10 @@ const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
141
145
  * `enumOptions` below needs the full literal set, not just one branch of it.
142
146
  */
143
147
  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;
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]);
151
152
  }
152
153
  /**
153
154
  * The literal option values of a field shaped like `Schema.Literal(...)` or
@@ -160,8 +161,8 @@ function enumOptions(ast) {
160
161
  const inner = unwrapWrapperUnion(ast);
161
162
  if (SchemaAST.isLiteral(inner)) return [String(inner.literal)];
162
163
  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));
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));
165
166
  }
166
167
  }
167
168
  /**
@@ -187,7 +188,7 @@ const probeKind = (schema) => {
187
188
  if (decodes(schema, true)) return "boolean";
188
189
  if (decodes(schema, 42)) return "number";
189
190
  const date = probe(schema, "2024-01-02T03:04:05.000Z");
190
- if (date.ok && date.value instanceof Date) return "date";
191
+ if (Exit.isSuccess(date) && date.value instanceof Date) return "date";
191
192
  if (decodes(schema, PROBE_UUID) && !decodes(schema, "not-a-uuid")) return "id";
192
193
  if (decodes(schema, "plain text")) return "text";
193
194
  };
@@ -214,7 +215,7 @@ const classify = (key, schema) => {
214
215
  const probed = probeKind(schema);
215
216
  if (probed !== void 0) return { kind: probed };
216
217
  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`);
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` });
218
219
  };
219
220
  /**
220
221
  * Read a create/update command struct pair into a {@link CommandPlan}.
@@ -224,22 +225,23 @@ const classify = (key, schema) => {
224
225
  const plan = (create, update) => {
225
226
  const createFields = create.fields;
226
227
  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);
228
+ const keys = [...Arr.filter(Rec.keys(updateFields), (key) => key !== "id"), ...Arr.filter(Rec.keys(createFields), (key) => key !== "id" && !(key in updateFields))];
230
229
  return {
231
- fields: keys.map((key) => {
230
+ fields: Arr.map(keys, (key) => {
232
231
  const createField = createFields[key];
233
232
  const updateField = updateFields[key];
234
233
  const { kind, options } = classify(key, createField ?? updateField);
235
- return {
234
+ const fieldPlan = {
236
235
  key,
237
236
  kind,
238
237
  inCreate: createField !== void 0,
239
238
  createRequired: createField !== void 0 && !decodes(createField, void 0),
240
239
  inUpdate: updateField !== void 0,
241
- updateClearable: updateField !== void 0 && decodes(updateField, null),
242
- ...options !== void 0 ? { options } : {}
240
+ updateClearable: updateField !== void 0 && decodes(updateField, null)
241
+ };
242
+ return options === void 0 ? fieldPlan : {
243
+ ...fieldPlan,
244
+ options
243
245
  };
244
246
  }),
245
247
  clientId: "id" in createFields
@@ -253,17 +255,16 @@ const plan = (create, update) => {
253
255
  * "create") })`, with `overrides` spread last for irregular fields.
254
256
  */
255
257
  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
- };
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
+ ]);
267
268
  };
268
269
  /**
269
270
  * Blank string defaults for one mode's Form-Input shape. Client-id entities
@@ -271,13 +272,8 @@ const formInputFields = (commandPlan, mode, overrides = {}) => {
271
272
  * crypto.randomUUID() }`) so retries stay idempotent.
272
273
  */
273
274
  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;
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, ""]));
281
277
  };
282
278
  /**
283
279
  * Parse a JSON-encoded row list (a contributor list) from a multipart form
@@ -289,25 +285,28 @@ const blankDefaults = (commandPlan, mode) => {
289
285
  */
290
286
  const jsonRowsFromField = (raw, label) => {
291
287
  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`);
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");
303
300
  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
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
308
305
  };
309
306
  });
310
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));
311
310
  /** Text fields: trim always; required create fields never skip. */
312
311
  const coerceText = (ctx, raw) => ctx.required ? (raw ?? "").trim() : ctx.clearable ? trimToNull(raw) : trimToUndefined(raw);
313
312
  /** Id fields: no trimming (pickers emit `""` or a UUID); required create FKs pass through raw. */
@@ -356,49 +355,66 @@ const coerceContextFor = (field, mode) => ({
356
355
  /** The default coercion for one field in one mode. */
357
356
  const coerce = (field, mode, raw) => {
358
357
  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
- }
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);
369
359
  };
370
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
+ /**
371
372
  * Derive the create adapter: validated all-string input → encoded create
372
373
  * command payload. `overrides` replace the default per-field coercion for
373
374
  * genuinely irregular fields (e.g. a create-time fallback default).
374
375
  */
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
- };
376
+ const createAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([...commandPlan.clientId ? [["id", trimToUndefined(input.id)]] : [], ...adaptFields(commandPlan, overrides, "create", input)]);
385
377
  /**
386
378
  * Derive the update adapter: validated all-string input → encoded update
387
379
  * command payload (value sets, `null` clears a clearable field, `undefined`
388
380
  * skips). The required `id` passes through as-is — the command's canonical
389
381
  * decode brands it.
390
382
  */
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
- };
383
+ const updateAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([["id", input.id], ...adaptFields(commandPlan, overrides, "update", input)]);
400
384
  //#endregion
401
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
+ */
402
418
  var Constraints_exports = /* @__PURE__ */ __exportAll({
403
419
  constraintAt: () => constraintAt,
404
420
  constraintsFromSchema: () => constraintsFromSchema,
@@ -416,17 +432,15 @@ const isOptional = (type) => type.context?.isOptional === true;
416
432
  */
417
433
  function unwrapOptional(type) {
418
434
  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;
435
+ return Arr.findFirst(type.types, (member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member)).pipe(Option.match({
436
+ onNone: () => type,
437
+ onSome: unwrapOptional
438
+ }));
424
439
  }
425
440
  /** 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);
441
+ function flattenChecks(checks) {
442
+ if (checks === void 0) return [];
443
+ return Arr.flatMap(checks, (check) => Predicate.isTagged(check, "FilterGroup") ? flattenChecks(check.checks) : [check]);
430
444
  }
431
445
  /**
432
446
  * One constraint patch per recognised representation id — the built-in
@@ -454,14 +468,18 @@ const payloadPatches = {
454
468
  }),
455
469
  "effect/schema/isMultipleOf": (p) => ({ step: p.divisor })
456
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);
457
473
  /**
458
474
  * Look up and apply `payloadPatches[representation.id]`. The cast is sound:
459
475
  * each payload is authored by the same built-in check constructor its id
460
476
  * names, so an entry is never called with a mismatched payload shape.
461
477
  */
462
478
  function applyPayloadPatch(representation) {
463
- const patch = payloadPatches[representation.id];
464
- 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);
465
483
  }
466
484
  /**
467
485
  * Derive an {@link InputConstraint}'s refinement attributes from the checks
@@ -472,25 +490,20 @@ 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 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;
494
507
  }
495
508
  /**
496
509
  * Whether `ast` is a homogeneous array/list — `Schema.Array(item)` or
@@ -503,8 +516,10 @@ function constraintFromChecks(outer, inner) {
503
516
  function homogeneousItem(ast) {
504
517
  if (ast.rest.length !== 1) return void 0;
505
518
  const [item] = ast.rest;
506
- if (ast.elements.length === 0) return item;
507
- 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
+ });
508
523
  }
509
524
  /**
510
525
  * Derive the constraint (or nested tree) for one property/item type: a
@@ -535,30 +550,43 @@ function constraintsForType(type) {
535
550
  }
536
551
  /** Build the constraint tree for one object AST node. */
537
552
  function constraintsForObjects(ast) {
538
- const out = {};
539
- for (const ps of ast.propertySignatures) out[String(ps.name)] = constraintsForType(ps.type);
540
- return out;
553
+ return Rec.fromEntries(Arr.map(ast.propertySignatures, (ps) => [String(ps.name), constraintsForType(ps.type)]));
541
554
  }
542
555
  /** Deep-merge constraint overrides over a derived tree. */
543
556
  function mergeConstraints(base, overrides) {
544
- const out = { ...base };
545
- for (const key of Object.keys(overrides)) {
557
+ const merged = Arr.flatMap(Rec.keys(overrides), (key) => {
546
558
  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] = {
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, {
551
563
  ...existing,
552
564
  ...override
553
- };
554
- else out[key] = override;
555
- }
556
- return out;
565
+ }]];
566
+ return [[key, override]];
567
+ });
568
+ return {
569
+ ...base,
570
+ ...Rec.fromEntries(merged)
571
+ };
557
572
  }
558
573
  /**
559
574
  * Derive the {@link InputConstraints} for a struct schema. Non-struct schemas
560
575
  * yield an empty tree. `overrides` (deep-merged over the derived tree, and
561
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
+ * ```
562
590
  */
563
591
  function constraintsFromSchema(schema, overrides = {}) {
564
592
  const ast = schema.ast;
@@ -575,16 +603,34 @@ function constraintsFromSchema(schema, overrides = {}) {
575
603
  * skips the index and resolves straight to `label`'s derived constraint.
576
604
  */
577
605
  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
- }
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
+ });
584
611
  return node !== void 0 && isLeafConstraint(node) ? node : {};
585
612
  }
586
613
  //#endregion
587
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
+ */
588
634
  var FormData_exports = /* @__PURE__ */ __exportAll({
589
635
  formDataToObject: () => formDataToObject,
590
636
  parseFieldPath: () => parseFieldPath,
@@ -597,48 +643,56 @@ 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 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)];
616
674
  return tokens.length > 1 && tokens[0] === "" ? tokens.slice(1) : tokens;
617
675
  }
618
676
  const isIndexToken = (token) => token === "" || /^\d+$/.test(token);
619
677
  /** Create the container a token should live in: array for index/append tokens. */
620
678
  const makeContainer = (nextToken) => isIndexToken(nextToken) ? [] : {};
621
679
  const setOnContainer = (container, token, value) => {
622
- if (Array.isArray(container)) {
680
+ if (Arr.isArray(container)) {
623
681
  if (token === "") container.push(value);
624
682
  else container[Number(token)] = value;
625
683
  } else container[token] = value;
626
684
  };
627
- 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];
628
686
  /** Assign `value` deep into `root` along `tokens`, creating containers as needed. */
629
687
  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];
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
+ });
642
696
  if (last !== "" && !Array.isArray(node)) {
643
697
  const existing = getFromContainer(node, last);
644
698
  if (existing !== void 0) {
@@ -651,27 +705,35 @@ function assign(root, tokens, value) {
651
705
  setOnContainer(node, last, value);
652
706
  }
653
707
  /** 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;
708
+ function entriesOf(source) {
709
+ if (source instanceof FormData || source instanceof URLSearchParams) return source.entries();
710
+ return source;
660
711
  }
661
712
  /**
662
713
  * Parse a form body into a nested object / array tree. Field names use dot or
663
714
  * bracket path notation (see {@link parseFieldPath}); repeated bare keys
664
715
  * become arrays; `File` values pass through. The result is ready to hand to
665
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
+ * ```
666
730
  */
667
731
  function formDataToObject(source) {
668
- const root = {};
669
- for (const [name, value] of entriesOf(source)) {
732
+ return Arr.reduce(entriesOf(source), Rec.empty(), (root, [name, value]) => {
670
733
  const tokens = parseFieldPath(name);
671
- if (tokens.length === 0) continue;
672
- assign(root, tokens, value);
673
- }
674
- return root;
734
+ if (Arr.isReadonlyArrayNonEmpty(tokens)) assign(root, tokens, value);
735
+ return root;
736
+ });
675
737
  }
676
738
  /** Read and parse a `Request`'s form body. Errors (e.g. wrong content-type) reject. */
677
739
  async function requestToObject(request) {
@@ -720,32 +782,41 @@ var Path_exports = /* @__PURE__ */ __exportAll({
720
782
  * traverses a non-object. The empty path returns the object itself.
721
783
  */
722
784
  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;
785
+ return Arr.reduce(path, obj, (current, segment) => Predicate.isObjectOrArray(current) && Predicate.hasProperty(current, segment) ? current[segment] : void 0);
729
786
  }
730
787
  /**
731
788
  * Return a new tree with the value at `path` replaced by `value`. Objects and
732
789
  * arrays along the path are cloned (structural sharing elsewhere); missing
733
790
  * containers are created as arrays for numeric segments and objects otherwise.
734
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
+ * ```
735
802
  */
736
803
  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;
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
+ });
743
816
  }
744
817
  /** Whether `path` is `root` or nested beneath it. */
745
818
  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;
819
+ return path.length >= root.length && Arr.every(root, (segment, i) => path[i] === segment);
749
820
  }
750
821
  /**
751
822
  * Render a {@link FormPath} as flat field-name notation: string segments join
@@ -753,10 +824,7 @@ function isUnder(path, root) {
753
824
  * The inverse of {@link fromString}.
754
825
  */
755
826
  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;
827
+ return Arr.reduce(path, "", (out, segment) => Predicate.isNumber(segment) ? `${out}[${segment}]` : out === "" ? segment : `${out}.${segment}`);
760
828
  }
761
829
  /**
762
830
  * Parse flat field-name notation into a {@link FormPath}: integer tokens become
@@ -801,7 +869,7 @@ const clearAll = () => null;
801
869
  function remapIndex(path, arrayPath, map) {
802
870
  if (!isUnder(path, arrayPath) || path.length === arrayPath.length) return path;
803
871
  const segment = path[arrayPath.length];
804
- if (typeof segment !== "number") return path;
872
+ if (!Predicate.isNumber(segment)) return path;
805
873
  const mapped = map(segment);
806
874
  if (mapped === null) return null;
807
875
  if (mapped === segment) return path;
@@ -817,15 +885,34 @@ function remapIndex(path, arrayPath, map) {
817
885
  * maps to `null` are dropped. Pure.
818
886
  */
819
887
  function remapKeyedRecord(record, arrayPath, map) {
820
- const out = {};
821
- for (const key of Object.keys(record)) {
888
+ return Rec.fromEntries(Arr.flatMap(Rec.toEntries(record), ([key, value]) => {
822
889
  const remapped = remapIndex(fromString(key), arrayPath, map);
823
- if (remapped !== null) out[toString(remapped)] = record[key];
824
- }
825
- return out;
890
+ return remapped === null ? [] : [[toString(remapped), value]];
891
+ }));
826
892
  }
827
893
  //#endregion
828
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
+ */
829
916
  var Errors_exports = /* @__PURE__ */ __exportAll({
830
917
  ROOT_ERROR_KEY: () => ROOT_ERROR_KEY,
831
918
  clearSourceAt: () => clearSourceAt,
@@ -844,7 +931,7 @@ var Errors_exports = /* @__PURE__ */ __exportAll({
844
931
  });
845
932
  /** The root key under which a branch's own (non-field) messages are stored. */
846
933
  const ROOT_ERROR_KEY = "_errors";
847
- const isMessageList = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
934
+ const isMessageList = (value) => Arr.isArray(value) && Arr.every(value, Predicate.isString);
848
935
  const ownErrors = (node) => {
849
936
  const existing = node[ROOT_ERROR_KEY];
850
937
  if (isMessageList(existing)) return existing;
@@ -853,15 +940,26 @@ const ownErrors = (node) => {
853
940
  return created;
854
941
  };
855
942
  /**
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.
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.
861
949
  */
862
- const promoteLeafToBranch = (node, key) => {
950
+ const branchAt = (node, key) => {
863
951
  const existing = node[key];
864
- 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;
865
963
  };
866
964
  /**
867
965
  * Insert one message at `path` into a mutable error tree. A leaf becomes a
@@ -871,18 +969,12 @@ const promoteLeafToBranch = (node, key) => {
871
969
  * tree root's {@link ROOT_ERROR_KEY}.
872
970
  */
873
971
  const insert = (root, path, message) => {
874
- if (path.length === 0) {
972
+ if (!Arr.isReadonlyArrayNonEmpty(path)) {
875
973
  ownErrors(root).push(message);
876
974
  return;
877
975
  }
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]);
976
+ const node = Arr.reduce(Arr.initNonEmpty(path), root, (parent, segment) => branchAt(parent, String(segment)));
977
+ const last = String(Arr.lastNonEmpty(path));
886
978
  const existing = node[last];
887
979
  if (existing === void 0) node[last] = [message];
888
980
  else if (isMessageList(existing)) existing.push(message);
@@ -891,7 +983,7 @@ const insert = (root, path, message) => {
891
983
  /** Build a nested {@link FormErrors} tree from a flat list of {@link FormIssue}s. */
892
984
  function issuesToErrors(issues) {
893
985
  const root = {};
894
- for (const issue of issues) insert(root, issue.path, issue.message);
986
+ Arr.forEach(issues, (issue) => insert(root, issue.path, issue.message));
895
987
  return root;
896
988
  }
897
989
  /**
@@ -900,32 +992,25 @@ function issuesToErrors(issues) {
900
992
  * ordering within a single field).
901
993
  */
902
994
  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;
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, []);
925
1010
  }
926
1011
  /** Whether the tree carries any message anywhere. */
927
1012
  function hasErrors(errors) {
928
- return flattenErrors(errors).length > 0;
1013
+ return Arr.isReadonlyArrayNonEmpty(flattenErrors(errors));
929
1014
  }
930
1015
  /**
931
1016
  * Read the messages attached directly to `path` (a leaf's list, or a branch's
@@ -933,11 +1018,7 @@ function hasErrors(errors) {
933
1018
  * messages.
934
1019
  */
935
1020
  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
- }
1021
+ const node = Arr.reduce(path, errors, (current, segment) => current === void 0 || isMessageList(current) ? void 0 : current[String(segment)]);
941
1022
  if (node === void 0) return void 0;
942
1023
  if (isMessageList(node)) return node;
943
1024
  return node[ROOT_ERROR_KEY];
@@ -961,15 +1042,13 @@ const noErrors = {};
961
1042
  * Messages whose index maps to `null` are dropped. Pure.
962
1043
  */
963
1044
  function remapIndices(errors, arrayPath, map) {
964
- const remapped = [];
965
- for (const issue of flattenErrors(errors)) {
1045
+ return issuesToErrors(Arr.flatMap(flattenErrors(errors), (issue) => {
966
1046
  const path = remapIndex(issue.path, arrayPath, map);
967
- if (path !== null) remapped.push({
1047
+ return path === null ? [] : [{
968
1048
  path,
969
1049
  message: issue.message
970
- });
971
- }
972
- return issuesToErrors(remapped);
1050
+ }];
1051
+ }));
973
1052
  }
974
1053
  /**
975
1054
  * Return a new tree with the messages *directly at* `path` replaced by
@@ -986,51 +1065,45 @@ function replaceAt(errors, path, messages) {
986
1065
  return next;
987
1066
  }
988
1067
  const setLeaf = (root, path, messages) => {
989
- if (path.length === 0) {
1068
+ if (!Arr.isReadonlyArrayNonEmpty(path)) {
990
1069
  setOwnMessages(root, messages);
991
1070
  return;
992
1071
  }
993
- const parent = descendClearing(root, path, messages);
1072
+ const parent = descendClearing(root, Arr.initNonEmpty(path), messages);
994
1073
  if (parent === void 0) return;
995
- setLeafMessages(parent, String(path[path.length - 1]), messages);
1074
+ setLeafMessages(parent, String(Arr.lastNonEmpty(path)), messages);
996
1075
  };
997
1076
  /** Set or clear a node's own {@link ROOT_ERROR_KEY} messages. */
998
1077
  const setOwnMessages = (node, messages) => {
999
- if (messages.length === 0) delete node[ROOT_ERROR_KEY];
1078
+ if (Arr.isReadonlyArrayEmpty(messages)) delete node[ROOT_ERROR_KEY];
1000
1079
  else node[ROOT_ERROR_KEY] = [...messages];
1001
1080
  };
1002
1081
  /**
1003
- * 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
1004
1084
  * `messages` is empty (a clear, not a set) a missing branch means there is
1005
1085
  * nothing to clear — descent stops early and returns `undefined` rather than
1006
1086
  * creating empty branches just to immediately no-op inside them.
1007
1087
  */
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
- };
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
+ });
1020
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}. */
1021
1095
  const setLeafMessages = (node, key, messages) => {
1022
1096
  const existing = node[key];
1023
1097
  if (existing === void 0) {
1024
- if (messages.length > 0) node[key] = [...messages];
1098
+ if (Arr.isReadonlyArrayNonEmpty(messages)) node[key] = [...messages];
1025
1099
  return;
1026
1100
  }
1027
1101
  if (isMessageList(existing)) {
1028
- if (messages.length === 0) delete node[key];
1102
+ if (Arr.isReadonlyArrayEmpty(messages)) delete node[key];
1029
1103
  else node[key] = [...messages];
1030
1104
  return;
1031
1105
  }
1032
- if (messages.length === 0) delete existing[ROOT_ERROR_KEY];
1033
- else existing[ROOT_ERROR_KEY] = [...messages];
1106
+ setOwnMessages(existing, messages);
1034
1107
  };
1035
1108
  /** Both source trees empty. */
1036
1109
  const emptySourced = {
@@ -1057,6 +1130,26 @@ function clearSourceAt(sourced, source, path) {
1057
1130
  }
1058
1131
  //#endregion
1059
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
+ */
1060
1153
  var Field_exports = /* @__PURE__ */ __exportAll({
1061
1154
  defaultEncodedValues: () => defaultEncodedValues,
1062
1155
  defaultItemAt: () => defaultItemAt,
@@ -1068,9 +1161,9 @@ var Field_exports = /* @__PURE__ */ __exportAll({
1068
1161
  schemaAt: () => schemaAt
1069
1162
  });
1070
1163
  /** Whether `def` is an {@link ArrayFieldDef}. */
1071
- const isArrayFieldDef = (def) => def._tag === "array";
1164
+ const isArrayFieldDef = (def) => Predicate.isTagged(def, "array");
1072
1165
  /** Whether `def` is a scalar {@link FieldDef}. */
1073
- const isFieldDef = (def) => def._tag === "field";
1166
+ const isFieldDef = (def) => Predicate.isTagged(def, "field");
1074
1167
  /** Construct a scalar {@link FieldDef}. */
1075
1168
  const makeField = (key, schema) => ({
1076
1169
  _tag: "field",
@@ -1100,19 +1193,14 @@ function defaultValue(schema) {
1100
1193
  */
1101
1194
  function unwrapToStructured(ast) {
1102
1195
  if (!SchemaAST.isUnion(ast)) return ast;
1103
- for (const member of ast.types) {
1196
+ return Arr.findFirst(ast.types, (member) => {
1104
1197
  const inner = unwrapToStructured(member);
1105
- if (SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner)) return inner;
1106
- }
1107
- return ast;
1198
+ return SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner) ? Option.some(inner) : Option.none();
1199
+ }).pipe(Option.getOrElse(() => ast));
1108
1200
  }
1109
1201
  function fromAst(ast) {
1110
1202
  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
- }
1203
+ if (SchemaAST.isObjects(unwrapped)) return Rec.fromEntries(Arr.map(unwrapped.propertySignatures, (ps) => [String(ps.name), fromAst(ps.type)]));
1116
1204
  if (SchemaAST.isArrays(unwrapped)) return [];
1117
1205
  return "";
1118
1206
  }
@@ -1140,12 +1228,7 @@ function stepIntoStruct(ast, key) {
1140
1228
  * schemas built directly with `Form.make` (no builder involved).
1141
1229
  */
1142
1230
  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;
1231
+ return Arr.reduce(path, unwrapToStructured(schema.ast), (ast, segment) => ast === void 0 ? void 0 : Predicate.isNumber(segment) ? stepIntoArray(ast) : stepIntoStruct(ast, segment));
1149
1232
  }
1150
1233
  /**
1151
1234
  * Derive a blank encoded item for the array field at `path` (any nesting
@@ -1154,18 +1237,34 @@ function schemaAt(schema, path) {
1154
1237
  */
1155
1238
  function defaultItemAt(schema, path) {
1156
1239
  const ast = schemaAt(schema, path);
1157
- if (ast === void 0 || !SchemaAST.isArrays(ast)) return "";
1158
- const item = arrayElementAst(ast);
1240
+ const item = ast !== void 0 && SchemaAST.isArrays(ast) ? arrayElementAst(ast) : void 0;
1159
1241
  return item === void 0 ? "" : fromAst(item);
1160
1242
  }
1161
1243
  /** Seed a blank encoded value for every field: `[]` for arrays, `""` otherwise. */
1162
1244
  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;
1245
+ return Rec.map(fields, (def) => isArrayFieldDef(def) ? [] : defaultValue(def.schema));
1166
1246
  }
1167
1247
  //#endregion
1168
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
+ */
1169
1268
  var Issues_exports = /* @__PURE__ */ __exportAll({
1170
1269
  defaultLeafFormatter: () => defaultLeafFormatter,
1171
1270
  filterOutputToFormIssues: () => filterOutputToFormIssues$1,
@@ -1178,21 +1277,12 @@ const defaultLeafFormatter = SchemaIssue.makeFormatterDefault();
1178
1277
  * keys become numbers (array indices), everything else becomes a string.
1179
1278
  */
1180
1279
  const normaliseSegment = (segment) => {
1181
- if (typeof segment === "number") return segment;
1182
- if (typeof segment === "symbol") return segment.toString();
1280
+ if (Predicate.isNumber(segment)) return segment;
1281
+ if (Predicate.isSymbol(segment)) return segment.toString();
1183
1282
  return /^\d+$/.test(segment) ? Number(segment) : segment;
1184
1283
  };
1185
1284
  /** 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
- };
1285
+ const isTerminal = Match.type().pipe(Match.tag("Pointer", "Composite", "Filter", "Encoding", () => false), Match.tag("AnyOf", (issue) => Arr.isReadonlyArrayEmpty(issue.issues)), Match.orElse(() => true));
1196
1286
  /**
1197
1287
  * Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
1198
1288
  * failure. Path segments accumulate through `Pointer` nodes; `Composite` and
@@ -1201,48 +1291,18 @@ const isTerminal = (issue) => {
1201
1291
  * mismatch) is itself a leaf.
1202
1292
  */
1203
1293
  function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
1204
- const out = [];
1205
1294
  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
- }
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));
1243
1304
  };
1244
- walk(issue, []);
1245
- return out;
1305
+ return walk(issue, []);
1246
1306
  }
1247
1307
  /**
1248
1308
  * Normalise a filter predicate's {@link Schema.FilterOutput} into a flat list of
@@ -1257,17 +1317,19 @@ function filterOutputToFormIssues$1(output) {
1257
1317
  path: [],
1258
1318
  message: "Invalid value"
1259
1319
  }];
1260
- if (Array.isArray(output)) return output.flatMap(filterIssueToFormIssues);
1320
+ if (isFilterIssueList(output)) return Arr.flatMap(output, filterIssueToFormIssues);
1261
1321
  return filterIssueToFormIssues(output);
1262
1322
  }
1323
+ /** `Array.isArray` as a guard that also narrows away a `ReadonlyArray` member. */
1324
+ const isFilterIssueList = (output) => Arr.isArray(output);
1263
1325
  function filterIssueToFormIssues(issue) {
1264
- if (typeof issue === "string") return [{
1326
+ if (Predicate.isString(issue)) return [{
1265
1327
  path: [],
1266
1328
  message: issue
1267
1329
  }];
1268
1330
  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" ? [{
1331
+ const prefix = Arr.map(issue.path, (s) => Predicate.isSymbol(s) ? s.toString() : s);
1332
+ return (Predicate.isString(issue.issue) ? [{
1271
1333
  path: [],
1272
1334
  message: issue.issue
1273
1335
  }] : schemaIssuesToFormIssues(issue.issue)).map((i) => ({
@@ -1315,10 +1377,12 @@ var FormBuilder_exports = /* @__PURE__ */ __exportAll({
1315
1377
  });
1316
1378
  /** Brand for a {@link FormBuilder}. */
1317
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]);
1318
1382
  const proto = {
1319
1383
  [TypeId]: TypeId,
1320
- addField(keyOrField, schema) {
1321
- const field = typeof keyOrField === "string" ? makeField(keyOrField, schema) : keyOrField;
1384
+ addField(...args) {
1385
+ const field = isKeyedArgs(args) ? makeField(...args) : args[0];
1322
1386
  return build({
1323
1387
  ...this.fields,
1324
1388
  [field.key]: field
@@ -1350,7 +1414,7 @@ function build(fields, refinements) {
1350
1414
  return self;
1351
1415
  }
1352
1416
  /** Whether `u` is a {@link FormBuilder}. */
1353
- const isFormBuilder = (u) => typeof u === "object" && u !== null && TypeId in u;
1417
+ const isFormBuilder = (u) => Predicate.isObjectOrArray(u) && Predicate.hasProperty(u, TypeId);
1354
1418
  /** The empty builder — the starting point for {@link FormBuilder.addField}. */
1355
1419
  const make$2 = build({}, []);
1356
1420
  /** Alias of {@link make} — the empty builder. */
@@ -1361,16 +1425,20 @@ const empty = make$2;
1361
1425
  * applied as a struct-level check. Ready for {@link module:effect-forms/Form}.
1362
1426
  */
1363
1427
  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;
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);
1370
1435
  }
1371
1436
  /** The asynchronous refinements carried on a builder, as bare predicates. */
1372
1437
  function asyncRefinements(self) {
1373
- 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
+ });
1374
1442
  }
1375
1443
  /**
1376
1444
  * Normalise a filter predicate's `Schema.FilterOutput` into a flat list of
@@ -1386,9 +1454,10 @@ const filterOutputToFormIssues = filterOutputToFormIssues$1;
1386
1454
  * concurrently; requirements propagate on the `R` channel.
1387
1455
  */
1388
1456
  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)));
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
+ });
1392
1461
  }
1393
1462
  //#endregion
1394
1463
  //#region src/State.ts
@@ -1414,15 +1483,16 @@ var State_exports = /* @__PURE__ */ __exportAll({
1414
1483
  /** Build a {@link FormState} from data plus overrides. `valid` defaults to "no errors". */
1415
1484
  function make$1(data, options = {}) {
1416
1485
  const errors = options.errors ?? noErrors;
1417
- return {
1486
+ const state = {
1418
1487
  id: options.id ?? "default",
1419
1488
  valid: options.valid ?? !hasErrors(errors),
1420
1489
  posted: options.posted ?? false,
1421
1490
  data,
1422
- errors,
1423
- ...options.message !== void 0 ? { message: options.message } : {},
1424
- ...options.constraints !== void 0 ? { constraints: options.constraints } : {}
1491
+ errors
1425
1492
  };
1493
+ if (options.message !== void 0) state.message = options.message;
1494
+ if (options.constraints !== void 0) state.constraints = options.constraints;
1495
+ return state;
1426
1496
  }
1427
1497
  /** Return a new state carrying `message` (and not otherwise changed). */
1428
1498
  function withMessage(state, message) {
@@ -1489,7 +1559,10 @@ var Form_exports = /* @__PURE__ */ __exportAll({
1489
1559
  });
1490
1560
  const toStructured = (input) => {
1491
1561
  if (input === null || input === void 0) return Effect.succeed(null);
1492
- 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);
1493
1566
  if (input instanceof FormData || input instanceof URLSearchParams) return Effect.succeed(formDataToObject(input));
1494
1567
  return Effect.succeed(input);
1495
1568
  };
@@ -1499,6 +1572,22 @@ const toStructured = (input) => {
1499
1572
  * its requirements are the schema's decoding services) and `empty()` seeds a
1500
1573
  * blank form from `defaults`. The returned {@link Form} is stateless and
1501
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
+ * ```
1502
1591
  */
1503
1592
  function make(schema, options) {
1504
1593
  const id = options.id ?? "default";
@@ -1522,16 +1611,19 @@ function make(schema, options) {
1522
1611
  posted: true,
1523
1612
  constraints
1524
1613
  })),
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
- }))
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
+ }
1535
1627
  }));
1536
1628
  }));
1537
1629
  return {
@@ -1565,17 +1657,20 @@ function fromBuilder(builder, options = {}) {
1565
1657
  ...object
1566
1658
  };
1567
1659
  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)
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
+ })
1579
1674
  }))),
1580
1675
  onFailure: (issue) => Effect.succeed(make$1(posted, {
1581
1676
  id: base.id,