@thomasfosterau/effect-forms-svelte 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"Tainted.js","names":[],"sources":["../src/Tainted.ts"],"sourcesContent":["/**\n * Tainted-field tracking — which fields the user has changed since the form\n * was seeded.\n *\n * A `tainted` store lets a UI show \"unsaved changes\",\n * guard navigation, or submit only what changed. This is the pure half: a deep\n * diff of the current data against the original snapshot producing a nested\n * boolean tree mirroring the data's shape. The reactive store\n * (`superForm.svelte.ts`) re-derives it as the data changes.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/Tainted\n */\n\nimport type { Issues } from \"@thomasfosterau/effect-forms\";\n\ntype FormPath = Issues.FormPath;\n\n/** A nested tree mirroring the form data, `true` where a leaf has changed. */\nexport interface TaintedFields {\n readonly [key: string]: boolean | TaintedFields | undefined;\n}\n\nconst isObject = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null;\n\n/** Structural deep equality for plain JSON-ish values. */\nexport function deepEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (Array.isArray(a) && Array.isArray(b)) {\n return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));\n }\n if (isObject(a) && isObject(b)) {\n const keys = new Set([...Object.keys(a), ...Object.keys(b)]);\n for (const key of keys) {\n if (!deepEqual(a[key], b[key])) return false;\n }\n return true;\n }\n return false;\n}\n\n/** Whether two containers are the same kind (both arrays, or both plain objects). */\nconst sameKind = (a: unknown, b: unknown): boolean => Array.isArray(a) === Array.isArray(b);\n\n/**\n * Compare `current` against `original`, returning the nested tainted tree.\n * A changed scalar leaf is `true`; an unchanged leaf is omitted; nested objects\n * **and arrays** recurse (array elements keyed by index), so a single changed\n * row taints only that index — `taintedAt([\"items\", 2, \"name\"])` stays precise.\n * Keys present in either side are considered.\n */\nexport function computeTainted(original: unknown, current: unknown): TaintedFields {\n if (!isObject(original) || !isObject(current) || !sameKind(original, current)) return {};\n\n const out: Record<string, boolean | TaintedFields> = {};\n const keys = new Set([...Object.keys(original), ...Object.keys(current)]);\n for (const key of keys) {\n const before = original[key];\n const after = current[key];\n if (isObject(before) && isObject(after) && sameKind(before, after)) {\n const nested = computeTainted(before, after);\n if (Object.keys(nested).length > 0) out[key] = nested;\n } else if (!deepEqual(before, after)) {\n out[key] = true;\n }\n }\n return out;\n}\n\n/** Whether anything in the tree is tainted. */\nexport function isAnyTainted(tainted: TaintedFields): boolean {\n for (const key of Object.keys(tainted)) {\n const value = tainted[key];\n if (value === true) return true;\n if (isObject(value) && isAnyTainted(value)) return true;\n }\n return false;\n}\n\n/** Whether the field at `path` (or anything beneath it) is tainted. */\nexport function isTaintedAt(tainted: TaintedFields, path: FormPath): boolean {\n let node: boolean | TaintedFields | undefined = tainted;\n for (const segment of path) {\n if (node === undefined || typeof node === \"boolean\") return node === true;\n node = node[String(segment)];\n }\n if (node === undefined) return false;\n if (typeof node === \"boolean\") return node;\n return isAnyTainted(node);\n}\n\n/**\n * Flatten a {@link TaintedFields} tree into the list of leaf {@link FormPath}s\n * that actually changed, in depth-first order — the per-field-changed events\n * a reactive store needs to run per-field validators / clear a superseded\n * server error at exactly the field that moved (as opposed to `isAnyTainted`,\n * which only says *something* changed).\n */\nexport function taintedLeafPaths(tainted: TaintedFields): FormPath[] {\n const out: FormPath[] = [];\n\n const walk = (node: TaintedFields, path: Array<string | number>): void => {\n for (const key of Object.keys(node)) {\n const value = node[key];\n if (value === undefined) continue;\n const segment: string | number = /^\\d+$/.test(key) ? Number(key) : key;\n if (value === true) out.push([...path, segment]);\n else if (typeof value === \"object\") walk(value, [...path, segment]);\n }\n };\n\n walk(tainted, []);\n return out;\n}\n"],"mappings":";AAuBA,MAAM,YAAY,UAChB,OAAO,UAAU,YAAY,UAAU;;AAGzC,SAAgB,UAAU,GAAY,GAAqB;CACzD,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GACrC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,MAAM,MAAM,UAAU,MAAM,EAAE,EAAE,CAAC;CAE5E,IAAI,SAAS,CAAC,KAAK,SAAS,CAAC,GAAG;EAC9B,MAAM,uBAAO,IAAI,IAAI,CAAC,GAAG,OAAO,KAAK,CAAC,GAAG,GAAG,OAAO,KAAK,CAAC,CAAC,CAAC;EAC3D,KAAK,MAAM,OAAO,MAChB,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,GAAG,OAAO;EAEzC,OAAO;CACT;CACA,OAAO;AACT;;AAGA,MAAM,YAAY,GAAY,MAAwB,MAAM,QAAQ,CAAC,MAAM,MAAM,QAAQ,CAAC;;;;;;;;AAS1F,SAAgB,eAAe,UAAmB,SAAiC;CACjF,IAAI,CAAC,SAAS,QAAQ,KAAK,CAAC,SAAS,OAAO,KAAK,CAAC,SAAS,UAAU,OAAO,GAAG,OAAO,CAAC;CAEvF,MAAM,MAA+C,CAAC;CACtD,MAAM,uBAAO,IAAI,IAAI,CAAC,GAAG,OAAO,KAAK,QAAQ,GAAG,GAAG,OAAO,KAAK,OAAO,CAAC,CAAC;CACxE,KAAK,MAAM,OAAO,MAAM;EACtB,MAAM,SAAS,SAAS;EACxB,MAAM,QAAQ,QAAQ;EACtB,IAAI,SAAS,MAAM,KAAK,SAAS,KAAK,KAAK,SAAS,QAAQ,KAAK,GAAG;GAClE,MAAM,SAAS,eAAe,QAAQ,KAAK;GAC3C,IAAI,OAAO,KAAK,MAAM,CAAC,CAAC,SAAS,GAAG,IAAI,OAAO;EACjD,OAAO,IAAI,CAAC,UAAU,QAAQ,KAAK,GACjC,IAAI,OAAO;CAEf;CACA,OAAO;AACT;;AAGA,SAAgB,aAAa,SAAiC;CAC5D,KAAK,MAAM,OAAO,OAAO,KAAK,OAAO,GAAG;EACtC,MAAM,QAAQ,QAAQ;EACtB,IAAI,UAAU,MAAM,OAAO;EAC3B,IAAI,SAAS,KAAK,KAAK,aAAa,KAAK,GAAG,OAAO;CACrD;CACA,OAAO;AACT;;AAGA,SAAgB,YAAY,SAAwB,MAAyB;CAC3E,IAAI,OAA4C;CAChD,KAAK,MAAM,WAAW,MAAM;EAC1B,IAAI,SAAS,KAAA,KAAa,OAAO,SAAS,WAAW,OAAO,SAAS;EACrE,OAAO,KAAK,OAAO,OAAO;CAC5B;CACA,IAAI,SAAS,KAAA,GAAW,OAAO;CAC/B,IAAI,OAAO,SAAS,WAAW,OAAO;CACtC,OAAO,aAAa,IAAI;AAC1B;;;;;;;;AASA,SAAgB,iBAAiB,SAAoC;CACnE,MAAM,MAAkB,CAAC;CAEzB,MAAM,QAAQ,MAAqB,SAAuC;EACxE,KAAK,MAAM,OAAO,OAAO,KAAK,IAAI,GAAG;GACnC,MAAM,QAAQ,KAAK;GACnB,IAAI,UAAU,KAAA,GAAW;GACzB,MAAM,UAA2B,QAAQ,KAAK,GAAG,IAAI,OAAO,GAAG,IAAI;GACnE,IAAI,UAAU,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,OAAO,CAAC;QAC1C,IAAI,OAAO,UAAU,UAAU,KAAK,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC;EACpE;CACF;CAEA,KAAK,SAAS,CAAC,CAAC;CAChB,OAAO;AACT"}
1
+ {"version":3,"file":"Tainted.js","names":[],"sources":["../src/Tainted.ts"],"sourcesContent":["/**\n * Tainted-field tracking — which fields the user has changed since the form\n * was seeded.\n *\n * A `tainted` store lets a UI show \"unsaved changes\",\n * guard navigation, or submit only what changed. This is the pure half: a deep\n * diff of the current data against the original snapshot producing a nested\n * boolean tree mirroring the data's shape. The reactive store\n * (`superForm.svelte.ts`) re-derives it as the data changes.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/Tainted\n */\n\nimport type { Issues } from \"@thomasfosterau/effect-forms\";\nimport * as Arr from \"effect/Array\";\nimport * as Predicate from \"effect/Predicate\";\nimport * as Record from \"effect/Record\";\n\ntype FormPath = Issues.FormPath;\n\n/** A nested tree mirroring the form data, `true` where a leaf has changed. */\nexport interface TaintedFields {\n readonly [key: string]: boolean | TaintedFields | undefined;\n}\n\n/** An object or array read through its string keys. */\ntype Indexable = { readonly [key: string]: unknown };\n\n/** A non-null object or array, indexable by its own string keys. */\nconst isObject = (value: unknown): value is Indexable => Predicate.isObjectOrArray(value);\n\n/** The union of both containers' own enumerable keys, `a`'s first. */\nconst unionKeys = (a: Indexable, b: Indexable): Array<string> =>\n Arr.union(Record.keys(a), Record.keys(b));\n\n/** Structural deep equality for plain JSON-ish values. */\nexport function deepEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (Array.isArray(a) && Array.isArray(b)) {\n return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));\n }\n if (isObject(a) && isObject(b)) {\n return Arr.every(unionKeys(a, b), (key) => deepEqual(a[key], b[key]));\n }\n return false;\n}\n\n/** Whether two containers are the same kind (both arrays, or both plain objects). */\nconst sameKind = (a: unknown, b: unknown): boolean => Array.isArray(a) === Array.isArray(b);\n\n/**\n * Compare `current` against `original`, returning the nested tainted tree.\n * A changed scalar leaf is `true`; an unchanged leaf is omitted; nested objects\n * **and arrays** recurse (array elements keyed by index), so a single changed\n * row taints only that index — `taintedAt([\"items\", 2, \"name\"])` stays precise.\n * Keys present in either side are considered.\n */\nexport function computeTainted(original: unknown, current: unknown): TaintedFields {\n if (!isObject(original) || !isObject(current) || !sameKind(original, current)) return {};\n\n return Record.fromEntries(\n Arr.flatMap(\n unionKeys(original, current),\n (key): Array<readonly [string, boolean | TaintedFields]> => {\n const before = original[key];\n const after = current[key];\n if (isObject(before) && isObject(after) && sameKind(before, after)) {\n const nested = computeTainted(before, after);\n return Record.isEmptyReadonlyRecord(nested) ? [] : [[key, nested]];\n }\n return deepEqual(before, after) ? [] : [[key, true]];\n },\n ),\n );\n}\n\n/** Whether anything in the tree is tainted. */\nexport function isAnyTainted(tainted: TaintedFields): boolean {\n return Arr.some(\n Record.values(tainted),\n (value) => value === true || (isObject(value) && isAnyTainted(value)),\n );\n}\n\n/** Whether the field at `path` (or anything beneath it) is tainted. */\nexport function isTaintedAt(tainted: TaintedFields, path: FormPath): boolean {\n const go = (node: boolean | TaintedFields | undefined, rest: FormPath): boolean =>\n Arr.match(rest, {\n onEmpty: () =>\n Predicate.isUndefined(node) ? false : Predicate.isBoolean(node) ? node : isAnyTainted(node),\n onNonEmpty: (segments) =>\n Predicate.isUndefined(node) || Predicate.isBoolean(node)\n ? node === true\n : go(node[String(Arr.headNonEmpty(segments))], Arr.tailNonEmpty(segments)),\n });\n return go(tainted, path);\n}\n\n/**\n * Flatten a {@link TaintedFields} tree into the list of leaf {@link FormPath}s\n * that actually changed, in depth-first order — the per-field-changed events\n * a reactive store needs to run per-field validators / clear a superseded\n * server error at exactly the field that moved (as opposed to `isAnyTainted`,\n * which only says *something* changed).\n */\nexport function taintedLeafPaths(tainted: TaintedFields): FormPath[] {\n const walk = (node: TaintedFields, path: Array<string | number>): FormPath[] =>\n Arr.flatMap(Record.toEntries(node), ([key, value]): FormPath[] => {\n if (Predicate.isUndefined(value)) return [];\n const segment: string | number = /^\\d+$/.test(key) ? Number(key) : key;\n if (value === true) return [[...path, segment]];\n if (isObject(value)) return walk(value, [...path, segment]);\n return [];\n });\n\n return walk(tainted, []);\n}\n"],"mappings":";;;;;AA8BA,MAAM,YAAY,UAAuC,UAAU,gBAAgB,KAAK;;AAGxF,MAAM,aAAa,GAAc,MAC/B,IAAI,MAAM,OAAO,KAAK,CAAC,GAAG,OAAO,KAAK,CAAC,CAAC;;AAG1C,SAAgB,UAAU,GAAY,GAAqB;CACzD,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GACrC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,MAAM,MAAM,UAAU,MAAM,EAAE,EAAE,CAAC;CAE5E,IAAI,SAAS,CAAC,KAAK,SAAS,CAAC,GAC3B,OAAO,IAAI,MAAM,UAAU,GAAG,CAAC,IAAI,QAAQ,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC;CAEtE,OAAO;AACT;;AAGA,MAAM,YAAY,GAAY,MAAwB,MAAM,QAAQ,CAAC,MAAM,MAAM,QAAQ,CAAC;;;;;;;;AAS1F,SAAgB,eAAe,UAAmB,SAAiC;CACjF,IAAI,CAAC,SAAS,QAAQ,KAAK,CAAC,SAAS,OAAO,KAAK,CAAC,SAAS,UAAU,OAAO,GAAG,OAAO,CAAC;CAEvF,OAAO,OAAO,YACZ,IAAI,QACF,UAAU,UAAU,OAAO,IAC1B,QAA2D;EAC1D,MAAM,SAAS,SAAS;EACxB,MAAM,QAAQ,QAAQ;EACtB,IAAI,SAAS,MAAM,KAAK,SAAS,KAAK,KAAK,SAAS,QAAQ,KAAK,GAAG;GAClE,MAAM,SAAS,eAAe,QAAQ,KAAK;GAC3C,OAAO,OAAO,sBAAsB,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,MAAM,CAAC;EACnE;EACA,OAAO,UAAU,QAAQ,KAAK,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,CAAC;CACrD,CACF,CACF;AACF;;AAGA,SAAgB,aAAa,SAAiC;CAC5D,OAAO,IAAI,KACT,OAAO,OAAO,OAAO,IACpB,UAAU,UAAU,QAAS,SAAS,KAAK,KAAK,aAAa,KAAK,CACrE;AACF;;AAGA,SAAgB,YAAY,SAAwB,MAAyB;CAC3E,MAAM,MAAM,MAA2C,SACrD,IAAI,MAAM,MAAM;EACd,eACE,UAAU,YAAY,IAAI,IAAI,QAAQ,UAAU,UAAU,IAAI,IAAI,OAAO,aAAa,IAAI;EAC5F,aAAa,aACX,UAAU,YAAY,IAAI,KAAK,UAAU,UAAU,IAAI,IACnD,SAAS,OACT,GAAG,KAAK,OAAO,IAAI,aAAa,QAAQ,CAAC,IAAI,IAAI,aAAa,QAAQ,CAAC;CAC/E,CAAC;CACH,OAAO,GAAG,SAAS,IAAI;AACzB;;;;;;;;AASA,SAAgB,iBAAiB,SAAoC;CACnE,MAAM,QAAQ,MAAqB,SACjC,IAAI,QAAQ,OAAO,UAAU,IAAI,IAAI,CAAC,KAAK,WAAuB;EAChE,IAAI,UAAU,YAAY,KAAK,GAAG,OAAO,CAAC;EAC1C,MAAM,UAA2B,QAAQ,KAAK,GAAG,IAAI,OAAO,GAAG,IAAI;EACnE,IAAI,UAAU,MAAM,OAAO,CAAC,CAAC,GAAG,MAAM,OAAO,CAAC;EAC9C,IAAI,SAAS,KAAK,GAAG,OAAO,KAAK,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC;EAC1D,OAAO,CAAC;CACV,CAAC;CAEH,OAAO,KAAK,SAAS,CAAC,CAAC;AACzB"}
@@ -1,6 +1,9 @@
1
1
  import { detailOf, jsonapiFailure } from "./CommandFailure.js";
2
- import * as Option from "effect/Option";
2
+ import * as Arr from "effect/Array";
3
3
  import * as Predicate from "effect/Predicate";
4
+ import * as Record from "effect/Record";
5
+ import * as HashSet from "effect/HashSet";
6
+ import * as Option from "effect/Option";
4
7
  //#region src/kit/ActionRedirect.ts
5
8
  /**
6
9
  * Failure routing for **action-only** routes: a route with no page of its
@@ -14,11 +17,7 @@ import * as Predicate from "effect/Predicate";
14
17
  * @module effect-forms-svelte/kit/ActionRedirect
15
18
  */
16
19
  /** The statuses whose failure the submitter can act on (the form layer's set). */
17
- const USER_FIXABLE_STATUSES = /* @__PURE__ */ new Set([
18
- 400,
19
- 409,
20
- 422
21
- ]);
20
+ const USER_FIXABLE_STATUSES = HashSet.make(400, 409, 422);
22
21
  /**
23
22
  * A user-fixable failure's message; `None` takes the hard-error path.
24
23
  *
@@ -33,13 +32,12 @@ const USER_FIXABLE_STATUSES = /* @__PURE__ */ new Set([
33
32
  */
34
33
  const userFixableMessage = (cause, fallback) => {
35
34
  if (!Predicate.isObjectOrArray(cause)) return Option.none();
36
- const tagged = cause;
37
- if (tagged._tag === "HttpError") {
38
- if (tagged.status !== 400) return Option.none();
39
- return Option.some(Predicate.isString(tagged.message) ? tagged.message : fallback);
35
+ if (Predicate.isTagged(cause, "HttpError")) {
36
+ if (!Predicate.hasProperty(cause, "status") || cause.status !== 400) return Option.none();
37
+ return Option.some(Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) ? cause.message : fallback);
40
38
  }
41
- if (tagged._tag !== "JsonapiError" && tagged._tag !== "ErrorResponse") return Option.none();
42
- return jsonapiFailure(cause).pipe(Option.filter((failure) => USER_FIXABLE_STATUSES.has(failure.status)), Option.map((failure) => failure._tag === "Routed" ? detailOf(failure.errors[0], fallback) : failure.message));
39
+ if (!Predicate.isTagged(cause, "JsonapiError") && !Predicate.isTagged(cause, "ErrorResponse")) return Option.none();
40
+ return jsonapiFailure(cause).pipe(Option.filter((failure) => HashSet.has(USER_FIXABLE_STATUSES, failure.status)), Option.map((failure) => Predicate.isTagged(failure, "Routed") ? detailOf(failure.errors[0], fallback) : failure.message));
43
41
  };
44
42
  /**
45
43
  * Append the failure's query parameters to a same-origin return target,
@@ -47,7 +45,7 @@ const userFixableMessage = (cause, fallback) => {
47
45
  */
48
46
  const withFailureParams = (target, params) => {
49
47
  const url = new URL(target, "http://localhost");
50
- for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
48
+ Arr.forEach(Record.toEntries(params), ([key, value]) => url.searchParams.set(key, value));
51
49
  return url.pathname + url.search;
52
50
  };
53
51
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"ActionRedirect.js","names":[],"sources":["../../src/kit/ActionRedirect.ts"],"sourcesContent":["/**\n * Failure routing for **action-only** routes: a route with no page of its\n * own (a restore, a confirm) always answers a submission with a redirect, so\n * a *user-fixable* command failure rides back to the submitting page as\n * query parameters instead of a `fail()` (which would try to render the\n * action route's nonexistent page for a no-JS submitter). Everything else —\n * auth denials, infrastructure faults — stays a hard error at the caller.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/ActionRedirect\n */\n\nimport * as Option from \"effect/Option\";\nimport * as Predicate from \"effect/Predicate\";\nimport { detailOf, jsonapiFailure } from \"./CommandFailure.ts\";\n\n/** The statuses whose failure the submitter can act on (the form layer's set). */\nconst USER_FIXABLE_STATUSES: ReadonlySet<number> = new Set([400, 409, 422]);\n\ninterface TaggedLike {\n readonly _tag?: unknown;\n readonly status?: unknown;\n readonly message?: unknown;\n}\n\n/**\n * A user-fixable failure's message; `None` takes the hard-error path.\n *\n * - a `JsonapiError` / `ErrorResponse`-tagged cause at 400 / 409 / 422 → its\n * first error object's `detail`, else `title`, else `fallback` (an\n * `ErrorResponse` with no document → its own message);\n * - an `HttpError`-tagged 400 → its message (or `fallback`);\n * - anything else → `None`.\n *\n * `Some(\"\")` is a deliberate *present* answer: an error object carrying a\n * blank `detail` redirects with an empty parameter rather than escalating.\n */\nexport const userFixableMessage = (cause: unknown, fallback: string): Option.Option<string> => {\n if (!Predicate.isObjectOrArray(cause)) return Option.none();\n const tagged = cause as TaggedLike;\n if (tagged._tag === \"HttpError\") {\n if (tagged.status !== 400) return Option.none();\n return Option.some(Predicate.isString(tagged.message) ? tagged.message : fallback);\n }\n if (tagged._tag !== \"JsonapiError\" && tagged._tag !== \"ErrorResponse\") return Option.none();\n return jsonapiFailure(cause).pipe(\n Option.filter((failure) => USER_FIXABLE_STATUSES.has(failure.status)),\n Option.map((failure) =>\n failure._tag === \"Routed\" ? detailOf(failure.errors[0], fallback) : failure.message,\n ),\n );\n};\n\n/**\n * Append the failure's query parameters to a same-origin return target,\n * answering the path and query only (never an origin).\n */\nexport const withFailureParams = (\n target: string,\n params: Readonly<Record<string, string>>,\n): string => {\n const url = new URL(target, \"http://localhost\");\n for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);\n return url.pathname + url.search;\n};\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,wCAA6C,IAAI,IAAI;CAAC;CAAK;CAAK;AAAG,CAAC;;;;;;;;;;;;;AAoB1E,MAAa,sBAAsB,OAAgB,aAA4C;CAC7F,IAAI,CAAC,UAAU,gBAAgB,KAAK,GAAG,OAAO,OAAO,KAAK;CAC1D,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,aAAa;EAC/B,IAAI,OAAO,WAAW,KAAK,OAAO,OAAO,KAAK;EAC9C,OAAO,OAAO,KAAK,UAAU,SAAS,OAAO,OAAO,IAAI,OAAO,UAAU,QAAQ;CACnF;CACA,IAAI,OAAO,SAAS,kBAAkB,OAAO,SAAS,iBAAiB,OAAO,OAAO,KAAK;CAC1F,OAAO,eAAe,KAAK,CAAC,CAAC,KAC3B,OAAO,QAAQ,YAAY,sBAAsB,IAAI,QAAQ,MAAM,CAAC,GACpE,OAAO,KAAK,YACV,QAAQ,SAAS,WAAW,SAAS,QAAQ,OAAO,IAAI,QAAQ,IAAI,QAAQ,OAC9E,CACF;AACF;;;;;AAMA,MAAa,qBACX,QACA,WACW;CACX,MAAM,MAAM,IAAI,IAAI,QAAQ,kBAAkB;CAC9C,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG,IAAI,aAAa,IAAI,KAAK,KAAK;CAClF,OAAO,IAAI,WAAW,IAAI;AAC5B"}
1
+ {"version":3,"file":"ActionRedirect.js","names":[],"sources":["../../src/kit/ActionRedirect.ts"],"sourcesContent":["/**\n * Failure routing for **action-only** routes: a route with no page of its\n * own (a restore, a confirm) always answers a submission with a redirect, so\n * a *user-fixable* command failure rides back to the submitting page as\n * query parameters instead of a `fail()` (which would try to render the\n * action route's nonexistent page for a no-JS submitter). Everything else —\n * auth denials, infrastructure faults — stays a hard error at the caller.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/ActionRedirect\n */\n\nimport * as Arr from \"effect/Array\";\nimport * as HashSet from \"effect/HashSet\";\nimport * as Option from \"effect/Option\";\nimport * as Predicate from \"effect/Predicate\";\nimport * as Record from \"effect/Record\";\nimport { detailOf, jsonapiFailure } from \"./CommandFailure.ts\";\n\n/** The statuses whose failure the submitter can act on (the form layer's set). */\nconst USER_FIXABLE_STATUSES: HashSet.HashSet<number> = HashSet.make(400, 409, 422);\n\n/**\n * A user-fixable failure's message; `None` takes the hard-error path.\n *\n * - a `JsonapiError` / `ErrorResponse`-tagged cause at 400 / 409 / 422 → its\n * first error object's `detail`, else `title`, else `fallback` (an\n * `ErrorResponse` with no document → its own message);\n * - an `HttpError`-tagged 400 → its message (or `fallback`);\n * - anything else → `None`.\n *\n * `Some(\"\")` is a deliberate *present* answer: an error object carrying a\n * blank `detail` redirects with an empty parameter rather than escalating.\n */\nexport const userFixableMessage = (cause: unknown, fallback: string): Option.Option<string> => {\n if (!Predicate.isObjectOrArray(cause)) return Option.none();\n if (Predicate.isTagged(cause, \"HttpError\")) {\n if (!Predicate.hasProperty(cause, \"status\") || cause.status !== 400) return Option.none();\n return Option.some(\n Predicate.hasProperty(cause, \"message\") && Predicate.isString(cause.message)\n ? cause.message\n : fallback,\n );\n }\n if (!Predicate.isTagged(cause, \"JsonapiError\") && !Predicate.isTagged(cause, \"ErrorResponse\")) {\n return Option.none();\n }\n return jsonapiFailure(cause).pipe(\n Option.filter((failure) => HashSet.has(USER_FIXABLE_STATUSES, failure.status)),\n Option.map((failure) =>\n Predicate.isTagged(failure, \"Routed\")\n ? detailOf(failure.errors[0], fallback)\n : failure.message,\n ),\n );\n};\n\n/**\n * Append the failure's query parameters to a same-origin return target,\n * answering the path and query only (never an origin).\n */\nexport const withFailureParams = (\n target: string,\n params: Readonly<Record<string, string>>,\n): string => {\n const url = new URL(target, \"http://localhost\");\n Arr.forEach(Record.toEntries(params), ([key, value]) => url.searchParams.set(key, value));\n return url.pathname + url.search;\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAoBA,MAAM,wBAAiD,QAAQ,KAAK,KAAK,KAAK,GAAG;;;;;;;;;;;;;AAcjF,MAAa,sBAAsB,OAAgB,aAA4C;CAC7F,IAAI,CAAC,UAAU,gBAAgB,KAAK,GAAG,OAAO,OAAO,KAAK;CAC1D,IAAI,UAAU,SAAS,OAAO,WAAW,GAAG;EAC1C,IAAI,CAAC,UAAU,YAAY,OAAO,QAAQ,KAAK,MAAM,WAAW,KAAK,OAAO,OAAO,KAAK;EACxF,OAAO,OAAO,KACZ,UAAU,YAAY,OAAO,SAAS,KAAK,UAAU,SAAS,MAAM,OAAO,IACvE,MAAM,UACN,QACN;CACF;CACA,IAAI,CAAC,UAAU,SAAS,OAAO,cAAc,KAAK,CAAC,UAAU,SAAS,OAAO,eAAe,GAC1F,OAAO,OAAO,KAAK;CAErB,OAAO,eAAe,KAAK,CAAC,CAAC,KAC3B,OAAO,QAAQ,YAAY,QAAQ,IAAI,uBAAuB,QAAQ,MAAM,CAAC,GAC7E,OAAO,KAAK,YACV,UAAU,SAAS,SAAS,QAAQ,IAChC,SAAS,QAAQ,OAAO,IAAI,QAAQ,IACpC,QAAQ,OACd,CACF;AACF;;;;;AAMA,MAAa,qBACX,QACA,WACW;CACX,MAAM,MAAM,IAAI,IAAI,QAAQ,kBAAkB;CAC9C,IAAI,QAAQ,OAAO,UAAU,MAAM,IAAI,CAAC,KAAK,WAAW,IAAI,aAAa,IAAI,KAAK,KAAK,CAAC;CACxF,OAAO,IAAI,WAAW,IAAI;AAC5B"}
@@ -1,6 +1,7 @@
1
1
  import { __exportAll } from "../_virtual/_rolldown/runtime.js";
2
- import * as Option from "effect/Option";
2
+ import * as Arr from "effect/Array";
3
3
  import * as Predicate from "effect/Predicate";
4
+ import * as Option from "effect/Option";
4
5
  //#region src/kit/CommandFailure.ts
5
6
  /**
6
7
  * Classifying what a form action's command failed with.
@@ -45,16 +46,16 @@ const DEFAULT_MESSAGE = "Request could not be completed";
45
46
  /** An error object's human message: its `detail`, else its `title`, else `fallback`. */
46
47
  const detailOf = (error, fallback = DEFAULT_MESSAGE) => error?.detail ?? error?.title ?? fallback;
47
48
  const isStatus = (u) => Predicate.isNumber(u) && Number.isInteger(u);
48
- const fromJsonapiError = (cause) => isStatus(cause.status) && Array.isArray(cause.errors) ? Option.some(routed(cause.status, cause.errors)) : Option.none();
49
- const fromErrorResponse = (cause) => {
50
- if (!isStatus(cause.status)) return Option.none();
51
- const errors = Option.isOption(cause.document) ? Option.flatMap(cause.document, (document) => Predicate.hasProperty(document, "errors") && Array.isArray(document.errors) && document.errors.length > 0 ? Option.some(document.errors) : Option.none()) : Option.none();
52
- const status = cause.status;
53
- return Option.some(Option.match(errors, {
49
+ /** The cause's `status`, when it carries an integer one. */
50
+ const statusOf = (cause) => Predicate.hasProperty(cause, "status") && isStatus(cause.status) ? Option.some(cause.status) : Option.none();
51
+ const fromJsonapiError = (cause) => Option.flatMap(statusOf(cause), (status) => Predicate.hasProperty(cause, "errors") && Array.isArray(cause.errors) ? Option.some(routed(status, cause.errors)) : Option.none());
52
+ const fromErrorResponse = (cause) => Option.map(statusOf(cause), (status) => {
53
+ const errors = Predicate.hasProperty(cause, "document") && Option.isOption(cause.document) ? Option.flatMap(cause.document, (document) => Predicate.hasProperty(document, "errors") && Array.isArray(document.errors) && Arr.isReadonlyArrayNonEmpty(document.errors) ? Option.some(document.errors) : Option.none()) : Option.none();
54
+ return Option.match(errors, {
54
55
  onSome: (errors) => routed(status, errors),
55
- onNone: () => message(status, Predicate.isString(cause.message) ? cause.message : DEFAULT_MESSAGE)
56
- }));
57
- };
56
+ onNone: () => message(status, Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) ? cause.message : DEFAULT_MESSAGE)
57
+ });
58
+ });
58
59
  /**
59
60
  * The default classifier:
60
61
  *
@@ -70,11 +71,9 @@ const fromErrorResponse = (cause) => {
70
71
  */
71
72
  const jsonapiFailure = (cause) => {
72
73
  if (!Predicate.isObjectOrArray(cause)) return Option.none();
73
- const tagged = cause;
74
- if (tagged._tag === "JsonapiError") return fromJsonapiError(tagged);
75
- if (tagged._tag === "ErrorResponse") return fromErrorResponse(tagged);
76
- if (Predicate.isError(cause) && isStatus(tagged.status)) return Option.some(message(tagged.status, cause.message));
77
- return Option.none();
74
+ if (Predicate.isTagged(cause, "JsonapiError")) return fromJsonapiError(cause);
75
+ if (Predicate.isTagged(cause, "ErrorResponse")) return fromErrorResponse(cause);
76
+ return Predicate.isError(cause) ? Option.map(statusOf(cause), (status) => message(status, cause.message)) : Option.none();
78
77
  };
79
78
  //#endregion
80
79
  export { CommandFailure_exports, DEFAULT_MESSAGE, detailOf, jsonapiFailure, message, routed };
@@ -1 +1 @@
1
- {"version":3,"file":"CommandFailure.js","names":[],"sources":["../../src/kit/CommandFailure.ts"],"sourcesContent":["/**\n * Classifying what a form action's command failed with.\n *\n * A command (an API client call, an Effect program) fails with whatever its\n * transport raises. The form layer only needs to know two things about it:\n * which JSON:API error objects it carried (so each can be routed onto a\n * field by its `source.pointer`), or, failing that, a status and a single\n * message for the whole form. {@link CommandFailure} is that view, and\n * {@link jsonapiFailure} is the default classifier.\n *\n * The error-object shape is `@thomasfosterau/effect-jsonapi`'s\n * `Document.ErrorObject` — an optional peer imported for its type only, so\n * nothing here depends on it at runtime and no application type leaks in.\n * Causes are recognised structurally by their `_tag`, so an application's\n * own `JsonapiError` with the same shape is recognised too.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/CommandFailure\n */\n\nimport * as Option from \"effect/Option\";\nimport * as Predicate from \"effect/Predicate\";\nimport type { Document } from \"@thomasfosterau/effect-jsonapi\";\n\n/** One JSON:API error object: `@thomasfosterau/effect-jsonapi`'s `Document.ErrorObject`. */\nexport type ErrorObject = typeof Document.ErrorObject.Type;\n\n/** A failure carrying JSON:API error objects, each routed to a field (or the root) by its pointer. */\nexport interface RoutedFailure {\n readonly _tag: \"Routed\";\n readonly status: number;\n readonly errors: ReadonlyArray<ErrorObject>;\n}\n\n/** A failure with no error objects to route: one whole-form message. */\nexport interface MessageFailure {\n readonly _tag: \"Message\";\n readonly status: number;\n readonly message: string;\n}\n\n/**\n * What a form action needs to know about a recognised command failure. An\n * unrecognised failure is not a `CommandFailure` at all: the classifier\n * answers `None` and the action treats it as unexpected.\n */\nexport type CommandFailure = RoutedFailure | MessageFailure;\n\n/** Construct a {@link RoutedFailure}. */\nexport const routed = (status: number, errors: ReadonlyArray<ErrorObject>): CommandFailure => ({\n _tag: \"Routed\",\n status,\n errors,\n});\n\n/** Construct a {@link MessageFailure}. */\nexport const message = (status: number, message: string): CommandFailure => ({\n _tag: \"Message\",\n status,\n message,\n});\n\n/** A classifier: `Some` for a failure the form can show, `None` for an unexpected one. */\nexport type Classify = (cause: unknown) => Option.Option<CommandFailure>;\n\n/** The fallback message for an error object with neither `detail` nor `title`. */\nexport const DEFAULT_MESSAGE = \"Request could not be completed\";\n\n/** An error object's human message: its `detail`, else its `title`, else `fallback`. */\nexport const detailOf = (error: ErrorObject | undefined, fallback = DEFAULT_MESSAGE): string =>\n error?.detail ?? error?.title ?? fallback;\n\ninterface TaggedLike {\n readonly _tag?: unknown;\n readonly status?: unknown;\n readonly message?: unknown;\n readonly errors?: unknown;\n readonly document?: unknown;\n}\n\nconst isStatus = (u: unknown): u is number => Predicate.isNumber(u) && Number.isInteger(u);\n\n// `ServerErrors.JsonapiError` (effect-jsonapi) and the applications' own\n// `JsonapiError`: `{ _tag: \"JsonapiError\", status, errors }`.\nconst fromJsonapiError = (cause: TaggedLike): Option.Option<CommandFailure> =>\n isStatus(cause.status) && Array.isArray(cause.errors)\n ? Option.some(routed(cause.status, cause.errors as ReadonlyArray<ErrorObject>))\n : Option.none();\n\n// `Client.ErrorResponse` (effect-jsonapi): `{ _tag: \"ErrorResponse\", status,\n// message, document: Option<ErrorDocument> }`. A decoded document routes its\n// error objects; a response with none is its message alone.\nconst fromErrorResponse = (cause: TaggedLike): Option.Option<CommandFailure> => {\n if (!isStatus(cause.status)) return Option.none();\n const errors = Option.isOption(cause.document)\n ? Option.flatMap(cause.document as Option.Option<unknown>, (document) =>\n Predicate.hasProperty(document, \"errors\") &&\n Array.isArray(document.errors) &&\n document.errors.length > 0\n ? Option.some(document.errors as ReadonlyArray<ErrorObject>)\n : Option.none(),\n )\n : Option.none<ReadonlyArray<ErrorObject>>();\n const status = cause.status;\n return Option.some(\n Option.match(errors, {\n onSome: (errors) => routed(status, errors),\n onNone: () =>\n message(status, Predicate.isString(cause.message) ? cause.message : DEFAULT_MESSAGE),\n }),\n );\n};\n\n/**\n * The default classifier:\n *\n * - a `JsonapiError`-tagged cause (`{ status, errors }`) → its error objects,\n * at its own status;\n * - an `ErrorResponse`-tagged cause (effect-jsonapi's client-side decoded\n * error) → its document's error objects, or its message when the response\n * carried no document;\n * - an `Error` carrying a numeric `status` (a transport's status error, e.g.\n * a 413 oversize upload the transport did not map to a document) → its\n * message, at its own status;\n * - anything else → `None` (unexpected).\n */\nexport const jsonapiFailure: Classify = (cause) => {\n if (!Predicate.isObjectOrArray(cause)) return Option.none();\n const tagged = cause as TaggedLike;\n if (tagged._tag === \"JsonapiError\") return fromJsonapiError(tagged);\n if (tagged._tag === \"ErrorResponse\") return fromErrorResponse(tagged);\n if (Predicate.isError(cause) && isStatus(tagged.status)) {\n return Option.some(message(tagged.status, cause.message));\n }\n return Option.none();\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,MAAa,UAAU,QAAgB,YAAwD;CAC7F,MAAM;CACN;CACA;AACF;;AAGA,MAAa,WAAW,QAAgB,aAAqC;CAC3E,MAAM;CACN;CACA;AACF;;AAMA,MAAa,kBAAkB;;AAG/B,MAAa,YAAY,OAAgC,WAAW,oBAClE,OAAO,UAAU,OAAO,SAAS;AAUnC,MAAM,YAAY,MAA4B,UAAU,SAAS,CAAC,KAAK,OAAO,UAAU,CAAC;AAIzF,MAAM,oBAAoB,UACxB,SAAS,MAAM,MAAM,KAAK,MAAM,QAAQ,MAAM,MAAM,IAChD,OAAO,KAAK,OAAO,MAAM,QAAQ,MAAM,MAAoC,CAAC,IAC5E,OAAO,KAAK;AAKlB,MAAM,qBAAqB,UAAqD;CAC9E,IAAI,CAAC,SAAS,MAAM,MAAM,GAAG,OAAO,OAAO,KAAK;CAChD,MAAM,SAAS,OAAO,SAAS,MAAM,QAAQ,IACzC,OAAO,QAAQ,MAAM,WAAqC,aACxD,UAAU,YAAY,UAAU,QAAQ,KACxC,MAAM,QAAQ,SAAS,MAAM,KAC7B,SAAS,OAAO,SAAS,IACrB,OAAO,KAAK,SAAS,MAAoC,IACzD,OAAO,KAAK,CAClB,IACA,OAAO,KAAiC;CAC5C,MAAM,SAAS,MAAM;CACrB,OAAO,OAAO,KACZ,OAAO,MAAM,QAAQ;EACnB,SAAS,WAAW,OAAO,QAAQ,MAAM;EACzC,cACE,QAAQ,QAAQ,UAAU,SAAS,MAAM,OAAO,IAAI,MAAM,UAAU,eAAe;CACvF,CAAC,CACH;AACF;;;;;;;;;;;;;;AAeA,MAAa,kBAA4B,UAAU;CACjD,IAAI,CAAC,UAAU,gBAAgB,KAAK,GAAG,OAAO,OAAO,KAAK;CAC1D,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,gBAAgB,OAAO,iBAAiB,MAAM;CAClE,IAAI,OAAO,SAAS,iBAAiB,OAAO,kBAAkB,MAAM;CACpE,IAAI,UAAU,QAAQ,KAAK,KAAK,SAAS,OAAO,MAAM,GACpD,OAAO,OAAO,KAAK,QAAQ,OAAO,QAAQ,MAAM,OAAO,CAAC;CAE1D,OAAO,OAAO,KAAK;AACrB"}
1
+ {"version":3,"file":"CommandFailure.js","names":[],"sources":["../../src/kit/CommandFailure.ts"],"sourcesContent":["/**\n * Classifying what a form action's command failed with.\n *\n * A command (an API client call, an Effect program) fails with whatever its\n * transport raises. The form layer only needs to know two things about it:\n * which JSON:API error objects it carried (so each can be routed onto a\n * field by its `source.pointer`), or, failing that, a status and a single\n * message for the whole form. {@link CommandFailure} is that view, and\n * {@link jsonapiFailure} is the default classifier.\n *\n * The error-object shape is `@thomasfosterau/effect-jsonapi`'s\n * `Document.ErrorObject` — an optional peer imported for its type only, so\n * nothing here depends on it at runtime and no application type leaks in.\n * Causes are recognised structurally by their `_tag`, so an application's\n * own `JsonapiError` with the same shape is recognised too.\n *\n * @copyright Thomas Foster\n * @module effect-forms-svelte/kit/CommandFailure\n */\n\nimport * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\nimport * as Predicate from \"effect/Predicate\";\nimport type { Document } from \"@thomasfosterau/effect-jsonapi\";\n\n/** One JSON:API error object: `@thomasfosterau/effect-jsonapi`'s `Document.ErrorObject`. */\nexport type ErrorObject = typeof Document.ErrorObject.Type;\n\n/** A failure carrying JSON:API error objects, each routed to a field (or the root) by its pointer. */\nexport interface RoutedFailure {\n readonly _tag: \"Routed\";\n readonly status: number;\n readonly errors: ReadonlyArray<ErrorObject>;\n}\n\n/** A failure with no error objects to route: one whole-form message. */\nexport interface MessageFailure {\n readonly _tag: \"Message\";\n readonly status: number;\n readonly message: string;\n}\n\n/**\n * What a form action needs to know about a recognised command failure. An\n * unrecognised failure is not a `CommandFailure` at all: the classifier\n * answers `None` and the action treats it as unexpected.\n */\nexport type CommandFailure = RoutedFailure | MessageFailure;\n\n/** Construct a {@link RoutedFailure}. */\nexport const routed = (status: number, errors: ReadonlyArray<ErrorObject>): CommandFailure => ({\n _tag: \"Routed\",\n status,\n errors,\n});\n\n/** Construct a {@link MessageFailure}. */\nexport const message = (status: number, message: string): CommandFailure => ({\n _tag: \"Message\",\n status,\n message,\n});\n\n/** A classifier: `Some` for a failure the form can show, `None` for an unexpected one. */\nexport type Classify = (cause: unknown) => Option.Option<CommandFailure>;\n\n/** The fallback message for an error object with neither `detail` nor `title`. */\nexport const DEFAULT_MESSAGE = \"Request could not be completed\";\n\n/** An error object's human message: its `detail`, else its `title`, else `fallback`. */\nexport const detailOf = (error: ErrorObject | undefined, fallback = DEFAULT_MESSAGE): string =>\n error?.detail ?? error?.title ?? fallback;\n\nconst isStatus = (u: unknown): u is number => Predicate.isNumber(u) && Number.isInteger(u);\n\n/** A cause recognised by its `_tag` as effect-jsonapi's (or an application's) `JsonapiError`. */\ninterface JsonapiErrorLike {\n readonly _tag: \"JsonapiError\";\n}\n\n/** A cause recognised by its `_tag` as effect-jsonapi's client-side `ErrorResponse`. */\ninterface ErrorResponseLike {\n readonly _tag: \"ErrorResponse\";\n}\n\n/** The cause's `status`, when it carries an integer one. */\nconst statusOf = (cause: JsonapiErrorLike | ErrorResponseLike | Error): Option.Option<number> =>\n Predicate.hasProperty(cause, \"status\") && isStatus(cause.status)\n ? Option.some(cause.status)\n : Option.none();\n\n// `ServerErrors.JsonapiError` (effect-jsonapi) and the applications' own\n// `JsonapiError`: `{ _tag: \"JsonapiError\", status, errors }`.\nconst fromJsonapiError = (cause: JsonapiErrorLike): Option.Option<CommandFailure> =>\n Option.flatMap(statusOf(cause), (status) =>\n Predicate.hasProperty(cause, \"errors\") && Array.isArray(cause.errors)\n ? // SAFETY: a `JsonapiError`-tagged cause carries JSON:API error objects in\n // `errors` by that tag's contract (effect-jsonapi's `ServerErrors.JsonapiError`\n // and the applications' own); causes are recognised structurally, not decoded.\n Option.some(routed(status, cause.errors as ReadonlyArray<ErrorObject>))\n : Option.none(),\n );\n\n// `Client.ErrorResponse` (effect-jsonapi): `{ _tag: \"ErrorResponse\", status,\n// message, document: Option<ErrorDocument> }`. A decoded document routes its\n// error objects; a response with none is its message alone.\nconst fromErrorResponse = (cause: ErrorResponseLike): Option.Option<CommandFailure> =>\n Option.map(statusOf(cause), (status) => {\n const errors =\n Predicate.hasProperty(cause, \"document\") && Option.isOption(cause.document)\n ? Option.flatMap(cause.document, (document) =>\n Predicate.hasProperty(document, \"errors\") &&\n Array.isArray(document.errors) &&\n Arr.isReadonlyArrayNonEmpty(document.errors)\n ? // SAFETY: an `ErrorResponse`'s `document` is effect-jsonapi's decoded\n // `ErrorDocument`, whose `errors` are JSON:API error objects.\n Option.some(document.errors as ReadonlyArray<ErrorObject>)\n : Option.none(),\n )\n : Option.none<ReadonlyArray<ErrorObject>>();\n return Option.match(errors, {\n onSome: (errors) => routed(status, errors),\n onNone: () =>\n message(\n status,\n Predicate.hasProperty(cause, \"message\") && Predicate.isString(cause.message)\n ? cause.message\n : DEFAULT_MESSAGE,\n ),\n });\n });\n\n/**\n * The default classifier:\n *\n * - a `JsonapiError`-tagged cause (`{ status, errors }`) → its error objects,\n * at its own status;\n * - an `ErrorResponse`-tagged cause (effect-jsonapi's client-side decoded\n * error) → its document's error objects, or its message when the response\n * carried no document;\n * - an `Error` carrying a numeric `status` (a transport's status error, e.g.\n * a 413 oversize upload the transport did not map to a document) → its\n * message, at its own status;\n * - anything else → `None` (unexpected).\n */\nexport const jsonapiFailure: Classify = (cause) => {\n if (!Predicate.isObjectOrArray(cause)) return Option.none();\n if (Predicate.isTagged(cause, \"JsonapiError\")) return fromJsonapiError(cause);\n if (Predicate.isTagged(cause, \"ErrorResponse\")) return fromErrorResponse(cause);\n return Predicate.isError(cause)\n ? Option.map(statusOf(cause), (status) => message(status, cause.message))\n : Option.none();\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkDA,MAAa,UAAU,QAAgB,YAAwD;CAC7F,MAAM;CACN;CACA;AACF;;AAGA,MAAa,WAAW,QAAgB,aAAqC;CAC3E,MAAM;CACN;CACA;AACF;;AAMA,MAAa,kBAAkB;;AAG/B,MAAa,YAAY,OAAgC,WAAW,oBAClE,OAAO,UAAU,OAAO,SAAS;AAEnC,MAAM,YAAY,MAA4B,UAAU,SAAS,CAAC,KAAK,OAAO,UAAU,CAAC;;AAazF,MAAM,YAAY,UAChB,UAAU,YAAY,OAAO,QAAQ,KAAK,SAAS,MAAM,MAAM,IAC3D,OAAO,KAAK,MAAM,MAAM,IACxB,OAAO,KAAK;AAIlB,MAAM,oBAAoB,UACxB,OAAO,QAAQ,SAAS,KAAK,IAAI,WAC/B,UAAU,YAAY,OAAO,QAAQ,KAAK,MAAM,QAAQ,MAAM,MAAM,IAIhE,OAAO,KAAK,OAAO,QAAQ,MAAM,MAAoC,CAAC,IACtE,OAAO,KAAK,CAClB;AAKF,MAAM,qBAAqB,UACzB,OAAO,IAAI,SAAS,KAAK,IAAI,WAAW;CACtC,MAAM,SACJ,UAAU,YAAY,OAAO,UAAU,KAAK,OAAO,SAAS,MAAM,QAAQ,IACtE,OAAO,QAAQ,MAAM,WAAW,aAC9B,UAAU,YAAY,UAAU,QAAQ,KACxC,MAAM,QAAQ,SAAS,MAAM,KAC7B,IAAI,wBAAwB,SAAS,MAAM,IAGvC,OAAO,KAAK,SAAS,MAAoC,IACzD,OAAO,KAAK,CAClB,IACA,OAAO,KAAiC;CAC9C,OAAO,OAAO,MAAM,QAAQ;EAC1B,SAAS,WAAW,OAAO,QAAQ,MAAM;EACzC,cACE,QACE,QACA,UAAU,YAAY,OAAO,SAAS,KAAK,UAAU,SAAS,MAAM,OAAO,IACvE,MAAM,UACN,eACN;CACJ,CAAC;AACH,CAAC;;;;;;;;;;;;;;AAeH,MAAa,kBAA4B,UAAU;CACjD,IAAI,CAAC,UAAU,gBAAgB,KAAK,GAAG,OAAO,OAAO,KAAK;CAC1D,IAAI,UAAU,SAAS,OAAO,cAAc,GAAG,OAAO,iBAAiB,KAAK;CAC5E,IAAI,UAAU,SAAS,OAAO,eAAe,GAAG,OAAO,kBAAkB,KAAK;CAC9E,OAAO,UAAU,QAAQ,KAAK,IAC1B,OAAO,IAAI,SAAS,KAAK,IAAI,WAAW,QAAQ,QAAQ,MAAM,OAAO,CAAC,IACtE,OAAO,KAAK;AAClB"}
@@ -1,9 +1,10 @@
1
1
  import { DEFAULT_MESSAGE, detailOf, jsonapiFailure } from "./CommandFailure.js";
2
2
  import { fieldFromPointer } from "./Pointer.js";
3
3
  import { Result, State } from "@thomasfosterau/effect-forms";
4
- import * as Effect from "effect/Effect";
5
- import * as Option from "effect/Option";
4
+ import * as Match from "effect/Match";
6
5
  import * as Predicate from "effect/Predicate";
6
+ import * as Option from "effect/Option";
7
+ import * as Effect from "effect/Effect";
7
8
  import { fail, redirect } from "@sveltejs/kit";
8
9
  import * as Cause from "effect/Cause";
9
10
  import * as Exit from "effect/Exit";
@@ -34,16 +35,12 @@ const toEffect = (thunk) => Effect.suspend(() => {
34
35
  catch: (cause) => cause
35
36
  });
36
37
  });
37
- const isConflict = (cause) => Predicate.isObjectOrArray(cause) && cause.status === 409 && (cause._tag === "JsonapiError" || cause._tag === "ErrorResponse");
38
- const submittedId = (input) => {
39
- const id = Predicate.isObjectOrArray(input) ? input.id : void 0;
40
- return Predicate.isString(id) && id !== "" ? id : void 0;
41
- };
42
- const retryOwnConflict = (create, fetchExisting, input) => toEffect(() => create(input)).pipe(Effect.catch((cause) => {
43
- const id = submittedId(input);
44
- if (id === void 0 || !isConflict(cause)) return Effect.fail(cause);
45
- return toEffect(() => fetchExisting(id)).pipe(Effect.mapError(() => cause));
46
- }));
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
+ })));
47
44
  /**
48
45
  * Wrap a client-id create command so a 409 conflict on the caller's OWN
49
46
  * submitted `id` resolves as success instead of a form error — the
@@ -75,7 +72,7 @@ const fieldPath = (error) => Option.toArray(fieldFromPointer(error.source !== vo
75
72
  * at the empty path, which the nested error tree exposes as `_errors` — the
76
73
  * form-level message surface (`form.errors._errors`).
77
74
  */
78
- const routeFailure = (state, failure) => failure._tag === "Routed" ? failure.errors.reduce((acc, error) => State.withError(acc, fieldPath(error), detailOf(error)), state) : State.withError(state, [], failure.message);
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);
79
76
  /**
80
77
  * Validate `request` against `form`, then:
81
78
  *
@@ -97,17 +94,26 @@ const routeFailure = (state, failure) => failure._tag === "Routed" ? failure.err
97
94
  */
98
95
  async function runFormAction(form, request, run, locationFor, options = {}) {
99
96
  const redact = options.redact ?? ((state) => state);
100
- const state = await Effect.runPromise(form.validate(request));
101
- if (!state.valid) return Result.failure(redact(state), 400);
102
- const idempotent = options.idempotent;
103
- const command = idempotent?.clientId === true ? retryOwnConflict(run, idempotent.fetchExisting, state.data) : toEffect(() => run(state.data));
104
- const exit = await Effect.runPromiseExit(command);
105
- if (Exit.isSuccess(exit)) return Result.redirect(locationFor(exit.value, state.data), 303);
106
- const cause = Cause.squash(exit.cause);
107
- const failure = (options.classify ?? jsonapiFailure)(cause);
108
- if (Option.isSome(failure)) return Result.failure(redact(routeFailure(state, failure.value)), failure.value.status);
109
- if (options.unrecognised === "throw") throw cause;
110
- return Result.errorResult(Predicate.isError(cause) ? cause.message : DEFAULT_MESSAGE, 500);
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);
111
117
  }
112
118
  /**
113
119
  * Derive a SvelteKit page form **action** from `form` plus its command. The
@@ -150,12 +156,12 @@ function formAction(form, run, locationFor, options) {
150
156
  return async (event) => {
151
157
  const result = await runFormAction(form, event.request, (input) => run(input, event), locationFor, options);
152
158
  const data = (d) => d;
153
- switch (result._tag) {
154
- case "Redirect": return redirect(result.status, result.location);
155
- case "Failure": return fail(result.status, data({ form: result.state }));
156
- case "Error": return fail(result.status, data({ message: result.message }));
157
- case "Success": return data({ form: result.state });
158
- }
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
+ });
159
165
  };
160
166
  }
161
167
  //#endregion
@@ -1 +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 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 (cause as { readonly status?: unknown }).status === 409 &&\n ((cause as { readonly _tag?: unknown })._tag === \"JsonapiError\" ||\n (cause as { readonly _tag?: unknown })._tag === \"ErrorResponse\");\n\nconst submittedId = (input: unknown): string | undefined => {\n const id = Predicate.isObjectOrArray(input) ? (input as { readonly id?: unknown }).id : undefined;\n return Predicate.isString(id) && id !== \"\" ? id : undefined;\n};\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 const id = submittedId(input);\n if (id === undefined || !isConflict(cause)) return Effect.fail(cause);\n // The re-fetch failing rethrows the ORIGINAL conflict, not its own error.\n return toEffect(() => fetchExisting(id)).pipe(Effect.mapError(() => cause));\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 failure._tag === \"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 // A posted form needs no decoding services at runtime; schemas projected\n // from elsewhere can still carry an unreduced `DecodingServices` type, so\n // only the requirements channel is narrowed here.\n const state = (await Effect.runPromise(\n form.validate(request) as Effect.Effect<FormState<S[\"Type\"], Message>>,\n )) as FormState<S[\"Type\"]>;\n if (!state.valid) return Result.failure(redact(state), 400);\n\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 const exit = await Effect.runPromiseExit(command);\n if (Exit.isSuccess(exit)) return Result.redirect(locationFor(exit.value, state.data), 303);\n\n const cause = Cause.squash(exit.cause);\n const failure = (options.classify ?? jsonapiFailure)(cause);\n if (Option.isSome(failure)) {\n return Result.failure(redact(routeFailure(state, failure.value)), failure.value.status);\n }\n if (options.unrecognised === \"throw\") throw cause;\n return Result.errorResult(Predicate.isError(cause) ? cause.message : DEFAULT_MESSAGE, 500);\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 switch (result._tag) {\n case \"Redirect\":\n return redirect(result.status, result.location);\n case \"Failure\":\n return fail(result.status, data({ form: result.state }));\n case \"Error\":\n return fail(result.status, data({ message: result.message }));\n case \"Success\":\n // `runFormAction` answers success with a redirect; a success result\n // carrying state is echoed back as-is.\n return data({ form: result.state });\n }\n };\n}\n"],"mappings":"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"}
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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"}
@@ -28,9 +28,10 @@ import * as Predicate from "effect/Predicate";
28
28
  * that carried a bare message instead.
29
29
  */
30
30
  const formStateOf = (form) => {
31
- if (!Predicate.isObjectOrArray(form)) return void 0;
31
+ if (!Predicate.isObjectOrArray(form) || !Predicate.hasProperty(form, "form")) return void 0;
32
32
  const state = form.form;
33
- return Predicate.isObjectOrArray(state) ? state : void 0;
33
+ if (!Predicate.isObjectOrArray(state)) return void 0;
34
+ return state;
34
35
  };
35
36
  /** Messages attached to a named field, or none. */
36
37
  const issuesFor = (state, field) => {
@@ -1 +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)) return undefined;\n const state = (form as { readonly form?: unknown }).form;\n return Predicate.isObjectOrArray(state) ? (state as ActionFormState) : undefined;\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 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,GAAG,OAAO,KAAA;CAC7C,MAAM,QAAS,KAAqC;CACpD,OAAO,UAAU,gBAAgB,KAAK,IAAK,QAA4B,KAAA;AACzE;;AAGA,MAAa,aACX,OACA,UAC0B;CAC1B,MAAM,SAAS,OAAO,SAAS;CAC/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"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomasfosterau/effect-forms-svelte",
3
- "version": "0.2.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
  },
@@ -49,16 +49,16 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
- "@thomasfosterau/effect-forms": "^0.2.0"
52
+ "@thomasfosterau/effect-forms": "^0.3.1"
53
53
  },
54
54
  "devDependencies": {
55
- "@sveltejs/kit": "3.0.0",
56
- "@thomasfosterau/effect-jsonapi": "^0.16.3"
55
+ "@sveltejs/kit": "3.1.0",
56
+ "@thomasfosterau/effect-jsonapi": "^0.17.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@sveltejs/kit": "^3.0.0",
60
60
  "@thomasfosterau/effect-jsonapi": ">=0.16.0",
61
- "effect": ">=4.0.0-rc.118",
61
+ "effect": ">=4.0.0",
62
62
  "svelte": "^5.40.0"
63
63
  },
64
64
  "peerDependenciesMeta": {