@thomasfosterau/effect-forms-svelte 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +135 -21
  2. package/dist/ClientValidate.d.ts +2 -3
  3. package/dist/ClientValidate.js +1 -2
  4. package/dist/ClientValidate.js.map +1 -1
  5. package/dist/EnhanceCore.d.ts +7 -8
  6. package/dist/EnhanceCore.js +2 -3
  7. package/dist/EnhanceCore.js.map +1 -1
  8. package/dist/EntityForm.d.ts +17 -18
  9. package/dist/EntityForm.js.map +1 -1
  10. package/dist/FormStore.svelte.d.ts +15 -16
  11. package/dist/FormStore.svelte.js +2 -3
  12. package/dist/FormStore.svelte.js.map +1 -1
  13. package/dist/Listeners.d.ts +5 -6
  14. package/dist/Listeners.js.map +1 -1
  15. package/dist/SvelteKit.d.ts +5 -4
  16. package/dist/SvelteKit.js +3 -1
  17. package/dist/SvelteKit.js.map +1 -1
  18. package/dist/Tainted.d.ts +6 -7
  19. package/dist/Tainted.js.map +1 -1
  20. package/dist/_virtual/_rolldown/runtime.js +13 -0
  21. package/dist/kit/ActionRedirect.d.ts +22 -0
  22. package/dist/kit/ActionRedirect.js +56 -0
  23. package/dist/kit/ActionRedirect.js.map +1 -0
  24. package/dist/kit/CommandFailure.d.ts +52 -0
  25. package/dist/kit/CommandFailure.js +82 -0
  26. package/dist/kit/CommandFailure.js.map +1 -0
  27. package/dist/kit/Enhance.d.ts +30 -0
  28. package/dist/kit/Enhance.js +57 -0
  29. package/dist/kit/Enhance.js.map +1 -0
  30. package/dist/kit/FormAction.d.ts +132 -0
  31. package/dist/kit/FormAction.js +164 -0
  32. package/dist/kit/FormAction.js.map +1 -0
  33. package/dist/kit/FormStateReaders.d.ts +58 -0
  34. package/dist/kit/FormStateReaders.js +63 -0
  35. package/dist/kit/FormStateReaders.js.map +1 -0
  36. package/dist/kit/Pointer.d.ts +23 -0
  37. package/dist/kit/Pointer.js +38 -0
  38. package/dist/kit/Pointer.js.map +1 -0
  39. package/dist/kit/index.d.ts +7 -0
  40. package/dist/kit/index.js +7 -0
  41. package/package.json +22 -3
package/README.md CHANGED
@@ -1,17 +1,27 @@
1
1
  # @thomasfosterau/effect-forms-svelte
2
2
 
3
- Svelte 5 (runes) bindings for [`@thomasfosterau/effect-forms`](../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**.
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**.
4
4
 
5
- This package depends only on `svelte` (a peer) and the framework-neutral core — not SvelteKit — so it works in any Svelte 5 app.
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
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @thomasfosterau/effect-forms-svelte @thomasfosterau/effect-forms effect svelte
11
+ # for the /kit entry
12
+ pnpm add @sveltejs/kit
13
+ ```
14
+
15
+ `effect` and `svelte` are required peers. `@sveltejs/kit` is an optional peer, required by `/kit`; `@thomasfosterau/effect-jsonapi` is an optional, type-only peer of `/kit`.
6
16
 
7
17
  ## Usage
8
18
 
9
- Server (`+page.server.ts` or any action) produces a `FormState` with the core's `makeForm`, and returns it to the page. The component hydrates it:
19
+ Server (`+page.server.ts` or any action) produces a `FormState` with the core's `Form.make(schema, …).validate(request)`, and returns it to the page. The component hydrates it:
10
20
 
11
21
  ```svelte
12
22
  <script lang="ts">
13
23
  import { createForm } from "@thomasfosterau/effect-forms-svelte";
14
- import { Signup } from "$lib/schemas";
24
+ import { Signup } from "#lib/schemas";
15
25
 
16
26
  let { data } = $props(); // data.form is the server FormState
17
27
 
@@ -35,11 +45,11 @@ Server (`+page.server.ts` or any action) produces a `FormState` with the core's
35
45
 
36
46
  ## The store
37
47
 
38
- `createForm(source, options)` returns a [`FormStore`](src/FormStore.svelte.ts). `source` is a server-produced `FormState`, a `{ schema, defaults }` seed, or a [`FormBuilder`](../effect-forms/src/FormBuilder.ts) — the builder derives its schema, blank defaults, and array-item defaults for you:
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:
39
49
 
40
50
  ```ts
41
51
  import { createForm } from "@thomasfosterau/effect-forms-svelte";
42
- import { signup } from "$lib/forms"; // a FormBuilder
52
+ import { signup } from "#lib/forms"; // a FormBuilder
43
53
 
44
54
  const form = createForm(signup, { mode: { validation: "onChange", debounce: 300 } });
45
55
  ```
@@ -68,11 +78,11 @@ const form = createForm(signup, { mode: { validation: "onChange", debounce: 300
68
78
  {#each name.visibleErrors as message}<small>{message}</small>{/each}
69
79
  ```
70
80
 
71
- `path` is checked against the form data's shape via `Path.FieldPath<A>` — a typo'd segment (`"authr.name"`) or a path into a field that doesn't exist is a compile error, and `field.value` is the leaf's actual type, not `unknown`. This replaced the core's unconsumed `FieldRef` seam.
81
+ `path` is checked against the form data's shape via `Path.FieldPath<A>` — a typo'd segment (`"authr.name"`) or a path into a field that doesn't exist is a compile error, and `field.value` is the leaf's actual type, not `unknown`.
72
82
 
73
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.
74
84
 
75
- **Validation modes** — the `mode` option (see [`Mode`](../effect-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`](../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).
76
86
 
77
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:
78
88
 
@@ -93,23 +103,127 @@ const form = createForm(
93
103
 
94
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.
95
105
 
96
- **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`](../effect-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`](../forms/src/Result.ts) or a bare `FormState`).
97
107
 
98
- ## Layout
108
+ ## API
99
109
 
100
- The reactive store lives in `FormStore.svelte.ts` (rune-compiled, checked by `svelte-check`). Its logic is delegated to pure, unit-tested modules:
110
+ Everything below is exported flat from the root entry (`@thomasfosterau/effect-forms-svelte`).
101
111
 
102
- | Module | Purpose |
103
- | ----------------------------------------- | ------------------------------------------------------------------------- |
104
- | [`Tainted`](src/Tainted.ts) | Deep-diff the data to compute the tainted tree. |
105
- | [`ClientValidate`](src/ClientValidate.ts) | Validate the form / one field against the schema. |
106
- | [`EnhanceCore`](src/EnhanceCore.ts) | Serialise data → form body, parse the response, compute the store update. |
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. |
107
121
 
108
- The store itself is mostly exercised only by `svelte-check`, but the submitter-aware enhanced submit path (`enhance` → `#runSubmit` → the posted `FormData`) is directly unit-tested in [`FormStore.svelte.test.ts`](tests/FormStore.svelte.test.ts) — a `.svelte.ts` rune file needs the Svelte compiler to run at all, hence the `.svelte.test.ts` extension and the `svelte()` plugin in `vite.config.ts` (scoped to a `happy-dom` DOM via a per-file `@vitest-environment` docblock, not the package default).
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).
109
123
 
110
- ## Development
124
+ ## /kit
111
125
 
112
- ```sh
113
- vp exec svelte-check --tsconfig ./tsconfig.json # typecheck (incl. runes)
114
- vp test run tests # unit tests (pure modules + the submit path)
126
+ `@thomasfosterau/effect-forms-svelte/kit` holds the SvelteKit form-action glue: the derived page action, the JSON:API error routing, the idempotent create retry, the `$app/forms` enhance, the `PageProps["form"]` readers, and failure routing for action-only routes. The root entry (`@thomasfosterau/effect-forms-svelte`) stays framework-neutral Svelte; only `/kit` imports `@sveltejs/kit` and `$app/forms`.
127
+
128
+ | Export | What it does |
129
+ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
130
+ | `formAction(form, run, locationFor, options?)` | A page action: validate, run the command, then `redirect(303)`, `fail(status, { form })` or `fail(500, { message })` |
131
+ | `runFormAction(form, request, run, locationFor, options?)` | The same, as a framework-neutral `Result.FormActionResult` |
132
+ | `fieldFromPointer(pointer)` | `/data/attributes/<field>…` → `Option<field>` |
133
+ | `idempotentCreate(create, fetchExisting)` | A 409 on the caller's own client id resolves as success |
134
+ | `CommandFailure.*`, `jsonapiFailure` | Classify a command failure into routed JSON:API errors or a root message |
135
+ | `enhanceForm(store)` | Spreadable `<form>` props on `$app/forms` `enhance` |
136
+ | `formStateOf` / `issuesFor` / `rootIssuesOf` / `valueOf` | Total readers over `PageProps["form"]` |
137
+ | `userFixableMessage` / `withFailureParams` | Redirect-with-query failure routing for routes that have no page |
138
+
139
+ ### Peers
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`.
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
+ - `@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
+
145
+ ### Commands and failures
146
+
147
+ A command is `(input, event) => Promise<O> | Effect<O, E>`. An Effect command must have no requirements left. Provide them first, e.g. with the application's `ManagedRuntime`. `locationFor(output, input)` builds the redirect, and must return a same-origin path such as `/organisations/${id}`. SvelteKit 3's `redirect` rejects an external location unless it is given `{ external }`, which `formAction` never passes, so an absolute URL on another origin (or a protocol-relative `//host/...`) makes the action throw an `Error` after the command has run. To send the user off-site, redirect to a same-origin route that performs the external redirect itself. `runFormAction` returns the location unchecked.
148
+
149
+ `jsonapiFailure`, the default classifier, recognises:
150
+
151
+ - `{ _tag: "JsonapiError", status, errors }`: effect-jsonapi's `ServerErrors.JsonapiError`, or any error of the same shape. Each error object goes to the field its `source.pointer` names, or to the root (`form.errors._errors`), at the cause's own status.
152
+ - `{ _tag: "ErrorResponse", status, message, document }`: effect-jsonapi's client-side decoded error. The document's error objects are routed as above. With no document, the message goes to the root.
153
+ - An `Error` with a numeric `status`, such as a transport's 413: its message goes to the root.
154
+
155
+ Anything else is unexpected. By default it becomes `fail(500, { message })`. Pass `unrecognised: "throw"` to hand it to `handleError` instead. `classify` replaces the classifier; compose with `CommandFailure.jsonapiFailure` to extend it. `redact` runs on every state echoed back to the browser. `idempotent: { clientId, fetchExisting }` turns on the client-id retry.
156
+
157
+ ### Worked example: a login action
158
+
159
+ A hand-written sign-in action typically hand-rolls validation, password blanking, 401 → generic message, a rethrow for anything else, and a same-origin redirect. With `/kit` it becomes:
160
+
161
+ ```ts
162
+ // src/routes/login/+page.server.ts
163
+ import { Form, State } from "@thomasfosterau/effect-forms";
164
+ import { CommandFailure, formAction } from "@thomasfosterau/effect-forms-svelte/kit";
165
+ import * as Option from "effect/Option";
166
+ import * as Schema from "effect/Schema";
167
+ import { SessionCallError } from "#lib/server/api/binding";
168
+ import { safeRedirect } from "#lib/redirects";
169
+
170
+ const SignInFields = Schema.Struct({
171
+ email: Schema.String,
172
+ _password: Schema.String,
173
+ redirectTo: Schema.optional(Schema.String),
174
+ });
175
+
176
+ const signInForm = Form.make(SignInFields, {
177
+ defaults: { email: "", _password: "", redirectTo: undefined },
178
+ id: "sign-in",
179
+ });
180
+
181
+ type SignIn = typeof SignInFields.Type;
182
+
183
+ /** Blank the password before a state is echoed back to the browser. */
184
+ const blankPassword = (state: State.FormState<SignIn>): State.FormState<SignIn> => ({
185
+ ...state,
186
+ data: { ...state.data, _password: "" },
187
+ });
188
+
189
+ /** A 401 → one generic whole-form message (never reveal whether the email exists). */
190
+ const classify: CommandFailure.Classify = (cause) =>
191
+ cause instanceof SessionCallError && cause.status === 401
192
+ ? Option.some(CommandFailure.message(401, "Invalid email or password"))
193
+ : Option.none();
194
+
195
+ export const actions = {
196
+ default: formAction(
197
+ signInForm,
198
+ (input, { locals }) =>
199
+ locals.session.signIn({ email: input.email, password: input._password, rememberMe: true }),
200
+ (_, input) => safeRedirect(input.redirectTo, "/"),
201
+ { classify, redact: blankPassword, unrecognised: "throw" },
202
+ ),
203
+ };
115
204
  ```
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.
207
+
208
+ The page renders server state without a client store:
209
+
210
+ ```svelte
211
+ <script lang="ts">
212
+ import { formStateOf, issuesFor, rootIssuesOf, valueOf } from "@thomasfosterau/effect-forms-svelte/kit";
213
+ let { form } = $props();
214
+ const state = $derived(formStateOf(form));
215
+ </script>
216
+
217
+ <form method="post">
218
+ {#each rootIssuesOf(state) as message}<p role="alert">{message}</p>{/each}
219
+ <input name="email" value={valueOf(state, "email")} />
220
+ {#each issuesFor(state, "email") as message}<small>{message}</small>{/each}
221
+ <input name="_password" type="password" />
222
+ </form>
223
+ ```
224
+
225
+ With a client store, spread `enhanceForm(form)` onto the `<form>` instead.
226
+
227
+ ## License
228
+
229
+ [MIT](LICENSE)
@@ -5,14 +5,13 @@ type FormErrors = Errors.FormErrors;
5
5
  type FormPath = Issues.FormPath;
6
6
  type LeafFormatter = Issues.LeafFormatter;
7
7
  /** Validate the whole form's data, returning the nested error tree (empty when valid). */
8
- declare function validateData<S extends Schema.Top>(schema: S, data: unknown, format?: LeafFormatter): FormErrors;
8
+ export declare function validateData<S extends Schema.Top>(schema: S, data: unknown, format?: LeafFormatter): FormErrors;
9
9
  /**
10
10
  * Validate the whole form and return just the messages at `path`. Validating
11
11
  * the whole object (rather than the leaf in isolation) keeps cross-field rules
12
12
  * working; the caller merges the result into its existing error tree for that
13
13
  * field only.
14
14
  */
15
- declare function validateField<S extends Schema.Top>(schema: S, data: unknown, path: FormPath, format?: LeafFormatter): readonly string[];
15
+ export declare function validateField<S extends Schema.Top>(schema: S, data: unknown, path: FormPath, format?: LeafFormatter): readonly string[];
16
16
  //#endregion
17
- export { validateData, validateField };
18
17
  //# sourceMappingURL=ClientValidate.d.ts.map
@@ -1,8 +1,6 @@
1
1
  import { Errors, Validate } from "@thomasfosterau/effect-forms";
2
2
  //#region src/ClientValidate.ts
3
3
  /**
4
- * @copyright Thomas Foster
5
- *
6
4
  * Client-side validation helpers — validate the whole form or a single field
7
5
  * against the same Effect Schema the server uses, returning the nested
8
6
  * {@link FormErrors} tree (or one field's messages).
@@ -12,6 +10,7 @@ import { Errors, Validate } from "@thomasfosterau/effect-forms";
12
10
  * have no decoding-service requirements (the common case for form schemas) so
13
11
  * validation can run synchronously in an event handler.
14
12
  *
13
+ * @copyright Thomas Foster
15
14
  * @module effect-forms-svelte/ClientValidate
16
15
  */
17
16
  const { getError, noErrors } = Errors;
@@ -1 +1 @@
1
- {"version":3,"file":"ClientValidate.js","names":[],"sources":["../src/ClientValidate.ts"],"sourcesContent":["/**\n * @copyright Thomas Foster\n *\n * Client-side validation helpers — validate the whole form or a single field\n * against the same Effect Schema the server uses, returning the nested\n * {@link FormErrors} tree (or one field's messages).\n *\n * Re-running the schema in the browser gives instant feedback; this is\n * the framework-neutral plumbing the Svelte store calls. Schemas used here must\n * have no decoding-service requirements (the common case for form schemas) so\n * validation can run synchronously in an event handler.\n *\n * @module effect-forms-svelte/ClientValidate\n */\n\nimport { Errors, Issues, Validate } from \"@thomasfosterau/effect-forms\";\n\n// Terse local aliases onto the namespaced core exports.\ntype FormErrors = Errors.FormErrors;\ntype FormPath = Issues.FormPath;\ntype LeafFormatter = Issues.LeafFormatter;\nconst { getError, noErrors } = Errors;\nconst { validateValueSync } = Validate;\nimport type * as Schema from \"effect/Schema\";\n\n/** Validate the whole form's data, returning the nested error tree (empty when valid). */\nexport function validateData<S extends Schema.Top>(\n schema: S,\n data: unknown,\n format?: LeafFormatter,\n): FormErrors {\n const result = validateValueSync(schema, data, format);\n return result.valid ? noErrors : result.errors;\n}\n\n/**\n * Validate the whole form and return just the messages at `path`. Validating\n * the whole object (rather than the leaf in isolation) keeps cross-field rules\n * working; the caller merges the result into its existing error tree for that\n * field only.\n */\nexport function validateField<S extends Schema.Top>(\n schema: S,\n data: unknown,\n path: FormPath,\n format?: LeafFormatter,\n): readonly string[] {\n const errors = validateData(schema, data, format);\n return getError(errors, path) ?? [];\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqBA,MAAM,EAAE,UAAU,aAAa;AAC/B,MAAM,EAAE,sBAAsB;;AAI9B,SAAgB,aACd,QACA,MACA,QACY;CACZ,MAAM,SAAS,kBAAkB,QAAQ,MAAM,MAAM;CACrD,OAAO,OAAO,QAAQ,WAAW,OAAO;AAC1C;;;;;;;AAQA,SAAgB,cACd,QACA,MACA,MACA,QACmB;CACnB,MAAM,SAAS,aAAa,QAAQ,MAAM,MAAM;CAChD,OAAO,SAAS,QAAQ,IAAI,KAAK,CAAC;AACpC"}
1
+ {"version":3,"file":"ClientValidate.js","names":[],"sources":["../src/ClientValidate.ts"],"sourcesContent":["/**\n * Client-side validation helpers — validate the whole form or a single field\n * against the same Effect Schema the server uses, returning the nested\n * {@link FormErrors} tree (or one field's messages).\n *\n * Re-running the schema in the browser gives instant feedback; this is\n * the framework-neutral plumbing the Svelte store calls. Schemas used here must\n * have no decoding-service requirements (the common case for form schemas) so\n * validation can run synchronously in an event handler.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/ClientValidate\n */\n\nimport { Errors, Issues, Validate } from \"@thomasfosterau/effect-forms\";\n\n// Terse local aliases onto the namespaced core exports.\ntype FormErrors = Errors.FormErrors;\ntype FormPath = Issues.FormPath;\ntype LeafFormatter = Issues.LeafFormatter;\nconst { getError, noErrors } = Errors;\nconst { validateValueSync } = Validate;\nimport type * as Schema from \"effect/Schema\";\n\n/** Validate the whole form's data, returning the nested error tree (empty when valid). */\nexport function validateData<S extends Schema.Top>(\n schema: S,\n data: unknown,\n format?: LeafFormatter,\n): FormErrors {\n const result = validateValueSync(schema, data, format);\n return result.valid ? noErrors : result.errors;\n}\n\n/**\n * Validate the whole form and return just the messages at `path`. Validating\n * the whole object (rather than the leaf in isolation) keeps cross-field rules\n * working; the caller merges the result into its existing error tree for that\n * field only.\n */\nexport function validateField<S extends Schema.Top>(\n schema: S,\n data: unknown,\n path: FormPath,\n format?: LeafFormatter,\n): readonly string[] {\n const errors = validateData(schema, data, format);\n return getError(errors, path) ?? [];\n}\n"],"mappings":";;;;;;;;;;;;;;;AAoBA,MAAM,EAAE,UAAU,aAAa;AAC/B,MAAM,EAAE,sBAAsB;;AAI9B,SAAgB,aACd,QACA,MACA,QACY;CACZ,MAAM,SAAS,kBAAkB,QAAQ,MAAM,MAAM;CACrD,OAAO,OAAO,QAAQ,WAAW,OAAO;AAC1C;;;;;;;AAQA,SAAgB,cACd,QACA,MACA,MACA,QACmB;CACnB,MAAM,SAAS,aAAa,QAAQ,MAAM,MAAM;CAChD,OAAO,SAAS,QAAQ,IAAI,KAAK,CAAC;AACpC"}
@@ -3,18 +3,18 @@ import { Errors, Result } from "@thomasfosterau/effect-forms";
3
3
  type FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;
4
4
  type FormErrors = Errors.FormErrors;
5
5
  /** Flatten a nested data object into `[name, value]` entries using path notation. */
6
- declare function objectToEntries(data: unknown, prefix?: string): Array<[string, string]>;
6
+ export declare function objectToEntries(data: unknown, prefix?: string): Array<[string, string]>;
7
7
  /** Serialise the store's data into a {@link FormData} body for submission. */
8
- declare function objectToFormData(data: unknown): FormData;
8
+ export declare function objectToFormData(data: unknown): FormData;
9
9
  /**
10
10
  * Normalise a parsed JSON response body into a {@link FormActionResult}. The
11
11
  * server may return a full action result (`{ _tag, … }`) or a bare
12
12
  * {@link FormState} — a bare state maps to `Success` / `Failure` by its `valid`
13
13
  * flag, using `status` for the failure code.
14
14
  */
15
- declare function parseActionResult<A, Message>(body: unknown, status?: number): FormActionResult<A, Message>;
15
+ export declare function parseActionResult<A, Message>(body: unknown, status?: number): FormActionResult<A, Message>;
16
16
  /** The mutation a {@link FormActionResult} implies for the store. */
17
- interface FormUpdate<A, Message> {
17
+ export interface FormUpdate<A, Message> {
18
18
  readonly data?: A | undefined;
19
19
  readonly errors?: FormErrors | undefined;
20
20
  readonly message?: Message | undefined;
@@ -27,9 +27,9 @@ interface FormUpdate<A, Message> {
27
27
  * caller applies the returned fields to its reactive state and acts on
28
28
  * `redirect` / `error`.
29
29
  */
30
- declare function resultToUpdate<A, Message>(result: FormActionResult<A, Message>): FormUpdate<A, Message>;
30
+ export declare function resultToUpdate<A, Message>(result: FormActionResult<A, Message>): FormUpdate<A, Message>;
31
31
  /** How a successful/failed submit's data should land on `data` and the tainted baseline. */
32
- interface DataApplication<A> {
32
+ export interface DataApplication<A> {
33
33
  /** The next value for `data`, or `undefined` to leave it untouched (an auto-submit keeps local edits). */
34
34
  readonly data: A | undefined;
35
35
  /** The next tainted baseline (`#initial`), or `undefined` when this isn't a successful submit. */
@@ -48,7 +48,7 @@ interface DataApplication<A> {
48
48
  * `submittedData` (the snapshot actually sent), not the live value, since the
49
49
  * user may have typed further edits that haven't been submitted yet.
50
50
  */
51
- declare function computeDataApplication<A>(params: {
51
+ export declare function computeDataApplication<A>(params: {
52
52
  readonly isSuccess: boolean;
53
53
  readonly appliedData: A;
54
54
  readonly initial: A;
@@ -57,5 +57,4 @@ declare function computeDataApplication<A>(params: {
57
57
  readonly autoSubmit: boolean;
58
58
  }): DataApplication<A>;
59
59
  //#endregion
60
- export { DataApplication, FormUpdate, computeDataApplication, objectToEntries, objectToFormData, parseActionResult, resultToUpdate };
61
60
  //# sourceMappingURL=EnhanceCore.d.ts.map
@@ -1,14 +1,13 @@
1
1
  import { Result } from "@thomasfosterau/effect-forms";
2
2
  //#region src/EnhanceCore.ts
3
3
  /**
4
- * @copyright Thomas Foster
5
- *
6
4
  * Pure plumbing for the `enhance` action: serialise the store's data back into
7
5
  * a form body, interpret a server response into a {@link FormActionResult}, and
8
6
  * compute the store update a result implies. Kept framework-free and side-
9
7
  * effect-free so it is unit-testable in isolation; the rune store
10
- * (`superForm.svelte.ts`) wires these to the DOM.
8
+ * (`FormStore.svelte.ts`) wires these to the DOM.
11
9
  *
10
+ * @copyright Thomas Foster
12
11
  * @module effect-forms-svelte/EnhanceCore
13
12
  */
14
13
  const { failure, success } = Result;
@@ -1 +1 @@
1
- {"version":3,"file":"EnhanceCore.js","names":[],"sources":["../src/EnhanceCore.ts"],"sourcesContent":["/**\n * @copyright Thomas Foster\n *\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 * (`superForm.svelte.ts`) wires these to the DOM.\n *\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":";;;;;;;;;;;;;AAkBA,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\";\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"}
@@ -2,10 +2,10 @@ import { Command } from "@thomasfosterau/effect-forms";
2
2
  import * as Schema from "effect/Schema";
3
3
  import { Component } from "svelte";
4
4
  //#region src/EntityForm.d.ts
5
- /** A render-time message thunk — pass the Paraglide function itself, uncalled. */
6
- type MessageThunk = () => string;
5
+ /** A render-time message thunk — pass the message function itself, uncalled. */
6
+ export type MessageThunk = () => string;
7
7
  /** Presentation options for a scalar text entry. */
8
- interface TextEntryOptions {
8
+ export interface TextEntryOptions {
9
9
  readonly label: MessageThunk;
10
10
  /** Presentation `type` attribute (`url`, `date`). Omitted → text input. */
11
11
  readonly type?: "text" | "url" | "date";
@@ -16,14 +16,14 @@ interface TextEntryOptions {
16
16
  readonly placeholder?: MessageThunk;
17
17
  }
18
18
  /** A scalar field rendered by the shared text/textarea primitives. */
19
- interface ScalarEntry {
19
+ export interface ScalarEntry {
20
20
  readonly _tag: "scalar";
21
21
  readonly key: string;
22
22
  readonly multiline: boolean;
23
23
  readonly options: TextEntryOptions;
24
24
  }
25
25
  /** Presentation options for a `<select>` entry over a literal-union (`enum` kind) field. */
26
- interface SelectEntryOptions {
26
+ export interface SelectEntryOptions {
27
27
  readonly label: MessageThunk;
28
28
  readonly required?: boolean;
29
29
  /**
@@ -40,23 +40,23 @@ interface SelectEntryOptions {
40
40
  * per-option labels — `defineEntityForm` validates the field is `enum`-kind
41
41
  * and that every derived value has a label.
42
42
  */
43
- interface SelectEntry {
43
+ export interface SelectEntry {
44
44
  readonly _tag: "select";
45
45
  readonly key: string;
46
46
  readonly options: SelectEntryOptions;
47
47
  }
48
48
  /** Options for a widget entry (picker / rows list). */
49
- interface WidgetEntryOptions {
49
+ export interface WidgetEntryOptions {
50
50
  /** Gate the submit button while this field's value is empty (required pickers). */
51
51
  readonly gate?: boolean;
52
52
  }
53
53
  /**
54
54
  * A bespoke widget field. The renderer bridges the store to the widget through
55
- * the stage-1 wrapper contract — `name`, `value`, `onChange`, `errors` — which
55
+ * the widget contract — `name`, `value`, `onChange`, `errors` — which
56
56
  * are **reserved keys**: the bridge is spread last, so a `props()` thunk or a
57
57
  * load-supplied field prop can never silently sever the store binding.
58
58
  */
59
- interface WidgetEntry {
59
+ export interface WidgetEntry {
60
60
  readonly _tag: "widget";
61
61
  readonly key: string;
62
62
  /** `"value"` bridges a string FK; `"rows"` bridges a controlled row list. */
@@ -66,18 +66,18 @@ interface WidgetEntry {
66
66
  readonly options: WidgetEntryOptions;
67
67
  }
68
68
  /** One ordered entry in an entity form definition. */
69
- type EntityFormEntry = ScalarEntry | WidgetEntry | SelectEntry;
69
+ export type EntityFormEntry = ScalarEntry | WidgetEntry | SelectEntry;
70
70
  /** The per-mode submit labels. */
71
- interface SubmitMessages {
71
+ export interface SubmitMessages {
72
72
  readonly create: MessageThunk;
73
73
  readonly update: MessageThunk;
74
74
  }
75
75
  /** The runtime shape `defineEntityForm` accepts for a command struct. */
76
- interface CommandStructLike {
76
+ export interface CommandStructLike {
77
77
  readonly fields: Record<string, Schema.Top>;
78
78
  }
79
79
  /** The value `defineEntityForm` returns — everything one renderer needs. */
80
- interface EntityFormDefinition {
80
+ export interface EntityFormDefinition {
81
81
  readonly plan: Command.CommandPlan;
82
82
  readonly entries: readonly EntityFormEntry[];
83
83
  readonly createInput: Schema.Top;
@@ -95,9 +95,9 @@ interface EntityFormDefinition {
95
95
  readonly fieldFor: (key: string) => Command.FieldPlan;
96
96
  }
97
97
  /** The writable command keys a definition may declare (the `id` key is renderer-owned). */
98
- type CommandKeys<C extends CommandStructLike, U extends CommandStructLike> = Exclude<Extract<keyof C["fields"] | keyof U["fields"], string>, "id">;
98
+ export type CommandKeys<C extends CommandStructLike, U extends CommandStructLike> = Exclude<Extract<keyof C["fields"] | keyof U["fields"], string>, "id">;
99
99
  /** The entry builders handed to the definition callback, key-typed to the command pair. */
100
- interface EntryBuilders<K extends string> {
100
+ export interface EntryBuilders<K extends string> {
101
101
  /** A scalar field rendered by the shared `TextInputField`. */
102
102
  text(key: K, options: TextEntryOptions): EntityFormEntry;
103
103
  /** A scalar field rendered by the shared `TextAreaInputField`. */
@@ -122,7 +122,7 @@ interface EntryBuilders<K extends string> {
122
122
  rows<P extends Record<string, unknown>>(key: K, component: Component<never>, props: () => P, options?: WidgetEntryOptions): EntityFormEntry;
123
123
  }
124
124
  /** Everything `defineEntityForm` needs beyond the entries. */
125
- interface DefineEntityFormOptions<C extends CommandStructLike, U extends CommandStructLike> {
125
+ export interface DefineEntityFormOptions<C extends CommandStructLike, U extends CommandStructLike> {
126
126
  readonly create: C;
127
127
  readonly update: U;
128
128
  readonly submit: SubmitMessages;
@@ -152,7 +152,6 @@ interface DefineEntityFormOptions<C extends CommandStructLike, U extends Command
152
152
  * the derivable command fields exactly (exhaustiveness) or name an unknown
153
153
  * key — a command-struct change must surface here, at definition time.
154
154
  */
155
- declare const defineEntityForm: <C extends CommandStructLike, U extends CommandStructLike>(options: DefineEntityFormOptions<C, U>, build: (f: EntryBuilders<CommandKeys<C, U>>) => readonly EntityFormEntry[]) => EntityFormDefinition;
155
+ export declare const defineEntityForm: <C extends CommandStructLike, U extends CommandStructLike>(options: DefineEntityFormOptions<C, U>, build: (f: EntryBuilders<CommandKeys<C, U>>) => readonly EntityFormEntry[]) => EntityFormDefinition;
156
156
  //#endregion
157
- export { CommandKeys, CommandStructLike, DefineEntityFormOptions, EntityFormDefinition, EntityFormEntry, EntryBuilders, MessageThunk, ScalarEntry, SelectEntry, SelectEntryOptions, SubmitMessages, TextEntryOptions, WidgetEntry, WidgetEntryOptions, defineEntityForm };
158
157
  //# sourceMappingURL=EntityForm.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"EntityForm.js","names":[],"sources":["../src/EntityForm.ts"],"sourcesContent":["/**\n * @copyright Thomas Foster\n *\n * The typed entity-form definition builder — the composition layer of the\n * schema-driven form phase. `defineEntityForm(commands, (f) => [...])` pairs a\n * `Command.plan` derivation (form-input schemas, blank defaults, tri-state\n * adapters — see `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 * Paraglide message function references) resolved at render time, never at\n * module scope — module-scope resolution would freeze the import-time locale.\n *\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 Paraglide 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 stage-1 wrapper 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":";;;AA0MA,MAAM,WAAkC;CACtC,OAAO,KAAK,aAAa;EAAE,MAAM;EAAU;EAAK,WAAW;EAAO;CAAQ;CAC1E,WAAW,KAAK,aAAa;EAAE,MAAM;EAAU;EAAK,WAAW;EAAM;CAAQ;CAC7E,SAAS,KAAK,aAAa;EAAE,MAAM;EAAU;EAAK;CAAQ;CAC1D,SAAS,KAAK,WAAW,OAAO,UAAU,CAAC,OAAO;EAChD,MAAM;EACN;EACA,QAAQ;EACG;EACX;EACA;CACF;CACA,OAAO,KAAK,WAAW,OAAO,UAAU,CAAC,OAAO;EAC9C,MAAM;EACN;EACA,QAAQ;EACG;EACX;EACA;CACF;AACF;;;;;;;AAQA,MAAM,uBAAuB,OAAoB,UAAmC;CAClF,IAAI,MAAM,SAAS,QACjB,MAAM,IAAI,MACR,4BAA4B,MAAM,IAAI,0CAA0C,MAAM,KAAK,iBAC7F;CAEF,MAAM,iBAAiB,MAAM,WAAW,CAAC,EAAA,CAAG,QACzC,UAAU,CAAC,OAAO,UAAU,eAAe,KAAK,MAAM,QAAQ,cAAc,KAAK,CACpF;CACA,IAAI,cAAc,SAAS,GACzB,MAAM,IAAI,MACR,4BAA4B,MAAM,IAAI,iCAAiC,cAAc,KAAK,IAAI,GAChG;AAEJ;;;;;;;AAQA,MAAa,oBACX,SACA,UACyB;CACzB,MAAM,UAAU,IAAI,IAAY,QAAQ,QAAQ,CAAC,CAAC;CAClD,MAAM,kBAAkB,WAAiD;EACvE,IAAI,QAAQ,SAAS,GAAG,OAAO;EAC/B,MAAM,SAAqC,CAAC;EAC5C,KAAK,MAAM,OAAO,OAAO,QAAQ,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,OAAO,OAAO,OAAO,OAAO;EACpF,OAAO,EAAE,OAAO;CAClB;CACA,MAAM,OAAO,QAAQ,KAAK,eAAe,QAAQ,MAAM,GAAG,eAAe,QAAQ,MAAM,CAAC;CACxF,MAAM,UAAU,MAAM,QAA4C;CAElE,MAAM,QAAQ,IAAI,IAA+B,KAAK,OAAO,KAAK,UAAU,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC;CAC/F,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,QAAQ,MAAM,IAAI,MAAM,GAAG;EACjC,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,4BAA4B,MAAM,IAAI,0CACxC;EAEF,IAAI,SAAS,IAAI,MAAM,GAAG,GACxB,MAAM,IAAI,MAAM,4BAA4B,MAAM,IAAI,oBAAoB;EAE5E,SAAS,IAAI,MAAM,GAAG;EACtB,IAAI,MAAM,SAAS,UAAU,oBAAoB,OAAO,KAAK;CAC/D;CACA,MAAM,UAAU,KAAK,OAAO,QAAQ,UAAU,CAAC,SAAS,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,UAAU,MAAM,GAAG;CAChG,IAAI,QAAQ,SAAS,GACnB,MAAM,IAAI,MACR,+DAA+D,QAAQ,KAAK,IAAI,EAAE,qCACpF;CAGF,MAAM,cAAc,SAClB,QAAQ,QAAQ,UAAU;EACxB,MAAM,QAAQ,MAAM,IAAI,MAAM,GAAG;EACjC,OAAO,UAAU,KAAA,MAAc,SAAS,WAAW,MAAM,WAAW,MAAM;CAC5E,CAAC;CAEH,OAAO;EACL;EACA;EACA,aAAa,OAAO,OAClB,QAAQ,gBAAgB,MAAM,UAAU,QAAQ,gBAAgB,MAAM,CAIxE;EACA,aAAa,OAAO,OAClB,QAAQ,gBAAgB,MAAM,UAAU,QAAQ,gBAAgB,MAAM,CAIxE;EACA,gBAAgB,QAAQ,cAAc,MAAM,QAAQ;EACpD,gBAAgB,QAAQ,cAAc,MAAM,QAAQ;EACpD,aAAa,QAAQ,cAAc,MAAM,QAAQ,gBAAgB,MAAM;EACvE,aAAa,QAAQ,cAAc,MAAM,QAAQ,gBAAgB,MAAM;EACvE,QAAQ,QAAQ;EAChB,UAAU,KAAK;EACf;EACA,WAAW,QAAgB;GACzB,MAAM,QAAQ,MAAM,IAAI,GAAG;GAC3B,IAAI,UAAU,KAAA,GAAW,MAAM,IAAI,MAAM,wCAAwC,IAAI,EAAE;GACvF,OAAO;EACT;CACF;AACF"}
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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"}