paramour 0.3.0 → 0.4.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 CHANGED
@@ -66,8 +66,8 @@ export interface Codec<Out, P extends Presence = "required", C extends boolean =
66
66
  /** Stored as a thunk regardless of the form passed to `.default()`. */
67
67
  readonly "~defaultValue": (() => Out) | undefined;
68
68
  /**
69
- * Element codec of a composite list codec (currently `p.csv`) — the
70
- * per-segment scalar; undefined for every non-composite kind (CV6).
69
+ * Element codec of a composite list codec (`p.csv`, `p.array`) — the
70
+ * per-segment/per-key scalar; undefined for every non-composite kind (CV6).
71
71
  */
72
72
  readonly "~element": AnyCodec | undefined;
73
73
  /** Members of a `p.enum` codec; undefined for every other kind. */
@@ -24,7 +24,7 @@ export interface CodecDescription {
24
24
  readonly defaultValue?: CodecDefaultDescription;
25
25
  /**
26
26
  * Nested description of a composite list codec's element scalar (CV6;
27
- * currently `p.csv`).
27
+ * `p.csv` and `p.array`).
28
28
  */
29
29
  readonly element?: CodecDescription;
30
30
  readonly enumMembers?: readonly string[];
package/dist/describe.js CHANGED
@@ -10,8 +10,9 @@ export function describeCodec(codec) {
10
10
  arity: codec["~arity"],
11
11
  caught: codec["~caught"],
12
12
  ...(defaultValue === undefined ? {} : { defaultValue }),
13
- // Recursion terminates: nested csv is rejected at construction (CV2),
14
- // and element codecs are unmodified scalars with no element of their own.
13
+ // Recursion terminates: composite nesting is bounded at construction —
14
+ // csv rejects nested csv (CV2) and array rejects arity-many inners
15
+ // (PP1), so the deepest legal chain is array<csv<scalar>>.
15
16
  ...(element === undefined ? {} : { element: describeCodec(element) }),
16
17
  ...(enumMembers === undefined ? {} : { enumMembers }),
17
18
  kind: codec["~kind"],
@@ -63,11 +64,22 @@ export function formatCodecDescription(description, style) {
63
64
  const kindLabel = (part) => part.enumMembers === undefined
64
65
  ? part.kind
65
66
  : `enum(${part.enumMembers.join(memberSeparator)})`;
66
- let label = description.element === undefined
67
- ? kindLabel(description)
68
- : `${description.kind}<${kindLabel(description.element)}>`;
69
- if (description.arity === "many")
70
- label += "[]";
67
+ // Composite labels: a one-key list wraps its element (`csv<integer>`); a
68
+ // repeated-key list IS its element, pluralized (`integer[]`) — the
69
+ // "array" kind never appears in a label, the `[]` carries it. The elision
70
+ // keys on the kind, NOT arity: consumers force arity "many" onto
71
+ // non-array descriptions (render.ts's catch-all params), where a csv
72
+ // wrapper must survive as `csv<integer>[]`. Recursive so `array<csv<E>>`
73
+ // renders `csv<E>[]`.
74
+ const shapeLabel = (part) => {
75
+ const base = part.element === undefined
76
+ ? kindLabel(part)
77
+ : part.kind === "array"
78
+ ? shapeLabel(part.element)
79
+ : `${part.kind}<${shapeLabel(part.element)}>`;
80
+ return part.arity === "many" ? `${base}[]` : base;
81
+ };
82
+ let label = shapeLabel(description);
71
83
  if (style === "compact") {
72
84
  if (description.presence === "optional")
73
85
  label += "?";
package/dist/p.d.ts CHANGED
@@ -5,10 +5,17 @@ import { type Codec } from "./codec.js";
5
5
  * Each codec defines how one value crosses the URL boundary, both directions.
6
6
  */
7
7
  export declare const p: {
8
+ /**
9
+ * A repeated-key list (`?tags=a&tags=b`, design-13 PP1): arity "many", so
10
+ * presence modifiers are unavailable — absent and `[]` are the same wire
11
+ * state (S6/P6) — unlike `p.csv`'s one-key packing (CV7). Elements are
12
+ * strings unless an element codec is given.
13
+ */
14
+ array<E = string>(element?: Codec<E>): Codec<E[], "required", false, "many">;
8
15
  boolean(): Codec<boolean>;
9
16
  /**
10
17
  * A comma-separated scalar list in ONE wire value (design-11 CV1): arity
11
- * "single", so the full modifier set applies — unlike `p.stringArray`'s
18
+ * "single", so the full modifier set applies — unlike `p.array`'s
12
19
  * repeated-key format (CV7: both are first-class; csv is the one-key
13
20
  * packing). Elements are strings unless an element codec is given.
14
21
  */
@@ -20,11 +27,21 @@ export declare const p: {
20
27
  serialize: (value: Out) => string;
21
28
  }): Codec<Out>;
22
29
  enum<const M extends readonly [string, ...string[]]>(members: M): Codec<M[number]>;
30
+ /**
31
+ * A 1-based-on-wire / 0-based-in-memory integer for pagination-style
32
+ * params (`?page=1` ↔ index 0, design-13 PP5). Deliberately stricter than
33
+ * nuqs's `parseAsIndex`, whose below-floor behavior is unspecified: wire
34
+ * values below 1 are a ParseError (recoverable via `.catch()`, like any
35
+ * other malformed input), and a negative in-memory index — which cannot
36
+ * round-trip through the 1-based wire floor — is a SerializeError at
37
+ * link-build time (the RL1 ethos). The optional schema validates the
38
+ * in-memory (0-based) value on both sides.
39
+ */
40
+ index<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
23
41
  integer<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
24
42
  isoDate(): Codec<Date>;
25
43
  json<S extends StandardSchemaV1>(schema: S): Codec<StandardSchemaV1.InferOutput<S>>;
26
44
  number<S extends StandardSchemaV1<number, number>>(schema?: S): Codec<S extends undefined ? number : StandardSchemaV1.InferOutput<S>>;
27
45
  string<S extends StandardSchemaV1<string, string>>(schema?: S): Codec<S extends undefined ? string : StandardSchemaV1.InferOutput<S>>;
28
- stringArray(): Codec<string[], "required", false, "many">;
29
46
  timestamp(): Codec<Date>;
30
47
  };
package/dist/p.js CHANGED
@@ -73,12 +73,46 @@ function refineForSerialize(schema, value) {
73
73
  }
74
74
  return result.value;
75
75
  }
76
+ /**
77
+ * Shared element admission for `p.array`/`p.csv` (PP1/CV2): resolves the
78
+ * no-arg default and mirrors the type-state exclusions — presence, catch,
79
+ * and arity-many inners — for JS consumers (the RL1 ethos). Only the
80
+ * element's parse/serialize functions are captured by the list builders, so
81
+ * an accepted modifier would be silently dropped, not applied; one guard
82
+ * keeps that runtime mirror of the type-state in a single place.
83
+ */
84
+ function resolveListElement(element, builder) {
85
+ const inner = element ?? (defaultListElement ??= p.string());
86
+ if (inner["~arity"] === "many" ||
87
+ inner["~caught"] ||
88
+ inner["~presence"] !== "required") {
89
+ throw new ParamourError(`${builder}() elements cannot carry modifiers (.optional()/.default()/.catch()) or be array codecs`);
90
+ }
91
+ return inner;
92
+ }
76
93
  function serializeFiniteNumber(value) {
77
94
  if (typeof value !== "number" || !Number.isFinite(value)) {
78
95
  throw new SerializeError(`Expected a finite number, got ${showValue(value)}`);
79
96
  }
80
97
  return String(value);
81
98
  }
99
+ /**
100
+ * Serialize-side twin of {@link parseIntegerElement}, shared by `p.integer`
101
+ * and `p.index`: schema refinement plus the finite and safe-integer guards.
102
+ * Returns the refined NUMBER — `p.integer` stringifies it as-is, `p.index`
103
+ * shifts it into the 1-based wire form first — so the happy path pays no
104
+ * throwaway stringification.
105
+ */
106
+ function serializeIntegerElement(schema, value) {
107
+ const refined = schema ? refineForSerialize(schema, value) : value;
108
+ if (typeof refined !== "number" || !Number.isFinite(refined)) {
109
+ throw new SerializeError(`Expected a finite number, got ${showValue(refined)}`);
110
+ }
111
+ if (!Number.isSafeInteger(refined)) {
112
+ throw new SerializeError(`${String(refined)} is not a safe integer`);
113
+ }
114
+ return refined;
115
+ }
82
116
  /**
83
117
  * JSON.stringify throws raw TypeErrors (circular refs, BigInt) and lets
84
118
  * toJSON() exceptions escape; wrap them so the ParamourError contract holds.
@@ -87,16 +121,43 @@ function stringifyJson(value) {
87
121
  return rebrandForeign(() => JSON.stringify(value), (error) => new SerializeError("Value is not JSON-serializable", { cause: error }));
88
122
  }
89
123
  /**
90
- * Shared element for no-arg `p.csv()` — codecs are immutable, so one
91
- * schemaless string codec serves every list. Lazily built: `p` does not
124
+ * Shared element for no-arg `p.csv()`/`p.array()` — codecs are immutable, so
125
+ * one schemaless string codec serves every list. Lazily built: `p` does not
92
126
  * exist yet while the module initializes.
93
127
  */
94
- let defaultCsvElement;
128
+ let defaultListElement;
95
129
  /**
96
130
  * The `p.*` wire-codec builders (DESIGN §5 layer 1, design-02 D9).
97
131
  * Each codec defines how one value crosses the URL boundary, both directions.
98
132
  */
99
133
  export const p = {
134
+ /**
135
+ * A repeated-key list (`?tags=a&tags=b`, design-13 PP1): arity "many", so
136
+ * presence modifiers are unavailable — absent and `[]` are the same wire
137
+ * state (S6/P6) — unlike `p.csv`'s one-key packing (CV7). Elements are
138
+ * strings unless an element codec is given.
139
+ */
140
+ array(element) {
141
+ // PP1: same element-by-composition shape as p.csv (CV2) — presence,
142
+ // catch, and arity-many inners are excluded by the parameter type;
143
+ // resolveListElement mirrors that type-state for JS consumers. Unlike
144
+ // csv there is no nested-composite special case: a csv element is a
145
+ // legal whole-value scalar per repeated key (?m=a,b&m=c,d), and
146
+ // repeated-key values have no separator for element serializations to
147
+ // collide with, so no CV4 twin is needed either.
148
+ const inner = resolveListElement(element, "p.array");
149
+ // The element functions already have the arity-"many" per-element
150
+ // contract (one wire value per array item), so they pass through as-is;
151
+ // the string-return contract on custom serializers is enforced at the
152
+ // search.ts/path.ts call sites, same as using the element directly.
153
+ return createCodec({
154
+ arity: "many",
155
+ element: inner,
156
+ kind: "array",
157
+ parseElement: inner["~parseElement"],
158
+ serializeElement: inner["~serializeElement"],
159
+ });
160
+ },
100
161
  boolean() {
101
162
  return createCodec({
102
163
  kind: "boolean",
@@ -117,28 +178,26 @@ export const p = {
117
178
  },
118
179
  /**
119
180
  * A comma-separated scalar list in ONE wire value (design-11 CV1): arity
120
- * "single", so the full modifier set applies — unlike `p.stringArray`'s
181
+ * "single", so the full modifier set applies — unlike `p.array`'s
121
182
  * repeated-key format (CV7: both are first-class; csv is the one-key
122
183
  * packing). Elements are strings unless an element codec is given.
123
184
  */
124
185
  csv(element) {
125
186
  // CV2: presence, catch, and arity-many inners are excluded by the
126
- // parameter type (the D3 philosophy); these guards mirror that
127
- // type-state for JS consumers (the RL1 ethos) — only the element
128
- // functions are captured below, so an accepted modifier would be
129
- // silently dropped, not applied. Nesting is detected structurally via
130
- // ~element (never via ~kind, which is reflection-only and free-form for
131
- // p.custom labels). Comma-emitting p.custom inners are undetectable
132
- // here and are caught by the CV4 serialize guard instead.
133
- const inner = element ?? (defaultCsvElement ??= p.string());
187
+ // parameter type (the D3 philosophy); resolveListElement mirrors that
188
+ // type-state for JS consumers (the RL1 ethos). Nesting is detected
189
+ // structurally via ~element (never via ~kind, which is reflection-only
190
+ // and free-form for p.custom labels). Comma-emitting p.custom inners
191
+ // are undetectable here and are caught by the CV4 serialize guard
192
+ // instead.
193
+ const inner = resolveListElement(element, "p.csv");
194
+ // The shared guard ran first: p.array also carries ~element (PP1), and
195
+ // the arity guard owns the "array codecs" wording — the ~element guard
196
+ // here is then specifically the nested-csv (arity-"single" composite)
197
+ // case.
134
198
  if (inner["~element"] !== undefined) {
135
199
  throw new ParamourError("p.csv() elements cannot themselves be csv lists");
136
200
  }
137
- if (inner["~arity"] === "many" ||
138
- inner["~caught"] ||
139
- inner["~presence"] !== "required") {
140
- throw new ParamourError("p.csv() elements cannot carry modifiers (.optional()/.default()/.catch()) or be array codecs");
141
- }
142
201
  const parseInner = inner["~parseElement"];
143
202
  const serializeInner = inner["~serializeElement"];
144
203
  return createCodec({
@@ -226,21 +285,48 @@ export const p = {
226
285
  },
227
286
  });
228
287
  },
229
- integer(schema) {
288
+ /**
289
+ * A 1-based-on-wire / 0-based-in-memory integer for pagination-style
290
+ * params (`?page=1` ↔ index 0, design-13 PP5). Deliberately stricter than
291
+ * nuqs's `parseAsIndex`, whose below-floor behavior is unspecified: wire
292
+ * values below 1 are a ParseError (recoverable via `.catch()`, like any
293
+ * other malformed input), and a negative in-memory index — which cannot
294
+ * round-trip through the 1-based wire floor — is a SerializeError at
295
+ * link-build time (the RL1 ethos). The optional schema validates the
296
+ * in-memory (0-based) value on both sides.
297
+ */
298
+ index(schema) {
230
299
  return createCodec({
231
- kind: "integer",
300
+ kind: "index",
232
301
  parseElement: (raw) => {
233
- const value = parseIntegerElement(raw);
302
+ const wire = parseIntegerElement(raw);
303
+ if (wire < 1) {
304
+ throw new ParseError(`"${raw}" is below the 1-based wire floor of 1`);
305
+ }
306
+ const value = wire - 1;
234
307
  return schema ? refine(schema, value) : value;
235
308
  },
236
309
  serializeElement: (value) => {
237
- const refined = schema ? refineForSerialize(schema, value) : value;
238
- const serialized = serializeFiniteNumber(refined);
239
- if (!Number.isSafeInteger(refined)) {
240
- throw new SerializeError(`${serialized} is not a safe integer`);
310
+ const index = serializeIntegerElement(schema, value);
311
+ if (index < 0) {
312
+ throw new SerializeError(`${String(index)} is negative and cannot round-trip through the 1-based wire form`);
241
313
  }
242
- return serialized;
314
+ const wire = index + 1;
315
+ if (!Number.isSafeInteger(wire)) {
316
+ throw new SerializeError(`${String(index)} is outside the 1-based wire form's safe integer range`);
317
+ }
318
+ return String(wire);
319
+ },
320
+ });
321
+ },
322
+ integer(schema) {
323
+ return createCodec({
324
+ kind: "integer",
325
+ parseElement: (raw) => {
326
+ const value = parseIntegerElement(raw);
327
+ return schema ? refine(schema, value) : value;
243
328
  },
329
+ serializeElement: (value) => String(serializeIntegerElement(schema, value)),
244
330
  });
245
331
  },
246
332
  isoDate() {
@@ -311,19 +397,6 @@ export const p = {
311
397
  },
312
398
  });
313
399
  },
314
- stringArray() {
315
- return createCodec({
316
- arity: "many",
317
- kind: "string",
318
- parseElement: (raw) => raw,
319
- serializeElement: (value) => {
320
- if (typeof value !== "string") {
321
- throw new SerializeError("Expected an array of strings");
322
- }
323
- return value;
324
- },
325
- });
326
- },
327
400
  timestamp() {
328
401
  return createCodec({
329
402
  kind: "timestamp",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paramour",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {