@atproto/lex-schema 0.0.11 → 0.0.13

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.
Files changed (261) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/core/$type.d.ts +149 -0
  3. package/dist/core/$type.d.ts.map +1 -1
  4. package/dist/core/$type.js +44 -0
  5. package/dist/core/$type.js.map +1 -1
  6. package/dist/core/record-key.d.ts +44 -0
  7. package/dist/core/record-key.d.ts.map +1 -1
  8. package/dist/core/record-key.js +30 -0
  9. package/dist/core/record-key.js.map +1 -1
  10. package/dist/core/result.d.ts +85 -4
  11. package/dist/core/result.d.ts.map +1 -1
  12. package/dist/core/result.js +60 -4
  13. package/dist/core/result.js.map +1 -1
  14. package/dist/core/schema.d.ts +232 -5
  15. package/dist/core/schema.d.ts.map +1 -1
  16. package/dist/core/schema.js +197 -4
  17. package/dist/core/schema.js.map +1 -1
  18. package/dist/core/string-format.d.ts +244 -11
  19. package/dist/core/string-format.d.ts.map +1 -1
  20. package/dist/core/string-format.js +150 -0
  21. package/dist/core/string-format.js.map +1 -1
  22. package/dist/core/types.d.ts +90 -3
  23. package/dist/core/types.d.ts.map +1 -1
  24. package/dist/core/types.js.map +1 -1
  25. package/dist/core/validation-error.d.ts +60 -0
  26. package/dist/core/validation-error.d.ts.map +1 -1
  27. package/dist/core/validation-error.js +60 -0
  28. package/dist/core/validation-error.js.map +1 -1
  29. package/dist/core/validation-issue.d.ts +61 -0
  30. package/dist/core/validation-issue.d.ts.map +1 -1
  31. package/dist/core/validation-issue.js +54 -1
  32. package/dist/core/validation-issue.js.map +1 -1
  33. package/dist/core/validator.d.ts +356 -11
  34. package/dist/core/validator.d.ts.map +1 -1
  35. package/dist/core/validator.js +203 -4
  36. package/dist/core/validator.js.map +1 -1
  37. package/dist/helpers.d.ts +12 -28
  38. package/dist/helpers.d.ts.map +1 -1
  39. package/dist/helpers.js.map +1 -1
  40. package/dist/schema/array.d.ts +46 -0
  41. package/dist/schema/array.d.ts.map +1 -1
  42. package/dist/schema/array.js +16 -1
  43. package/dist/schema/array.js.map +1 -1
  44. package/dist/schema/blob.d.ts +50 -2
  45. package/dist/schema/blob.d.ts.map +1 -1
  46. package/dist/schema/blob.js +44 -2
  47. package/dist/schema/blob.js.map +1 -1
  48. package/dist/schema/boolean.d.ts +29 -0
  49. package/dist/schema/boolean.d.ts.map +1 -1
  50. package/dist/schema/boolean.js +30 -1
  51. package/dist/schema/boolean.js.map +1 -1
  52. package/dist/schema/bytes.d.ts +39 -0
  53. package/dist/schema/bytes.d.ts.map +1 -1
  54. package/dist/schema/bytes.js +34 -1
  55. package/dist/schema/bytes.js.map +1 -1
  56. package/dist/schema/cid.d.ts +39 -0
  57. package/dist/schema/cid.d.ts.map +1 -1
  58. package/dist/schema/cid.js +35 -1
  59. package/dist/schema/cid.js.map +1 -1
  60. package/dist/schema/custom.d.ts +67 -1
  61. package/dist/schema/custom.d.ts.map +1 -1
  62. package/dist/schema/custom.js +55 -0
  63. package/dist/schema/custom.js.map +1 -1
  64. package/dist/schema/dict.d.ts +45 -0
  65. package/dist/schema/dict.d.ts.map +1 -1
  66. package/dist/schema/dict.js +46 -1
  67. package/dist/schema/dict.js.map +1 -1
  68. package/dist/schema/discriminated-union.d.ts +59 -0
  69. package/dist/schema/discriminated-union.d.ts.map +1 -1
  70. package/dist/schema/discriminated-union.js +47 -1
  71. package/dist/schema/discriminated-union.js.map +1 -1
  72. package/dist/schema/enum.d.ts +49 -0
  73. package/dist/schema/enum.d.ts.map +1 -1
  74. package/dist/schema/enum.js +49 -0
  75. package/dist/schema/enum.js.map +1 -1
  76. package/dist/schema/integer.d.ts +43 -0
  77. package/dist/schema/integer.d.ts.map +1 -1
  78. package/dist/schema/integer.js +38 -1
  79. package/dist/schema/integer.js.map +1 -1
  80. package/dist/schema/intersection.d.ts +55 -0
  81. package/dist/schema/intersection.d.ts.map +1 -1
  82. package/dist/schema/intersection.js +50 -0
  83. package/dist/schema/intersection.js.map +1 -1
  84. package/dist/schema/lex-map.d.ts +37 -0
  85. package/dist/schema/lex-map.d.ts.map +1 -0
  86. package/dist/schema/lex-map.js +60 -0
  87. package/dist/schema/lex-map.js.map +1 -0
  88. package/dist/schema/lex-value.d.ts +35 -0
  89. package/dist/schema/lex-value.d.ts.map +1 -0
  90. package/dist/schema/lex-value.js +87 -0
  91. package/dist/schema/lex-value.js.map +1 -0
  92. package/dist/schema/literal.d.ts +45 -0
  93. package/dist/schema/literal.d.ts.map +1 -1
  94. package/dist/schema/literal.js +45 -0
  95. package/dist/schema/literal.js.map +1 -1
  96. package/dist/schema/never.d.ts +43 -0
  97. package/dist/schema/never.d.ts.map +1 -1
  98. package/dist/schema/never.js +44 -1
  99. package/dist/schema/never.js.map +1 -1
  100. package/dist/schema/null.d.ts +30 -0
  101. package/dist/schema/null.d.ts.map +1 -1
  102. package/dist/schema/null.js +31 -1
  103. package/dist/schema/null.js.map +1 -1
  104. package/dist/schema/nullable.d.ts +42 -0
  105. package/dist/schema/nullable.d.ts.map +1 -1
  106. package/dist/schema/nullable.js +42 -0
  107. package/dist/schema/nullable.js.map +1 -1
  108. package/dist/schema/object.d.ts +57 -0
  109. package/dist/schema/object.d.ts.map +1 -1
  110. package/dist/schema/object.js +53 -1
  111. package/dist/schema/object.js.map +1 -1
  112. package/dist/schema/optional.d.ts +43 -0
  113. package/dist/schema/optional.d.ts.map +1 -1
  114. package/dist/schema/optional.js +43 -0
  115. package/dist/schema/optional.js.map +1 -1
  116. package/dist/schema/params.d.ts +96 -12
  117. package/dist/schema/params.d.ts.map +1 -1
  118. package/dist/schema/params.js +155 -21
  119. package/dist/schema/params.js.map +1 -1
  120. package/dist/schema/payload.d.ts +111 -15
  121. package/dist/schema/payload.d.ts.map +1 -1
  122. package/dist/schema/payload.js +73 -3
  123. package/dist/schema/payload.js.map +1 -1
  124. package/dist/schema/permission-set.d.ts +58 -0
  125. package/dist/schema/permission-set.d.ts.map +1 -1
  126. package/dist/schema/permission-set.js +50 -0
  127. package/dist/schema/permission-set.js.map +1 -1
  128. package/dist/schema/permission.d.ts +42 -0
  129. package/dist/schema/permission.d.ts.map +1 -1
  130. package/dist/schema/permission.js +39 -0
  131. package/dist/schema/permission.js.map +1 -1
  132. package/dist/schema/procedure.d.ts +64 -0
  133. package/dist/schema/procedure.d.ts.map +1 -1
  134. package/dist/schema/procedure.js +64 -0
  135. package/dist/schema/procedure.js.map +1 -1
  136. package/dist/schema/query.d.ts +55 -0
  137. package/dist/schema/query.d.ts.map +1 -1
  138. package/dist/schema/query.js +55 -0
  139. package/dist/schema/query.js.map +1 -1
  140. package/dist/schema/record.d.ts +76 -25
  141. package/dist/schema/record.d.ts.map +1 -1
  142. package/dist/schema/record.js +21 -0
  143. package/dist/schema/record.js.map +1 -1
  144. package/dist/schema/ref.d.ts +51 -0
  145. package/dist/schema/ref.d.ts.map +1 -1
  146. package/dist/schema/ref.js +18 -0
  147. package/dist/schema/ref.js.map +1 -1
  148. package/dist/schema/refine.d.ts +58 -9
  149. package/dist/schema/refine.d.ts.map +1 -1
  150. package/dist/schema/refine.js.map +1 -1
  151. package/dist/schema/regexp.d.ts +45 -0
  152. package/dist/schema/regexp.d.ts.map +1 -1
  153. package/dist/schema/regexp.js +46 -1
  154. package/dist/schema/regexp.js.map +1 -1
  155. package/dist/schema/string.d.ts +72 -6
  156. package/dist/schema/string.d.ts.map +1 -1
  157. package/dist/schema/string.js +56 -8
  158. package/dist/schema/string.js.map +1 -1
  159. package/dist/schema/subscription.d.ts +72 -2
  160. package/dist/schema/subscription.d.ts.map +1 -1
  161. package/dist/schema/subscription.js +59 -0
  162. package/dist/schema/subscription.js.map +1 -1
  163. package/dist/schema/token.d.ts +48 -0
  164. package/dist/schema/token.d.ts.map +1 -1
  165. package/dist/schema/token.js +49 -1
  166. package/dist/schema/token.js.map +1 -1
  167. package/dist/schema/typed-object.d.ts +73 -23
  168. package/dist/schema/typed-object.d.ts.map +1 -1
  169. package/dist/schema/typed-object.js +20 -1
  170. package/dist/schema/typed-object.js.map +1 -1
  171. package/dist/schema/typed-ref.d.ts +54 -0
  172. package/dist/schema/typed-ref.d.ts.map +1 -1
  173. package/dist/schema/typed-ref.js +16 -0
  174. package/dist/schema/typed-ref.js.map +1 -1
  175. package/dist/schema/typed-union.d.ts +51 -1
  176. package/dist/schema/typed-union.d.ts.map +1 -1
  177. package/dist/schema/typed-union.js +52 -2
  178. package/dist/schema/typed-union.js.map +1 -1
  179. package/dist/schema/union.d.ts +46 -0
  180. package/dist/schema/union.d.ts.map +1 -1
  181. package/dist/schema/union.js +41 -0
  182. package/dist/schema/union.js.map +1 -1
  183. package/dist/schema/unknown.d.ts +34 -0
  184. package/dist/schema/unknown.d.ts.map +1 -1
  185. package/dist/schema/unknown.js +34 -0
  186. package/dist/schema/unknown.js.map +1 -1
  187. package/dist/schema/with-default.d.ts +45 -0
  188. package/dist/schema/with-default.d.ts.map +1 -1
  189. package/dist/schema/with-default.js +45 -0
  190. package/dist/schema/with-default.js.map +1 -1
  191. package/dist/schema.d.ts +2 -1
  192. package/dist/schema.d.ts.map +1 -1
  193. package/dist/schema.js +2 -1
  194. package/dist/schema.js.map +1 -1
  195. package/dist/util/if-any.d.ts +2 -0
  196. package/dist/util/if-any.d.ts.map +1 -0
  197. package/dist/util/if-any.js +3 -0
  198. package/dist/util/if-any.js.map +1 -0
  199. package/package.json +3 -3
  200. package/src/core/$type.ts +150 -18
  201. package/src/core/record-key.ts +44 -0
  202. package/src/core/result.ts +86 -4
  203. package/src/core/schema.ts +244 -9
  204. package/src/core/string-format.ts +259 -13
  205. package/src/core/types.ts +91 -3
  206. package/src/core/validation-error.ts +60 -0
  207. package/src/core/validation-issue.ts +68 -2
  208. package/src/core/validator.ts +373 -12
  209. package/src/helpers.test.ts +110 -29
  210. package/src/helpers.ts +54 -25
  211. package/src/schema/array.test.ts +94 -79
  212. package/src/schema/array.ts +48 -1
  213. package/src/schema/blob.ts +50 -1
  214. package/src/schema/boolean.ts +31 -1
  215. package/src/schema/bytes.ts +41 -1
  216. package/src/schema/cid.ts +41 -1
  217. package/src/schema/custom.ts +68 -1
  218. package/src/schema/dict.ts +47 -1
  219. package/src/schema/discriminated-union.ts +61 -1
  220. package/src/schema/enum.ts +50 -0
  221. package/src/schema/integer.ts +45 -1
  222. package/src/schema/intersection.ts +56 -0
  223. package/src/schema/{unknown-object.test.ts → lex-map.test.ts} +9 -9
  224. package/src/schema/lex-map.ts +63 -0
  225. package/src/schema/lex-value.test.ts +81 -0
  226. package/src/schema/lex-value.ts +86 -0
  227. package/src/schema/literal.ts +46 -0
  228. package/src/schema/never.ts +45 -1
  229. package/src/schema/null.ts +32 -1
  230. package/src/schema/nullable.ts +43 -0
  231. package/src/schema/object.ts +59 -1
  232. package/src/schema/optional.ts +44 -0
  233. package/src/schema/params.test.ts +133 -38
  234. package/src/schema/params.ts +237 -37
  235. package/src/schema/payload.test.ts +3 -3
  236. package/src/schema/payload.ts +145 -42
  237. package/src/schema/permission-set.ts +58 -0
  238. package/src/schema/permission.ts +42 -0
  239. package/src/schema/procedure.ts +64 -0
  240. package/src/schema/query.ts +55 -0
  241. package/src/schema/record.ts +82 -16
  242. package/src/schema/ref.ts +52 -0
  243. package/src/schema/refine.ts +58 -9
  244. package/src/schema/regexp.ts +47 -1
  245. package/src/schema/string.test.ts +99 -2
  246. package/src/schema/string.ts +108 -15
  247. package/src/schema/subscription.ts +72 -2
  248. package/src/schema/token.ts +50 -1
  249. package/src/schema/typed-object.ts +81 -16
  250. package/src/schema/typed-ref.ts +55 -0
  251. package/src/schema/typed-union.ts +58 -3
  252. package/src/schema/union.ts +47 -0
  253. package/src/schema/unknown.ts +35 -0
  254. package/src/schema/with-default.ts +46 -0
  255. package/src/schema.ts +2 -1
  256. package/src/util/if-any.ts +3 -0
  257. package/dist/schema/unknown-object.d.ts +0 -8
  258. package/dist/schema/unknown-object.d.ts.map +0 -1
  259. package/dist/schema/unknown-object.js +0 -19
  260. package/dist/schema/unknown-object.js.map +0 -1
  261. package/src/schema/unknown-object.ts +0 -19
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { array } from './array.js'
3
3
  import { boolean } from './boolean.js'
4
+ import { enumSchema } from './enum.js'
4
5
  import { integer } from './integer.js'
6
+ import { literal } from './literal.js'
5
7
  import { optional } from './optional.js'
6
8
  import { paramSchema, params, paramsSchema } from './params.js'
7
9
  import { string } from './string.js'
@@ -260,78 +262,105 @@ describe('ParamsSchema', () => {
260
262
  })
261
263
  })
262
264
 
265
+ describe('coercion', () => {
266
+ it('throws for invalid enum values', () => {
267
+ const schema = params({
268
+ status: enumSchema(['active', 'inactive']),
269
+ })
270
+ expect(() => schema.fromURLSearchParams('status=unknown')).toThrow(
271
+ 'Expected one of "active" or "inactive"',
272
+ )
273
+ })
274
+
275
+ it('throws for invalid const values', () => {
276
+ const schema = params({
277
+ version: literal(42),
278
+ })
279
+ expect(() => schema.fromURLSearchParams('version=99')).toThrow(
280
+ 'Expected 42',
281
+ )
282
+ })
283
+
284
+ it('handles negative integer enum values', () => {
285
+ const schema = params({
286
+ offset: enumSchema([-10, 0, 10]),
287
+ })
288
+ const result = schema.fromURLSearchParams('offset=-10')
289
+ expect(result).toEqual({ offset: -10 })
290
+ })
291
+
292
+ it('handles boolean const false', () => {
293
+ const schema = params({
294
+ disabled: literal(false),
295
+ })
296
+ const result = schema.fromURLSearchParams('disabled=false')
297
+ expect(result).toEqual({ disabled: false })
298
+ })
299
+ })
300
+
263
301
  describe('fromURLSearchParams', () => {
264
302
  const schema = params({
265
303
  name: string(),
266
304
  age: optional(integer()),
267
305
  active: optional(boolean()),
306
+ tags: optional(array(string())),
307
+ ids: optional(array(integer())),
308
+ bools: optional(array(boolean())),
268
309
  })
269
310
 
270
311
  it('parses string parameters', () => {
271
- const urlParams = new URLSearchParams('name=Alice')
272
- const result = schema.fromURLSearchParams(urlParams)
312
+ const result = schema.fromURLSearchParams('name=Alice')
273
313
  expect(result).toEqual({ name: 'Alice' })
274
314
  })
275
315
 
276
316
  it('parses and coerces boolean true', () => {
277
- const urlParams = new URLSearchParams('name=Alice&active=true')
278
- const result = schema.fromURLSearchParams(urlParams)
317
+ const result = schema.fromURLSearchParams('name=Alice&active=true')
279
318
  expect(result).toEqual({ name: 'Alice', active: true })
280
319
  })
281
320
 
282
321
  it('parses and coerces boolean false', () => {
283
- const urlParams = new URLSearchParams('name=Alice&active=false')
284
- const result = schema.fromURLSearchParams(urlParams)
322
+ const result = schema.fromURLSearchParams('name=Alice&active=false')
285
323
  expect(result).toEqual({ name: 'Alice', active: false })
286
324
  })
287
325
 
288
326
  it('parses and coerces integer values', () => {
289
- const urlParams = new URLSearchParams('name=Alice&age=30')
290
- const result = schema.fromURLSearchParams(urlParams)
327
+ const result = schema.fromURLSearchParams('name=Alice&age=30')
291
328
  expect(result).toEqual({ name: 'Alice', age: 30 })
292
329
  })
293
330
 
294
331
  it('parses and coerces negative integers', () => {
295
- const urlParams = new URLSearchParams('name=Alice&age=-5')
296
- const result = schema.fromURLSearchParams(urlParams)
332
+ const result = schema.fromURLSearchParams('name=Alice&age=-5')
297
333
  expect(result).toEqual({ name: 'Alice', age: -5 })
298
334
  })
299
335
 
300
336
  it('does not coerce non-integer numbers', () => {
301
- const urlParams = new URLSearchParams('name=Alice&extra=3.14')
302
- const result = schema.fromURLSearchParams(urlParams)
337
+ const result = schema.fromURLSearchParams('name=Alice&extra=3.14')
303
338
  expect(result).toEqual({ name: 'Alice', extra: '3.14' })
304
339
  })
305
340
 
306
341
  it('keeps string values for string schema even if they look like numbers', () => {
307
- const urlParams = new URLSearchParams('name=123')
308
- const result = schema.fromURLSearchParams(urlParams)
342
+ const result = schema.fromURLSearchParams('name=123')
309
343
  expect(result).toEqual({ name: '123' })
310
344
  })
311
345
 
312
346
  it('parses multiple values as array', () => {
313
- const urlParams = new URLSearchParams('name=Alice&tag=one&tag=two')
314
- const result = schema.fromURLSearchParams(urlParams)
315
- expect(result).toEqual({ name: 'Alice', tag: ['one', 'two'] })
347
+ const result = schema.fromURLSearchParams('name=Alice&tags=one&tags=two')
348
+ expect(result).toEqual({ name: 'Alice', tags: ['one', 'two'] })
316
349
  })
317
350
 
318
- it('coerces array values correctly', () => {
319
- const urlParams = new URLSearchParams('name=Alice&num=1&num=2&num=3')
320
- const result = schema.fromURLSearchParams(urlParams)
321
- expect(result).toEqual({ name: 'Alice', num: [1, 2, 3] })
322
- })
351
+ it('does not coerce numeric values of unknown params', () => {
352
+ expect(
353
+ schema.fromURLSearchParams('name=Alice&num=1&num=2&num=3&foo=3'),
354
+ ).toEqual({ name: 'Alice', num: ['1', '2', '3'], foo: '3' })
323
355
 
324
- it('handles mixed types in arrays', () => {
325
- const urlParams = new URLSearchParams(
326
- 'name=Alice&val=true&val=123&val=text',
327
- )
328
- const result = schema.fromURLSearchParams(urlParams)
329
- expect(result).toEqual({ name: 'Alice', val: [true, 123, 'text'] })
356
+ expect(
357
+ schema.fromURLSearchParams('name=Alice&val=true&val=123&val=text'),
358
+ ).toEqual({ name: 'Alice', val: ['true', '123', 'text'] })
330
359
  })
331
360
 
332
361
  it('handles empty URLSearchParams', () => {
333
- const urlParams = new URLSearchParams()
334
- expect(() => schema.fromURLSearchParams(urlParams)).toThrow()
362
+ expect(() => schema.fromURLSearchParams(new URLSearchParams())).toThrow()
363
+ expect(() => schema.fromURLSearchParams('')).toThrow()
335
364
  })
336
365
 
337
366
  it('handles multiple parameters', () => {
@@ -346,6 +375,64 @@ describe('ParamsSchema', () => {
346
375
  extra: 'value',
347
376
  })
348
377
  })
378
+
379
+ it('coerces single values into arrays in parse mode', () => {
380
+ expect(
381
+ schema.fromURLSearchParams([
382
+ ['name', 'Alice'],
383
+ ['tags', 'tag1'],
384
+ ]),
385
+ ).toEqual({ name: 'Alice', tags: ['tag1'] })
386
+
387
+ expect(
388
+ schema.fromURLSearchParams([
389
+ ['name', 'Alice'],
390
+ ['tags', 'true'],
391
+ ]),
392
+ ).toEqual({ name: 'Alice', tags: ['true'] })
393
+
394
+ expect(
395
+ schema.fromURLSearchParams([
396
+ ['name', 'Alice'],
397
+ ['tags', '1'],
398
+ ]),
399
+ ).toEqual({ name: 'Alice', tags: ['1'] })
400
+ })
401
+
402
+ it('coerces single boolean values into arrays in parse mode', () => {
403
+ expect(
404
+ schema.fromURLSearchParams([
405
+ ['name', 'Alice'],
406
+ ['bools', 'true'],
407
+ ]),
408
+ ).toEqual({ name: 'Alice', bools: [true] })
409
+
410
+ expect(
411
+ schema.fromURLSearchParams([
412
+ ['name', 'Alice'],
413
+ ['bools', 'false'],
414
+ ]),
415
+ ).toEqual({ name: 'Alice', bools: [false] })
416
+
417
+ expect(() =>
418
+ schema.fromURLSearchParams([
419
+ ['name', 'Alice'],
420
+ ['bools', 'notabool'],
421
+ ]),
422
+ ).toThrow('Expected boolean value type at $.bools (got string)')
423
+
424
+ expect(() =>
425
+ schema.fromURLSearchParams(
426
+ [
427
+ ['name', 'Alice'],
428
+ ['bools', '2'],
429
+ ],
430
+ {
431
+ path: ['foo', 'bar'],
432
+ },
433
+ ),
434
+ ).toThrow('Expected boolean value type at $.foo.bar.bools (got string)')
435
+ })
349
436
  })
350
437
 
351
438
  describe('toURLSearchParams', () => {
@@ -405,15 +492,23 @@ describe('ParamsSchema', () => {
405
492
  expect(result.toString()).toBe('name=Alice')
406
493
  })
407
494
 
495
+ it('rejects arrays with multiple types', () => {
496
+ expect(() => {
497
+ schema.toURLSearchParams({
498
+ name: 'Alice',
499
+ // @ts-expect-error
500
+ values: [1, true, 'text'],
501
+ })
502
+ }).toThrow()
503
+ })
504
+
408
505
  it('handles arrays with multiple types', () => {
409
506
  const result = schema.toURLSearchParams({
410
507
  name: 'Alice',
411
508
  // @ts-expect-error
412
- values: [1, true, 'text'],
509
+ values: ['foo', 'bar'],
413
510
  })
414
- expect(result.toString()).toBe(
415
- 'name=Alice&values=1&values=true&values=text',
416
- )
511
+ expect(result.toString()).toBe('name=Alice&values=foo&values=bar')
417
512
  })
418
513
 
419
514
  it('handles undefined input', () => {
@@ -655,9 +750,9 @@ describe('paramSchema', () => {
655
750
  expect(result.success).toBe(true)
656
751
  })
657
752
 
658
- it('validates arrays with mixed scalar types', () => {
753
+ it('rejects arrays with mixed scalar types', () => {
659
754
  const result = paramSchema.safeParse([true, 42, 'text'])
660
- expect(result.success).toBe(true)
755
+ expect(result.success).toBe(false)
661
756
  })
662
757
 
663
758
  it('validates arrays with negative integers', () => {
@@ -828,11 +923,11 @@ describe('paramsSchema', () => {
828
923
  expect(result.success).toBe(true)
829
924
  })
830
925
 
831
- it('validates object with arrays of mixed scalar types', () => {
926
+ it('rejects object with arrays of mixed scalar types', () => {
832
927
  const result = paramsSchema.safeParse({
833
928
  values: [true, 42, 'text'],
834
929
  })
835
- expect(result.success).toBe(true)
930
+ expect(result.success).toBe(false)
836
931
  })
837
932
 
838
933
  it('validates object with numeric string keys', () => {
@@ -3,38 +3,123 @@ import {
3
3
  Infer,
4
4
  InferInput,
5
5
  InferOutput,
6
+ Issue,
7
+ IssueInvalidType,
8
+ IssueInvalidValue,
9
+ ParseOptions,
6
10
  Schema,
7
11
  ValidationContext,
12
+ ValidationError,
8
13
  Validator,
9
14
  WithOptionalProperties,
10
15
  } from '../core.js'
11
16
  import { lazyProperty } from '../util/lazy-property.js'
12
17
  import { memoizedOptions } from '../util/memoize.js'
13
- import { array } from './array.js'
14
- import { boolean } from './boolean.js'
18
+ import { ArraySchema, array } from './array.js'
19
+ import { BooleanSchema, boolean } from './boolean.js'
15
20
  import { dict } from './dict.js'
16
- import { integer } from './integer.js'
17
- import { optional } from './optional.js'
21
+ import { EnumSchema } from './enum.js'
22
+ import { IntegerSchema, integer } from './integer.js'
23
+ import { LiteralSchema } from './literal.js'
24
+ import { OptionalSchema, optional } from './optional.js'
18
25
  import { StringSchema, string } from './string.js'
19
26
  import { union } from './union.js'
27
+ import { WithDefaultSchema } from './with-default.js'
20
28
 
29
+ /**
30
+ * Scalar types allowed in URL parameters: boolean, integer, or string.
31
+ */
21
32
  export type ParamScalar = Infer<typeof paramScalarSchema>
22
33
  const paramScalarSchema = union([boolean(), integer(), string()])
23
34
 
35
+ /**
36
+ * A single parameter value: scalar or array of scalars.
37
+ */
24
38
  export type Param = Infer<typeof paramSchema>
25
- export const paramSchema = union([paramScalarSchema, array(paramScalarSchema)])
26
39
 
40
+ /**
41
+ * Schema for validating individual parameter values.
42
+ */
43
+ export const paramSchema = union([
44
+ paramScalarSchema,
45
+ array(boolean()),
46
+ array(integer()),
47
+ array(string()),
48
+ ])
49
+
50
+ /**
51
+ * Type for a params object with string keys and optional param values.
52
+ */
27
53
  export type Params = Infer<typeof paramsSchema>
54
+
55
+ /**
56
+ * Schema for validating arbitrary params objects.
57
+ */
28
58
  export const paramsSchema = dict(string(), optional(paramSchema))
29
59
 
30
- export type ParamsSchemaShape = {
31
- [x: string]: Validator<Param | undefined>
60
+ export type ParamScalarValidator =
61
+ // @NOTE In order to properly coerce URLSearchParams, we need to distinguish
62
+ // between scalar and array validators, requiring to be able to detect which
63
+ // schema types are being used, restricting the allowed param validators here.
64
+ | LiteralSchema<string>
65
+ | LiteralSchema<number>
66
+ | LiteralSchema<boolean>
67
+ | EnumSchema<string>
68
+ | EnumSchema<number>
69
+ // | EnumSchema<boolean> // Boolean lexicon definitions don't allow "enum"
70
+ | StringSchema<any>
71
+ | BooleanSchema
72
+ | IntegerSchema
73
+
74
+ type AsArrayParamSchema<TSchema extends Validator> =
75
+ // This allows to "distribute" any union of scalar validators into a union of
76
+ // arrays of those validators, instead of an array of union. If TSchema is
77
+ // BooleanSchema | IntegerSchema, we want the result to be
78
+ // ArraySchema<BooleanSchema> | ArraySchema<IntegerSchema>, not
79
+ // ArraySchema<BooleanSchema | IntegerSchema>, since the latter would allow
80
+ // arrays with mixed types (e.g. [true, 42]), which we don't want.
81
+ TSchema extends any ? ArraySchema<TSchema> : never
82
+
83
+ export type ParamValueValidator =
84
+ | ParamScalarValidator
85
+ | AsArrayParamSchema<ParamScalarValidator>
86
+
87
+ export type ParamValidator =
88
+ | ParamValueValidator
89
+ | OptionalSchema<ParamValueValidator>
90
+ | OptionalSchema<WithDefaultSchema<ParamValueValidator>>
91
+ | WithDefaultSchema<ParamValueValidator>
92
+
93
+ /**
94
+ * Type representing the shape of a params schema definition.
95
+ *
96
+ * Maps parameter names to their validators (must be Param or undefined).
97
+ */
98
+ export type ParamsShape = {
99
+ [x: string]: ParamValidator
32
100
  }
33
101
 
102
+ /**
103
+ * Schema for validating URL query parameters in Lexicon endpoints.
104
+ *
105
+ * Params are the query string parameters passed to queries, procedures,
106
+ * and subscriptions. Values must be scalars (boolean, integer, string)
107
+ * or arrays of scalars, as they need to be serializable to URL format.
108
+ *
109
+ * Provides methods for converting to/from URLSearchParams.
110
+ *
111
+ * @template TShape - The params shape type mapping names to validators
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const schema = new ParamsSchema({
116
+ * limit: l.optional(l.integer({ minimum: 1, maximum: 100 })),
117
+ * cursor: l.optional(l.string()),
118
+ * })
119
+ * ```
120
+ */
34
121
  export class ParamsSchema<
35
- const TShape extends ParamsSchemaShape = {
36
- [x: string]: Validator<Param | undefined>
37
- },
122
+ const TShape extends ParamsShape = ParamsShape,
38
123
  > extends Schema<
39
124
  WithOptionalProperties<{
40
125
  [K in keyof TShape]: InferInput<TShape[K]>
@@ -43,20 +128,21 @@ export class ParamsSchema<
43
128
  [K in keyof TShape]: InferOutput<TShape[K]>
44
129
  }>
45
130
  > {
131
+ readonly type = 'params' as const
132
+
46
133
  constructor(readonly shape: TShape) {
47
134
  super()
48
135
  }
49
136
 
50
- get shapeValidators(): Map<string, Validator<Param | undefined>> {
137
+ get shapeValidators(): Map<string, ParamValidator> {
51
138
  const map = new Map(Object.entries(this.shape))
52
139
 
53
140
  return lazyProperty(this, 'shapeValidators', map)
54
141
  }
55
142
 
56
143
  validateInContext(input: unknown, ctx: ValidationContext) {
57
- // @TODO BETTER SUPPORT Input/Output
58
144
  if (!isPlainObject(input)) {
59
- return ctx.issueInvalidType(input, 'object')
145
+ return ctx.issueUnexpectedType(input, 'object')
60
146
  }
61
147
 
62
148
  // Lazily copy value
@@ -111,33 +197,38 @@ export class ParamsSchema<
111
197
  return ctx.success(copy ?? input)
112
198
  }
113
199
 
114
- fromURLSearchParams(urlSearchParams: URLSearchParams): InferOutput<this> {
115
- const params: Record<string, Param> = {}
116
-
117
- for (const [key, value] of urlSearchParams.entries()) {
118
- const validator = this.shapeValidators.get(key)
119
-
120
- const coerced: ParamScalar =
121
- validator instanceof StringSchema
122
- ? value
123
- : value === 'true'
124
- ? true
125
- : value === 'false'
126
- ? false
127
- : /^-?\d+$/.test(value)
128
- ? Number(value)
129
- : value
130
-
131
- if (params[key] === undefined) {
132
- params[key] = coerced
133
- } else if (Array.isArray(params[key])) {
134
- params[key].push(coerced)
200
+ fromURLSearchParams(
201
+ input: string | Iterable<[string, string]>,
202
+ options?: ParseOptions,
203
+ ): InferOutput<this> {
204
+ const params: Record<string, unknown> = {}
205
+
206
+ const iterable =
207
+ typeof input === 'string' ? new URLSearchParams(input) : input
208
+ const entries =
209
+ iterable instanceof URLSearchParams ? iterable.entries() : iterable
210
+
211
+ for (const [name, value] of entries) {
212
+ const validator = this.shapeValidators.get(name)
213
+ const innerValidator = validator ? unwrapSchema(validator) : undefined
214
+ const expectsArray = innerValidator instanceof ArraySchema
215
+ const scalarValidator = expectsArray
216
+ ? unwrapSchema(innerValidator.validator)
217
+ : innerValidator
218
+
219
+ const coerced = coerceParam(name, value, scalarValidator, options)
220
+
221
+ const currentParam = params[name]
222
+ if (currentParam === undefined) {
223
+ params[name] = expectsArray ? [coerced] : coerced
224
+ } else if (Array.isArray(currentParam)) {
225
+ currentParam.push(coerced)
135
226
  } else {
136
- params[key] = [params[key] as ParamScalar, coerced]
227
+ params[name] = [currentParam, coerced]
137
228
  }
138
229
  }
139
230
 
140
- return this.parse(params)
231
+ return this.parse(params, options)
141
232
  }
142
233
 
143
234
  toURLSearchParams(input: InferInput<this>): URLSearchParams {
@@ -161,8 +252,117 @@ export class ParamsSchema<
161
252
  }
162
253
  }
163
254
 
255
+ function coerceParam(
256
+ name: string,
257
+ param: string,
258
+ schema?: ParamScalarValidator,
259
+ options?: ParseOptions,
260
+ ): ParamScalar {
261
+ let issue: Issue
262
+
263
+ if (!schema) {
264
+ // The param is unknown (not defined in schema), so we don't apply any
265
+ // coercion and just return the string value.
266
+ return param
267
+ } else if (schema instanceof StringSchema) {
268
+ return param
269
+ } else if (schema instanceof IntegerSchema) {
270
+ if (/^-?\d+$/.test(param)) return Number(param)
271
+ issue = new IssueInvalidType(paramPath(name, options), param, ['integer'])
272
+ } else if (schema instanceof BooleanSchema) {
273
+ if (param === 'true') return true
274
+ if (param === 'false') return false
275
+ issue = new IssueInvalidType(paramPath(name, options), param, ['boolean'])
276
+ } else if (schema instanceof LiteralSchema) {
277
+ const { value } = schema
278
+ if (String(value) === param) return value
279
+ issue = new IssueInvalidValue(paramPath(name, options), param, [value])
280
+ } else if (schema instanceof EnumSchema) {
281
+ const { values } = schema
282
+ for (const value of values) {
283
+ if (String(value) === param) return value
284
+ }
285
+ issue = new IssueInvalidValue(paramPath(name, options), param, values)
286
+ } else {
287
+ // This should never happen. If it *does*, it means that the user of
288
+ // lex-schema is mixing different versions of the lib, which is not
289
+ // supported. Throwing an error here is better than silently accepting
290
+ // invalid params and causing unexpected behavior down the line (ie. error
291
+ // message returning the string value instead of the expected
292
+ // boolean/number/string value).
293
+ throw new Error(`Unsupported schema type for param coercion: ${schema}`)
294
+ }
295
+
296
+ // We were not able to coerce the param to the expected type. There is no
297
+ // point in returning the original string value since it doesn't conform to
298
+ // the expected schema, so we throw a validation error instead. We could
299
+ // return the "param" here, which would cause the validation to fail later on
300
+ // (see fromURLSearchParams()'s return statement). The main benefit of
301
+ // returning the original "param" value is that the error path would include
302
+ // the index of the param in case of array params (e.g. "tags[1]"), which
303
+ // could be helpful for debugging. The cost overhead is not worth it though
304
+ // (IMO).
305
+ throw new ValidationError([issue])
306
+ }
307
+
308
+ function paramPath(key: string, options?: ParseOptions) {
309
+ return options?.path ? [...options.path, key] : [key]
310
+ }
311
+
312
+ /**
313
+ * Creates a params schema for URL query parameters.
314
+ *
315
+ * Params schemas validate query string parameters for Lexicon endpoints.
316
+ * Values must be boolean, integer, string, or arrays of those types.
317
+ *
318
+ * @param properties - Object mapping parameter names to their validators
319
+ * @returns A new {@link ParamsSchema} instance
320
+ *
321
+ * @example
322
+ * ```ts
323
+ * // Simple pagination params
324
+ * const paginationParams = l.params({
325
+ * limit: l.optional(l.withDefault(l.integer({ minimum: 1, maximum: 100 }), 50)),
326
+ * cursor: l.optional(l.string()),
327
+ * })
328
+ *
329
+ * // Required parameter
330
+ * const actorParams = l.params({
331
+ * actor: l.string({ format: 'at-identifier' }),
332
+ * })
333
+ *
334
+ * // Array parameter (multiple values)
335
+ * const filterParams = l.params({
336
+ * tags: l.optional(l.array(l.string())),
337
+ * })
338
+ *
339
+ * // Convert from URL
340
+ * const urlParams = new URLSearchParams('limit=25&cursor=abc')
341
+ * const validated = paginationParams.fromURLSearchParams(urlParams)
342
+ *
343
+ * // Convert to URL
344
+ * const searchParams = paginationParams.toURLSearchParams({ limit: 25 })
345
+ * ```
346
+ */
164
347
  export const params = /*#__PURE__*/ memoizedOptions(function params<
165
- const TShape extends ParamsSchemaShape = NonNullable<unknown>,
348
+ const TShape extends ParamsShape = NonNullable<unknown>,
166
349
  >(properties: TShape = {} as TShape) {
167
350
  return new ParamsSchema<TShape>(properties)
168
351
  })
352
+
353
+ type UnwrapSchema<S extends Validator> =
354
+ S extends OptionalSchema<infer U>
355
+ ? UnwrapSchema<U>
356
+ : S extends WithDefaultSchema<infer U>
357
+ ? UnwrapSchema<U>
358
+ : S
359
+
360
+ function unwrapSchema<S extends Validator>(schema: S): UnwrapSchema<S> {
361
+ while (
362
+ schema instanceof OptionalSchema ||
363
+ schema instanceof WithDefaultSchema
364
+ ) {
365
+ return unwrapSchema(schema.validator)
366
+ }
367
+ return schema as UnwrapSchema<S>
368
+ }
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { integer } from './integer.js'
3
+ import { lexMap } from './lex-map.js'
3
4
  import { object } from './object.js'
4
5
  import { payload } from './payload.js'
5
6
  import { string } from './string.js'
6
- import { unknown } from './unknown.js'
7
7
 
8
8
  describe('Payload', () => {
9
9
  describe('basic construction', () => {
@@ -113,7 +113,7 @@ describe('Payload', () => {
113
113
  })
114
114
 
115
115
  it('creates payload with unknown schema', () => {
116
- const schema = unknown()
116
+ const schema = object({})
117
117
  const def = payload('application/json', schema)
118
118
  expect(def.encoding).toBe('application/json')
119
119
  expect(def.schema).toBe(schema)
@@ -224,7 +224,7 @@ describe('Payload', () => {
224
224
  'application/json',
225
225
  object({
226
226
  success: string(),
227
- data: unknown(),
227
+ data: lexMap(),
228
228
  }),
229
229
  )
230
230
  expect(def.encoding).toBe('application/json')