paramour 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { AnyCodec, OutputOf, PresenceOf } from "./codec.js";
3
+ /**
4
+ * href-input side (design-02 D4): required presence stays required;
5
+ * optional and defaulted keys may be omitted. Array (arity-"many") keys may
6
+ * also be omitted: absent and [] are the same wire state (S6/P6), so
7
+ * requiring `tags: []` ceremony would be pure noise. Omittable keys also
8
+ * admit an EXPLICIT `undefined` — encodeSearch already treats that value as
9
+ * absent (S3), and without the `| undefined` widening a decoded
10
+ * {@link InferSearchOutput} (every key present, optional presence as
11
+ * `| undefined`) could not flow back into href under
12
+ * `exactOptionalPropertyTypes` without key-by-key reassembly.
13
+ */
14
+ export type InferSearchInput<S extends SearchConfig> = {
15
+ [K in Exclude<keyof S, OptionalInputKeys<S>>]: OutputOf<S[K]>;
16
+ } & {
17
+ [K in OptionalInputKeys<S>]?: OutputOf<S[K]> | undefined;
18
+ };
19
+ /**
20
+ * Parse-output side (design-02 D4): every declared key is PRESENT on the
21
+ * object; optional presence contributes `| undefined` to the value type.
22
+ */
23
+ export type InferSearchOutput<S extends SearchConfig> = {
24
+ [K in keyof S]: PresenceOf<S[K]> extends "optional" ? OutputOf<S[K]> | undefined : OutputOf<S[K]>;
25
+ };
26
+ /**
27
+ * The whole-object search escape hatch (design-04 SS1/SS2): wraps a bare
28
+ * Standard Schema in a branded marker so `search:` config discrimination is
29
+ * unambiguous at both the type and runtime level. `~`-prefixed members are
30
+ * reserved (codec convention) — a codec map never carries them at the top
31
+ * level, so there is no collision with user param names.
32
+ */
33
+ export interface RawSearch<S extends StandardSchemaV1> {
34
+ readonly "~kind": "raw-search";
35
+ readonly "~schema": S;
36
+ }
37
+ /** A search-params schema: key → codec. */
38
+ export type SearchConfig = Record<string, AnyCodec>;
39
+ /**
40
+ * href / encode side of a `search:` config (design-04 SS6): a `RawSearch`
41
+ * route accepts the raw wire record (SS5 — the schema never runs on encode,
42
+ * so there's no encode-side type to infer from it); a codec map keeps its
43
+ * existing `InferSearchInput` behavior. Module-exported for route.ts/href.ts,
44
+ * not barrel-exported — same precedent as `encodeComponent`/`readInputValue`.
45
+ */
46
+ export type SearchInputOf<SC> = SC extends RawSearch<StandardSchemaV1> ? Record<string, string | string[]> : SC extends SearchConfig ? InferSearchInput<SC> : never;
47
+ /**
48
+ * Parse-output side of a `search:` config (design-04 SS6): a `RawSearch`
49
+ * route's output is the schema's own inferred output; a codec map keeps its
50
+ * existing `InferSearchOutput` behavior. Module-exported for
51
+ * route.ts/href.ts, not barrel-exported.
52
+ */
53
+ export type SearchOutputOf<SC> = SC extends RawSearch<infer S> ? StandardSchemaV1.InferOutput<S> : SC extends SearchConfig ? InferSearchOutput<SC> : never;
54
+ /**
55
+ * The `search:` config slot's full type (design-04 SS2): a codec map (the
56
+ * main road) or a `RawSearch` marker (the escape hatch). Internal — not
57
+ * barrel-exported; `Route`/`RouteConfig`/`HrefArgs` consume it as their `SC`
58
+ * bound.
59
+ */
60
+ export type SearchSlot = RawSearch<StandardSchemaV1> | SearchConfig;
61
+ /**
62
+ * Decoded value-layer sources (wire spec §1): Next's server `searchParams`
63
+ * shape or the client's `URLSearchParams`. Both are already percent-decoded
64
+ * by the platform.
65
+ */
66
+ export type SearchSource = Record<string, string | string[] | undefined> | URLSearchParams;
67
+ type OptionalInputKeys<S extends SearchConfig> = {
68
+ [K in keyof S]: S[K]["~arity"] extends "many" ? K : PresenceOf<S[K]> extends "required" ? never : K;
69
+ }[keyof S];
70
+ /**
71
+ * Builds the byte-layer query string from decoded pairs. Hand-rolled on
72
+ * purpose: URLSearchParams#toString would emit `+` for space; we emit `%20`
73
+ * (S2). Returns "" for an empty pair set (S1). Unencodable text (lone
74
+ * surrogates) throws {@link SerializeError} (S7).
75
+ */
76
+ export declare function buildSearchString(pairs: readonly (readonly [string, string])[]): string;
77
+ /**
78
+ * Decodes search params against a config. Unknown keys are ignored (P8):
79
+ * source values are only read (and validated) for declared keys, so junk
80
+ * under keys paramour doesn't own can never fail a decode.
81
+ * Throws {@link SearchDecodeError} carrying one issue per failed key.
82
+ *
83
+ * A `RawSearch` config (design-04 SS2) branches to the whole-object schema
84
+ * path instead: every source key reaches the schema (P8 does not apply
85
+ * there — the schema owns stripping or passing through extras).
86
+ */
87
+ export declare function decodeSearch<S extends SearchSlot>(config: S, source: SearchSource): SearchOutputOf<S>;
88
+ /**
89
+ * encodeURIComponent throws a raw URIError on lone surrogates; wrap it so
90
+ * the documented "every error is a ParamourError" contract holds (S7).
91
+ * Exported for path.ts (the byte-layer chokepoint is shared with RL5's
92
+ * segment encoding), not from the package barrel.
93
+ */
94
+ export declare function encodeComponent(text: string): string;
95
+ /**
96
+ * Encodes an input object to ordered wire pairs (decoded value layer).
97
+ * Deterministic: config declaration order, array elements in order (S5).
98
+ * Caveat: JS property enumeration puts integer-like keys ("0", "42") first
99
+ * in ascending numeric order regardless of declaration — declaration order
100
+ * is unrecoverable for those, so they sort numerically before all others.
101
+ * Params equal to their `.default()` are elided (design-02 D8), compared by
102
+ * serialized wire form against the live default (re-serialized per encode —
103
+ * a build-time snapshot would go stale if a reference-typed default were
104
+ * mutated, silently dropping explicit values that then decode differently).
105
+ * Only value-form defaults elide — factory defaults are excluded, since a
106
+ * time-varying factory would elide an explicit value that later decodes as
107
+ * a different one.
108
+ *
109
+ * A `RawSearch` config (design-04 SS5) branches to a raw pass-through
110
+ * instead: no serializer exists for a whole-object schema, so the caller's
111
+ * record goes straight to the byte layer and the schema never runs on encode.
112
+ */
113
+ export declare function encodeSearch<S extends SearchSlot>(config: S, input: SearchInputOf<S>): [string, string][];
114
+ /**
115
+ * Runtime discriminant for the `search:` slot (design-04 SS2): the reserved
116
+ * `~kind` marker is unambiguous against a codec map, which never carries a
117
+ * top-level `~`-prefixed key. Module-exported for standard-schema.ts, not
118
+ * barrel-exported.
119
+ */
120
+ export declare function isRawSearch(config: SearchSlot): config is RawSearch<StandardSchemaV1>;
121
+ /**
122
+ * The whole-object search escape hatch (design-04 SS1, maintainer ruling):
123
+ * an explicit, greppable wrapper around a bare Standard Schema so a route's
124
+ * `search:` slot never falls into the degraded raw mode by accident — a
125
+ * bare `search: schema` could be confused for a codec map, but `rawSearch`
126
+ * is a conscious act. Per-key defaults/`.catch()` and round-trip encoding
127
+ * are deliberately unavailable here (SS7); reach for `p.custom` if you need
128
+ * bidirectional per-key transforms instead.
129
+ */
130
+ export declare function rawSearch<S extends StandardSchemaV1>(schema: S): RawSearch<S>;
131
+ /**
132
+ * Reads one input property for {@link encodeSearch} (and path.ts's
133
+ * encodeParams — exported for it, not from the package barrel). Not a bare
134
+ * `values[key]` read: keys like "constructor" must not pick up inherited
135
+ * Object.prototype members as present values. Not plain `Object.hasOwn`
136
+ * either: class instances expose their values through prototype getters. So:
137
+ * own properties always count; prototype levels count only accessors (a data
138
+ * property there is a class method or `constructor`, not a value); and the
139
+ * walk stops before the terminal prototype by chain position, not identity,
140
+ * so cross-realm inputs (vm, jsdom, iframes) exclude THEIR Object.prototype
141
+ * members too.
142
+ */
143
+ export declare function readInputValue(values: Record<string, unknown>, key: string): unknown;
144
+ /**
145
+ * The TS contract makes a non-object config unrepresentable, but a
146
+ * hand-built route missing `~search` reaches both codecs' entry points via
147
+ * href/parseSearch in plain JS; fail branded — a missing config is a
148
+ * config-contract violation (requireCodec's precedent), never a raw
149
+ * TypeError out of Object.entries/Object.keys. Module-exported for
150
+ * standard-schema.ts, not barrel-exported.
151
+ */
152
+ export declare function requireSearchConfig(config: SearchSlot): void;
153
+ /** Convenience: encode + build in one step. */
154
+ export declare function searchToString<S extends SearchSlot>(config: S, input: SearchInputOf<S>): string;
155
+ export {};
package/dist/search.js ADDED
@@ -0,0 +1,474 @@
1
+ import { describeType, foreignMessage, ParamourError, ParseError, rebrandForeign, SearchDecodeError, SearchSourceError, SerializeError, } from "./errors.js";
2
+ import { runStandardSchemaSync } from "./schema.js";
3
+ /**
4
+ * Builds the byte-layer query string from decoded pairs. Hand-rolled on
5
+ * purpose: URLSearchParams#toString would emit `+` for space; we emit `%20`
6
+ * (S2). Returns "" for an empty pair set (S1). Unencodable text (lone
7
+ * surrogates) throws {@link SerializeError} (S7).
8
+ */
9
+ export function buildSearchString(pairs) {
10
+ if (pairs.length === 0)
11
+ return "";
12
+ return `?${pairs
13
+ .map(([key, value]) => `${encodeComponent(key)}=${encodeComponent(value)}`)
14
+ .join("&")}`;
15
+ }
16
+ /**
17
+ * Decodes search params against a config. Unknown keys are ignored (P8):
18
+ * source values are only read (and validated) for declared keys, so junk
19
+ * under keys paramour doesn't own can never fail a decode.
20
+ * Throws {@link SearchDecodeError} carrying one issue per failed key.
21
+ *
22
+ * A `RawSearch` config (design-04 SS2) branches to the whole-object schema
23
+ * path instead: every source key reaches the schema (P8 does not apply
24
+ * there — the schema owns stripping or passing through extras).
25
+ */
26
+ export function decodeSearch(config, source) {
27
+ requireSearchConfig(config);
28
+ if (isRawSearch(config)) {
29
+ return decodeRawSearch(config, source);
30
+ }
31
+ // The conditional SearchSlot doesn't narrow inside the generic body once
32
+ // the RawSearch branch returns (S stays a generic type parameter); this
33
+ // cast unifies the two branches at the one chokepoint (same move as
34
+ // routeData's config cast). tsc requires the cast (isRawSearch's `is
35
+ // RawSearch<...>` guard doesn't narrow a generic-typed parameter's negative
36
+ // branch); no-unnecessary-type-assertion mis-flags it as redundant for this
37
+ // exact generic-parameter-plus-user-guard shape.
38
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
39
+ const searchConfig = config;
40
+ const issues = [];
41
+ // Built as entries so keys like "__proto__" become ordinary own properties
42
+ // of the result (Object.fromEntries uses define, not set, semantics).
43
+ const entries = [];
44
+ // Snapshot every declared key's wire values before any user code (custom
45
+ // parse, default/catch factories) runs: code holding the source reference
46
+ // can't change what later declared keys read mid-decode.
47
+ const sourceValues = readDeclaredValues(searchConfig, source);
48
+ // Absence is presence's job — .catch() only ever recovers parse
49
+ // *failures* (D2), which is why this is shared by both arity branches.
50
+ const recoverParseError = (error, key, codec) => {
51
+ if (error instanceof ParseError && codec["~catchValue"] !== undefined) {
52
+ entries.push([key, codec["~catchValue"]()]);
53
+ }
54
+ else if (error instanceof ParseError) {
55
+ issues.push({ key, message: error.message });
56
+ }
57
+ else {
58
+ throw error;
59
+ }
60
+ };
61
+ for (const [key, codec] of Object.entries(searchConfig)) {
62
+ const values = sourceValues.get(key) ?? [];
63
+ if (codec["~arity"] === "many") {
64
+ // Array codecs consume all values in wire order; absent → [] (P6).
65
+ // Presence modifiers are banned on array codecs, so no absence
66
+ // handling exists here.
67
+ try {
68
+ entries.push([key, values.map((raw) => codec["~parseElement"](raw))]);
69
+ }
70
+ catch (error) {
71
+ recoverParseError(error, key, codec);
72
+ }
73
+ continue;
74
+ }
75
+ if (values.length === 0) {
76
+ switch (codec["~presence"]) {
77
+ case "defaulted":
78
+ // The optional-chain covers structurally-built codecs with no
79
+ // default thunk (unreachable via the public builders); the D4
80
+ // every-declared-key-present invariant holds either way.
81
+ entries.push([key, codec["~defaultValue"]?.()]);
82
+ break;
83
+ case "optional":
84
+ entries.push([key, undefined]);
85
+ break;
86
+ case "required":
87
+ issues.push({ key, message: "required search param is missing" });
88
+ break;
89
+ }
90
+ continue;
91
+ }
92
+ const first = values[0];
93
+ if (first === undefined)
94
+ continue; // unreachable: values.length >= 1 here
95
+ try {
96
+ if (values.length > 1) {
97
+ // Duplicate keys on a scalar codec: never silently disambiguated (P5).
98
+ throw new ParseError(`received ${String(values.length)} values for a single-value param`);
99
+ }
100
+ entries.push([key, codec["~parseElement"](first)]);
101
+ }
102
+ catch (error) {
103
+ recoverParseError(error, key, codec);
104
+ }
105
+ }
106
+ if (issues.length > 0) {
107
+ throw new SearchDecodeError(issues);
108
+ }
109
+ return Object.fromEntries(entries);
110
+ }
111
+ /**
112
+ * encodeURIComponent throws a raw URIError on lone surrogates; wrap it so
113
+ * the documented "every error is a ParamourError" contract holds (S7).
114
+ * Exported for path.ts (the byte-layer chokepoint is shared with RL5's
115
+ * segment encoding), not from the package barrel.
116
+ */
117
+ export function encodeComponent(text) {
118
+ return rebrandForeign(() => encodeURIComponent(text), (error) => new SerializeError(`text is not encodable as a URL component: ${foreignMessage(error)}`, { cause: error }));
119
+ }
120
+ /**
121
+ * Encodes an input object to ordered wire pairs (decoded value layer).
122
+ * Deterministic: config declaration order, array elements in order (S5).
123
+ * Caveat: JS property enumeration puts integer-like keys ("0", "42") first
124
+ * in ascending numeric order regardless of declaration — declaration order
125
+ * is unrecoverable for those, so they sort numerically before all others.
126
+ * Params equal to their `.default()` are elided (design-02 D8), compared by
127
+ * serialized wire form against the live default (re-serialized per encode —
128
+ * a build-time snapshot would go stale if a reference-typed default were
129
+ * mutated, silently dropping explicit values that then decode differently).
130
+ * Only value-form defaults elide — factory defaults are excluded, since a
131
+ * time-varying factory would elide an explicit value that later decodes as
132
+ * a different one.
133
+ *
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.
137
+ */
138
+ export function encodeSearch(config, input) {
139
+ requireSearchConfig(config);
140
+ if (isRawSearch(config)) {
141
+ return encodeRawSearch(input);
142
+ }
143
+ // See the matching cast + comment in decodeSearch above.
144
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
145
+ const searchConfig = config;
146
+ // The TS contract forbids non-object inputs, but plain-JS callers reach
147
+ // here; a null input must fail loud, not read as every-key-absent.
148
+ const untrusted = input;
149
+ if (typeof untrusted !== "object" || untrusted === null) {
150
+ throw new SerializeError(`search input must be an object, got ${describeType(untrusted)}`);
151
+ }
152
+ const pairs = [];
153
+ const values = untrusted;
154
+ for (const [key, codec] of Object.entries(searchConfig)) {
155
+ const value = readInputValue(values, key);
156
+ // Arity first: array codecs carry runtime ~presence "required", so the
157
+ // scalar required-missing check below must never see them.
158
+ if (codec["~arity"] === "many") {
159
+ if (value === undefined) {
160
+ continue; // absent array param ≡ [] → nothing on the wire (S6)
161
+ }
162
+ if (!Array.isArray(value)) {
163
+ throw new SerializeError(`search param "${key}" expects an array, got ${typeof value}`);
164
+ }
165
+ // Array codecs cannot carry defaults, so no elision applies.
166
+ for (const element of value) {
167
+ pairs.push([key, serializeValue(codec, key, element)]);
168
+ }
169
+ continue;
170
+ }
171
+ if (value === undefined) {
172
+ if (codec["~presence"] === "required") {
173
+ throw new SerializeError(`required search param "${key}" is missing`);
174
+ }
175
+ continue; // absent optional/defaulted param → key omitted (S3)
176
+ }
177
+ const serialized = serializeValue(codec, key, value);
178
+ // D8 elision, gated on an elidable default existing — an ungated
179
+ // comparison would let a (contract-violating) serialize that returns
180
+ // undefined match undefined and silently drop the param.
181
+ if (codec["~defaultElides"] &&
182
+ codec["~defaultValue"] !== undefined &&
183
+ serialized === serializeValue(codec, key, codec["~defaultValue"]())) {
184
+ continue;
185
+ }
186
+ pairs.push([key, serialized]);
187
+ }
188
+ return pairs;
189
+ }
190
+ /**
191
+ * Runtime discriminant for the `search:` slot (design-04 SS2): the reserved
192
+ * `~kind` marker is unambiguous against a codec map, which never carries a
193
+ * top-level `~`-prefixed key. Module-exported for standard-schema.ts, not
194
+ * barrel-exported.
195
+ */
196
+ export function isRawSearch(config) {
197
+ return "~kind" in config && config["~kind"] === "raw-search";
198
+ }
199
+ /**
200
+ * The whole-object search escape hatch (design-04 SS1, maintainer ruling):
201
+ * an explicit, greppable wrapper around a bare Standard Schema so a route's
202
+ * `search:` slot never falls into the degraded raw mode by accident — a
203
+ * bare `search: schema` could be confused for a codec map, but `rawSearch`
204
+ * is a conscious act. Per-key defaults/`.catch()` and round-trip encoding
205
+ * are deliberately unavailable here (SS7); reach for `p.custom` if you need
206
+ * bidirectional per-key transforms instead.
207
+ */
208
+ export function rawSearch(schema) {
209
+ return { "~kind": "raw-search", "~schema": schema };
210
+ }
211
+ /**
212
+ * Reads one input property for {@link encodeSearch} (and path.ts's
213
+ * encodeParams — exported for it, not from the package barrel). Not a bare
214
+ * `values[key]` read: keys like "constructor" must not pick up inherited
215
+ * Object.prototype members as present values. Not plain `Object.hasOwn`
216
+ * either: class instances expose their values through prototype getters. So:
217
+ * own properties always count; prototype levels count only accessors (a data
218
+ * property there is a class method or `constructor`, not a value); and the
219
+ * walk stops before the terminal prototype by chain position, not identity,
220
+ * so cross-realm inputs (vm, jsdom, iframes) exclude THEIR Object.prototype
221
+ * members too.
222
+ */
223
+ export function readInputValue(values, key) {
224
+ if (Object.hasOwn(values, key))
225
+ return readThroughReceiver(values, key);
226
+ let current = Object.getPrototypeOf(values);
227
+ while (current !== null && Object.getPrototypeOf(current) !== null) {
228
+ const descriptor = Object.getOwnPropertyDescriptor(current, key);
229
+ if (descriptor !== undefined) {
230
+ // Nearest declaration wins: a data property here shadows anything
231
+ // deeper and is not a value.
232
+ return descriptor.get === undefined
233
+ ? undefined
234
+ : readThroughReceiver(values, key);
235
+ }
236
+ current = Object.getPrototypeOf(current);
237
+ }
238
+ return undefined;
239
+ }
240
+ /**
241
+ * The TS contract makes a non-object config unrepresentable, but a
242
+ * hand-built route missing `~search` reaches both codecs' entry points via
243
+ * href/parseSearch in plain JS; fail branded — a missing config is a
244
+ * config-contract violation (requireCodec's precedent), never a raw
245
+ * TypeError out of Object.entries/Object.keys. Module-exported for
246
+ * standard-schema.ts, not barrel-exported.
247
+ */
248
+ export function requireSearchConfig(config) {
249
+ const untrusted = config;
250
+ if (typeof untrusted !== "object" || untrusted === null) {
251
+ throw new ParamourError(`search config must be an object, got ${describeType(untrusted)}`);
252
+ }
253
+ }
254
+ /** Convenience: encode + build in one step. */
255
+ export function searchToString(config, input) {
256
+ return buildSearchString(encodeSearch(config, input));
257
+ }
258
+ /**
259
+ * The `RawSearch` decode path (design-04 SS3/SS4). The schema receives EVERY
260
+ * source key, normalized to Next's own `searchParams` shape — P8's
261
+ * declared-keys-only stance doesn't apply to a whole-object schema, which
262
+ * owns stripping or passing through extras itself. Sync only, per D7 (the
263
+ * shared runner throws on an async `validate`). A validator that THROWS
264
+ * (rather than returning issues) is rebranded at this chokepoint — the
265
+ * shared runner deliberately stays throw-preserving (plan-04 step 1) so this
266
+ * call site owns the wrap, mirroring how a foreign throw is branded
267
+ * elsewhere in the package.
268
+ */
269
+ function decodeRawSearch(config, source) {
270
+ const record = readAllValues(source);
271
+ const result = rebrandForeign(() => runStandardSchemaSync(config["~schema"], record), (error) => new ParamourError(`raw-search schema validation threw: ${foreignMessage(error)}`, { cause: error }));
272
+ if (result.issues) {
273
+ // SS3/SS4: the spec types issue.path as ReadonlyArray<PropertyKey |
274
+ // PathSegment> where PathSegment is { key }. Valibot emits the object
275
+ // form (a bare String(seg) would be "[object Object]"); Zod emits [] for
276
+ // a root-level issue, Valibot omits path entirely (both join to "", so
277
+ // the sentinel keys off the empty join, not just a nullish path).
278
+ //
279
+ // Array.from, not .map: these arrays belong to the validator, and a
280
+ // ReadonlyArray may be an Array subclass. ArkType's `path` is one, with a
281
+ // variadic constructor -- Array.prototype.map builds its result via
282
+ // Symbol.species (`new ReadonlyPath(0)`), which yields the one-element
283
+ // array [0], so an empty root path would map to the key "0". Array.from
284
+ // always produces a plain Array and is immune.
285
+ const issues = Array.from(result.issues, (issue) => {
286
+ const key = Array.from(issue.path ?? [], (seg) => String(typeof seg === "object" ? seg.key : seg)).join(".");
287
+ return { key: key === "" ? "<search>" : key, message: issue.message };
288
+ });
289
+ throw new SearchDecodeError(issues);
290
+ }
291
+ return result.value;
292
+ }
293
+ /**
294
+ * The `RawSearch` encode path (design-04 SS5): no serializer exists for a
295
+ * whole-object schema, so the caller's already-wire-shaped record is pushed
296
+ * straight to the byte layer — one pair per string value, one repeated pair
297
+ * per array element — and the schema never runs on encode.
298
+ */
299
+ function encodeRawSearch(input) {
300
+ const untrusted = input;
301
+ if (typeof untrusted !== "object" || untrusted === null) {
302
+ throw new SerializeError(`search input must be an object, got ${describeType(untrusted)}`);
303
+ }
304
+ const values = untrusted;
305
+ const pairs = [];
306
+ for (const key of Object.keys(values)) {
307
+ const value = readThroughReceiver(values, key);
308
+ if (value === undefined)
309
+ continue;
310
+ if (Array.isArray(value)) {
311
+ for (const element of value) {
312
+ pairs.push([key, requireRawSearchString(key, element)]);
313
+ }
314
+ continue;
315
+ }
316
+ pairs.push([key, requireRawSearchString(key, value)]);
317
+ }
318
+ return pairs;
319
+ }
320
+ /**
321
+ * Snapshots EVERY source key's wire values, before any user code (the
322
+ * whole-object schema's `validate`) runs — sibling of
323
+ * {@link readDeclaredValues} that reads all keys instead of declared-only
324
+ * ones (SS3: a whole-object schema has no declared keys of its own).
325
+ * Collapses each key's values by occurrence count (plan-04 point 2): one
326
+ * value → `string`, multiple → `string[]`, uniformly for both
327
+ * `URLSearchParams` and Next-record sources, so the schema author writes one
328
+ * mental model regardless of which source it came from.
329
+ */
330
+ function readAllValues(source) {
331
+ // The TS contract forbids non-object sources, but plain-JS callers reach
332
+ // here; fail branded, not with a raw TypeError out of Object.keys.
333
+ const untrusted = source;
334
+ if (typeof untrusted !== "object" || untrusted === null) {
335
+ throw new SearchSourceError(`search source must be an object, got ${describeType(untrusted)}`, null);
336
+ }
337
+ const grouped = new Map();
338
+ if (source instanceof URLSearchParams) {
339
+ // Values are validated even though the platform type guarantees strings:
340
+ // a lying subclass or polyfill must surface loudly, exactly like the
341
+ // Record branch below.
342
+ for (const [key, value] of source) {
343
+ if (typeof value !== "string") {
344
+ throw new SearchSourceError(`search source values for "${key}" must be strings, got ${typeof value}`, key);
345
+ }
346
+ const list = grouped.get(key);
347
+ if (list === undefined)
348
+ grouped.set(key, [value]);
349
+ else
350
+ list.push(value);
351
+ }
352
+ }
353
+ else {
354
+ for (const key of Object.keys(source)) {
355
+ const values = readRecordValues(source, key);
356
+ if (values.length > 0)
357
+ grouped.set(key, values);
358
+ }
359
+ }
360
+ // Built as entries so keys like "__proto__" become ordinary own properties
361
+ // of the result (Object.fromEntries uses define, not set, semantics).
362
+ const entries = [];
363
+ for (const [key, values] of grouped) {
364
+ // A single value collapses to a scalar, else stays an array. `grouped`
365
+ // only holds non-empty arrays, so `first` is always defined at length 1;
366
+ // the check re-narrows for noUncheckedIndexedAccess (the repo bans the
367
+ // non-null assertion that would otherwise say so), it guards no real case.
368
+ const [first] = values;
369
+ entries.push([
370
+ key,
371
+ values.length === 1 && first !== undefined ? first : values,
372
+ ]);
373
+ }
374
+ return Object.fromEntries(entries);
375
+ }
376
+ /**
377
+ * Snapshots the wire values of every declared key from a source, before any
378
+ * user code runs. Declared keys only, on purpose: unknown keys are never
379
+ * validated, so malformed junk under keys paramour doesn't own (qs bracket
380
+ * params, numbers) can't fail a decode (P8). Malformed values under a
381
+ * DECLARED key are a loud {@link SearchSourceError} — the source doesn't
382
+ * match its stated contract — never a silent key drop.
383
+ */
384
+ function readDeclaredValues(config, source) {
385
+ // The TS contract forbids non-object sources, but plain-JS callers reach
386
+ // here; fail branded, not with a raw TypeError out of Object.hasOwn.
387
+ const untrusted = source;
388
+ if (typeof untrusted !== "object" || untrusted === null) {
389
+ throw new SearchSourceError(`search source must be an object, got ${describeType(untrusted)}`, null);
390
+ }
391
+ const values = new Map();
392
+ if (source instanceof URLSearchParams) {
393
+ // Single pass over the pairs (getAll per key would rescan the whole
394
+ // list for every declared key). Values are validated even though the
395
+ // platform type guarantees strings: a lying subclass or polyfill must
396
+ // surface loudly, exactly like the Record branch below.
397
+ for (const [key, value] of source) {
398
+ if (!Object.hasOwn(config, key))
399
+ continue;
400
+ if (typeof value !== "string") {
401
+ throw new SearchSourceError(`search source values for "${key}" must be strings, got ${typeof value}`, key);
402
+ }
403
+ const list = values.get(key);
404
+ if (list === undefined)
405
+ values.set(key, [value]);
406
+ else
407
+ list.push(value);
408
+ }
409
+ return values;
410
+ }
411
+ for (const key of Object.keys(config)) {
412
+ values.set(key, readRecordValues(source, key));
413
+ }
414
+ return values;
415
+ }
416
+ /**
417
+ * Record-source twin of the URLSearchParams branch in
418
+ * {@link readDeclaredValues}.
419
+ */
420
+ function readRecordValues(source, key) {
421
+ if (!Object.hasOwn(source, key))
422
+ return [];
423
+ const value = source[key];
424
+ if (value === undefined)
425
+ return [];
426
+ if (typeof value === "string")
427
+ return [value];
428
+ if (Array.isArray(value)) {
429
+ // Copy FIRST, then validate the copy: validating the caller's array and
430
+ // re-reading it afterwards would let impure index getters present
431
+ // strings to validation yet deliver junk into the returned copy.
432
+ const copy = [...value];
433
+ for (const element of copy) {
434
+ if (typeof element !== "string") {
435
+ throw new SearchSourceError(`search source values for "${key}" must be strings, got ${typeof element}`, key);
436
+ }
437
+ }
438
+ return copy;
439
+ }
440
+ throw new SearchSourceError(`search source value for "${key}" must be a string or string[], got ${typeof value}`, key);
441
+ }
442
+ /**
443
+ * Property reads run user getters (the class-instance shape
444
+ * {@link readInputValue} supports); a throwing getter must not escape as a
445
+ * raw foreign error. Reads through the original receiver so getters see the
446
+ * instance.
447
+ */
448
+ function readThroughReceiver(values, key) {
449
+ return rebrandForeign(() => values[key], (error) => new SerializeError(`reading input "${key}" threw: ${foreignMessage(error)}`, { cause: error }));
450
+ }
451
+ /**
452
+ * Enforces {@link encodeRawSearch}'s wire-value contract (SS5): a raw-search
453
+ * input is already wire-shaped strings, unlike a codec's serializer, so a
454
+ * non-string leaf is a caller-contract violation, not something to coerce.
455
+ */
456
+ function requireRawSearchString(key, value) {
457
+ if (typeof value !== "string") {
458
+ throw new SerializeError(`search param "${key}" expects a string or string[], got ${typeof value}`);
459
+ }
460
+ return value;
461
+ }
462
+ /**
463
+ * Invokes a codec's serializer and enforces its string contract: a custom
464
+ * codec written in plain JS can return undefined, which would otherwise
465
+ * reach the byte layer as the literal text "undefined" — or, worse, match
466
+ * an absent default and silently drop the param.
467
+ */
468
+ function serializeValue(codec, key, value) {
469
+ const serialized = codec["~serializeElement"](value);
470
+ if (typeof serialized !== "string") {
471
+ throw new SerializeError(`serializer for search param "${key}" must return a string, got ${typeof serialized}`);
472
+ }
473
+ return serialized;
474
+ }
@@ -0,0 +1,27 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { AnyRoute } from "./route.js";
3
+ import { type SearchOutputOf } from "./search.js";
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.
8
+ */
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.
16
+ */
17
+ export type StandardSearchSchema<SC> = StandardSchemaV1<Record<string, string | string[] | undefined>, SearchOutputOf<SC>>;
18
+ /**
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`).
26
+ */
27
+ export declare function standardSearchSchema<R extends AnyRoute>(route: R): StandardSearchSchema<R["~search"]>;