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.
- package/dist/codec.d.ts +76 -0
- package/dist/codec.js +110 -0
- package/dist/describe.d.ts +63 -0
- package/dist/describe.js +75 -0
- package/dist/errors.d.ts +73 -0
- package/dist/errors.js +157 -0
- package/dist/href.d.ts +79 -0
- package/dist/href.js +32 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/p.d.ts +23 -0
- package/dist/p.js +265 -0
- package/dist/path.d.ts +108 -0
- package/dist/path.js +439 -0
- package/dist/route.d.ts +293 -0
- package/dist/route.js +210 -0
- package/dist/safe-decode.d.ts +16 -0
- package/dist/safe-decode.js +42 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +16 -0
- package/dist/search.d.ts +155 -0
- package/dist/search.js +474 -0
- package/dist/standard-schema.d.ts +27 -0
- package/dist/standard-schema.js +77 -0
- package/package.json +28 -4
- package/LICENSE +0 -21
- package/README.md +0 -28
package/dist/search.d.ts
ADDED
|
@@ -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"]>;
|