@thomasfosterau/effect-forms-svelte 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @thomasfosterau/effect-forms-svelte
2
2
 
3
- Svelte 5 (runes) bindings for [`@thomasfosterau/effect-forms`](../forms): a reactive form store with progressive-enhancement submission, tainted tracking, lifecycle events, and optional client-side validation against the **same Effect Schema the server uses**.
3
+ Svelte 5 (runes) bindings for [`@thomasfosterau/effect-forms`](https://www.npmjs.com/package/@thomasfosterau/effect-forms): a reactive form store with progressive-enhancement submission, tainted tracking, lifecycle events, and optional client-side validation against the **same Effect Schema the server uses**.
4
4
 
5
5
  The root entry depends only on `svelte` and the framework-neutral core, not SvelteKit, so it works in any Svelte 5 app. The [`/kit` entry](#kit) (`@thomasfosterau/effect-forms-svelte/kit`) is the SvelteKit-only half: derived form actions, JSON:API error routing and `$app/forms` glue.
6
6
 
@@ -45,7 +45,7 @@ Server (`+page.server.ts` or any action) produces a `FormState` with the core's
45
45
 
46
46
  ## The store
47
47
 
48
- `createForm(source, options)` returns a [`FormStore`](src/FormStore.svelte.ts). `source` is a server-produced `FormState`, a `{ schema, defaults }` seed, or a [`FormBuilder`](../forms/src/FormBuilder.ts) — the builder derives its schema, blank defaults, and array-item defaults for you:
48
+ `createForm(source, options)` returns a [`FormStore`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/FormStore.svelte.ts). `source` is a server-produced `FormState`, a `{ schema, defaults }` seed, or a [`FormBuilder`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/forms/src/FormBuilder.ts) — the builder derives its schema, blank defaults, and array-item defaults for you:
49
49
 
50
50
  ```ts
51
51
  import { createForm } from "@thomasfosterau/effect-forms-svelte";
@@ -56,13 +56,13 @@ const form = createForm(signup, { mode: { validation: "onChange", debounce: 300
56
56
 
57
57
  **Reactive state** — `data` (mutable, bind inputs to it), `errors` (merged client + server), `clientErrors` / `serverErrors` (per-source views), `message`, `constraints`, `submitting`, `delayed`, `posted`, `valid`, `canSubmit`, `isValidating`, `isSubmitted`, `isSubmitSuccessful`, `submissionAttempts`, `tainted`, `isTainted`, and (against the last submission) `changedSinceSubmit` / `hasChangedSinceSubmit`.
58
58
 
59
- **Methods** — `enhance(node)` (the form action), `validate()` / `validateField(path)` (client-side, needs `schema`; also runs a builder's `refineEffect` refinements), `errorsAt(path)` / `taintedAt(path)` / `isValidatingAt(path)`, `setValue(path, v)` / `valueAt(path)`, `handleBlur(path)` / `setTouched(path)` / `isTouchedAt(path)`, `isDirtyAt(path)` / `isDefaultValueAt(path)` (persistent per-field dirty tracking — see [Field handles](#field-handles)), `setFieldValidators(path, validators)` (register a field's validators dynamically), `shouldShowError(path)` / `visibleErrorsAt(path)` (mode-aware display gating), `constraintAt(path)`, `revertToLastSubmit()`, `reset()`, `capture()` / `restore()` (SvelteKit snapshots), `setServerState(data, errors)` (apply a result received some other way), `toFormData()`.
59
+ **Methods** — `enhance(node)` (the form action), `validate()` / `validateField(path)` (client-side, needs `schema`; also runs a builder's `refineEffect` refinements, on the decoded values as the server does, once they decode), `errorsAt(path)` / `taintedAt(path)` / `isValidatingAt(path)`, `setValue(path, v)` / `valueAt(path)`, `handleBlur(path)` / `setTouched(path)` / `isTouchedAt(path)`, `isDirtyAt(path)` / `isDefaultValueAt(path)` (persistent per-field dirty tracking — see [Field handles](#field-handles)), `setFieldValidators(path, validators)` (register a field's validators dynamically), `shouldShowError(path)` / `visibleErrorsAt(path)` (mode-aware display gating), `constraintAt(path)`, `revertToLastSubmit()`, `reset()`, `capture()` / `restore()` (SvelteKit snapshots), `setServerState(data, errors)` (apply a result received some other way), `toFormData()`.
60
60
 
61
61
  **Field arrays** — `append(path, value?)`, `insertItem(path, i, value?)`, `replaceItem(path, i, value)`, `removeItem(path, i)`, `clearItems(path)`, `swap(path, a, b)`, `move(path, from, to)` mutate the array at `path` immutably. `append` / `insertItem` derive a blank item by walking the bound schema's AST at `path` (any nesting depth, e.g. `authors[0].emails`), falling back to the top-level `FormBuilder` field when no schema is bound. Every index-shifting mutation (`insertItem` / `removeItem` / `swap` / `move` / `clearItems`) remaps per-index touched flags and errors so they keep following their row — `replaceItem` drops only the replaced index's own touched flag / errors.
62
62
 
63
63
  ## Field handles
64
64
 
65
- `form.field(path)` (modelled on TanStack Form's `FieldApi`) returns a typed, reactive [`FieldHandle`](src/FormStore.svelte.ts) bundling everything one input needs, instead of wiring `bind:value` / `name` / `handleBlur` / `visibleErrorsAt` / `constraints` against the store separately:
65
+ `form.field(path)` (modelled on TanStack Form's `FieldApi`) returns a typed, reactive [`FieldHandle`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/FormStore.svelte.ts) bundling everything one input needs, instead of wiring `bind:value` / `name` / `handleBlur` / `visibleErrorsAt` / `constraints` against the store separately:
66
66
 
67
67
  ```svelte
68
68
  <script lang="ts">
@@ -82,7 +82,7 @@ const form = createForm(signup, { mode: { validation: "onChange", debounce: 300
82
82
 
83
83
  `field.meta` exposes TanStack-style field state: `isBlurred` (lost focus at least once), `isTouched` (`isBlurred || isDirty`), `isDirty` (changed away from the default at some point — **persistent**, unlike `taintedAt` it does not clear on reverting to the default; only `reset()` clears it), and `isDefaultValue` (current value equals the default — flips back on revert). `isDirty` only tracks writes made through `field.value = …` / `setValue` / the array mutators (`append` / `removeItem` / `swap` / `move`) — a form that still binds `bind:value={form.data.x}` directly, bypassing `field()`, never marks that field dirty.
84
84
 
85
- **Validation modes** — the `mode` option (see [`Mode`](../forms/src/Mode.ts)) drives when the form validates: `onSubmit` (default), `onBlur` (call `handleBlur` from an input's blur), or `onChange` (debounced). `onBlur` / `onChange` may `autoSubmit`, turning the form into a live-saving surface. `revalidate` (`"onChange"` default / `"onBlur"` / `"never"`) governs what happens _after_ a failed post in `onSubmit` mode: fixing a field re-validates just that field, clearing its error without a resubmit. Errors are computed for the whole form; gate their display per field with `visibleErrorsAt(path)` (shown only once the field is tainted / touched, or after a submit).
85
+ **Validation modes** — the `mode` option (see [`Mode`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/forms/src/Mode.ts)) drives when the form validates: `onSubmit` (default), `onBlur` (call `handleBlur` from an input's blur), or `onChange` (debounced). `onBlur` / `onChange` may `autoSubmit`, turning the form into a live-saving surface. `revalidate` (`"onChange"` default / `"onBlur"` / `"never"`) governs what happens _after_ a failed post in `onSubmit` mode: fixing a field re-validates just that field, clearing its error without a resubmit. Errors are computed for the whole form; gate their display per field with `visibleErrorsAt(path)` (shown only once the field is tainted / touched, or after a submit).
86
86
 
87
87
  **Per-field validators** — the `validators` option (or `setFieldValidators`) attaches TanStack-Form-style `{ onChange?, onBlur?, onChangeAsync?, onBlurAsync?, asyncDebounceMs? }` per field, keyed by `Path.toString` (e.g. `"author.name"`, `"tags[0]"`). Sync validators return a `Schema.FilterOutput`; async ones are `Effect`s, run as a fiber per field — a new value interrupts the field's in-flight fiber before forking the next run, which is what gives the debounce + "latest wins" cancellation (no timer bookkeeping needed). Pass `layer` when a validator (or a builder's `refineEffect` refinement) needs services on its `R` channel:
88
88
 
@@ -103,23 +103,23 @@ const form = createForm(
103
103
 
104
104
  **Error sources** — a field's server-reported error (from the last submission) survives a client-side revalidation of some _other_ field; it clears once _that_ field's own value changes (or the server clears it on the next submit). `errors` is the merged view; `clientErrors` / `serverErrors` expose the two trees separately for a UI that wants to style them differently.
105
105
 
106
- **Lifecycle options** — `onSubmit` (with `cancel()`), `onResult`, `onUpdate`, `onUpdated`, `onError`, `onRedirect`, plus `resetForm`, `taintedMessage`, `delayMs`, and a `submit` transport override (defaults to `fetch` to the form's action, parsing a JSON [`FormActionResult`](../forms/src/Result.ts) or a bare `FormState`).
106
+ **Lifecycle options** — `onSubmit` (with `cancel()`), `onResult`, `onUpdate`, `onUpdated`, `onError`, `onRedirect`, plus `resetForm`, `taintedMessage`, `delayMs`, and a `submit` transport override (defaults to `fetch` to the form's action, parsing a JSON [`FormActionResult`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/forms/src/Result.ts) or a bare `FormState`).
107
107
 
108
108
  ## API
109
109
 
110
110
  Everything below is exported flat from the root entry (`@thomasfosterau/effect-forms-svelte`).
111
111
 
112
- | Module | Exports |
113
- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
114
- | [`FormStore`](src/FormStore.svelte.ts) | `createForm`, `FormStore`, `FieldHandle`, `FieldMeta`, `FormOptions`, `FormSeed` — the reactive store above. |
115
- | [`EntityForm`](src/EntityForm.ts) | `defineEntityForm` — an ordered, key-checked field list over a core `Command.plan`, walked by one generic renderer component; its entry types (`ScalarEntry`, `SelectEntry`, `WidgetEntry`, …). |
116
- | [`SvelteKit`](src/SvelteKit.ts) | `applyActionResult` — apply a SvelteKit `ActionResult` (`ServerActionResult`) to a store; returns whether it consumed a validation failure. |
117
- | [`Listeners`](src/Listeners.ts) | `changeEvents` / `dispatchChange` and the `FormListeners` / `FieldListener` types — per-field and form-wide change listeners (linked fields). |
118
- | [`Tainted`](src/Tainted.ts) | Deep-diff the data to compute the tainted tree. |
119
- | [`ClientValidate`](src/ClientValidate.ts) | `validateData` / `validateField` — validate the form, or one field, against the schema. |
120
- | [`EnhanceCore`](src/EnhanceCore.ts) | Serialise data → form body, parse the response, compute the store update. |
112
+ | Module | Exports |
113
+ | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
114
+ | [`FormStore`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/FormStore.svelte.ts) | `createForm`, `FormStore`, `FieldHandle`, `FieldMeta`, `FormOptions`, `FormSeed` — the reactive store above. |
115
+ | [`EntityForm`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/EntityForm.ts) | `defineEntityForm` — an ordered, key-checked field list over a core `Command.plan`, walked by one generic renderer component; its entry types (`ScalarEntry`, `SelectEntry`, `WidgetEntry`, …). |
116
+ | [`SvelteKit`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/SvelteKit.ts) | `applyActionResult` — apply a SvelteKit `ActionResult` (`ServerActionResult`) to a store; returns whether it consumed a validation failure. |
117
+ | [`Listeners`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/Listeners.ts) | `changeEvents` / `dispatchChange` and the `FormListeners` / `FieldListener` types — per-field and form-wide change listeners (linked fields). |
118
+ | [`Tainted`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/Tainted.ts) | Deep-diff the data to compute the tainted tree. |
119
+ | [`ClientValidate`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/ClientValidate.ts) | `validateData` / `validateField` — validate the form, or one field, against the schema. |
120
+ | [`EnhanceCore`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/src/EnhanceCore.ts) | Serialise data → form body, parse the response, compute the store update. |
121
121
 
122
- The reactive store (`FormStore.svelte.ts`, rune-compiled) delegates its logic to the pure, unit-tested modules. The store itself is type-checked as plain TypeScript; its submitter-aware submit path (`enhance` → the posted `FormData`) is unit-tested in [`FormStore.svelte.test.ts`](tests/FormStore.svelte.test.ts).
122
+ The reactive store (`FormStore.svelte.ts`, rune-compiled) delegates its logic to the pure, unit-tested modules. The store itself is type-checked (by `vp check` and by `svelte-check`); its submitter-aware submit path (`enhance` → the posted `FormData`) is unit-tested in [`FormStore.svelte.test.ts`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/tests/FormStore.svelte.test.ts).
123
123
 
124
124
  ## /kit
125
125
 
@@ -138,7 +138,7 @@ The reactive store (`FormStore.svelte.ts`, rune-compiled) delegates its logic to
138
138
 
139
139
  ### Peers
140
140
 
141
- - `@sveltejs/kit` (`^3.0.0`) is an **optional** peer of the package and **required** by `/kit`. SvelteKit 2 is not supported. The repository's tests run against SvelteKit 3.0.0, pinned in its `pnpm-workspace.yaml`.
141
+ - `@sveltejs/kit` (`^3.0.0`) is an **optional** peer of the package and **required** by `/kit`. SvelteKit 2 is not supported. The repository's tests run against the SvelteKit version pinned in its `pnpm-workspace.yaml`.
142
142
  - `$app/forms` is a SvelteKit virtual module, not a dependency. It resolves because SvelteKit compiles this package (its `exports` carry the `svelte` condition), so `/kit` only works inside a SvelteKit build.
143
143
  - `@thomasfosterau/effect-jsonapi` (`>=0.16.0`) is an **optional** peer used **for its types only**. `CommandFailure.ErrorObject` is `Document.ErrorObject.Type`. At runtime a failure is recognised by its `_tag`, so an application's own `JsonapiError` with the same shape is recognised too.
144
144
 
@@ -203,7 +203,7 @@ export const actions = {
203
203
  };
204
204
  ```
205
205
 
206
- The runnable copy is [`packages/svelte/tests/kit/examples/login.page.server.ts`](tests/kit/examples/login.page.server.ts). [`login.example.test.ts`](tests/kit/login.example.test.ts) runs it against the real `@sveltejs/kit`, covering the success redirect, the off-site `redirectTo` guard, the 401 with a blanked password, and the rethrow.
206
+ The runnable copy is [`packages/svelte/tests/kit/examples/login.page.server.ts`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/tests/kit/examples/login.page.server.ts). [`login.example.test.ts`](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/tests/kit/login.example.test.ts) runs it against the real `@sveltejs/kit`, covering the success redirect, the off-site `redirectTo` guard, the 401 with a blanked password, and the rethrow.
207
207
 
208
208
  The page renders server state without a client store:
209
209
 
@@ -226,4 +226,4 @@ With a client store, spread `enhanceForm(form)` onto the `<form>` instead.
226
226
 
227
227
  ## License
228
228
 
229
- [MIT](LICENSE)
229
+ [MIT](https://github.com/thomasfosterau/effect-forms/blob/trunk/packages/svelte/LICENSE)
@@ -14,4 +14,5 @@ export declare function validateData<S extends Schema.Top>(schema: S, data: unkn
14
14
  */
15
15
  export declare function validateField<S extends Schema.Top>(schema: S, data: unknown, path: FormPath, format?: LeafFormatter): readonly string[];
16
16
  //#endregion
17
+ export {};
17
18
  //# sourceMappingURL=ClientValidate.d.ts.map
@@ -2,9 +2,40 @@ import { Errors, Result } from "@thomasfosterau/effect-forms";
2
2
  //#region src/EnhanceCore.d.ts
3
3
  type FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;
4
4
  type FormErrors = Errors.FormErrors;
5
- /** Flatten a nested data object into `[name, value]` entries using path notation. */
5
+ /**
6
+ * Flatten a nested data object into `[name, value]` string entries using path
7
+ * notation.
8
+ *
9
+ * Nested objects become dotted names (`author.name`) and array items indexed
10
+ * names (`tags[0]`). Each leaf kind is handled as follows:
11
+ *
12
+ * - A `string`, `number`, `boolean` or `bigint` is stringified.
13
+ * - `null` becomes `""`.
14
+ * - A `Date` becomes the `YYYY-MM-DD` of its **UTC** calendar day (what
15
+ * `<input type="date">` submits, and what `Schema.DateFromString` decodes
16
+ * back to UTC midnight); the time of day is dropped, so a datetime needs its
17
+ * own string field. An invalid `Date` is omitted.
18
+ * - A `Blob` or `File` is omitted here, since a string entry can't carry it;
19
+ * {@link objectToFormData} keeps it.
20
+ * - An `undefined`, `function` or `symbol` is omitted, as `JSON.stringify`
21
+ * omits such object properties, so a callback or symbol left in form data
22
+ * never reaches the form body as `"Symbol(…)"` or function source.
23
+ * - Any other object contributes its own enumerable fields, so one with none
24
+ * (a `Map`, a `Set`, an empty object) contributes no entries.
25
+ *
26
+ * An omitted array item leaves a gap (where `JSON.stringify` would write
27
+ * `null`), so the remaining items keep their indices (`tags[0]`, `tags[2]`).
28
+ */
6
29
  export declare function objectToEntries(data: unknown, prefix?: string): Array<[string, string]>;
7
- /** Serialise the store's data into a {@link FormData} body for submission. */
30
+ /**
31
+ * Serialise the store's data into a {@link FormData} body for submission.
32
+ *
33
+ * Names and leaves follow {@link objectToEntries}, except that a `Blob` or
34
+ * `File` leaf is appended as a file (a `File` keeps its name; a bare `Blob`
35
+ * arrives as a `File` named `"blob"`, the `FormData` default), as an
36
+ * `<input type="file">` would submit it, so it reaches the core's
37
+ * `FormData.formDataToObject` (`@thomasfosterau/effect-forms`) unchanged.
38
+ */
8
39
  export declare function objectToFormData(data: unknown): FormData;
9
40
  /**
10
41
  * Normalise a parsed JSON response body into a {@link FormActionResult}. The
@@ -57,4 +88,5 @@ export declare function computeDataApplication<A>(params: {
57
88
  readonly autoSubmit: boolean;
58
89
  }): DataApplication<A>;
59
90
  //#endregion
91
+ export {};
60
92
  //# sourceMappingURL=EnhanceCore.d.ts.map
@@ -1,4 +1,8 @@
1
1
  import { Result } from "@thomasfosterau/effect-forms";
2
+ import * as Arr from "effect/Array";
3
+ import * as Match from "effect/Match";
4
+ import * as Predicate from "effect/Predicate";
5
+ import * as Record from "effect/Record";
2
6
  //#region src/EnhanceCore.ts
3
7
  /**
4
8
  * Pure plumbing for the `enhance` action: serialise the store's data back into
@@ -11,38 +15,105 @@ import { Result } from "@thomasfosterau/effect-forms";
11
15
  * @module effect-forms-svelte/EnhanceCore
12
16
  */
13
17
  const { failure, success } = Result;
14
- /** Flatten a nested data object into `[name, value]` entries using path notation. */
15
- function objectToEntries(data, prefix = "") {
18
+ /**
19
+ * Whether a value is a `Blob` (a `File` is one). Guards on `globalThis.Blob`
20
+ * so it holds in any runtime, including one without a global `Blob`.
21
+ */
22
+ const isBlob = (value) => !Predicate.isUndefined(globalThis.Blob) && value instanceof Blob;
23
+ /**
24
+ * Encode a `Date` as the `YYYY-MM-DD` of its UTC calendar day, or `undefined`
25
+ * for an invalid `Date`. The UTC day round-trips: a `YYYY-MM-DD` string
26
+ * decodes to UTC midnight. Taking everything before the `T` keeps
27
+ * `toISOString`'s expanded form for a year outside 0000–9999
28
+ * (`+010000-01-01`), where a fixed 10-character slice would cut it off.
29
+ */
30
+ const encodeDate = (value) => {
31
+ if (Number.isNaN(value.getTime())) return void 0;
32
+ const iso = value.toISOString();
33
+ return iso.slice(0, iso.indexOf("T"));
34
+ };
35
+ /** Append the entries for one named value to `out`, recursing into containers. */
36
+ function appendEntries(out, key, value) {
37
+ if (value === null) out.push([key, ""]);
38
+ else if (isBlob(value)) out.push([key, value]);
39
+ else if (value instanceof Date) {
40
+ const encoded = encodeDate(value);
41
+ if (encoded !== void 0) out.push([key, encoded]);
42
+ } else if (Array.isArray(value)) value.forEach((item, i) => appendEntries(out, `${key}[${i}]`, item));
43
+ else if (Predicate.isObject(value)) appendFields(out, value, key);
44
+ else if (Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value) || Predicate.isBigInt(value)) out.push([key, String(value)]);
45
+ }
46
+ /** Append an object's own enumerable fields to `out`, as `prefix.key` names. */
47
+ function appendFields(out, data, prefix) {
48
+ Arr.forEach(Record.toEntries(data), ([key, value]) => appendEntries(out, prefix ? `${prefix}.${key}` : key, value));
49
+ }
50
+ /** Flatten `data` into form-body entries, `Blob` / `File` leaves included. */
51
+ function toBodyEntries(data, prefix) {
16
52
  const out = [];
17
- const push = (key, value) => {
18
- if (value === void 0) return;
19
- if (value === null) out.push([key, ""]);
20
- else if (Array.isArray(value)) value.forEach((item, i) => out.push(...objectToEntries(item, `${key}[${i}]`)));
21
- else if (typeof value === "object") out.push(...objectToEntries(value, key));
22
- else out.push([key, String(value)]);
23
- };
24
- if (data !== null && typeof data === "object" && !Array.isArray(data)) for (const [key, value] of Object.entries(data)) push(prefix ? `${prefix}.${key}` : key, value);
25
- else if (prefix) push(prefix, data);
53
+ if (prefix) appendEntries(out, prefix, data);
54
+ else if (Predicate.isObject(data)) appendFields(out, data, "");
26
55
  return out;
27
56
  }
28
- /** Serialise the store's data into a {@link FormData} body for submission. */
57
+ /**
58
+ * Flatten a nested data object into `[name, value]` string entries using path
59
+ * notation.
60
+ *
61
+ * Nested objects become dotted names (`author.name`) and array items indexed
62
+ * names (`tags[0]`). Each leaf kind is handled as follows:
63
+ *
64
+ * - A `string`, `number`, `boolean` or `bigint` is stringified.
65
+ * - `null` becomes `""`.
66
+ * - A `Date` becomes the `YYYY-MM-DD` of its **UTC** calendar day (what
67
+ * `<input type="date">` submits, and what `Schema.DateFromString` decodes
68
+ * back to UTC midnight); the time of day is dropped, so a datetime needs its
69
+ * own string field. An invalid `Date` is omitted.
70
+ * - A `Blob` or `File` is omitted here, since a string entry can't carry it;
71
+ * {@link objectToFormData} keeps it.
72
+ * - An `undefined`, `function` or `symbol` is omitted, as `JSON.stringify`
73
+ * omits such object properties, so a callback or symbol left in form data
74
+ * never reaches the form body as `"Symbol(…)"` or function source.
75
+ * - Any other object contributes its own enumerable fields, so one with none
76
+ * (a `Map`, a `Set`, an empty object) contributes no entries.
77
+ *
78
+ * An omitted array item leaves a gap (where `JSON.stringify` would write
79
+ * `null`), so the remaining items keep their indices (`tags[0]`, `tags[2]`).
80
+ */
81
+ function objectToEntries(data, prefix = "") {
82
+ return toBodyEntries(data, prefix).filter((entry) => Predicate.isString(entry[1]));
83
+ }
84
+ /**
85
+ * Serialise the store's data into a {@link FormData} body for submission.
86
+ *
87
+ * Names and leaves follow {@link objectToEntries}, except that a `Blob` or
88
+ * `File` leaf is appended as a file (a `File` keeps its name; a bare `Blob`
89
+ * arrives as a `File` named `"blob"`, the `FormData` default), as an
90
+ * `<input type="file">` would submit it, so it reaches the core's
91
+ * `FormData.formDataToObject` (`@thomasfosterau/effect-forms`) unchanged.
92
+ */
29
93
  function objectToFormData(data) {
30
94
  const formData = new FormData();
31
- for (const [key, value] of objectToEntries(data)) formData.append(key, value);
95
+ Arr.forEach(toBodyEntries(data, ""), ([key, value]) => formData.append(key, value));
32
96
  return formData;
33
97
  }
34
- /** Whether a value looks like a {@link FormActionResult} (carries a known `_tag`). */
98
+ /**
99
+ * Whether a value looks like a {@link FormActionResult} (carries a known
100
+ * `_tag`). Only the tag is checked: `A` / `Message` are the caller's declared
101
+ * expectation of the server's response, not verified here.
102
+ */
35
103
  function isActionResult(value) {
36
- return typeof value === "object" && value !== null && typeof value._tag === "string" && [
104
+ return Predicate.isObjectOrArray(value) && Predicate.hasProperty(value, "_tag") && Predicate.isString(value._tag) && [
37
105
  "Success",
38
106
  "Failure",
39
107
  "Redirect",
40
108
  "Error"
41
109
  ].includes(value._tag);
42
110
  }
43
- /** Whether a value looks like a bare {@link FormState}. */
111
+ /**
112
+ * Whether a value looks like a bare {@link FormState}. Only the keys are
113
+ * checked: `A` / `Message` are the caller's declared expectation, not verified.
114
+ */
44
115
  function isFormState(value) {
45
- return typeof value === "object" && value !== null && "valid" in value && "data" in value && "errors" in value;
116
+ return Predicate.isObjectOrArray(value) && "valid" in value && "data" in value && "errors" in value;
46
117
  }
47
118
  /**
48
119
  * Normalise a parsed JSON response body into a {@link FormActionResult}. The
@@ -52,10 +123,7 @@ function isFormState(value) {
52
123
  */
53
124
  function parseActionResult(body, status = 200) {
54
125
  if (isActionResult(body)) return body;
55
- if (isFormState(body)) {
56
- const state = body;
57
- return state.valid ? success(state, status) : failure(state, status);
58
- }
126
+ if (isFormState(body)) return body.valid ? success(body, status) : failure(body, status);
59
127
  return {
60
128
  _tag: "Error",
61
129
  status,
@@ -68,25 +136,25 @@ function parseActionResult(body, status = 200) {
68
136
  * `redirect` / `error`.
69
137
  */
70
138
  function resultToUpdate(result) {
71
- switch (result._tag) {
72
- case "Success": return {
73
- data: result.state.data,
74
- errors: result.state.errors,
75
- message: result.state.message,
139
+ return Match.valueTags(result, {
140
+ Success: ({ state }) => ({
141
+ data: state.data,
142
+ errors: state.errors,
143
+ message: state.message,
76
144
  valid: true
77
- };
78
- case "Failure": return {
79
- data: result.state.data,
80
- errors: result.state.errors,
81
- message: result.state.message,
145
+ }),
146
+ Failure: ({ state }) => ({
147
+ data: state.data,
148
+ errors: state.errors,
149
+ message: state.message,
82
150
  valid: false
83
- };
84
- case "Redirect": return { redirect: result.location };
85
- case "Error": return {
86
- error: result.message,
151
+ }),
152
+ Redirect: ({ location }) => ({ redirect: location }),
153
+ Error: ({ message }) => ({
154
+ error: message,
87
155
  valid: false
88
- };
89
- }
156
+ })
157
+ });
90
158
  }
91
159
  /**
92
160
  * Decide how a submit result's data applies to the store, given a defined
@@ -1 +1 @@
1
- {"version":3,"file":"EnhanceCore.js","names":[],"sources":["../src/EnhanceCore.ts"],"sourcesContent":["/**\n * Pure plumbing for the `enhance` action: serialise the store's data back into\n * a form body, interpret a server response into a {@link FormActionResult}, and\n * compute the store update a result implies. Kept framework-free and side-\n * effect-free so it is unit-testable in isolation; the rune store\n * (`FormStore.svelte.ts`) wires these to the DOM.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/EnhanceCore\n */\n\nimport { Errors, Result, State } from \"@thomasfosterau/effect-forms\";\n\n// Terse local aliases onto the namespaced core exports.\ntype FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;\ntype FormErrors = Errors.FormErrors;\ntype FormState<A, Message = unknown> = State.FormState<A, Message>;\nconst { failure, success } = Result;\n\n/** Flatten a nested data object into `[name, value]` entries using path notation. */\nexport function objectToEntries(data: unknown, prefix = \"\"): Array<[string, string]> {\n const out: Array<[string, string]> = [];\n const push = (key: string, value: unknown): void => {\n if (value === undefined) return;\n if (value === null) {\n out.push([key, \"\"]);\n } else if (Array.isArray(value)) {\n value.forEach((item, i) => out.push(...objectToEntries(item, `${key}[${i}]`)));\n } else if (typeof value === \"object\") {\n out.push(...objectToEntries(value, key));\n } else {\n out.push([key, String(value)]);\n }\n };\n\n if (data !== null && typeof data === \"object\" && !Array.isArray(data)) {\n for (const [key, value] of Object.entries(data as Record<string, unknown>)) {\n push(prefix ? `${prefix}.${key}` : key, value);\n }\n } else if (prefix) {\n push(prefix, data);\n }\n return out;\n}\n\n/** Serialise the store's data into a {@link FormData} body for submission. */\nexport function objectToFormData(data: unknown): FormData {\n const formData = new FormData();\n for (const [key, value] of objectToEntries(data)) formData.append(key, value);\n return formData;\n}\n\n/** Whether a value looks like a {@link FormActionResult} (carries a known `_tag`). */\nfunction isActionResult(value: unknown): value is FormActionResult<unknown, unknown> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { _tag?: unknown })._tag === \"string\" &&\n [\"Success\", \"Failure\", \"Redirect\", \"Error\"].includes((value as { _tag: string })._tag)\n );\n}\n\n/** Whether a value looks like a bare {@link FormState}. */\nfunction isFormState(value: unknown): value is FormState<unknown, unknown> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"valid\" in value &&\n \"data\" in value &&\n \"errors\" in value\n );\n}\n\n/**\n * Normalise a parsed JSON response body into a {@link FormActionResult}. The\n * server may return a full action result (`{ _tag, … }`) or a bare\n * {@link FormState} — a bare state maps to `Success` / `Failure` by its `valid`\n * flag, using `status` for the failure code.\n */\nexport function parseActionResult<A, Message>(\n body: unknown,\n status = 200,\n): FormActionResult<A, Message> {\n if (isActionResult(body)) return body as FormActionResult<A, Message>;\n if (isFormState(body)) {\n const state = body as FormState<A, Message>;\n return state.valid ? success(state, status) : failure(state, status);\n }\n return { _tag: \"Error\", status, message: \"Unrecognised form response\" };\n}\n\n/** The mutation a {@link FormActionResult} implies for the store. */\nexport interface FormUpdate<A, Message> {\n readonly data?: A | undefined;\n readonly errors?: FormErrors | undefined;\n readonly message?: Message | undefined;\n readonly redirect?: string | undefined;\n readonly error?: string | undefined;\n readonly valid?: boolean | undefined;\n}\n\n/**\n * Compute the store update implied by a {@link FormActionResult}. Pure: the\n * caller applies the returned fields to its reactive state and acts on\n * `redirect` / `error`.\n */\nexport function resultToUpdate<A, Message>(\n result: FormActionResult<A, Message>,\n): FormUpdate<A, Message> {\n switch (result._tag) {\n case \"Success\":\n return {\n data: result.state.data,\n errors: result.state.errors,\n message: result.state.message,\n valid: true,\n };\n case \"Failure\":\n return {\n data: result.state.data,\n errors: result.state.errors,\n message: result.state.message,\n valid: false,\n };\n case \"Redirect\":\n return { redirect: result.location };\n case \"Error\":\n return { error: result.message, valid: false };\n }\n}\n\n/** How a successful/failed submit's data should land on `data` and the tainted baseline. */\nexport interface DataApplication<A> {\n /** The next value for `data`, or `undefined` to leave it untouched (an auto-submit keeps local edits). */\n readonly data: A | undefined;\n /** The next tainted baseline (`#initial`), or `undefined` when this isn't a successful submit. */\n readonly rebaseTo: A | undefined;\n}\n\n/**\n * Decide how a submit result's data applies to the store, given a defined\n * `update.data` (redirects/errors never reach this — see {@link resultToUpdate}).\n * Pure so the save-then-navigate baseline rebase is unit-testable without\n * compiling the rune store: on a successful submit the tainted baseline\n * rebases to whatever was actually persisted, so `isTainted` goes back to\n * `false` for values that were just saved, not just the original seed. An\n * auto-submitting (live-save) form keeps its local `data` as the source of\n * truth while a request is in flight — adopting the server's echo would\n * discard keystrokes made during the round-trip — but on success rebases to\n * `submittedData` (the snapshot actually sent), not the live value, since the\n * user may have typed further edits that haven't been submitted yet.\n */\nexport function computeDataApplication<A>(params: {\n readonly isSuccess: boolean;\n readonly appliedData: A;\n readonly initial: A;\n readonly submittedData: A;\n readonly resetForm: boolean;\n readonly autoSubmit: boolean;\n}): DataApplication<A> {\n const { isSuccess, appliedData, initial, submittedData, resetForm, autoSubmit } = params;\n if (resetForm && isSuccess) return { data: initial, rebaseTo: initial };\n if (autoSubmit) return { data: undefined, rebaseTo: isSuccess ? submittedData : undefined };\n return { data: appliedData, rebaseTo: isSuccess ? appliedData : undefined };\n}\n"],"mappings":";;;;;;;;;;;;AAiBA,MAAM,EAAE,SAAS,YAAY;;AAG7B,SAAgB,gBAAgB,MAAe,SAAS,IAA6B;CACnF,MAAM,MAA+B,CAAC;CACtC,MAAM,QAAQ,KAAa,UAAyB;EAClD,IAAI,UAAU,KAAA,GAAW;EACzB,IAAI,UAAU,MACZ,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;OACb,IAAI,MAAM,QAAQ,KAAK,GAC5B,MAAM,SAAS,MAAM,MAAM,IAAI,KAAK,GAAG,gBAAgB,MAAM,GAAG,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;OACxE,IAAI,OAAO,UAAU,UAC1B,IAAI,KAAK,GAAG,gBAAgB,OAAO,GAAG,CAAC;OAEvC,IAAI,KAAK,CAAC,KAAK,OAAO,KAAK,CAAC,CAAC;CAEjC;CAEA,IAAI,SAAS,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,GAClE,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAA+B,GACvE,KAAK,SAAS,GAAG,OAAO,GAAG,QAAQ,KAAK,KAAK;MAE1C,IAAI,QACT,KAAK,QAAQ,IAAI;CAEnB,OAAO;AACT;;AAGA,SAAgB,iBAAiB,MAAyB;CACxD,MAAM,WAAW,IAAI,SAAS;CAC9B,KAAK,MAAM,CAAC,KAAK,UAAU,gBAAgB,IAAI,GAAG,SAAS,OAAO,KAAK,KAAK;CAC5E,OAAO;AACT;;AAGA,SAAS,eAAe,OAA6D;CACnF,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS,YAC9C;EAAC;EAAW;EAAW;EAAY;CAAO,CAAC,CAAC,SAAU,MAA2B,IAAI;AAEzF;;AAGA,SAAS,YAAY,OAAsD;CACzE,OACE,OAAO,UAAU,YACjB,UAAU,QACV,WAAW,SACX,UAAU,SACV,YAAY;AAEhB;;;;;;;AAQA,SAAgB,kBACd,MACA,SAAS,KACqB;CAC9B,IAAI,eAAe,IAAI,GAAG,OAAO;CACjC,IAAI,YAAY,IAAI,GAAG;EACrB,MAAM,QAAQ;EACd,OAAO,MAAM,QAAQ,QAAQ,OAAO,MAAM,IAAI,QAAQ,OAAO,MAAM;CACrE;CACA,OAAO;EAAE,MAAM;EAAS;EAAQ,SAAS;CAA6B;AACxE;;;;;;AAiBA,SAAgB,eACd,QACwB;CACxB,QAAQ,OAAO,MAAf;EACE,KAAK,WACH,OAAO;GACL,MAAM,OAAO,MAAM;GACnB,QAAQ,OAAO,MAAM;GACrB,SAAS,OAAO,MAAM;GACtB,OAAO;EACT;EACF,KAAK,WACH,OAAO;GACL,MAAM,OAAO,MAAM;GACnB,QAAQ,OAAO,MAAM;GACrB,SAAS,OAAO,MAAM;GACtB,OAAO;EACT;EACF,KAAK,YACH,OAAO,EAAE,UAAU,OAAO,SAAS;EACrC,KAAK,SACH,OAAO;GAAE,OAAO,OAAO;GAAS,OAAO;EAAM;CACjD;AACF;;;;;;;;;;;;;;AAuBA,SAAgB,uBAA0B,QAOnB;CACrB,MAAM,EAAE,WAAW,aAAa,SAAS,eAAe,WAAW,eAAe;CAClF,IAAI,aAAa,WAAW,OAAO;EAAE,MAAM;EAAS,UAAU;CAAQ;CACtE,IAAI,YAAY,OAAO;EAAE,MAAM,KAAA;EAAW,UAAU,YAAY,gBAAgB,KAAA;CAAU;CAC1F,OAAO;EAAE,MAAM;EAAa,UAAU,YAAY,cAAc,KAAA;CAAU;AAC5E"}
1
+ {"version":3,"file":"EnhanceCore.js","names":[],"sources":["../src/EnhanceCore.ts"],"sourcesContent":["/**\n * Pure plumbing for the `enhance` action: serialise the store's data back into\n * a form body, interpret a server response into a {@link FormActionResult}, and\n * compute the store update a result implies. Kept framework-free and side-\n * effect-free so it is unit-testable in isolation; the rune store\n * (`FormStore.svelte.ts`) wires these to the DOM.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/EnhanceCore\n */\n\nimport { Errors, Result, State } from \"@thomasfosterau/effect-forms\";\nimport * as Arr from \"effect/Array\";\nimport * as Match from \"effect/Match\";\nimport * as Predicate from \"effect/Predicate\";\nimport * as Record from \"effect/Record\";\n\n// Terse local aliases onto the namespaced core exports.\ntype FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;\ntype FormErrors = Errors.FormErrors;\ntype FormState<A, Message = unknown> = State.FormState<A, Message>;\nconst { failure, success } = Result;\n\n/** A form-body entry: a stringified leaf, or a `Blob` / `File` passed through. */\ntype BodyEntry = [string, string | Blob];\n\n/**\n * Whether a value is a `Blob` (a `File` is one). Guards on `globalThis.Blob`\n * so it holds in any runtime, including one without a global `Blob`.\n */\nconst isBlob = (value: unknown): value is Blob =>\n !Predicate.isUndefined(globalThis.Blob) && value instanceof Blob;\n\n/**\n * Encode a `Date` as the `YYYY-MM-DD` of its UTC calendar day, or `undefined`\n * for an invalid `Date`. The UTC day round-trips: a `YYYY-MM-DD` string\n * decodes to UTC midnight. Taking everything before the `T` keeps\n * `toISOString`'s expanded form for a year outside 0000–9999\n * (`+010000-01-01`), where a fixed 10-character slice would cut it off.\n */\nconst encodeDate = (value: Date): string | undefined => {\n if (Number.isNaN(value.getTime())) return undefined;\n const iso = value.toISOString();\n return iso.slice(0, iso.indexOf(\"T\"));\n};\n\n/** Append the entries for one named value to `out`, recursing into containers. */\nfunction appendEntries(out: Array<BodyEntry>, key: string, value: unknown): void {\n if (value === null) {\n out.push([key, \"\"]);\n } else if (isBlob(value)) {\n out.push([key, value]);\n } else if (value instanceof Date) {\n const encoded = encodeDate(value);\n if (encoded !== undefined) out.push([key, encoded]);\n } else if (Array.isArray(value)) {\n value.forEach((item, i) => appendEntries(out, `${key}[${i}]`, item));\n } else if (Predicate.isObject(value)) {\n appendFields(out, value, key);\n } else if (\n Predicate.isString(value) ||\n Predicate.isNumber(value) ||\n Predicate.isBoolean(value) ||\n Predicate.isBigInt(value)\n ) {\n out.push([key, String(value)]);\n }\n // `undefined`, `function` and `symbol` leaves are omitted.\n}\n\n/** Append an object's own enumerable fields to `out`, as `prefix.key` names. */\nfunction appendFields<V>(\n out: Array<BodyEntry>,\n data: Record.ReadonlyRecord<string, V>,\n prefix: string,\n): void {\n Arr.forEach(Record.toEntries(data), ([key, value]) =>\n appendEntries(out, prefix ? `${prefix}.${key}` : key, value),\n );\n}\n\n/** Flatten `data` into form-body entries, `Blob` / `File` leaves included. */\nfunction toBodyEntries(data: unknown, prefix: string): Array<BodyEntry> {\n const out: Array<BodyEntry> = [];\n if (prefix) appendEntries(out, prefix, data);\n else if (Predicate.isObject(data)) {\n appendFields(out, data, \"\");\n }\n return out;\n}\n\n/**\n * Flatten a nested data object into `[name, value]` string entries using path\n * notation.\n *\n * Nested objects become dotted names (`author.name`) and array items indexed\n * names (`tags[0]`). Each leaf kind is handled as follows:\n *\n * - A `string`, `number`, `boolean` or `bigint` is stringified.\n * - `null` becomes `\"\"`.\n * - A `Date` becomes the `YYYY-MM-DD` of its **UTC** calendar day (what\n * `<input type=\"date\">` submits, and what `Schema.DateFromString` decodes\n * back to UTC midnight); the time of day is dropped, so a datetime needs its\n * own string field. An invalid `Date` is omitted.\n * - A `Blob` or `File` is omitted here, since a string entry can't carry it;\n * {@link objectToFormData} keeps it.\n * - An `undefined`, `function` or `symbol` is omitted, as `JSON.stringify`\n * omits such object properties, so a callback or symbol left in form data\n * never reaches the form body as `\"Symbol(…)\"` or function source.\n * - Any other object contributes its own enumerable fields, so one with none\n * (a `Map`, a `Set`, an empty object) contributes no entries.\n *\n * An omitted array item leaves a gap (where `JSON.stringify` would write\n * `null`), so the remaining items keep their indices (`tags[0]`, `tags[2]`).\n */\nexport function objectToEntries(data: unknown, prefix = \"\"): Array<[string, string]> {\n return toBodyEntries(data, prefix).filter((entry): entry is [string, string] =>\n Predicate.isString(entry[1]),\n );\n}\n\n/**\n * Serialise the store's data into a {@link FormData} body for submission.\n *\n * Names and leaves follow {@link objectToEntries}, except that a `Blob` or\n * `File` leaf is appended as a file (a `File` keeps its name; a bare `Blob`\n * arrives as a `File` named `\"blob\"`, the `FormData` default), as an\n * `<input type=\"file\">` would submit it, so it reaches the core's\n * `FormData.formDataToObject` (`@thomasfosterau/effect-forms`) unchanged.\n */\nexport function objectToFormData(data: unknown): FormData {\n const formData = new FormData();\n Arr.forEach(toBodyEntries(data, \"\"), ([key, value]) => formData.append(key, value));\n return formData;\n}\n\n/**\n * Whether a value looks like a {@link FormActionResult} (carries a known\n * `_tag`). Only the tag is checked: `A` / `Message` are the caller's declared\n * expectation of the server's response, not verified here.\n */\nfunction isActionResult<A, Message>(value: unknown): value is FormActionResult<A, Message> {\n return (\n Predicate.isObjectOrArray(value) &&\n Predicate.hasProperty(value, \"_tag\") &&\n Predicate.isString(value._tag) &&\n [\"Success\", \"Failure\", \"Redirect\", \"Error\"].includes(value._tag)\n );\n}\n\n/**\n * Whether a value looks like a bare {@link FormState}. Only the keys are\n * checked: `A` / `Message` are the caller's declared expectation, not verified.\n */\nfunction isFormState<A, Message>(value: unknown): value is FormState<A, Message> {\n return (\n Predicate.isObjectOrArray(value) && \"valid\" in value && \"data\" in value && \"errors\" in value\n );\n}\n\n/**\n * Normalise a parsed JSON response body into a {@link FormActionResult}. The\n * server may return a full action result (`{ _tag, … }`) or a bare\n * {@link FormState} — a bare state maps to `Success` / `Failure` by its `valid`\n * flag, using `status` for the failure code.\n */\nexport function parseActionResult<A, Message>(\n body: unknown,\n status = 200,\n): FormActionResult<A, Message> {\n if (isActionResult<A, Message>(body)) return body;\n if (isFormState<A, Message>(body)) {\n return body.valid ? success(body, status) : failure(body, status);\n }\n return { _tag: \"Error\", status, message: \"Unrecognised form response\" };\n}\n\n/** The mutation a {@link FormActionResult} implies for the store. */\nexport interface FormUpdate<A, Message> {\n readonly data?: A | undefined;\n readonly errors?: FormErrors | undefined;\n readonly message?: Message | undefined;\n readonly redirect?: string | undefined;\n readonly error?: string | undefined;\n readonly valid?: boolean | undefined;\n}\n\n/**\n * Compute the store update implied by a {@link FormActionResult}. Pure: the\n * caller applies the returned fields to its reactive state and acts on\n * `redirect` / `error`.\n */\nexport function resultToUpdate<A, Message>(\n result: FormActionResult<A, Message>,\n): FormUpdate<A, Message> {\n return Match.valueTags(result, {\n Success: ({ state }): FormUpdate<A, Message> => ({\n data: state.data,\n errors: state.errors,\n message: state.message,\n valid: true,\n }),\n Failure: ({ state }): FormUpdate<A, Message> => ({\n data: state.data,\n errors: state.errors,\n message: state.message,\n valid: false,\n }),\n Redirect: ({ location }): FormUpdate<A, Message> => ({ redirect: location }),\n Error: ({ message }): FormUpdate<A, Message> => ({ error: message, valid: false }),\n });\n}\n\n/** How a successful/failed submit's data should land on `data` and the tainted baseline. */\nexport interface DataApplication<A> {\n /** The next value for `data`, or `undefined` to leave it untouched (an auto-submit keeps local edits). */\n readonly data: A | undefined;\n /** The next tainted baseline (`#initial`), or `undefined` when this isn't a successful submit. */\n readonly rebaseTo: A | undefined;\n}\n\n/**\n * Decide how a submit result's data applies to the store, given a defined\n * `update.data` (redirects/errors never reach this — see {@link resultToUpdate}).\n * Pure so the save-then-navigate baseline rebase is unit-testable without\n * compiling the rune store: on a successful submit the tainted baseline\n * rebases to whatever was actually persisted, so `isTainted` goes back to\n * `false` for values that were just saved, not just the original seed. An\n * auto-submitting (live-save) form keeps its local `data` as the source of\n * truth while a request is in flight — adopting the server's echo would\n * discard keystrokes made during the round-trip — but on success rebases to\n * `submittedData` (the snapshot actually sent), not the live value, since the\n * user may have typed further edits that haven't been submitted yet.\n */\nexport function computeDataApplication<A>(params: {\n readonly isSuccess: boolean;\n readonly appliedData: A;\n readonly initial: A;\n readonly submittedData: A;\n readonly resetForm: boolean;\n readonly autoSubmit: boolean;\n}): DataApplication<A> {\n const { isSuccess, appliedData, initial, submittedData, resetForm, autoSubmit } = params;\n if (resetForm && isSuccess) return { data: initial, rebaseTo: initial };\n if (autoSubmit) return { data: undefined, rebaseTo: isSuccess ? submittedData : undefined };\n return { data: appliedData, rebaseTo: isSuccess ? appliedData : undefined };\n}\n"],"mappings":"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"}
@@ -1,4 +1,10 @@
1
1
  import { Command } from "@thomasfosterau/effect-forms";
2
+ import * as Arr from "effect/Array";
3
+ import * as Predicate from "effect/Predicate";
4
+ import * as Record from "effect/Record";
5
+ import * as HashMap from "effect/HashMap";
6
+ import * as HashSet from "effect/HashSet";
7
+ import * as Option from "effect/Option";
2
8
  import * as Schema from "effect/Schema";
3
9
  //#region src/EntityForm.ts
4
10
  const builders = {
@@ -37,15 +43,22 @@ const builders = {
37
43
  })
38
44
  };
39
45
  /**
46
+ * A definition-time error: the entries don't match the command structs. Thrown
47
+ * synchronously from `defineEntityForm` (and `fieldFor`), never as an Effect
48
+ * failure — a definition module that doesn't match its commands must fail at
49
+ * import.
50
+ */
51
+ var EntityFormDefinitionError = class extends Schema.TaggedError()("EntityFormDefinitionError", { message: Schema.String }) {};
52
+ /**
40
53
  * Validate an `f.select` entry against its plan field at definition time: the
41
54
  * field must be `enum`-kind (the only kind `Command.plan` derives option
42
55
  * values for), and every derived value must have a matching `optionLabels`
43
56
  * thunk.
44
57
  */
45
58
  const validateSelectEntry = (entry, field) => {
46
- if (field.kind !== "enum") throw new Error(`defineEntityForm: entry "${entry.key}" is f.select but its command field is "${field.kind}"-kind, not enum`);
59
+ if (field.kind !== "enum") throw new EntityFormDefinitionError({ message: `defineEntityForm: entry "${entry.key}" is f.select but its command field is "${field.kind}"-kind, not enum` });
47
60
  const missingLabels = (field.options ?? []).filter((value) => !Object.prototype.hasOwnProperty.call(entry.options.optionLabels, value));
48
- if (missingLabels.length > 0) throw new Error(`defineEntityForm: entry "${entry.key}" is missing optionLabels for: ${missingLabels.join(", ")}`);
61
+ if (Arr.isReadonlyArrayNonEmpty(missingLabels)) throw new EntityFormDefinitionError({ message: `defineEntityForm: entry "${entry.key}" is missing optionLabels for: ${missingLabels.join(", ")}` });
49
62
  };
50
63
  /**
51
64
  * Build an entity-form definition from a command-struct pair and an ordered
@@ -54,30 +67,21 @@ const validateSelectEntry = (entry, field) => {
54
67
  * key — a command-struct change must surface here, at definition time.
55
68
  */
56
69
  const defineEntityForm = (options, build) => {
57
- const omitted = new Set(options.omit ?? []);
58
- const withoutOmitted = (struct) => {
59
- if (omitted.size === 0) return struct;
60
- const fields = {};
61
- for (const key in struct.fields) if (!omitted.has(key)) fields[key] = struct.fields[key];
62
- return { fields };
63
- };
70
+ const omitted = HashSet.fromIterable(options.omit ?? []);
71
+ const withoutOmitted = (struct) => HashSet.size(omitted) === 0 ? struct : { fields: Record.filter(struct.fields, (_, key) => !HashSet.has(omitted, key)) };
64
72
  const plan = Command.plan(withoutOmitted(options.create), withoutOmitted(options.update));
65
73
  const entries = build(builders);
66
- const byKey = new Map(plan.fields.map((field) => [field.key, field]));
67
- const declared = /* @__PURE__ */ new Set();
68
- for (const entry of entries) {
69
- const field = byKey.get(entry.key);
70
- if (field === void 0) throw new Error(`defineEntityForm: entry "${entry.key}" is not a field of either command struct`);
71
- if (declared.has(entry.key)) throw new Error(`defineEntityForm: entry "${entry.key}" is declared twice`);
72
- declared.add(entry.key);
73
- if (entry._tag === "select") validateSelectEntry(entry, field);
74
- }
75
- const missing = plan.fields.filter((field) => !declared.has(field.key)).map((field) => field.key);
76
- if (missing.length > 0) throw new Error(`defineEntityForm: command fields not declared by any entry: ${missing.join(", ")} — declare or explicitly widget them`);
77
- const entriesFor = (mode) => entries.filter((entry) => {
78
- const field = byKey.get(entry.key);
79
- return field !== void 0 && (mode === "create" ? field.inCreate : field.inUpdate);
74
+ const byKey = HashMap.fromIterable(Arr.map(plan.fields, (field) => [field.key, field]));
75
+ const declared = Arr.reduce(entries, HashSet.empty(), (declared, entry) => {
76
+ const field = HashMap.get(byKey, entry.key);
77
+ if (Option.isNone(field)) throw new EntityFormDefinitionError({ message: `defineEntityForm: entry "${entry.key}" is not a field of either command struct` });
78
+ if (HashSet.has(declared, entry.key)) throw new EntityFormDefinitionError({ message: `defineEntityForm: entry "${entry.key}" is declared twice` });
79
+ if (Predicate.isTagged(entry, "select")) validateSelectEntry(entry, field.value);
80
+ return HashSet.add(declared, entry.key);
80
81
  });
82
+ const missing = Arr.map(Arr.filter(plan.fields, (field) => !HashSet.has(declared, field.key)), (field) => field.key);
83
+ if (Arr.isReadonlyArrayNonEmpty(missing)) throw new EntityFormDefinitionError({ message: `defineEntityForm: command fields not declared by any entry: ${missing.join(", ")} — declare or explicitly widget them` });
84
+ const entriesFor = (mode) => Arr.filter(entries, (entry) => Option.exists(HashMap.get(byKey, entry.key), (field) => mode === "create" ? field.inCreate : field.inUpdate));
81
85
  return {
82
86
  plan,
83
87
  entries,
@@ -90,11 +94,7 @@ const defineEntityForm = (options, build) => {
90
94
  submit: options.submit,
91
95
  clientId: plan.clientId,
92
96
  entriesFor,
93
- fieldFor: (key) => {
94
- const field = byKey.get(key);
95
- if (field === void 0) throw new Error(`EntityFormDefinition: unknown field "${key}"`);
96
- return field;
97
- }
97
+ fieldFor: (key) => Option.getOrThrowWith(HashMap.get(byKey, key), () => new EntityFormDefinitionError({ message: `EntityFormDefinition: unknown field "${key}"` }))
98
98
  };
99
99
  };
100
100
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"EntityForm.js","names":[],"sources":["../src/EntityForm.ts"],"sourcesContent":["/**\n * The typed entity-form definition builder.\n * `defineEntityForm(commands, (f) => [...])` pairs a `Command.plan`\n * derivation (form-input schemas, blank defaults, tri-state adapters — see\n * `effect-forms/Command`) with an ordered list of typed field\n * entries: shared scalar primitives by kind, and arbitrary widget components\n * for the bespoke fields (pickers, contributor lists). One generic renderer\n * component walks `definition.entries`; per-entity code shrinks to one\n * definition module.\n *\n * Field keys are type-constrained to the command structs' keys, and the\n * builder enforces exhaustiveness at definition time (a runtime check, kept\n * deliberately simple over deep conditional types): every derivable command\n * field must be declared exactly once, so adding a field to a command struct\n * breaks the entity's definition module until the form declares it.\n *\n * Labels and other message-bearing props are **thunks** (`() => string`,\n * e.g. an i18n message function) resolved at render time, never at\n * module scope — module-scope resolution would freeze the import-time locale.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/EntityForm\n */\n\nimport type { Component } from \"svelte\";\nimport { Command } from \"@thomasfosterau/effect-forms\";\nimport * as Schema from \"effect/Schema\";\n\n/** A render-time message thunk — pass the message function itself, uncalled. */\nexport type MessageThunk = () => string;\n\n/** Presentation options for a scalar text entry. */\nexport interface TextEntryOptions {\n readonly label: MessageThunk;\n /** Presentation `type` attribute (`url`, `date`). Omitted → text input. */\n readonly type?: \"text\" | \"url\" | \"date\";\n /** Keyboard hint for numeric text fields; defaults from the field's derived kind. */\n readonly inputmode?: \"numeric\";\n /** Force required-ness (defaults to the schema-derived constraint). */\n readonly required?: boolean;\n readonly placeholder?: MessageThunk;\n}\n\n/** A scalar field rendered by the shared text/textarea primitives. */\nexport interface ScalarEntry {\n readonly _tag: \"scalar\";\n readonly key: string;\n readonly multiline: boolean;\n readonly options: TextEntryOptions;\n}\n\n/** Presentation options for a `<select>` entry over a literal-union (`enum` kind) field. */\nexport interface SelectEntryOptions {\n readonly label: MessageThunk;\n readonly required?: boolean;\n /**\n * Per-option render-time label thunks, keyed by the field's derived option\n * *value* (`Command.FieldPlan.options`, read via `fieldFor` — not declared\n * here). `defineEntityForm` throws at definition time if a derived value has\n * no matching thunk.\n */\n readonly optionLabels: Readonly<Record<string, MessageThunk>>;\n}\n\n/**\n * A `<select>` field over a literal-union (`enum` kind) command field. Option\n * *values* derive from the field's plan; this entry supplies only the\n * per-option labels — `defineEntityForm` validates the field is `enum`-kind\n * and that every derived value has a label.\n */\nexport interface SelectEntry {\n readonly _tag: \"select\";\n readonly key: string;\n readonly options: SelectEntryOptions;\n}\n\n/** Options for a widget entry (picker / rows list). */\nexport interface WidgetEntryOptions {\n /** Gate the submit button while this field's value is empty (required pickers). */\n readonly gate?: boolean;\n}\n\n/**\n * A bespoke widget field. The renderer bridges the store to the widget through\n * the widget contract — `name`, `value`, `onChange`, `errors` — which\n * are **reserved keys**: the bridge is spread last, so a `props()` thunk or a\n * load-supplied field prop can never silently sever the store binding.\n */\nexport interface WidgetEntry {\n readonly _tag: \"widget\";\n readonly key: string;\n /** `\"value\"` bridges a string FK; `\"rows\"` bridges a controlled row list. */\n readonly bridge: \"value\" | \"rows\";\n // reason: the renderer composes arbitrary widgets; per-widget prop checking\n // happens in the definition module via the builder's generic signature.\n readonly component: Component<any>;\n readonly props: () => Record<string, unknown>;\n readonly options: WidgetEntryOptions;\n}\n\n/** One ordered entry in an entity form definition. */\nexport type EntityFormEntry = ScalarEntry | WidgetEntry | SelectEntry;\n\n/** The per-mode submit labels. */\nexport interface SubmitMessages {\n readonly create: MessageThunk;\n readonly update: MessageThunk;\n}\n\n/** The runtime shape `defineEntityForm` accepts for a command struct. */\nexport interface CommandStructLike {\n readonly fields: Record<string, Schema.Top>;\n}\n\n/** The value `defineEntityForm` returns — everything one renderer needs. */\nexport interface EntityFormDefinition {\n readonly plan: Command.CommandPlan;\n readonly entries: readonly EntityFormEntry[];\n readonly createInput: Schema.Top;\n readonly updateInput: Schema.Top;\n readonly createDefaults: Readonly<Record<string, string>>;\n readonly updateDefaults: Readonly<Record<string, string>>;\n readonly adaptCreate: (input: Command.FormInputRecord) => Record<string, unknown>;\n readonly adaptUpdate: (input: Command.FormInputRecord) => Record<string, unknown>;\n readonly submit: SubmitMessages;\n /** The create struct carries a client-generated id (idempotent retries). */\n readonly clientId: boolean;\n /** Entries visible in one mode, in declaration order. */\n readonly entriesFor: (mode: \"create\" | \"update\") => readonly EntityFormEntry[];\n /** The plan field behind an entry key. */\n readonly fieldFor: (key: string) => Command.FieldPlan;\n}\n\n/** The writable command keys a definition may declare (the `id` key is renderer-owned). */\nexport type CommandKeys<C extends CommandStructLike, U extends CommandStructLike> = Exclude<\n Extract<keyof C[\"fields\"] | keyof U[\"fields\"], string>,\n \"id\"\n>;\n\n/** The entry builders handed to the definition callback, key-typed to the command pair. */\nexport interface EntryBuilders<K extends string> {\n /** A scalar field rendered by the shared `TextInputField`. */\n text(key: K, options: TextEntryOptions): EntityFormEntry;\n /** A scalar field rendered by the shared `TextAreaInputField`. */\n textarea(key: K, options: TextEntryOptions): EntityFormEntry;\n /**\n * A `<select>` field over a literal-union (`enum` kind) command field.\n * Option values are derived from the field's plan; `options.optionLabels`\n * supplies each derived value's render-time label thunk.\n */\n select(key: K, options: SelectEntryOptions): EntityFormEntry;\n /**\n * A bespoke string-valued widget (an FK picker). The renderer supplies\n * `name` / `value` / `onChange` / `errors`; `props()` supplies the rest at\n * render time (labels stay thunk-resolved).\n */\n custom<P extends Record<string, unknown>>(\n key: K,\n component: Component<never>,\n props: () => P,\n options?: WidgetEntryOptions,\n ): EntityFormEntry;\n /**\n * A bespoke row-list widget (a contributor list). The renderer owns the row\n * state and supplies `name` / `value` / `onChange` / `errors`; the widget\n * owns the wire serialisation (its hidden input).\n */\n rows<P extends Record<string, unknown>>(\n key: K,\n component: Component<never>,\n props: () => P,\n options?: WidgetEntryOptions,\n ): EntityFormEntry;\n}\n\n/** Everything `defineEntityForm` needs beyond the entries. */\nexport interface DefineEntityFormOptions<C extends CommandStructLike, U extends CommandStructLike> {\n readonly create: C;\n readonly update: U;\n readonly submit: SubmitMessages;\n /** Per-field adapter overrides, per mode — the irregular-coercion escape hatch. */\n readonly adaptOverrides?: {\n readonly create?: Command.AdaptOverrides;\n readonly update?: Command.AdaptOverrides;\n };\n /** Per-field Form-Input schema overrides, per mode (e.g. an optional create title with a server default). */\n readonly inputOverrides?: {\n readonly create?: Readonly<Record<string, Schema.Top>>;\n readonly update?: Readonly<Record<string, Schema.Top>>;\n };\n /**\n * Command fields deliberately absent from the standalone form (a carve-out,\n * e.g. BookSeries' `editors` — managed through the Book form's inline-create\n * path instead). Omitted fields are stripped from the create/update field\n * records before `Command.plan` runs, so they need no entry, are never\n * classified (a kind-less omitted field can't throw at definition time),\n * and never appear in the derived input schema, defaults, or adapters.\n */\n readonly omit?: readonly CommandKeys<C, U>[];\n}\n\nconst builders: EntryBuilders<string> = {\n text: (key, options) => ({ _tag: \"scalar\", key, multiline: false, options }),\n textarea: (key, options) => ({ _tag: \"scalar\", key, multiline: true, options }),\n select: (key, options) => ({ _tag: \"select\", key, options }),\n custom: (key, component, props, options = {}) => ({\n _tag: \"widget\",\n key,\n bridge: \"value\",\n component: component as Component<any>,\n props,\n options,\n }),\n rows: (key, component, props, options = {}) => ({\n _tag: \"widget\",\n key,\n bridge: \"rows\",\n component: component as Component<any>,\n props,\n options,\n }),\n};\n\n/**\n * Validate an `f.select` entry against its plan field at definition time: the\n * field must be `enum`-kind (the only kind `Command.plan` derives option\n * values for), and every derived value must have a matching `optionLabels`\n * thunk.\n */\nconst validateSelectEntry = (entry: SelectEntry, field: Command.FieldPlan): void => {\n if (field.kind !== \"enum\") {\n throw new Error(\n `defineEntityForm: entry \"${entry.key}\" is f.select but its command field is \"${field.kind}\"-kind, not enum`,\n );\n }\n const missingLabels = (field.options ?? []).filter(\n (value) => !Object.prototype.hasOwnProperty.call(entry.options.optionLabels, value),\n );\n if (missingLabels.length > 0) {\n throw new Error(\n `defineEntityForm: entry \"${entry.key}\" is missing optionLabels for: ${missingLabels.join(\", \")}`,\n );\n }\n};\n\n/**\n * Build an entity-form definition from a command-struct pair and an ordered\n * entry list. Runs once at module load. Throws when the entries don't cover\n * the derivable command fields exactly (exhaustiveness) or name an unknown\n * key — a command-struct change must surface here, at definition time.\n */\nexport const defineEntityForm = <C extends CommandStructLike, U extends CommandStructLike>(\n options: DefineEntityFormOptions<C, U>,\n build: (f: EntryBuilders<CommandKeys<C, U>>) => readonly EntityFormEntry[],\n): EntityFormDefinition => {\n const omitted = new Set<string>(options.omit ?? []);\n const withoutOmitted = (struct: CommandStructLike): CommandStructLike => {\n if (omitted.size === 0) return struct;\n const fields: Record<string, Schema.Top> = {};\n for (const key in struct.fields) if (!omitted.has(key)) fields[key] = struct.fields[key];\n return { fields };\n };\n const plan = Command.plan(withoutOmitted(options.create), withoutOmitted(options.update));\n const entries = build(builders as EntryBuilders<CommandKeys<C, U>>);\n\n const byKey = new Map<string, Command.FieldPlan>(plan.fields.map((field) => [field.key, field]));\n const declared = new Set<string>();\n for (const entry of entries) {\n const field = byKey.get(entry.key);\n if (field === undefined) {\n throw new Error(\n `defineEntityForm: entry \"${entry.key}\" is not a field of either command struct`,\n );\n }\n if (declared.has(entry.key)) {\n throw new Error(`defineEntityForm: entry \"${entry.key}\" is declared twice`);\n }\n declared.add(entry.key);\n if (entry._tag === \"select\") validateSelectEntry(entry, field);\n }\n const missing = plan.fields.filter((field) => !declared.has(field.key)).map((field) => field.key);\n if (missing.length > 0) {\n throw new Error(\n `defineEntityForm: command fields not declared by any entry: ${missing.join(\", \")} — declare or explicitly widget them`,\n );\n }\n\n const entriesFor = (mode: \"create\" | \"update\") =>\n entries.filter((entry) => {\n const field = byKey.get(entry.key);\n return field !== undefined && (mode === \"create\" ? field.inCreate : field.inUpdate);\n });\n\n return {\n plan,\n entries,\n createInput: Schema.Struct(\n Command.formInputFields(plan, \"create\", options.inputOverrides?.create) as Record<\n string,\n never\n >,\n ),\n updateInput: Schema.Struct(\n Command.formInputFields(plan, \"update\", options.inputOverrides?.update) as Record<\n string,\n never\n >,\n ),\n createDefaults: Command.blankDefaults(plan, \"create\"),\n updateDefaults: Command.blankDefaults(plan, \"update\"),\n adaptCreate: Command.createAdapter(plan, options.adaptOverrides?.create),\n adaptUpdate: Command.updateAdapter(plan, options.adaptOverrides?.update),\n submit: options.submit,\n clientId: plan.clientId,\n entriesFor,\n fieldFor: (key: string) => {\n const field = byKey.get(key);\n if (field === undefined) throw new Error(`EntityFormDefinition: unknown field \"${key}\"`);\n return field;\n },\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"EntityForm.js","names":[],"sources":["../src/EntityForm.ts"],"sourcesContent":["/**\n * The typed entity-form definition builder.\n * `defineEntityForm(commands, (f) => [...])` pairs a `Command.plan`\n * derivation (form-input schemas, blank defaults, tri-state adapters — see\n * `effect-forms/Command`) with an ordered list of typed field\n * entries: shared scalar primitives by kind, and arbitrary widget components\n * for the bespoke fields (pickers, contributor lists). One generic renderer\n * component walks `definition.entries`; per-entity code shrinks to one\n * definition module.\n *\n * Field keys are type-constrained to the command structs' keys, and the\n * builder enforces exhaustiveness at definition time (a runtime check, kept\n * deliberately simple over deep conditional types): every derivable command\n * field must be declared exactly once, so adding a field to a command struct\n * breaks the entity's definition module until the form declares it.\n *\n * Labels and other message-bearing props are **thunks** (`() => string`,\n * e.g. an i18n message function) resolved at render time, never at\n * module scope — module-scope resolution would freeze the import-time locale.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/EntityForm\n */\n\nimport type { Component } from \"svelte\";\nimport { Command } from \"@thomasfosterau/effect-forms\";\nimport * as Arr from \"effect/Array\";\nimport * as HashMap from \"effect/HashMap\";\nimport * as HashSet from \"effect/HashSet\";\nimport * as Option from \"effect/Option\";\nimport * as Predicate from \"effect/Predicate\";\nimport * as Record from \"effect/Record\";\nimport * as Schema from \"effect/Schema\";\n\n/** A render-time message thunk — pass the message function itself, uncalled. */\nexport type MessageThunk = () => string;\n\n/** Presentation options for a scalar text entry. */\nexport interface TextEntryOptions {\n readonly label: MessageThunk;\n /** Presentation `type` attribute (`url`, `date`). Omitted → text input. */\n readonly type?: \"text\" | \"url\" | \"date\";\n /** Keyboard hint for numeric text fields; defaults from the field's derived kind. */\n readonly inputmode?: \"numeric\";\n /** Force required-ness (defaults to the schema-derived constraint). */\n readonly required?: boolean;\n readonly placeholder?: MessageThunk;\n}\n\n/** A scalar field rendered by the shared text/textarea primitives. */\nexport interface ScalarEntry {\n readonly _tag: \"scalar\";\n readonly key: string;\n readonly multiline: boolean;\n readonly options: TextEntryOptions;\n}\n\n/** Presentation options for a `<select>` entry over a literal-union (`enum` kind) field. */\nexport interface SelectEntryOptions {\n readonly label: MessageThunk;\n readonly required?: boolean;\n /**\n * Per-option render-time label thunks, keyed by the field's derived option\n * *value* (`Command.FieldPlan.options`, read via `fieldFor` — not declared\n * here). `defineEntityForm` throws at definition time if a derived value has\n * no matching thunk.\n */\n readonly optionLabels: Readonly<Record<string, MessageThunk>>;\n}\n\n/**\n * A `<select>` field over a literal-union (`enum` kind) command field. Option\n * *values* derive from the field's plan; this entry supplies only the\n * per-option labels — `defineEntityForm` validates the field is `enum`-kind\n * and that every derived value has a label.\n */\nexport interface SelectEntry {\n readonly _tag: \"select\";\n readonly key: string;\n readonly options: SelectEntryOptions;\n}\n\n/** Options for a widget entry (picker / rows list). */\nexport interface WidgetEntryOptions {\n /** Gate the submit button while this field's value is empty (required pickers). */\n readonly gate?: boolean;\n}\n\n/**\n * A bespoke widget field. The renderer bridges the store to the widget through\n * the widget contract — `name`, `value`, `onChange`, `errors` — which\n * are **reserved keys**: the bridge is spread last, so a `props()` thunk or a\n * load-supplied field prop can never silently sever the store binding.\n */\nexport interface WidgetEntry {\n readonly _tag: \"widget\";\n readonly key: string;\n /** `\"value\"` bridges a string FK; `\"rows\"` bridges a controlled row list. */\n readonly bridge: \"value\" | \"rows\";\n // reason: the renderer composes arbitrary widgets; per-widget prop checking\n // happens in the definition module via the builder's generic signature.\n readonly component: Component<any>;\n readonly props: () => Record<string, unknown>;\n readonly options: WidgetEntryOptions;\n}\n\n/** One ordered entry in an entity form definition. */\nexport type EntityFormEntry = ScalarEntry | WidgetEntry | SelectEntry;\n\n/** The per-mode submit labels. */\nexport interface SubmitMessages {\n readonly create: MessageThunk;\n readonly update: MessageThunk;\n}\n\n/** The runtime shape `defineEntityForm` accepts for a command struct. */\nexport interface CommandStructLike {\n readonly fields: Record<string, Schema.Top>;\n}\n\n/** The value `defineEntityForm` returns — everything one renderer needs. */\nexport interface EntityFormDefinition {\n readonly plan: Command.CommandPlan;\n readonly entries: readonly EntityFormEntry[];\n readonly createInput: Schema.Top;\n readonly updateInput: Schema.Top;\n readonly createDefaults: Readonly<Record<string, string>>;\n readonly updateDefaults: Readonly<Record<string, string>>;\n readonly adaptCreate: (input: Command.FormInputRecord) => Record<string, unknown>;\n readonly adaptUpdate: (input: Command.FormInputRecord) => Record<string, unknown>;\n readonly submit: SubmitMessages;\n /** The create struct carries a client-generated id (idempotent retries). */\n readonly clientId: boolean;\n /** Entries visible in one mode, in declaration order. */\n readonly entriesFor: (mode: \"create\" | \"update\") => readonly EntityFormEntry[];\n /** The plan field behind an entry key. */\n readonly fieldFor: (key: string) => Command.FieldPlan;\n}\n\n/** The writable command keys a definition may declare (the `id` key is renderer-owned). */\nexport type CommandKeys<C extends CommandStructLike, U extends CommandStructLike> = Exclude<\n Extract<keyof C[\"fields\"] | keyof U[\"fields\"], string>,\n \"id\"\n>;\n\n/** The entry builders handed to the definition callback, key-typed to the command pair. */\nexport interface EntryBuilders<K extends string> {\n /** A scalar field rendered by the shared `TextInputField`. */\n text(key: K, options: TextEntryOptions): EntityFormEntry;\n /** A scalar field rendered by the shared `TextAreaInputField`. */\n textarea(key: K, options: TextEntryOptions): EntityFormEntry;\n /**\n * A `<select>` field over a literal-union (`enum` kind) command field.\n * Option values are derived from the field's plan; `options.optionLabels`\n * supplies each derived value's render-time label thunk.\n */\n select(key: K, options: SelectEntryOptions): EntityFormEntry;\n /**\n * A bespoke string-valued widget (an FK picker). The renderer supplies\n * `name` / `value` / `onChange` / `errors`; `props()` supplies the rest at\n * render time (labels stay thunk-resolved).\n */\n custom<P extends Record<string, unknown>>(\n key: K,\n component: Component<never>,\n props: () => P,\n options?: WidgetEntryOptions,\n ): EntityFormEntry;\n /**\n * A bespoke row-list widget (a contributor list). The renderer owns the row\n * state and supplies `name` / `value` / `onChange` / `errors`; the widget\n * owns the wire serialisation (its hidden input).\n */\n rows<P extends Record<string, unknown>>(\n key: K,\n component: Component<never>,\n props: () => P,\n options?: WidgetEntryOptions,\n ): EntityFormEntry;\n}\n\n/** Everything `defineEntityForm` needs beyond the entries. */\nexport interface DefineEntityFormOptions<C extends CommandStructLike, U extends CommandStructLike> {\n readonly create: C;\n readonly update: U;\n readonly submit: SubmitMessages;\n /** Per-field adapter overrides, per mode — the irregular-coercion escape hatch. */\n readonly adaptOverrides?: {\n readonly create?: Command.AdaptOverrides;\n readonly update?: Command.AdaptOverrides;\n };\n /** Per-field Form-Input schema overrides, per mode (e.g. an optional create title with a server default). */\n readonly inputOverrides?: {\n readonly create?: Readonly<Record<string, Schema.Top>>;\n readonly update?: Readonly<Record<string, Schema.Top>>;\n };\n /**\n * Command fields deliberately absent from the standalone form (a carve-out,\n * e.g. BookSeries' `editors` — managed through the Book form's inline-create\n * path instead). Omitted fields are stripped from the create/update field\n * records before `Command.plan` runs, so they need no entry, are never\n * classified (a kind-less omitted field can't throw at definition time),\n * and never appear in the derived input schema, defaults, or adapters.\n */\n readonly omit?: readonly CommandKeys<C, U>[];\n}\n\nconst builders: EntryBuilders<string> = {\n text: (key, options) => ({ _tag: \"scalar\", key, multiline: false, options }),\n textarea: (key, options) => ({ _tag: \"scalar\", key, multiline: true, options }),\n select: (key, options) => ({ _tag: \"select\", key, options }),\n custom: (key, component, props, options = {}) => ({\n _tag: \"widget\",\n key,\n bridge: \"value\",\n // SAFETY: `Component<never>` is the widest component type (every component\n // is assignable to it); the entry erases its props to `any` because the\n // renderer only ever mounts it with this entry's own `props()` plus the\n // widget-contract bridge props, the props the definition module chose\n // this component for.\n component: component as Component<any>,\n props,\n options,\n }),\n rows: (key, component, props, options = {}) => ({\n _tag: \"widget\",\n key,\n bridge: \"rows\",\n // SAFETY: `Component<never>` is the widest component type (every component\n // is assignable to it); the entry erases its props to `any` because the\n // renderer only ever mounts it with this entry's own `props()` plus the\n // widget-contract bridge props, the props the definition module chose\n // this component for.\n component: component as Component<any>,\n props,\n options,\n }),\n};\n\n/**\n * A definition-time error: the entries don't match the command structs. Thrown\n * synchronously from `defineEntityForm` (and `fieldFor`), never as an Effect\n * failure — a definition module that doesn't match its commands must fail at\n * import.\n */\nclass EntityFormDefinitionError extends Schema.TaggedError<\n EntityFormDefinitionError,\n { readonly brand: unique symbol }\n>()(\"EntityFormDefinitionError\", { message: Schema.String }) {}\n\n/**\n * Validate an `f.select` entry against its plan field at definition time: the\n * field must be `enum`-kind (the only kind `Command.plan` derives option\n * values for), and every derived value must have a matching `optionLabels`\n * thunk.\n */\nconst validateSelectEntry = (entry: SelectEntry, field: Command.FieldPlan): void => {\n if (field.kind !== \"enum\") {\n throw new EntityFormDefinitionError({\n message: `defineEntityForm: entry \"${entry.key}\" is f.select but its command field is \"${field.kind}\"-kind, not enum`,\n });\n }\n const missingLabels = (field.options ?? []).filter(\n (value) => !Object.prototype.hasOwnProperty.call(entry.options.optionLabels, value),\n );\n if (Arr.isReadonlyArrayNonEmpty(missingLabels)) {\n throw new EntityFormDefinitionError({\n message: `defineEntityForm: entry \"${entry.key}\" is missing optionLabels for: ${missingLabels.join(\", \")}`,\n });\n }\n};\n\n/**\n * Build an entity-form definition from a command-struct pair and an ordered\n * entry list. Runs once at module load. Throws when the entries don't cover\n * the derivable command fields exactly (exhaustiveness) or name an unknown\n * key — a command-struct change must surface here, at definition time.\n */\nexport const defineEntityForm = <C extends CommandStructLike, U extends CommandStructLike>(\n options: DefineEntityFormOptions<C, U>,\n build: (f: EntryBuilders<CommandKeys<C, U>>) => readonly EntityFormEntry[],\n): EntityFormDefinition => {\n const omitted = HashSet.fromIterable<string>(options.omit ?? []);\n const withoutOmitted = (struct: CommandStructLike): CommandStructLike =>\n HashSet.size(omitted) === 0\n ? struct\n : { fields: Record.filter(struct.fields, (_, key) => !HashSet.has(omitted, key)) };\n const plan = Command.plan(withoutOmitted(options.create), withoutOmitted(options.update));\n const entries = build(builders);\n\n const byKey = HashMap.fromIterable(Arr.map(plan.fields, (field) => [field.key, field] as const));\n const declared = Arr.reduce(entries, HashSet.empty<string>(), (declared, entry) => {\n const field = HashMap.get(byKey, entry.key);\n if (Option.isNone(field)) {\n throw new EntityFormDefinitionError({\n message: `defineEntityForm: entry \"${entry.key}\" is not a field of either command struct`,\n });\n }\n if (HashSet.has(declared, entry.key)) {\n throw new EntityFormDefinitionError({\n message: `defineEntityForm: entry \"${entry.key}\" is declared twice`,\n });\n }\n if (Predicate.isTagged(entry, \"select\")) validateSelectEntry(entry, field.value);\n return HashSet.add(declared, entry.key);\n });\n const missing = Arr.map(\n Arr.filter(plan.fields, (field) => !HashSet.has(declared, field.key)),\n (field) => field.key,\n );\n if (Arr.isReadonlyArrayNonEmpty(missing)) {\n throw new EntityFormDefinitionError({\n message: `defineEntityForm: command fields not declared by any entry: ${missing.join(\", \")} — declare or explicitly widget them`,\n });\n }\n\n const entriesFor = (mode: \"create\" | \"update\") =>\n Arr.filter(entries, (entry) =>\n Option.exists(HashMap.get(byKey, entry.key), (field) =>\n mode === \"create\" ? field.inCreate : field.inUpdate,\n ),\n );\n\n return {\n plan,\n entries,\n createInput: Schema.Struct(\n Command.formInputFields(plan, \"create\", options.inputOverrides?.create),\n ),\n updateInput: Schema.Struct(\n Command.formInputFields(plan, \"update\", options.inputOverrides?.update),\n ),\n createDefaults: Command.blankDefaults(plan, \"create\"),\n updateDefaults: Command.blankDefaults(plan, \"update\"),\n adaptCreate: Command.createAdapter(plan, options.adaptOverrides?.create),\n adaptUpdate: Command.updateAdapter(plan, options.adaptOverrides?.update),\n submit: options.submit,\n clientId: plan.clientId,\n entriesFor,\n fieldFor: (key: string) =>\n Option.getOrThrowWith(\n HashMap.get(byKey, key),\n () =>\n new EntityFormDefinitionError({\n message: `EntityFormDefinition: unknown field \"${key}\"`,\n }),\n ),\n };\n};\n"],"mappings":"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"}