paramour 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/search.js CHANGED
@@ -1,4 +1,5 @@
1
- import { describeType, foreignMessage, ParamourError, ParseError, rebrandForeign, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
1
+ import { codecShapeLabel } from "./describe.js";
2
+ import { describeType, foreignMessage, ParamourError, ParseError, parseIssueReason, rebrandForeign, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
2
3
  import { runStandardSchemaSync } from "./schema.js";
3
4
  /**
4
5
  * Builds the byte-layer query string from decoded pairs. Hand-rolled on
@@ -19,14 +20,18 @@ export function buildSearchString(pairs) {
19
20
  * under keys paramour doesn't own can never fail a decode.
20
21
  * Throws {@link SearchDecodeError} carrying one issue per failed key.
21
22
  *
22
- * A `RawSearch` config (design-04 SS2) branches to the whole-object schema
23
+ * A `RawSearch` config (SS2) branches to the whole-object schema
23
24
  * path instead: every source key reaches the schema (P8 does not apply
24
25
  * there — the schema owns stripping or passing through extras).
26
+ *
27
+ * `routePath` anchors a thrown {@link SearchDecodeError} to the owning
28
+ * route's path pattern — route-level surfaces pass `route.path`; standalone
29
+ * callers (nuqs, devtools) omit it and the error stays route-less.
25
30
  */
26
- export function decodeSearch(config, source) {
31
+ export function decodeSearch(config, source, routePath) {
27
32
  requireSearchConfig(config);
28
33
  if (isRawSearch(config)) {
29
- return decodeRawSearch(config, source);
34
+ return decodeRawSearch(config, source, routePath ?? null);
30
35
  }
31
36
  // The conditional SearchSlot doesn't narrow inside the generic body once
32
37
  // the RawSearch branch returns (S stays a generic type parameter); this
@@ -47,12 +52,21 @@ export function decodeSearch(config, source) {
47
52
  const sourceValues = readDeclaredValues(searchConfig, source);
48
53
  // Absence is presence's job — .catch() only ever recovers parse
49
54
  // *failures* (D2), which is why this is shared by both arity branches.
50
- const recoverParseError = (error, key, codec) => {
55
+ const recoverParseError = (error, key, codec, wire, reason) => {
51
56
  if (error instanceof ParseError && codec["~catchValue"] !== undefined) {
52
57
  entries.push([key, codec["~catchValue"]()]);
53
58
  }
54
59
  else if (error instanceof ParseError) {
55
- issues.push({ key, message: error.message });
60
+ issues.push({
61
+ expected: codecShapeLabel(codec),
62
+ key,
63
+ message: error.message,
64
+ // An explicitly passed reason (the duplicate-scalar rejection) wins;
65
+ // otherwise the ParseError's own selfDescribing flag decides
66
+ // "parse" vs "validate" — structural, never message sniffing.
67
+ reason: reason ?? parseIssueReason(error),
68
+ ...(wire === undefined ? {} : { wire }),
69
+ });
56
70
  }
57
71
  else {
58
72
  throw error;
@@ -64,11 +78,17 @@ export function decodeSearch(config, source) {
64
78
  // Array codecs consume all values in wire order; absent → [] (P6).
65
79
  // Presence modifiers are banned on array codecs, so no absence
66
80
  // handling exists here.
81
+ let offending;
67
82
  try {
68
- entries.push([key, values.map((raw) => codec["~parseElement"](raw))]);
83
+ const parsed = [];
84
+ for (const raw of values) {
85
+ offending = raw;
86
+ parsed.push(codec["~parseElement"](raw));
87
+ }
88
+ entries.push([key, parsed]);
69
89
  }
70
90
  catch (error) {
71
- recoverParseError(error, key, codec);
91
+ recoverParseError(error, key, codec, offending);
72
92
  }
73
93
  continue;
74
94
  }
@@ -84,7 +104,12 @@ export function decodeSearch(config, source) {
84
104
  entries.push([key, undefined]);
85
105
  break;
86
106
  case "required":
87
- issues.push({ key, message: "required search param is missing" });
107
+ issues.push({
108
+ expected: codecShapeLabel(codec),
109
+ key,
110
+ message: "required search param is missing",
111
+ reason: "missing",
112
+ });
88
113
  break;
89
114
  }
90
115
  continue;
@@ -100,18 +125,21 @@ export function decodeSearch(config, source) {
100
125
  entries.push([key, codec["~parseElement"](first)]);
101
126
  }
102
127
  catch (error) {
103
- recoverParseError(error, key, codec);
128
+ // The duplicate-scalar rejection (P5) has no SINGLE offending value —
129
+ // only a genuine one-value parse cites its wire form — and its reason
130
+ // is passed explicitly: duplication is what failed, not the grammar.
131
+ recoverParseError(error, key, codec, values.length > 1 ? undefined : first, values.length > 1 ? "duplicate" : undefined);
104
132
  }
105
133
  }
106
134
  if (issues.length > 0) {
107
- throw new SearchDecodeError(issues);
135
+ throw new SearchDecodeError(issues, routePath ?? null);
108
136
  }
109
137
  return Object.fromEntries(entries);
110
138
  }
111
139
  /**
112
140
  * encodeURIComponent throws a raw URIError on lone surrogates; wrap it so
113
141
  * the documented "every error is a ParamourError" contract holds (S7).
114
- * Exported for path.ts (the byte-layer chokepoint is shared with RL5's
142
+ * Exported for path.ts (the byte-layer chokepoint is shared with path
115
143
  * segment encoding), not from the package barrel.
116
144
  */
117
145
  export function encodeComponent(text) {
@@ -123,7 +151,7 @@ export function encodeComponent(text) {
123
151
  * Caveat: JS property enumeration puts integer-like keys ("0", "42") first
124
152
  * in ascending numeric order regardless of declaration — declaration order
125
153
  * is unrecoverable for those, so they sort numerically before all others.
126
- * Params equal to their `.default()` are elided (design-02 D8), compared by
154
+ * Params equal to their `.default()` are elided (D8), compared by
127
155
  * serialized wire form against the live default (re-serialized per encode —
128
156
  * a build-time snapshot would go stale if a reference-typed default were
129
157
  * mutated, silently dropping explicit values that then decode differently).
@@ -131,9 +159,9 @@ export function encodeComponent(text) {
131
159
  * time-varying factory would elide an explicit value that later decodes as
132
160
  * a different one.
133
161
  *
134
- * A `RawSearch` config (design-04 SS5) branches to a raw pass-through
135
- * instead: no serializer exists for a whole-object schema, so the caller's
136
- * record goes straight to the byte layer and the schema never runs on encode.
162
+ * A `RawSearch` config (SS5) branches to a raw pass-through instead: no
163
+ * serializer exists for a whole-object schema, so the caller's record goes
164
+ * straight to the byte layer and the schema never runs on encode.
137
165
  */
138
166
  export function encodeSearch(config, input) {
139
167
  requireSearchConfig(config);
@@ -192,7 +220,7 @@ export function encodeSearch(config, input) {
192
220
  return pairs;
193
221
  }
194
222
  /**
195
- * Runtime discriminant for the `search:` slot (design-04 SS2): probes the
223
+ * Runtime discriminant for the `search:` slot (SS2): probes the
196
224
  * `~kind` marker's VALUE, which is unambiguous against a codec map — a map
197
225
  * key literally named "~kind" would hold a codec object, never the marker
198
226
  * string. Exported from the package barrel so derived surfaces
@@ -208,10 +236,10 @@ export function isRawSearch(config) {
208
236
  * the codec's `.catch()` recovery applied (decodeSearch always recovers a
209
237
  * caught failure, so a probe through it cannot tell "parsed cleanly" from
210
238
  * "failed and was caught"). Exists for reflection-driven tooling — the
211
- * devtools panel's catch-attribution probe (design-12 DT7) — and any other
212
- * derived surface that must observe the raw parse outcome. A parse failure
213
- * throws the codec's own {@link ParseError}; foreign throws from a custom
214
- * codec propagate unwrapped, matching decodeSearch's taxonomy. For
239
+ * devtools panel's catch-attribution probe — and any other derived surface
240
+ * that must observe the raw parse outcome. A parse failure throws the
241
+ * codec's own {@link ParseError}; foreign throws from a custom codec
242
+ * propagate unwrapped, matching decodeSearch's taxonomy. For
215
243
  * arity-"many" codecs this parses ONE element of the repeated-key array,
216
244
  * not the whole array (the same contract as `~parseElement` itself).
217
245
  */
@@ -219,13 +247,13 @@ export function parseValue(codec, raw) {
219
247
  return codec["~parseElement"](raw);
220
248
  }
221
249
  /**
222
- * The whole-object search escape hatch (design-04 SS1, maintainer ruling):
223
- * an explicit, greppable wrapper around a bare Standard Schema so a route's
224
- * `search:` slot never falls into the degraded raw mode by accident — a
225
- * bare `search: schema` could be confused for a codec map, but `rawSearch`
226
- * is a conscious act. Per-key defaults/`.catch()` and round-trip encoding
227
- * are deliberately unavailable here (SS7); reach for `p.custom` if you need
228
- * bidirectional per-key transforms instead.
250
+ * The whole-object search escape hatch (SS1): an explicit, greppable wrapper
251
+ * around a bare Standard Schema so a route's `search:` slot never falls into
252
+ * the degraded raw mode by accident — a bare `search: schema` could be
253
+ * confused for a codec map, but `rawSearch` is a conscious act. Per-key
254
+ * defaults/`.catch()` and round-trip encoding are deliberately unavailable
255
+ * here (SS7); reach for `p.custom` if you need bidirectional per-key
256
+ * transforms instead.
229
257
  */
230
258
  export function rawSearch(schema) {
231
259
  return { "~kind": "raw-search", "~schema": schema };
@@ -296,17 +324,17 @@ export function serializeValue(codec, label, value) {
296
324
  return serialized;
297
325
  }
298
326
  /**
299
- * The `RawSearch` decode path (design-04 SS3/SS4). The schema receives EVERY
327
+ * The `RawSearch` decode path (SS3/SS4). The schema receives EVERY
300
328
  * source key, normalized to Next's own `searchParams` shape — P8's
301
329
  * declared-keys-only stance doesn't apply to a whole-object schema, which
302
- * owns stripping or passing through extras itself. Sync only, per D7 (the
303
- * shared runner throws on an async `validate`). A validator that THROWS
330
+ * owns stripping or passing through extras itself. Sync only — the shared
331
+ * runner throws on an async `validate`. A validator that THROWS
304
332
  * (rather than returning issues) is rebranded at this chokepoint — the
305
- * shared runner deliberately stays throw-preserving (plan-04 step 1) so this
306
- * call site owns the wrap, mirroring how a foreign throw is branded
307
- * elsewhere in the package.
333
+ * shared runner deliberately stays throw-preserving so this call site owns
334
+ * the wrap, mirroring how a foreign throw is branded elsewhere in the
335
+ * package.
308
336
  */
309
- function decodeRawSearch(config, source) {
337
+ function decodeRawSearch(config, source, routePath) {
310
338
  const record = readAllValues(source);
311
339
  const result = rebrandForeign(() => runStandardSchemaSync(config["~schema"], record), (error) => new ParamourError(`raw-search schema validation threw: ${foreignMessage(error)}`, { cause: error }));
312
340
  if (result.issues) {
@@ -324,14 +352,20 @@ function decodeRawSearch(config, source) {
324
352
  // always produces a plain Array and is immune.
325
353
  const issues = Array.from(result.issues, (issue) => {
326
354
  const key = Array.from(issue.path ?? [], (seg) => String(typeof seg === "object" ? seg.key : seg)).join(".");
327
- return { key: key === "" ? "<search>" : key, message: issue.message };
355
+ // "validate": the prose belongs to the user's whole-object schema —
356
+ // same classification as a per-key schema failure.
357
+ return {
358
+ key: key === "" ? "<search>" : key,
359
+ message: issue.message,
360
+ reason: "validate",
361
+ };
328
362
  });
329
- throw new SearchDecodeError(issues);
363
+ throw new SearchDecodeError(issues, routePath);
330
364
  }
331
365
  return result.value;
332
366
  }
333
367
  /**
334
- * The `RawSearch` encode path (design-04 SS5): no serializer exists for a
368
+ * The `RawSearch` encode path (SS5): no serializer exists for a
335
369
  * whole-object schema, so the caller's already-wire-shaped record is pushed
336
370
  * straight to the byte layer — one pair per string value, one repeated pair
337
371
  * per array element — and the schema never runs on encode.
@@ -362,10 +396,10 @@ function encodeRawSearch(input) {
362
396
  * whole-object schema's `validate`) runs — sibling of
363
397
  * {@link readDeclaredValues} that reads all keys instead of declared-only
364
398
  * ones (SS3: a whole-object schema has no declared keys of its own).
365
- * Collapses each key's values by occurrence count (plan-04 point 2): one
366
- * value → `string`, multiple → `string[]`, uniformly for both
367
- * `URLSearchParams` and Next-record sources, so the schema author writes one
368
- * mental model regardless of which source it came from.
399
+ * Collapses each key's values by occurrence count: one value → `string`,
400
+ * multiple → `string[]`, uniformly for both `URLSearchParams` and
401
+ * Next-record sources, so the schema author writes one mental model
402
+ * regardless of which source it came from.
369
403
  */
370
404
  function readAllValues(source) {
371
405
  // The TS contract forbids non-object sources, but plain-JS callers reach
@@ -2,26 +2,26 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { AnyRoute } from "./route.js";
3
3
  import { type SearchOutputOf } from "./search.js";
4
4
  /**
5
- * Standard Schema generate-OUT (design-08): exports a route's `search:`
6
- * config as a spec-compliant Standard Schema. The mirror of schema.ts, which
7
- * runs Standard Schemas coming IN.
5
+ * Standard Schema generate-OUT: exports a route's `search:` config as a
6
+ * spec-compliant Standard Schema. The mirror of schema.ts, which runs
7
+ * Standard Schemas coming IN.
8
8
  */
9
9
  /**
10
- * The schema {@link standardSearchSchema} returns (STD1/STD3): input is
11
- * advertised as the wire-shaped record only — `URLSearchParams` is accepted
12
- * at runtime but kept out of the type, so client-side inference (tRPC) never
13
- * sees a shape that cannot serialize over JSON. Output is the route's decoded
14
- * search shape. `types` is carried by this annotation alone; the spec reads
15
- * it at the type level only, so no runtime key exists.
10
+ * The schema {@link standardSearchSchema} returns: input is advertised as
11
+ * the wire-shaped record only — `URLSearchParams` is accepted at runtime but
12
+ * kept out of the type, so client-side inference (tRPC) never sees a shape
13
+ * that cannot serialize over JSON. Output is the route's decoded search
14
+ * shape. `types` is carried by this annotation alone; the spec reads it at
15
+ * the type level only, so no runtime key exists.
16
16
  */
17
17
  export type StandardSearchSchema<SC> = StandardSchemaV1<Record<string, string | string[] | undefined>, SearchOutputOf<SC>>;
18
18
  /**
19
19
  * Exports a route's `search:` config as the URL wire contract in Standard
20
- * Schema form (design-08 STD1/STD5), for consumers like tRPC inputs or
21
- * TanStack `validateSearch`. Semantics are byte-identical to `decodeSearch`
22
- * (STD6): defaults apply, `.catch()` recovers parse failures — invalid API
23
- * input silently coerces to the fallback — unknown keys strip (P8), and
24
- * duplicate values on a scalar codec reject (P5). No coercion, ever (STD2):
25
- * the schema accepts wire strings (`"42"`), not decoded values (`42`).
20
+ * Schema form, for consumers like tRPC inputs or TanStack `validateSearch`.
21
+ * Semantics are byte-identical to `decodeSearch`: defaults apply, `.catch()`
22
+ * recovers parse failures — invalid API input silently coerces to the
23
+ * fallback — unknown keys strip (P8), and duplicate values on a scalar codec
24
+ * reject (P5). No coercion, ever: the schema accepts wire strings (`"42"`),
25
+ * not decoded values (`42`).
26
26
  */
27
27
  export declare function standardSearchSchema<R extends AnyRoute>(route: R): StandardSearchSchema<R["~search"]>;
@@ -3,16 +3,16 @@ import { safeDecodeSearch } from "./safe-decode.js";
3
3
  import { isRawSearch, requireSearchConfig, } from "./search.js";
4
4
  /**
5
5
  * Exports a route's `search:` config as the URL wire contract in Standard
6
- * Schema form (design-08 STD1/STD5), for consumers like tRPC inputs or
7
- * TanStack `validateSearch`. Semantics are byte-identical to `decodeSearch`
8
- * (STD6): defaults apply, `.catch()` recovers parse failures — invalid API
9
- * input silently coerces to the fallback — unknown keys strip (P8), and
10
- * duplicate values on a scalar codec reject (P5). No coercion, ever (STD2):
11
- * the schema accepts wire strings (`"42"`), not decoded values (`42`).
6
+ * Schema form, for consumers like tRPC inputs or TanStack `validateSearch`.
7
+ * Semantics are byte-identical to `decodeSearch`: defaults apply, `.catch()`
8
+ * recovers parse failures — invalid API input silently coerces to the
9
+ * fallback — unknown keys strip (P8), and duplicate values on a scalar codec
10
+ * reject (P5). No coercion, ever: the schema accepts wire strings (`"42"`),
11
+ * not decoded values (`42`).
12
12
  */
13
13
  export function standardSearchSchema(route) {
14
14
  const config = route["~search"];
15
- // A missing/malformed config is a programming error and stays loud (STD7);
15
+ // A missing/malformed config is a programming error and stays loud;
16
16
  // checking eagerly fails at construction, not at first validate().
17
17
  requireSearchConfig(config);
18
18
  // A config's raw/codec-map shape is fixed at construction; hoisted so the
@@ -31,7 +31,7 @@ export function standardSearchSchema(route) {
31
31
  return { value: result.data };
32
32
  }
33
33
  catch (error) {
34
- // STD7: validate() receives genuinely untrusted input, so the
34
+ // validate() receives genuinely untrusted input, so the
35
35
  // source-shape contract the read layer enforces with loud throws
36
36
  // softens to issues at this one boundary — SearchSourceError exists
37
37
  // as its own branded class exactly so this catch can't swallow
@@ -39,9 +39,9 @@ export function standardSearchSchema(route) {
39
39
  if (error instanceof SearchSourceError) {
40
40
  return { issues: [toSourceIssue(error)] };
41
41
  }
42
- // Async raw schema (design-02 D7), throwing raw validators, and
43
- // throwing .default()/.catch() factories are true programming
44
- // errors: loud (STD7).
42
+ // Async raw schema, throwing raw validators, and throwing
43
+ // .default()/.catch() factories are true programming errors: they
44
+ // stay loud.
45
45
  throw error;
46
46
  }
47
47
  },
@@ -51,8 +51,8 @@ export function standardSearchSchema(route) {
51
51
  };
52
52
  }
53
53
  /**
54
- * Maps a source-shape violation to spec shape (STD7): keyed where the read
55
- * layer could attribute it, root-level (absent path) for a malformed source.
54
+ * Maps a source-shape violation to spec shape: keyed where the read layer
55
+ * could attribute it, root-level (absent path) for a malformed source.
56
56
  */
57
57
  function toSourceIssue(error) {
58
58
  return error.key === null
@@ -62,8 +62,8 @@ function toSourceIssue(error) {
62
62
  /**
63
63
  * Maps a decode issue to spec shape. decodeRawSearch collapses a root-level
64
64
  * schema issue to the "<search>" sentinel key (SS3/SS4); a Standard Schema
65
- * expresses "root" as an ABSENT path, so the sentinel un-maps here (STD7
66
- * "root-level otherwise") — for raw configs only, since a codec map's issues
65
+ * expresses "root" as an ABSENT path, so the sentinel un-maps back to a
66
+ * root-level issue here — for raw configs only, since a codec map's issues
67
67
  * are always keyed and a param may literally be named "<search>". (A raw
68
68
  * vendor issue whose real path is ["<search>"] still collides with the
69
69
  * sentinel — indistinguishable after the flat-key collapse.) Nested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paramour",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -33,7 +33,7 @@
33
33
  "url": "git+https://github.com/JasonPaff/paramour.git",
34
34
  "directory": "packages/core"
35
35
  },
36
- "homepage": "https://github.com/JasonPaff/paramour#readme",
36
+ "homepage": "https://paramour.dev",
37
37
  "bugs": {
38
38
  "url": "https://github.com/JasonPaff/paramour/issues"
39
39
  },