@thomasfosterau/effect-forms-svelte 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +137 -23
  2. package/dist/ClientValidate.d.ts +3 -3
  3. package/dist/ClientValidate.js +1 -2
  4. package/dist/ClientValidate.js.map +1 -1
  5. package/dist/EnhanceCore.d.ts +41 -10
  6. package/dist/EnhanceCore.js +107 -40
  7. package/dist/EnhanceCore.js.map +1 -1
  8. package/dist/EntityForm.d.ts +17 -18
  9. package/dist/EntityForm.js +28 -28
  10. package/dist/EntityForm.js.map +1 -1
  11. package/dist/FormStore.svelte.d.ts +25 -18
  12. package/dist/FormStore.svelte.js +95 -62
  13. package/dist/FormStore.svelte.js.map +1 -1
  14. package/dist/Listeners.d.ts +6 -6
  15. package/dist/Listeners.js +3 -2
  16. package/dist/Listeners.js.map +1 -1
  17. package/dist/SvelteKit.d.ts +6 -4
  18. package/dist/SvelteKit.js +5 -2
  19. package/dist/SvelteKit.js.map +1 -1
  20. package/dist/Tainted.d.ts +7 -7
  21. package/dist/Tainted.js +27 -39
  22. package/dist/Tainted.js.map +1 -1
  23. package/dist/_virtual/_rolldown/runtime.js +13 -0
  24. package/dist/kit/ActionRedirect.d.ts +22 -0
  25. package/dist/kit/ActionRedirect.js +54 -0
  26. package/dist/kit/ActionRedirect.js.map +1 -0
  27. package/dist/kit/CommandFailure.d.ts +52 -0
  28. package/dist/kit/CommandFailure.js +81 -0
  29. package/dist/kit/CommandFailure.js.map +1 -0
  30. package/dist/kit/Enhance.d.ts +30 -0
  31. package/dist/kit/Enhance.js +57 -0
  32. package/dist/kit/Enhance.js.map +1 -0
  33. package/dist/kit/FormAction.d.ts +132 -0
  34. package/dist/kit/FormAction.js +170 -0
  35. package/dist/kit/FormAction.js.map +1 -0
  36. package/dist/kit/FormStateReaders.d.ts +58 -0
  37. package/dist/kit/FormStateReaders.js +64 -0
  38. package/dist/kit/FormStateReaders.js.map +1 -0
  39. package/dist/kit/Pointer.d.ts +23 -0
  40. package/dist/kit/Pointer.js +38 -0
  41. package/dist/kit/Pointer.js.map +1 -0
  42. package/dist/kit/index.d.ts +7 -0
  43. package/dist/kit/index.js +7 -0
  44. package/package.json +23 -4
@@ -0,0 +1,132 @@
1
+ import { Classify } from "./CommandFailure.js";
2
+ import { Form, Result, State } from "@thomasfosterau/effect-forms";
3
+ import * as Schema from "effect/Schema";
4
+ import * as Effect from "effect/Effect";
5
+ import { RequestEvent } from "@sveltejs/kit";
6
+ //#region src/kit/FormAction.d.ts
7
+ type FormState<A, Message = unknown> = State.FormState<A, Message>;
8
+ type FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;
9
+ type FormActionData<A = unknown, Message = unknown> = Result.FormActionData<A, Message>;
10
+ /**
11
+ * A command: a Promise-returning function or one returning an `Effect` with
12
+ * no requirements (provide them first, e.g. through a `ManagedRuntime`).
13
+ */
14
+ type Command$1<I, O> = (input: I) => Promise<O> | Effect.Effect<O, unknown>;
15
+ /**
16
+ * Wrap a client-id create command so a 409 conflict on the caller's OWN
17
+ * submitted `id` resolves as success instead of a form error — the
18
+ * double-submit of a create whose id the client generated.
19
+ *
20
+ * On any 409 (a `JsonapiError` / `ErrorResponse`-tagged cause), re-fetches
21
+ * `input.id` via `fetchExisting` and returns that. This is deliberately
22
+ * unconditional on *why* the 409 fired: the re-fetch is the proof, not the
23
+ * status. A 409 from an unrelated precondition never left a row at
24
+ * `input.id`, so the re-fetch fails and the original conflict rethrows
25
+ * unchanged. An input with no `id`, or any other failure, rethrows as is.
26
+ *
27
+ * `input.id` is read structurally rather than through a constraint on
28
+ * `Input`: a constraint here would be used only contravariantly (as
29
+ * `create`'s parameter), which makes TypeScript infer `Input` from the
30
+ * constraint instead of from the command's own input type.
31
+ */
32
+ export declare function idempotentCreate<Input, Output>(create: (input: Input) => Promise<Output>, fetchExisting: (id: string) => Promise<Output>): (input: Input) => Promise<Output>;
33
+ /**
34
+ * Idempotent-retry wiring for a client-id create action: `clientId` gates the
35
+ * wrap to commands whose input actually carries a client-generated `id` (an
36
+ * entity form definition's own `clientId`, from `defineEntityForm`);
37
+ * `fetchExisting` re-fetches the submitted id (see {@link idempotentCreate}).
38
+ */
39
+ export interface IdempotentCreateOptions<O> {
40
+ readonly clientId: boolean;
41
+ readonly fetchExisting: (id: string) => Promise<O> | Effect.Effect<O, unknown>;
42
+ }
43
+ /** Options for {@link runFormAction} and {@link formAction}. */
44
+ export interface FormActionOptions<A, O> {
45
+ /** Retry a client-id create's own 409 as success (see {@link IdempotentCreateOptions}). */
46
+ readonly idempotent?: IdempotentCreateOptions<O> | undefined;
47
+ /**
48
+ * How a command failure is recognised. Defaults to
49
+ * {@link CommandFailure.jsonapiFailure}; compose with it to add an
50
+ * application's own failures.
51
+ */
52
+ readonly classify?: Classify | undefined;
53
+ /**
54
+ * Applied to every form state echoed back to the browser (an invalid
55
+ * submission, a routed command failure) — e.g. blanking password fields
56
+ * so a secret never rides a response body.
57
+ */
58
+ readonly redact?: ((state: FormState<A>) => FormState<A>) | undefined;
59
+ /**
60
+ * What happens to a command failure the classifier does not recognise:
61
+ * `"error"` (the default) answers an `Error` result at 500 carrying the
62
+ * cause's message; `"throw"` rethrows the cause, leaving it to SvelteKit's
63
+ * `handleError`.
64
+ */
65
+ readonly unrecognised?: "error" | "throw" | undefined;
66
+ }
67
+ /**
68
+ * Validate `request` against `form`, then:
69
+ *
70
+ * - invalid → a `Failure` carrying the form state (nested errors + posted
71
+ * values), status 400, and the command never runs;
72
+ * - valid → run the command (wrapped in the idempotent retry when
73
+ * `options.idempotent.clientId` is true); on success a `Redirect` (303) to
74
+ * `locationFor(output, input)`;
75
+ * - a recognised command failure → a `Failure` at the failure's own status,
76
+ * each JSON:API error routed onto its field (or the root);
77
+ * - an unrecognised one → an `Error` result at 500 (or a rethrow, per
78
+ * `options.unrecognised`).
79
+ *
80
+ * Posted values survive every failure, so the form re-renders what was typed.
81
+ *
82
+ * `locationFor` returns the redirect target as-is: `runFormAction` does not
83
+ * check it. {@link formAction} hands it to SvelteKit's `redirect`, which
84
+ * accepts only a same-origin location (see there).
85
+ */
86
+ export declare function runFormAction<S extends Schema.Top, O, Message = unknown>(form: Form.Form<S, Message>, request: Request, run: Command$1<S["Type"], O>, locationFor: (output: O, input: S["Type"]) => string, options?: FormActionOptions<S["Type"], O>): Promise<FormActionResult<S["Type"]>>;
87
+ /**
88
+ * A command for {@link formAction}: like {@link Command}, and also handed the
89
+ * `RequestEvent` (for `locals`, `cookies`, `fetch`).
90
+ */
91
+ export type ActionCommand<I, O> = (input: I, event: RequestEvent) => Promise<O> | Effect.Effect<O, unknown>;
92
+ /**
93
+ * Derive a SvelteKit page form **action** from `form` plus its command. The
94
+ * handler validates the submission, runs the command on success, and maps
95
+ * the outcome onto SvelteKit's action protocol:
96
+ *
97
+ * - success → `redirect(303, locationFor(output, input))`;
98
+ * - an invalid submission or a recognised command failure →
99
+ * `fail(status, { form })`, carrying the (redacted) state;
100
+ * - an unrecognised failure → `fail(500, { message })` (or a rethrow).
101
+ *
102
+ * Wire it into `export const actions`; the client `createForm` store applies
103
+ * the returned `form` via {@link enhanceForm} (or `applyActionResult`).
104
+ *
105
+ * `locationFor` must return a same-origin path, such as
106
+ * `/organisations/${id}`. SvelteKit 3's `redirect` rejects an external
107
+ * location unless it is passed `{ external }`, and `formAction` never passes
108
+ * it, so a `locationFor` that returns an absolute URL on another origin (or a
109
+ * protocol-relative `//host/...`) makes the action throw an `Error` instead of
110
+ * redirecting, after the command has already run. To send the user off-site,
111
+ * redirect to a same-origin route that performs the external redirect itself.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * // src/routes/organisations/create/+page.server.ts
116
+ * import { formAction } from "@thomasfosterau/effect-forms-svelte/kit";
117
+ * import { organisationForm } from "#lib/forms";
118
+ * import { api } from "#lib/server/api";
119
+ *
120
+ * export const actions = {
121
+ * default: formAction(
122
+ * organisationForm,
123
+ * (input, { locals }) => api(locals).organisations.create(input),
124
+ * (id) => `/organisations/${id}`,
125
+ * ),
126
+ * };
127
+ * ```
128
+ */
129
+ export declare function formAction<S extends Schema.Top, O, Message = unknown>(form: Form.Form<S, Message>, run: ActionCommand<S["Type"], O>, locationFor: (output: O, input: S["Type"]) => string, options?: FormActionOptions<S["Type"], O>): (event: RequestEvent) => Promise<FormActionData<S["Type"], unknown> | import("@sveltejs/kit").ActionFailure<FormActionData<S["Type"], unknown>>>;
130
+ //#endregion
131
+ export { Command$1 as Command };
132
+ //# sourceMappingURL=FormAction.d.ts.map
@@ -0,0 +1,170 @@
1
+ import { DEFAULT_MESSAGE, detailOf, jsonapiFailure } from "./CommandFailure.js";
2
+ import { fieldFromPointer } from "./Pointer.js";
3
+ import { Result, State } from "@thomasfosterau/effect-forms";
4
+ import * as Match from "effect/Match";
5
+ import * as Predicate from "effect/Predicate";
6
+ import * as Option from "effect/Option";
7
+ import * as Effect from "effect/Effect";
8
+ import { fail, redirect } from "@sveltejs/kit";
9
+ import * as Cause from "effect/Cause";
10
+ import * as Exit from "effect/Exit";
11
+ //#region src/kit/FormAction.ts
12
+ /**
13
+ * Derive a SvelteKit page form action from an `@thomasfosterau/effect-forms`
14
+ * form plus the command it submits to.
15
+ *
16
+ * {@link runFormAction} is the framework-neutral seam — validate the request
17
+ * against the form's schema (every field error collected, posted values
18
+ * retained), run the command on a valid submission, and describe the outcome
19
+ * as a `Result.FormActionResult`. {@link formAction} maps that onto
20
+ * SvelteKit's action protocol (`redirect()` / `fail()`), which the client
21
+ * store applies back via `applyActionResult` / {@link enhanceForm}.
22
+ *
23
+ * A command is either a Promise-returning function (an API client call) or
24
+ * an `Effect` with no requirements; a failure on either is classified by
25
+ * {@link CommandFailure.jsonapiFailure} (or a caller's own classifier) and
26
+ * routed onto the form's fields by each JSON:API error's `source.pointer`.
27
+ *
28
+ * @copyright Thomas Foster
29
+ * @module effect-forms-svelte/kit/FormAction
30
+ */
31
+ const toEffect = (thunk) => Effect.suspend(() => {
32
+ const out = thunk();
33
+ return Effect.isEffect(out) ? out : Effect.tryPromise({
34
+ try: () => out,
35
+ catch: (cause) => cause
36
+ });
37
+ });
38
+ const isConflict = (cause) => Predicate.isObjectOrArray(cause) && Predicate.hasProperty(cause, "status") && cause.status === 409 && (Predicate.isTagged(cause, "JsonapiError") || Predicate.isTagged(cause, "ErrorResponse"));
39
+ const submittedId = (input) => Predicate.isObjectOrArray(input) && Predicate.hasProperty(input, "id") && Predicate.isString(input.id) && input.id !== "" ? Option.some(input.id) : Option.none();
40
+ const retryOwnConflict = (create, fetchExisting, input) => toEffect(() => create(input)).pipe(Effect.catch((cause) => Option.match(isConflict(cause) ? submittedId(input) : Option.none(), {
41
+ onNone: () => Effect.fail(cause),
42
+ onSome: (id) => toEffect(() => fetchExisting(id)).pipe(Effect.mapError(() => cause))
43
+ })));
44
+ /**
45
+ * Wrap a client-id create command so a 409 conflict on the caller's OWN
46
+ * submitted `id` resolves as success instead of a form error — the
47
+ * double-submit of a create whose id the client generated.
48
+ *
49
+ * On any 409 (a `JsonapiError` / `ErrorResponse`-tagged cause), re-fetches
50
+ * `input.id` via `fetchExisting` and returns that. This is deliberately
51
+ * unconditional on *why* the 409 fired: the re-fetch is the proof, not the
52
+ * status. A 409 from an unrelated precondition never left a row at
53
+ * `input.id`, so the re-fetch fails and the original conflict rethrows
54
+ * unchanged. An input with no `id`, or any other failure, rethrows as is.
55
+ *
56
+ * `input.id` is read structurally rather than through a constraint on
57
+ * `Input`: a constraint here would be used only contravariantly (as
58
+ * `create`'s parameter), which makes TypeScript infer `Input` from the
59
+ * constraint instead of from the command's own input type.
60
+ */
61
+ function idempotentCreate(create, fetchExisting) {
62
+ return async (input) => {
63
+ const exit = await Effect.runPromiseExit(retryOwnConflict(create, fetchExisting, input));
64
+ if (Exit.isSuccess(exit)) return exit.value;
65
+ throw Cause.squash(exit.cause);
66
+ };
67
+ }
68
+ /** The field path an error object's pointer routes to, or `[]` (the root). */
69
+ const fieldPath = (error) => Option.toArray(fieldFromPointer(error.source !== void 0 && "pointer" in error.source ? error.source.pointer : void 0));
70
+ /**
71
+ * Route a recognised command failure onto the form state. A root issue lands
72
+ * at the empty path, which the nested error tree exposes as `_errors` — the
73
+ * form-level message surface (`form.errors._errors`).
74
+ */
75
+ const routeFailure = (state, failure) => Predicate.isTagged(failure, "Routed") ? failure.errors.reduce((acc, error) => State.withError(acc, fieldPath(error), detailOf(error)), state) : State.withError(state, [], failure.message);
76
+ /**
77
+ * Validate `request` against `form`, then:
78
+ *
79
+ * - invalid → a `Failure` carrying the form state (nested errors + posted
80
+ * values), status 400, and the command never runs;
81
+ * - valid → run the command (wrapped in the idempotent retry when
82
+ * `options.idempotent.clientId` is true); on success a `Redirect` (303) to
83
+ * `locationFor(output, input)`;
84
+ * - a recognised command failure → a `Failure` at the failure's own status,
85
+ * each JSON:API error routed onto its field (or the root);
86
+ * - an unrecognised one → an `Error` result at 500 (or a rethrow, per
87
+ * `options.unrecognised`).
88
+ *
89
+ * Posted values survive every failure, so the form re-renders what was typed.
90
+ *
91
+ * `locationFor` returns the redirect target as-is: `runFormAction` does not
92
+ * check it. {@link formAction} hands it to SvelteKit's `redirect`, which
93
+ * accepts only a same-origin location (see there).
94
+ */
95
+ async function runFormAction(form, request, run, locationFor, options = {}) {
96
+ const redact = options.redact ?? ((state) => state);
97
+ const classify = options.classify ?? jsonapiFailure;
98
+ const validate = form.validate(request);
99
+ const program = Effect.flatMap(validate, (state) => {
100
+ if (!state.valid) return Effect.succeed(Result.failure(redact(state), 400));
101
+ const idempotent = options.idempotent;
102
+ const command = idempotent?.clientId === true ? retryOwnConflict(run, idempotent.fetchExisting, state.data) : toEffect(() => run(state.data));
103
+ return Effect.matchCauseEffect(command, {
104
+ onSuccess: (output) => Effect.sync(() => Result.redirect(locationFor(output, state.data), 303)),
105
+ onFailure: (commandCause) => Effect.suspend(() => {
106
+ const cause = Cause.squash(commandCause);
107
+ return Option.match(classify(cause), {
108
+ onSome: (failure) => Effect.sync(() => Result.failure(redact(routeFailure(state, failure)), failure.status)),
109
+ onNone: () => options.unrecognised === "throw" ? Effect.fail(cause) : Effect.succeed(Result.errorResult(Predicate.isError(cause) ? cause.message : DEFAULT_MESSAGE, 500))
110
+ });
111
+ })
112
+ });
113
+ });
114
+ const exit = await Effect.runPromiseExit(program);
115
+ if (Exit.isSuccess(exit)) return exit.value;
116
+ throw Cause.squash(exit.cause);
117
+ }
118
+ /**
119
+ * Derive a SvelteKit page form **action** from `form` plus its command. The
120
+ * handler validates the submission, runs the command on success, and maps
121
+ * the outcome onto SvelteKit's action protocol:
122
+ *
123
+ * - success → `redirect(303, locationFor(output, input))`;
124
+ * - an invalid submission or a recognised command failure →
125
+ * `fail(status, { form })`, carrying the (redacted) state;
126
+ * - an unrecognised failure → `fail(500, { message })` (or a rethrow).
127
+ *
128
+ * Wire it into `export const actions`; the client `createForm` store applies
129
+ * the returned `form` via {@link enhanceForm} (or `applyActionResult`).
130
+ *
131
+ * `locationFor` must return a same-origin path, such as
132
+ * `/organisations/${id}`. SvelteKit 3's `redirect` rejects an external
133
+ * location unless it is passed `{ external }`, and `formAction` never passes
134
+ * it, so a `locationFor` that returns an absolute URL on another origin (or a
135
+ * protocol-relative `//host/...`) makes the action throw an `Error` instead of
136
+ * redirecting, after the command has already run. To send the user off-site,
137
+ * redirect to a same-origin route that performs the external redirect itself.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * // src/routes/organisations/create/+page.server.ts
142
+ * import { formAction } from "@thomasfosterau/effect-forms-svelte/kit";
143
+ * import { organisationForm } from "#lib/forms";
144
+ * import { api } from "#lib/server/api";
145
+ *
146
+ * export const actions = {
147
+ * default: formAction(
148
+ * organisationForm,
149
+ * (input, { locals }) => api(locals).organisations.create(input),
150
+ * (id) => `/organisations/${id}`,
151
+ * ),
152
+ * };
153
+ * ```
154
+ */
155
+ function formAction(form, run, locationFor, options) {
156
+ return async (event) => {
157
+ const result = await runFormAction(form, event.request, (input) => run(input, event), locationFor, options);
158
+ const data = (d) => d;
159
+ return Match.valueTags(result, {
160
+ Redirect: (redirectResult) => redirect(redirectResult.status, redirectResult.location),
161
+ Failure: (failure) => fail(failure.status, data({ form: failure.state })),
162
+ Error: (error) => fail(error.status, data({ message: error.message })),
163
+ Success: (success) => data({ form: success.state })
164
+ });
165
+ };
166
+ }
167
+ //#endregion
168
+ export { formAction, idempotentCreate, runFormAction };
169
+
170
+ //# sourceMappingURL=FormAction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormAction.js","names":[],"sources":["../../src/kit/FormAction.ts"],"sourcesContent":["/**\n * Derive a SvelteKit page form action from an `@thomasfosterau/effect-forms`\n * form plus the command it submits to.\n *\n * {@link runFormAction} is the framework-neutral seam — validate the request\n * against the form's schema (every field error collected, posted values\n * retained), run the command on a valid submission, and describe the outcome\n * as a `Result.FormActionResult`. {@link formAction} maps that onto\n * SvelteKit's action protocol (`redirect()` / `fail()`), which the client\n * store applies back via `applyActionResult` / {@link enhanceForm}.\n *\n * A command is either a Promise-returning function (an API client call) or\n * an `Effect` with no requirements; a failure on either is classified by\n * {@link CommandFailure.jsonapiFailure} (or a caller's own classifier) and\n * routed onto the form's fields by each JSON:API error's `source.pointer`.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/FormAction\n */\n\nimport { fail, redirect, type RequestEvent } from \"@sveltejs/kit\";\nimport * as Cause from \"effect/Cause\";\nimport * as Effect from \"effect/Effect\";\nimport * as Exit from \"effect/Exit\";\nimport * as Match from \"effect/Match\";\nimport * as Option from \"effect/Option\";\nimport * as Predicate from \"effect/Predicate\";\nimport type * as Schema from \"effect/Schema\";\nimport { type Form, Result, State } from \"@thomasfosterau/effect-forms\";\nimport {\n type Classify,\n type CommandFailure,\n DEFAULT_MESSAGE,\n detailOf,\n type ErrorObject,\n jsonapiFailure,\n} from \"./CommandFailure.ts\";\nimport { fieldFromPointer } from \"./Pointer.ts\";\n\ntype FormState<A, Message = unknown> = State.FormState<A, Message>;\ntype FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;\ntype FormActionData<A = unknown, Message = unknown> = Result.FormActionData<A, Message>;\n\n/**\n * A command: a Promise-returning function or one returning an `Effect` with\n * no requirements (provide them first, e.g. through a `ManagedRuntime`).\n */\nexport type Command<I, O> = (input: I) => Promise<O> | Effect.Effect<O, unknown>;\n\nconst toEffect = <O>(\n thunk: () => Promise<O> | Effect.Effect<O, unknown>,\n): Effect.Effect<O, unknown> =>\n Effect.suspend(() => {\n const out = thunk();\n return Effect.isEffect(out)\n ? out\n : Effect.tryPromise({ try: () => out, catch: (cause) => cause });\n });\n\n// ── Idempotent client-id create retry ──────────────────────────────────\n\nconst isConflict = (cause: unknown): boolean =>\n Predicate.isObjectOrArray(cause) &&\n Predicate.hasProperty(cause, \"status\") &&\n cause.status === 409 &&\n (Predicate.isTagged(cause, \"JsonapiError\") || Predicate.isTagged(cause, \"ErrorResponse\"));\n\nconst submittedId = (input: unknown): Option.Option<string> =>\n Predicate.isObjectOrArray(input) &&\n Predicate.hasProperty(input, \"id\") &&\n Predicate.isString(input.id) &&\n input.id !== \"\"\n ? Option.some(input.id)\n : Option.none();\n\nconst retryOwnConflict = <I, O>(\n create: Command<I, O>,\n fetchExisting: (id: string) => Promise<O> | Effect.Effect<O, unknown>,\n input: I,\n): Effect.Effect<O, unknown> =>\n toEffect(() => create(input)).pipe(\n Effect.catch((cause) =>\n Option.match(isConflict(cause) ? submittedId(input) : Option.none(), {\n onNone: () => Effect.fail(cause),\n // The re-fetch failing rethrows the ORIGINAL conflict, not its own error.\n onSome: (id) => toEffect(() => fetchExisting(id)).pipe(Effect.mapError(() => cause)),\n }),\n ),\n );\n\n/**\n * Wrap a client-id create command so a 409 conflict on the caller's OWN\n * submitted `id` resolves as success instead of a form error — the\n * double-submit of a create whose id the client generated.\n *\n * On any 409 (a `JsonapiError` / `ErrorResponse`-tagged cause), re-fetches\n * `input.id` via `fetchExisting` and returns that. This is deliberately\n * unconditional on *why* the 409 fired: the re-fetch is the proof, not the\n * status. A 409 from an unrelated precondition never left a row at\n * `input.id`, so the re-fetch fails and the original conflict rethrows\n * unchanged. An input with no `id`, or any other failure, rethrows as is.\n *\n * `input.id` is read structurally rather than through a constraint on\n * `Input`: a constraint here would be used only contravariantly (as\n * `create`'s parameter), which makes TypeScript infer `Input` from the\n * constraint instead of from the command's own input type.\n */\nexport function idempotentCreate<Input, Output>(\n create: (input: Input) => Promise<Output>,\n fetchExisting: (id: string) => Promise<Output>,\n): (input: Input) => Promise<Output> {\n return async (input) => {\n const exit = await Effect.runPromiseExit(retryOwnConflict(create, fetchExisting, input));\n if (Exit.isSuccess(exit)) return exit.value;\n throw Cause.squash(exit.cause);\n };\n}\n\n/**\n * Idempotent-retry wiring for a client-id create action: `clientId` gates the\n * wrap to commands whose input actually carries a client-generated `id` (an\n * entity form definition's own `clientId`, from `defineEntityForm`);\n * `fetchExisting` re-fetches the submitted id (see {@link idempotentCreate}).\n */\nexport interface IdempotentCreateOptions<O> {\n readonly clientId: boolean;\n readonly fetchExisting: (id: string) => Promise<O> | Effect.Effect<O, unknown>;\n}\n\n/** Options for {@link runFormAction} and {@link formAction}. */\nexport interface FormActionOptions<A, O> {\n /** Retry a client-id create's own 409 as success (see {@link IdempotentCreateOptions}). */\n readonly idempotent?: IdempotentCreateOptions<O> | undefined;\n /**\n * How a command failure is recognised. Defaults to\n * {@link CommandFailure.jsonapiFailure}; compose with it to add an\n * application's own failures.\n */\n readonly classify?: Classify | undefined;\n /**\n * Applied to every form state echoed back to the browser (an invalid\n * submission, a routed command failure) — e.g. blanking password fields\n * so a secret never rides a response body.\n */\n readonly redact?: ((state: FormState<A>) => FormState<A>) | undefined;\n /**\n * What happens to a command failure the classifier does not recognise:\n * `\"error\"` (the default) answers an `Error` result at 500 carrying the\n * cause's message; `\"throw\"` rethrows the cause, leaving it to SvelteKit's\n * `handleError`.\n */\n readonly unrecognised?: \"error\" | \"throw\" | undefined;\n}\n\n/** The field path an error object's pointer routes to, or `[]` (the root). */\nconst fieldPath = (error: ErrorObject): ReadonlyArray<string> =>\n Option.toArray(\n fieldFromPointer(\n error.source !== undefined && \"pointer\" in error.source ? error.source.pointer : undefined,\n ),\n );\n\n/**\n * Route a recognised command failure onto the form state. A root issue lands\n * at the empty path, which the nested error tree exposes as `_errors` — the\n * form-level message surface (`form.errors._errors`).\n */\nconst routeFailure = <A>(state: FormState<A>, failure: CommandFailure): FormState<A> =>\n Predicate.isTagged(failure, \"Routed\")\n ? failure.errors.reduce(\n (acc, error) => State.withError(acc, fieldPath(error), detailOf(error)),\n state,\n )\n : State.withError(state, [], failure.message);\n\n/**\n * Validate `request` against `form`, then:\n *\n * - invalid → a `Failure` carrying the form state (nested errors + posted\n * values), status 400, and the command never runs;\n * - valid → run the command (wrapped in the idempotent retry when\n * `options.idempotent.clientId` is true); on success a `Redirect` (303) to\n * `locationFor(output, input)`;\n * - a recognised command failure → a `Failure` at the failure's own status,\n * each JSON:API error routed onto its field (or the root);\n * - an unrecognised one → an `Error` result at 500 (or a rethrow, per\n * `options.unrecognised`).\n *\n * Posted values survive every failure, so the form re-renders what was typed.\n *\n * `locationFor` returns the redirect target as-is: `runFormAction` does not\n * check it. {@link formAction} hands it to SvelteKit's `redirect`, which\n * accepts only a same-origin location (see there).\n */\nexport async function runFormAction<S extends Schema.Top, O, Message = unknown>(\n form: Form.Form<S, Message>,\n request: Request,\n run: Command<S[\"Type\"], O>,\n locationFor: (output: O, input: S[\"Type\"]) => string,\n options: FormActionOptions<S[\"Type\"], O> = {},\n): Promise<FormActionResult<S[\"Type\"]>> {\n const redact = options.redact ?? ((state: FormState<S[\"Type\"]>) => state);\n const classify = options.classify ?? jsonapiFailure;\n // SAFETY: a posted form needs no decoding services at runtime; schemas\n // projected from elsewhere can still carry an unreduced `DecodingServices`\n // type, so only the requirements channel is narrowed here.\n const validate = form.validate(request) as Effect.Effect<FormState<S[\"Type\"], Message>>;\n const program = Effect.flatMap(\n validate,\n (state): Effect.Effect<FormActionResult<S[\"Type\"]>, unknown> => {\n if (!state.valid) return Effect.succeed(Result.failure(redact(state), 400));\n const idempotent = options.idempotent;\n const command =\n idempotent?.clientId === true\n ? retryOwnConflict(run, idempotent.fetchExisting, state.data)\n : toEffect(() => run(state.data));\n return Effect.matchCauseEffect(command, {\n onSuccess: (output) =>\n Effect.sync(() => Result.redirect(locationFor(output, state.data), 303)),\n onFailure: (commandCause) =>\n Effect.suspend(() => {\n const cause = Cause.squash(commandCause);\n return Option.match(classify(cause), {\n onSome: (failure) =>\n Effect.sync(() =>\n Result.failure(redact(routeFailure(state, failure)), failure.status),\n ),\n onNone: () =>\n options.unrecognised === \"throw\"\n ? Effect.fail(cause)\n : Effect.succeed(\n Result.errorResult(\n Predicate.isError(cause) ? cause.message : DEFAULT_MESSAGE,\n 500,\n ),\n ),\n });\n }),\n });\n },\n );\n // The Promise boundary: an unrecognised failure under `\"throw\"` (or a defect\n // anywhere above) rejects with the squashed cause, as a thrown value would.\n const exit = await Effect.runPromiseExit(program);\n if (Exit.isSuccess(exit)) return exit.value;\n throw Cause.squash(exit.cause);\n}\n\n/**\n * A command for {@link formAction}: like {@link Command}, and also handed the\n * `RequestEvent` (for `locals`, `cookies`, `fetch`).\n */\nexport type ActionCommand<I, O> = (\n input: I,\n event: RequestEvent,\n) => Promise<O> | Effect.Effect<O, unknown>;\n\n/**\n * Derive a SvelteKit page form **action** from `form` plus its command. The\n * handler validates the submission, runs the command on success, and maps\n * the outcome onto SvelteKit's action protocol:\n *\n * - success → `redirect(303, locationFor(output, input))`;\n * - an invalid submission or a recognised command failure →\n * `fail(status, { form })`, carrying the (redacted) state;\n * - an unrecognised failure → `fail(500, { message })` (or a rethrow).\n *\n * Wire it into `export const actions`; the client `createForm` store applies\n * the returned `form` via {@link enhanceForm} (or `applyActionResult`).\n *\n * `locationFor` must return a same-origin path, such as\n * `/organisations/${id}`. SvelteKit 3's `redirect` rejects an external\n * location unless it is passed `{ external }`, and `formAction` never passes\n * it, so a `locationFor` that returns an absolute URL on another origin (or a\n * protocol-relative `//host/...`) makes the action throw an `Error` instead of\n * redirecting, after the command has already run. To send the user off-site,\n * redirect to a same-origin route that performs the external redirect itself.\n *\n * @example\n * ```ts\n * // src/routes/organisations/create/+page.server.ts\n * import { formAction } from \"@thomasfosterau/effect-forms-svelte/kit\";\n * import { organisationForm } from \"#lib/forms\";\n * import { api } from \"#lib/server/api\";\n *\n * export const actions = {\n * default: formAction(\n * organisationForm,\n * (input, { locals }) => api(locals).organisations.create(input),\n * (id) => `/organisations/${id}`,\n * ),\n * };\n * ```\n */\nexport function formAction<S extends Schema.Top, O, Message = unknown>(\n form: Form.Form<S, Message>,\n run: ActionCommand<S[\"Type\"], O>,\n locationFor: (output: O, input: S[\"Type\"]) => string,\n options?: FormActionOptions<S[\"Type\"], O>,\n) {\n return async (event: RequestEvent) => {\n const result = await runFormAction(\n form,\n event.request,\n (input) => run(input, event),\n locationFor,\n options,\n );\n const data = (d: FormActionData<S[\"Type\"]>): FormActionData<S[\"Type\"]> => d;\n return Match.valueTags(result, {\n Redirect: (redirectResult) => redirect(redirectResult.status, redirectResult.location),\n Failure: (failure) => fail(failure.status, data({ form: failure.state })),\n Error: (error) => fail(error.status, data({ message: error.message })),\n // `runFormAction` answers success with a redirect; a success result\n // carrying state is echoed back as-is.\n Success: (success) => data({ form: success.state }),\n });\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ //#region src/kit/FormStateReaders.d.ts
2
+ /**
3
+ * Total readers over the form state a failed form action hands back on
4
+ * `PageProps["form"]`.
5
+ *
6
+ * {@link formAction} answers an invalid submission with
7
+ * `fail(status, { form: state })`, where `state` carries the posted values
8
+ * and a nested error tree. A server-rendered page (no client store) needs
9
+ * three things per field off a value SvelteKit types as `ActionData | null`:
10
+ * what the user typed, what was wrong with it, and what was wrong with the
11
+ * form as a whole.
12
+ *
13
+ * Every reader is deliberately **total**: it answers sensibly for a first
14
+ * render (`form` is `null`), for a successful submission, and for a shape
15
+ * that is not a form state at all. A create page that threw on
16
+ * `form === null` would fail on the one render it does most.
17
+ *
18
+ * `undefined` rather than `Option` for "no submission": these are called from
19
+ * Svelte templates, where `Option` has no rendering.
20
+ *
21
+ * @copyright Thomas Foster
22
+ * @module effect-forms-svelte/kit/FormStateReaders
23
+ */
24
+ /**
25
+ * The subset of `State.FormState` the readers touch: the posted values and
26
+ * the nested error tree. Structural rather than imported so a page can hand
27
+ * in its raw `ActionData` without a cast.
28
+ */
29
+ export interface ActionFormState {
30
+ readonly data?: Readonly<Record<string, unknown>>;
31
+ readonly errors?: Readonly<Record<string, unknown>>;
32
+ }
33
+ /**
34
+ * The carried form state, or `undefined` when there is none — a first
35
+ * render, a successful submission (which redirects), or an action result
36
+ * that carried a bare message instead.
37
+ */
38
+ export declare const formStateOf: (form: unknown) => ActionFormState | undefined;
39
+ /** Messages attached to a named field, or none. */
40
+ export declare const issuesFor: (state: ActionFormState | undefined, field: string) => ReadonlyArray<string>;
41
+ /**
42
+ * Whole-form messages — the ones with no field to attach to (a 409, a
43
+ * rejected command). {@link runFormAction} routes them to the error tree's
44
+ * empty path, which the nested tree exposes as `_errors`.
45
+ */
46
+ export declare const rootIssuesOf: (state: ActionFormState | undefined) => ReadonlyArray<string>;
47
+ /**
48
+ * What to render in a field: the value the user posted if this is a failed
49
+ * submission, else `fallback` (the row's stored value on an edit form, or
50
+ * the empty string on a create form). A posted blank stays blank.
51
+ *
52
+ * Non-string posted values (a checkbox's boolean, a number) render through
53
+ * `String(...)` rather than being dropped: the point of repopulation is that
54
+ * the user sees what they sent.
55
+ */
56
+ export declare const valueOf: (state: ActionFormState | undefined, field: string, fallback?: string) => string;
57
+ //#endregion
58
+ //# sourceMappingURL=FormStateReaders.d.ts.map
@@ -0,0 +1,64 @@
1
+ import * as Predicate from "effect/Predicate";
2
+ //#region src/kit/FormStateReaders.ts
3
+ /**
4
+ * Total readers over the form state a failed form action hands back on
5
+ * `PageProps["form"]`.
6
+ *
7
+ * {@link formAction} answers an invalid submission with
8
+ * `fail(status, { form: state })`, where `state` carries the posted values
9
+ * and a nested error tree. A server-rendered page (no client store) needs
10
+ * three things per field off a value SvelteKit types as `ActionData | null`:
11
+ * what the user typed, what was wrong with it, and what was wrong with the
12
+ * form as a whole.
13
+ *
14
+ * Every reader is deliberately **total**: it answers sensibly for a first
15
+ * render (`form` is `null`), for a successful submission, and for a shape
16
+ * that is not a form state at all. A create page that threw on
17
+ * `form === null` would fail on the one render it does most.
18
+ *
19
+ * `undefined` rather than `Option` for "no submission": these are called from
20
+ * Svelte templates, where `Option` has no rendering.
21
+ *
22
+ * @copyright Thomas Foster
23
+ * @module effect-forms-svelte/kit/FormStateReaders
24
+ */
25
+ /**
26
+ * The carried form state, or `undefined` when there is none — a first
27
+ * render, a successful submission (which redirects), or an action result
28
+ * that carried a bare message instead.
29
+ */
30
+ const formStateOf = (form) => {
31
+ if (!Predicate.isObjectOrArray(form) || !Predicate.hasProperty(form, "form")) return void 0;
32
+ const state = form.form;
33
+ if (!Predicate.isObjectOrArray(state)) return void 0;
34
+ return state;
35
+ };
36
+ /** Messages attached to a named field, or none. */
37
+ const issuesFor = (state, field) => {
38
+ const issues = state?.errors?.[field];
39
+ return Array.isArray(issues) ? issues : [];
40
+ };
41
+ /**
42
+ * Whole-form messages — the ones with no field to attach to (a 409, a
43
+ * rejected command). {@link runFormAction} routes them to the error tree's
44
+ * empty path, which the nested tree exposes as `_errors`.
45
+ */
46
+ const rootIssuesOf = (state) => issuesFor(state, "_errors");
47
+ /**
48
+ * What to render in a field: the value the user posted if this is a failed
49
+ * submission, else `fallback` (the row's stored value on an edit form, or
50
+ * the empty string on a create form). A posted blank stays blank.
51
+ *
52
+ * Non-string posted values (a checkbox's boolean, a number) render through
53
+ * `String(...)` rather than being dropped: the point of repopulation is that
54
+ * the user sees what they sent.
55
+ */
56
+ const valueOf = (state, field, fallback = "") => {
57
+ const value = state?.data?.[field];
58
+ if (value === void 0 || value === null) return fallback;
59
+ return Predicate.isString(value) ? value : String(value);
60
+ };
61
+ //#endregion
62
+ export { formStateOf, issuesFor, rootIssuesOf, valueOf };
63
+
64
+ //# sourceMappingURL=FormStateReaders.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormStateReaders.js","names":[],"sources":["../../src/kit/FormStateReaders.ts"],"sourcesContent":["/**\n * Total readers over the form state a failed form action hands back on\n * `PageProps[\"form\"]`.\n *\n * {@link formAction} answers an invalid submission with\n * `fail(status, { form: state })`, where `state` carries the posted values\n * and a nested error tree. A server-rendered page (no client store) needs\n * three things per field off a value SvelteKit types as `ActionData | null`:\n * what the user typed, what was wrong with it, and what was wrong with the\n * form as a whole.\n *\n * Every reader is deliberately **total**: it answers sensibly for a first\n * render (`form` is `null`), for a successful submission, and for a shape\n * that is not a form state at all. A create page that threw on\n * `form === null` would fail on the one render it does most.\n *\n * `undefined` rather than `Option` for \"no submission\": these are called from\n * Svelte templates, where `Option` has no rendering.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/FormStateReaders\n */\n\nimport * as Predicate from \"effect/Predicate\";\n\n/**\n * The subset of `State.FormState` the readers touch: the posted values and\n * the nested error tree. Structural rather than imported so a page can hand\n * in its raw `ActionData` without a cast.\n */\nexport interface ActionFormState {\n readonly data?: Readonly<Record<string, unknown>>;\n readonly errors?: Readonly<Record<string, unknown>>;\n}\n\n/**\n * The carried form state, or `undefined` when there is none — a first\n * render, a successful submission (which redirects), or an action result\n * that carried a bare message instead.\n */\nexport const formStateOf = (form: unknown): ActionFormState | undefined => {\n if (!Predicate.isObjectOrArray(form) || !Predicate.hasProperty(form, \"form\")) return undefined;\n const state = form.form;\n if (!Predicate.isObjectOrArray(state)) return undefined;\n // SAFETY: `ActionFormState` is all-optional and every reader below reaches\n // `data` / `errors` only through optional chaining and re-checks the value it\n // finds (`Array.isArray`, `isString`), so any object answers totally.\n return state as ActionFormState;\n};\n\n/** Messages attached to a named field, or none. */\nexport const issuesFor = (\n state: ActionFormState | undefined,\n field: string,\n): ReadonlyArray<string> => {\n const issues = state?.errors?.[field];\n // SAFETY: the error tree `formAction` hands back holds the form's messages\n // at each field, and these readers serve pages whose messages are strings.\n return Array.isArray(issues) ? (issues as ReadonlyArray<string>) : [];\n};\n\n/**\n * Whole-form messages — the ones with no field to attach to (a 409, a\n * rejected command). {@link runFormAction} routes them to the error tree's\n * empty path, which the nested tree exposes as `_errors`.\n */\nexport const rootIssuesOf = (state: ActionFormState | undefined): ReadonlyArray<string> =>\n issuesFor(state, \"_errors\");\n\n/**\n * What to render in a field: the value the user posted if this is a failed\n * submission, else `fallback` (the row's stored value on an edit form, or\n * the empty string on a create form). A posted blank stays blank.\n *\n * Non-string posted values (a checkbox's boolean, a number) render through\n * `String(...)` rather than being dropped: the point of repopulation is that\n * the user sees what they sent.\n */\nexport const valueOf = (\n state: ActionFormState | undefined,\n field: string,\n fallback = \"\",\n): string => {\n const value = state?.data?.[field];\n if (value === undefined || value === null) return fallback;\n // oxlint-disable-next-line typescript/no-base-to-string -- posted values are form scalars (a checkbox's boolean, a number); rendering them is the point\n return Predicate.isString(value) ? value : String(value);\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,MAAa,eAAe,SAA+C;CACzE,IAAI,CAAC,UAAU,gBAAgB,IAAI,KAAK,CAAC,UAAU,YAAY,MAAM,MAAM,GAAG,OAAO,KAAA;CACrF,MAAM,QAAQ,KAAK;CACnB,IAAI,CAAC,UAAU,gBAAgB,KAAK,GAAG,OAAO,KAAA;CAI9C,OAAO;AACT;;AAGA,MAAa,aACX,OACA,UAC0B;CAC1B,MAAM,SAAS,OAAO,SAAS;CAG/B,OAAO,MAAM,QAAQ,MAAM,IAAK,SAAmC,CAAC;AACtE;;;;;;AAOA,MAAa,gBAAgB,UAC3B,UAAU,OAAO,SAAS;;;;;;;;;;AAW5B,MAAa,WACX,OACA,OACA,WAAW,OACA;CACX,MAAM,QAAQ,OAAO,OAAO;CAC5B,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO;CAElD,OAAO,UAAU,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK;AACzD"}
@@ -0,0 +1,23 @@
1
+ import * as Option from "effect/Option";
2
+ //#region src/kit/Pointer.d.ts
3
+ /**
4
+ * The field a JSON Pointer of the form `/data/attributes/<field>…` names.
5
+ * Deeper segments are ignored: the form issue layer keys by the top-level
6
+ * field name.
7
+ *
8
+ * `None` covers every non-field pointer, including an absent one, a pointer
9
+ * outside `/data/attributes`, and an empty field segment
10
+ * (`/data/attributes/`), so a caller can read the result as a plain `Option`
11
+ * with no extra `""` test.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { fieldFromPointer } from "@thomasfosterau/effect-forms-svelte/kit";
16
+ *
17
+ * fieldFromPointer("/data/attributes/name"); // Option.some("name")
18
+ * fieldFromPointer("/data/relationships/author"); // Option.none()
19
+ * ```
20
+ */
21
+ export declare const fieldFromPointer: (pointer: string | undefined) => Option.Option<string>;
22
+ //#endregion
23
+ //# sourceMappingURL=Pointer.d.ts.map
@@ -0,0 +1,38 @@
1
+ import * as Option from "effect/Option";
2
+ //#region src/kit/Pointer.ts
3
+ /**
4
+ * JSON Pointer → form field routing for a JSON:API error object's
5
+ * `source.pointer`.
6
+ *
7
+ * @copyright Thomas Foster
8
+ * @module effect-forms-svelte/kit/Pointer
9
+ */
10
+ const unescapeSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
11
+ /**
12
+ * The field a JSON Pointer of the form `/data/attributes/<field>…` names.
13
+ * Deeper segments are ignored: the form issue layer keys by the top-level
14
+ * field name.
15
+ *
16
+ * `None` covers every non-field pointer, including an absent one, a pointer
17
+ * outside `/data/attributes`, and an empty field segment
18
+ * (`/data/attributes/`), so a caller can read the result as a plain `Option`
19
+ * with no extra `""` test.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { fieldFromPointer } from "@thomasfosterau/effect-forms-svelte/kit";
24
+ *
25
+ * fieldFromPointer("/data/attributes/name"); // Option.some("name")
26
+ * fieldFromPointer("/data/relationships/author"); // Option.none()
27
+ * ```
28
+ */
29
+ const fieldFromPointer = (pointer) => {
30
+ if (pointer === void 0 || pointer === "") return Option.none();
31
+ const [data, attributes, field] = pointer.split("/").slice(1).map(unescapeSegment);
32
+ if (data !== "data" || attributes !== "attributes") return Option.none();
33
+ return field === void 0 || field === "" ? Option.none() : Option.some(field);
34
+ };
35
+ //#endregion
36
+ export { fieldFromPointer };
37
+
38
+ //# sourceMappingURL=Pointer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pointer.js","names":[],"sources":["../../src/kit/Pointer.ts"],"sourcesContent":["/**\n * JSON Pointer → form field routing for a JSON:API error object's\n * `source.pointer`.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/Pointer\n */\n\nimport * as Option from \"effect/Option\";\n\n// RFC 6901 escapes: `~1` → `/`, `~0` → `~` (in that order).\nconst unescapeSegment = (segment: string): string =>\n segment.replace(/~1/g, \"/\").replace(/~0/g, \"~\");\n\n/**\n * The field a JSON Pointer of the form `/data/attributes/<field>…` names.\n * Deeper segments are ignored: the form issue layer keys by the top-level\n * field name.\n *\n * `None` covers every non-field pointer, including an absent one, a pointer\n * outside `/data/attributes`, and an empty field segment\n * (`/data/attributes/`), so a caller can read the result as a plain `Option`\n * with no extra `\"\"` test.\n *\n * @example\n * ```ts\n * import { fieldFromPointer } from \"@thomasfosterau/effect-forms-svelte/kit\";\n *\n * fieldFromPointer(\"/data/attributes/name\"); // Option.some(\"name\")\n * fieldFromPointer(\"/data/relationships/author\"); // Option.none()\n * ```\n */\nexport const fieldFromPointer = (\n // A JSON:API error object's `source?.pointer`, verbatim: absent is `undefined`.\n pointer: string | undefined,\n): Option.Option<string> => {\n if (pointer === undefined || pointer === \"\") return Option.none();\n const [data, attributes, field] = pointer.split(\"/\").slice(1).map(unescapeSegment);\n if (data !== \"data\" || attributes !== \"attributes\") return Option.none();\n return field === undefined || field === \"\" ? Option.none() : Option.some(field);\n};\n"],"mappings":";;;;;;;;;AAWA,MAAM,mBAAmB,YACvB,QAAQ,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,GAAG;;;;;;;;;;;;;;;;;;;AAoBhD,MAAa,oBAEX,YAC0B;CAC1B,IAAI,YAAY,KAAA,KAAa,YAAY,IAAI,OAAO,OAAO,KAAK;CAChE,MAAM,CAAC,MAAM,YAAY,SAAS,QAAQ,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,eAAe;CACjF,IAAI,SAAS,UAAU,eAAe,cAAc,OAAO,OAAO,KAAK;CACvE,OAAO,UAAU,KAAA,KAAa,UAAU,KAAK,OAAO,KAAK,IAAI,OAAO,KAAK,KAAK;AAChF"}
@@ -0,0 +1,7 @@
1
+ import { userFixableMessage, withFailureParams } from "./ActionRedirect.js";
2
+ import { CommandFailure_d_exports, jsonapiFailure } from "./CommandFailure.js";
3
+ import { EnhancedFormProps, enhanceForm } from "./Enhance.js";
4
+ import { ActionCommand, Command, FormActionOptions, IdempotentCreateOptions, formAction, idempotentCreate, runFormAction } from "./FormAction.js";
5
+ import { ActionFormState, formStateOf, issuesFor, rootIssuesOf, valueOf } from "./FormStateReaders.js";
6
+ import { fieldFromPointer } from "./Pointer.js";
7
+ export { ActionCommand, ActionFormState, Command, CommandFailure_d_exports as CommandFailure, EnhancedFormProps, FormActionOptions, IdempotentCreateOptions, enhanceForm, fieldFromPointer, formAction, formStateOf, idempotentCreate, issuesFor, jsonapiFailure, rootIssuesOf, runFormAction, userFixableMessage, valueOf, withFailureParams };
@@ -0,0 +1,7 @@
1
+ import { CommandFailure_exports, jsonapiFailure } from "./CommandFailure.js";
2
+ import { userFixableMessage, withFailureParams } from "./ActionRedirect.js";
3
+ import { enhanceForm } from "./Enhance.js";
4
+ import { fieldFromPointer } from "./Pointer.js";
5
+ import { formAction, idempotentCreate, runFormAction } from "./FormAction.js";
6
+ import { formStateOf, issuesFor, rootIssuesOf, valueOf } from "./FormStateReaders.js";
7
+ export { CommandFailure_exports as CommandFailure, enhanceForm, fieldFromPointer, formAction, formStateOf, idempotentCreate, issuesFor, jsonapiFailure, rootIssuesOf, runFormAction, userFixableMessage, valueOf, withFailureParams };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomasfosterau/effect-forms-svelte",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Svelte 5 (runes) bindings for @thomasfosterau/effect-forms — a reactive form store, an enhance action, tainted tracking, and client-side validation.",
5
5
  "keywords": [
6
6
  "effect",
@@ -12,7 +12,7 @@
12
12
  "sveltekit",
13
13
  "typescript"
14
14
  ],
15
- "homepage": "https://github.com/thomasfosterau/effect-forms/tree/main/packages/svelte#readme",
15
+ "homepage": "https://github.com/thomasfosterau/effect-forms/tree/trunk/packages/svelte#readme",
16
16
  "bugs": {
17
17
  "url": "https://github.com/thomasfosterau/effect-forms/issues"
18
18
  },
@@ -38,18 +38,37 @@
38
38
  "types": "./dist/index.d.ts",
39
39
  "svelte": "./dist/index.js",
40
40
  "import": "./dist/index.js"
41
+ },
42
+ "./kit": {
43
+ "types": "./dist/kit/index.d.ts",
44
+ "svelte": "./dist/kit/index.js",
45
+ "import": "./dist/kit/index.js"
41
46
  }
42
47
  },
43
48
  "publishConfig": {
44
49
  "access": "public"
45
50
  },
46
51
  "dependencies": {
47
- "@thomasfosterau/effect-forms": "^0.1.0"
52
+ "@thomasfosterau/effect-forms": "^0.3.1"
53
+ },
54
+ "devDependencies": {
55
+ "@sveltejs/kit": "3.1.0",
56
+ "@thomasfosterau/effect-jsonapi": "^0.17.0"
48
57
  },
49
58
  "peerDependencies": {
50
- "effect": "^4.0.0-beta.102",
59
+ "@sveltejs/kit": "^3.0.0",
60
+ "@thomasfosterau/effect-jsonapi": ">=0.16.0",
61
+ "effect": ">=4.0.0",
51
62
  "svelte": "^5.40.0"
52
63
  },
64
+ "peerDependenciesMeta": {
65
+ "@sveltejs/kit": {
66
+ "optional": true
67
+ },
68
+ "@thomasfosterau/effect-jsonapi": {
69
+ "optional": true
70
+ }
71
+ },
53
72
  "engines": {
54
73
  "node": "^20.19.0 || >=22.12.0"
55
74
  },