@atproto/lex-schema 0.0.15 → 0.0.17

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 (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/schema.d.ts +6 -12
  3. package/dist/core/schema.d.ts.map +1 -1
  4. package/dist/core/schema.js +11 -17
  5. package/dist/core/schema.js.map +1 -1
  6. package/dist/core/string-format.d.ts +22 -4
  7. package/dist/core/string-format.d.ts.map +1 -1
  8. package/dist/core/string-format.js +43 -19
  9. package/dist/core/string-format.js.map +1 -1
  10. package/dist/core/validation-error.d.ts +2 -2
  11. package/dist/core/validation-error.d.ts.map +1 -1
  12. package/dist/core/validation-error.js +1 -1
  13. package/dist/core/validation-error.js.map +1 -1
  14. package/dist/core/validation-issue.d.ts.map +1 -1
  15. package/dist/core/validation-issue.js +19 -38
  16. package/dist/core/validation-issue.js.map +1 -1
  17. package/dist/core/validator.d.ts +13 -1
  18. package/dist/core/validator.d.ts.map +1 -1
  19. package/dist/core/validator.js +1 -0
  20. package/dist/core/validator.js.map +1 -1
  21. package/dist/helpers.d.ts +4 -3
  22. package/dist/helpers.d.ts.map +1 -1
  23. package/dist/helpers.js +6 -1
  24. package/dist/helpers.js.map +1 -1
  25. package/dist/schema/blob.d.ts +10 -8
  26. package/dist/schema/blob.d.ts.map +1 -1
  27. package/dist/schema/blob.js +39 -14
  28. package/dist/schema/blob.js.map +1 -1
  29. package/dist/schema/payload.d.ts +2 -2
  30. package/dist/schema/payload.d.ts.map +1 -1
  31. package/dist/schema/payload.js +2 -3
  32. package/dist/schema/payload.js.map +1 -1
  33. package/dist/schema/record.d.ts +6 -8
  34. package/dist/schema/record.d.ts.map +1 -1
  35. package/dist/schema/record.js +1 -1
  36. package/dist/schema/record.js.map +1 -1
  37. package/dist/schema/regexp.d.ts +3 -2
  38. package/dist/schema/regexp.d.ts.map +1 -1
  39. package/dist/schema/regexp.js +6 -4
  40. package/dist/schema/regexp.js.map +1 -1
  41. package/dist/schema/string.d.ts.map +1 -1
  42. package/dist/schema/string.js +10 -3
  43. package/dist/schema/string.js.map +1 -1
  44. package/dist/schema/typed-object.d.ts +5 -7
  45. package/dist/schema/typed-object.d.ts.map +1 -1
  46. package/dist/schema/typed-object.js +1 -1
  47. package/dist/schema/typed-object.js.map +1 -1
  48. package/package.json +4 -3
  49. package/src/core/$type.test.ts +9 -5
  50. package/src/core/schema.ts +20 -17
  51. package/src/core/string-format.ts +62 -16
  52. package/src/core/validation-error.ts +2 -2
  53. package/src/core/validation-issue.ts +20 -36
  54. package/src/core/validator.ts +17 -1
  55. package/src/helpers.ts +7 -1
  56. package/src/schema/array.test.ts +1 -1
  57. package/src/schema/blob.test.ts +317 -49
  58. package/src/schema/blob.ts +56 -23
  59. package/src/schema/params.test.ts +2 -2
  60. package/src/schema/payload.ts +4 -5
  61. package/src/schema/record.test.ts +135 -17
  62. package/src/schema/record.ts +14 -9
  63. package/src/schema/regexp.ts +14 -4
  64. package/src/schema/string.test.ts +63 -0
  65. package/src/schema/string.ts +9 -3
  66. package/src/schema/typed-object.test.ts +77 -0
  67. package/src/schema/typed-object.ts +11 -10
@@ -47,7 +47,7 @@ export interface SchemaInternals<out TInput = unknown, out TOutput = TInput> {
47
47
  * - **Assertion methods**: `assert()`, `check()` - throw on invalid input
48
48
  * - **Type guard methods**: `matches()`, `ifMatches()` - return boolean or optional value
49
49
  * - **Parse methods**: `parse()`, `safeParse()` - allow value transformation/coercion
50
- * - **Validate methods**: `validate()`, `safeValidate()` - strict validation without coercion
50
+ * - **Validate methods**: `validate()`, `safeValidate()` - validation without coercion
51
51
  *
52
52
  * All methods are also available with a `$` prefix (e.g., `$parse()`, `$validate()`)
53
53
  * for consistent access in generated lexicon namespaces.
@@ -120,8 +120,11 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
120
120
  * will typically arise in generic contexts, where the narrowed type is not
121
121
  * needed.
122
122
  */
123
- assert(input: unknown): asserts input is InferInput<this> {
124
- const result = ValidationContext.validate(input, this)
123
+ assert(
124
+ input: unknown,
125
+ options?: ValidateOptions,
126
+ ): asserts input is InferInput<this> {
127
+ const result = this.safeValidate(input, options)
125
128
  if (!result.success) throw result.reason
126
129
  }
127
130
 
@@ -131,8 +134,8 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
131
134
  * every name in the call target to be declared with an explicit type
132
135
  * annotation. ts(2775)_" errors.
133
136
  */
134
- check(input: unknown): void {
135
- this.assert(input)
137
+ check(input: unknown, options?: ValidateOptions): void {
138
+ this.assert(input, options)
136
139
  }
137
140
 
138
141
  /**
@@ -140,8 +143,8 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
140
143
  * schema, otherwise throws. This is the same as calling {@link parse}() with
141
144
  * `mode: "validate"`.
142
145
  */
143
- cast<I>(input: I): I & InferInput<this> {
144
- const result = ValidationContext.validate(input, this)
146
+ cast<I>(input: I, options?: ValidateOptions): I & InferInput<this> {
147
+ const result = this.safeValidate(input, options)
145
148
  if (result.success) return result.value
146
149
  throw result.reason
147
150
  }
@@ -149,9 +152,6 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
149
152
  /**
150
153
  * Type guard that checks if the input matches this schema.
151
154
  *
152
- * @param input - The value to check
153
- * @returns `true` if the input is valid according to this schema
154
- *
155
155
  * @example
156
156
  * ```typescript
157
157
  * if (schema.matches(data)) {
@@ -160,8 +160,11 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
160
160
  * }
161
161
  * ```
162
162
  */
163
- matches<I>(input: I): input is I & InferInput<this> {
164
- const result = ValidationContext.validate(input, this)
163
+ matches<I>(
164
+ input: I,
165
+ options?: ValidateOptions,
166
+ ): input is I & InferInput<this> {
167
+ const result = this.safeValidate(input, options)
165
168
  return result.success
166
169
  }
167
170
 
@@ -171,9 +174,6 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
171
174
  * This is useful for optional filtering operations where you want to
172
175
  * conditionally extract values that match a schema.
173
176
  *
174
- * @param input - The value to check
175
- * @returns The input value with narrowed type if valid, otherwise `undefined`
176
- *
177
177
  * @example
178
178
  * ```typescript
179
179
  * const validData = schema.ifMatches(data)
@@ -183,8 +183,11 @@ export abstract class Schema<out TInput = unknown, out TOutput = TInput>
183
183
  * }
184
184
  * ```
185
185
  */
186
- ifMatches<I>(input: I): (I & InferInput<this>) | undefined {
187
- return this.matches(input) ? input : undefined
186
+ ifMatches<I>(
187
+ input: I,
188
+ options?: ValidateOptions,
189
+ ): (I & InferInput<this>) | undefined {
190
+ return this.matches(input, options) ? input : undefined
188
191
  }
189
192
 
190
193
  /**
@@ -1,3 +1,4 @@
1
+ import { isValidISODateString } from 'iso-datestring-validator'
1
2
  import { validateCidString } from '@atproto/lex-data'
2
3
  import {
3
4
  AtIdentifierString,
@@ -48,6 +49,26 @@ export {
48
49
  isDatetimeString,
49
50
  } from '@atproto/syntax'
50
51
 
52
+ /**
53
+ * Matches any ISO-ish datetime string. This is a more lenient check than
54
+ * the strict {@link isDatetimeString} guard, which only allows datetimes that
55
+ * fully conform to the AT Protocol specification (e.g. must include timezone).
56
+ */
57
+ export function isDatetimeStringLoose<I>(
58
+ input: I,
59
+ ): input is I & DatetimeString {
60
+ // @NOTE the returned type assertion is inaccurate wrt. the DatetimeString
61
+ // type definition. A more accurate solution would be to use a branded type
62
+ // instead of a template literal for the "datetime" format
63
+ if (typeof input !== 'string') return false
64
+ try {
65
+ return isValidISODateString(input)
66
+ } catch {
67
+ // @NOTE isValidISODateString throws on some inputs
68
+ return false
69
+ }
70
+ }
71
+
51
72
  // DatetimeString utilities
52
73
  export { currentDatetimeString, toDatetimeString } from '@atproto/syntax'
53
74
 
@@ -223,23 +244,39 @@ type StringFormats = {
223
244
  export type StringFormat = Extract<keyof StringFormats, string>
224
245
 
225
246
  const stringFormatVerifiers: {
226
- readonly [K in StringFormat]: CheckFn<StringFormats[K]>
247
+ readonly [K in StringFormat]: readonly [
248
+ strict: CheckFn<StringFormats[K]>,
249
+ loose?: CheckFn<StringFormats[K]>,
250
+ ]
227
251
  } = /*#__PURE__*/ Object.freeze({
228
252
  __proto__: null,
229
253
 
230
- 'at-identifier': isAtIdentifierString,
231
- 'at-uri': isAtUriString,
232
- cid: isCidString,
233
- datetime: isDatetimeString,
234
- did: isDidString,
235
- handle: isHandleString,
236
- language: isLanguageString,
237
- nsid: isNsidString,
238
- 'record-key': isRecordKeyString,
239
- tid: isTidString,
240
- uri: isUriString,
254
+ 'at-identifier': [isAtIdentifierString],
255
+ 'at-uri': [isAtUriString],
256
+ cid: [isCidString],
257
+ datetime: [isDatetimeString, isDatetimeStringLoose],
258
+ did: [isDidString],
259
+ handle: [isHandleString],
260
+ language: [isLanguageString],
261
+ nsid: [isNsidString],
262
+ 'record-key': [isRecordKeyString],
263
+ tid: [isTidString],
264
+ uri: [isUriString],
241
265
  })
242
266
 
267
+ export type StringFormatValidationOptions = {
268
+ /**
269
+ * Allows to be more lenient in validation by using a "loose" verification
270
+ * function, if available. The behavior of the loose verifier depends on the
271
+ * specific format, but generally it may allow for a wider range of valid
272
+ * inputs, including values that are not compliant with the AT Protocol
273
+ * specification.
274
+ *
275
+ * @default true
276
+ */
277
+ strict?: boolean
278
+ }
279
+
243
280
  /**
244
281
  * Infers the string type for a given format name.
245
282
  *
@@ -277,12 +314,18 @@ export type InferStringFormat<F extends StringFormat> = F extends StringFormat
277
314
  export function isStringFormat<I extends string, F extends StringFormat>(
278
315
  input: I,
279
316
  format: F,
317
+ options?: StringFormatValidationOptions,
280
318
  ): input is I & StringFormats[F] {
281
319
  const formatVerifier = stringFormatVerifiers[format]
282
320
  // Fool-proof
283
321
  if (!formatVerifier) throw new TypeError(`Unknown string format: ${format}`)
284
322
 
285
- return formatVerifier(input)
323
+ const check: CheckFn<StringFormats[F]> =
324
+ options?.strict === false
325
+ ? formatVerifier[1] ?? formatVerifier[0]
326
+ : formatVerifier[0]
327
+
328
+ return check(input)
286
329
  }
287
330
 
288
331
  /**
@@ -304,8 +347,9 @@ export function isStringFormat<I extends string, F extends StringFormat>(
304
347
  export function assertStringFormat<I extends string, F extends StringFormat>(
305
348
  input: I,
306
349
  format: F,
350
+ options?: StringFormatValidationOptions,
307
351
  ): asserts input is I & StringFormats[F] {
308
- if (!isStringFormat(input, format)) {
352
+ if (!isStringFormat(input, format, options)) {
309
353
  throw new TypeError(`Invalid string format (${format}): ${input}`)
310
354
  }
311
355
  }
@@ -332,8 +376,9 @@ export function assertStringFormat<I extends string, F extends StringFormat>(
332
376
  export function asStringFormat<I extends string, F extends StringFormat>(
333
377
  input: I,
334
378
  format: F,
379
+ options?: StringFormatValidationOptions,
335
380
  ): I & StringFormats[F] {
336
- assertStringFormat(input, format)
381
+ assertStringFormat(input, format, options)
337
382
  return input
338
383
  }
339
384
 
@@ -361,8 +406,9 @@ export function asStringFormat<I extends string, F extends StringFormat>(
361
406
  export function ifStringFormat<I extends string, F extends StringFormat>(
362
407
  input: I,
363
408
  format: F,
409
+ options?: StringFormatValidationOptions,
364
410
  ): undefined | (I & StringFormats[F]) {
365
- return isStringFormat(input, format) ? input : undefined
411
+ return isStringFormat(input, format, options) ? input : undefined
366
412
  }
367
413
 
368
414
  /**
@@ -23,7 +23,7 @@ import {
23
23
  * new IssueInvalidType(['user', 'age'], 'hello', ['number'])
24
24
  * ])
25
25
  * console.log(error.message)
26
- * // "Expected number value type at $.user.age (got string)"
26
+ * // "Expected integer value type (got "some-string") at $.user.age"
27
27
  *
28
28
  * console.log(error.issues.length) // 1
29
29
  * console.log(error.toJSON())
@@ -46,7 +46,7 @@ export class LexValidationError
46
46
  * Issues are aggregated when possible (e.g., multiple invalid type issues
47
47
  * at the same path are combined into a single issue listing all expected types).
48
48
  */
49
- readonly issues: Issue[]
49
+ readonly issues: readonly Issue[]
50
50
 
51
51
  /**
52
52
  * Creates a new validation error from a list of issues.
@@ -1,5 +1,8 @@
1
1
  import { ifCid, isLegacyBlobRef, isPlainObject } from '@atproto/lex-data'
2
2
 
3
+ const STRING_PREVIEW_MAX_LENGTH = 48
4
+ const STRING_PREVIEW_TRUNCATED_SUFFIX = '…'
5
+
3
6
  /**
4
7
  * Abstract base class for all validation issues.
5
8
  *
@@ -120,7 +123,7 @@ export class IssueInvalidType extends Issue {
120
123
  }
121
124
 
122
125
  override get message(): string {
123
- return `Expected ${oneOf(this.expected.map(stringifyExpectedType))} value type (got ${stringifyType(this.input)})`
126
+ return `Expected ${oneOf(this.expected.map(stringifyExpectedType))} value type (got ${stringifyValue(this.input)})`
124
127
  }
125
128
 
126
129
  toJSON() {
@@ -289,55 +292,36 @@ function oneOf(arr: readonly string[]): string {
289
292
  return `one of ${arr.slice(0, -1).join(', ')} or ${arr.at(-1)}`
290
293
  }
291
294
 
292
- function stringifyType(value: unknown): string {
293
- switch (typeof value) {
294
- case 'object':
295
- if (value === null) return 'null'
296
- if (Array.isArray(value)) return 'array'
297
- if (ifCid(value)) return 'cid'
298
- if (isLegacyBlobRef(value)) return 'legacy-blob'
299
- if (value instanceof Date) return 'date'
300
- if (value instanceof RegExp) return 'regexp'
301
- if (value instanceof Map) return 'map'
302
- if (value instanceof Set) return 'set'
303
- return 'object'
304
- case 'number':
305
- if (Number.isInteger(value) && Number.isSafeInteger(value)) {
306
- return 'integer'
307
- }
308
- if (Number.isNaN(value)) {
309
- return 'NaN'
310
- }
311
- if (value === Infinity) {
312
- return 'Infinity'
313
- }
314
- if (value === -Infinity) {
315
- return '-Infinity'
316
- }
317
- return 'float'
318
- default:
319
- return typeof value
320
- }
321
- }
322
-
323
295
  function stringifyValue(value: unknown): string {
324
296
  switch (typeof value) {
325
297
  case 'bigint':
326
298
  return `${value}n`
327
299
  case 'number':
328
- case 'string':
329
300
  case 'boolean':
330
- return JSON.stringify(value)
301
+ return String(value)
302
+ case 'string':
303
+ return JSON.stringify(
304
+ value.length < STRING_PREVIEW_MAX_LENGTH
305
+ ? value
306
+ : `${value.slice(0, STRING_PREVIEW_MAX_LENGTH - STRING_PREVIEW_TRUNCATED_SUFFIX.length)}${STRING_PREVIEW_TRUNCATED_SUFFIX}`,
307
+ )
331
308
  case 'object':
309
+ if (value === null) return 'null'
332
310
  if (Array.isArray(value)) {
333
311
  return `[${stringifyArray(value, stringifyValue)}]`
334
312
  }
335
313
  if (isPlainObject(value)) {
336
314
  return `{${stringifyArray(Object.entries(value), stringifyObjectEntry)}}`
337
315
  }
338
- // fallthrough
316
+ if (ifCid(value)) return 'cid'
317
+ if (isLegacyBlobRef(value)) return 'legacy-blob'
318
+ if (value instanceof Date) return 'date'
319
+ if (value instanceof RegExp) return 'regexp'
320
+ if (value instanceof Map) return 'map'
321
+ if (value instanceof Set) return 'set'
322
+ return 'object'
339
323
  default:
340
- return stringifyType(value)
324
+ return typeof value
341
325
  }
342
326
  }
343
327
 
@@ -152,11 +152,13 @@ export type ValidationOptions = {
152
152
  /**
153
153
  * The validation mode determining how transformations are handled.
154
154
  *
155
- * - `"validate"` (default): Strict validation where the result must be
155
+ * - `"validate"`: Strict validation where the result must be
156
156
  * strictly equal to the input value. No transformations such as applying
157
157
  * default values are allowed.
158
158
  * - `"parse"`: Allows the schema to transform the input value, such as
159
159
  * applying default values or performing type coercion.
160
+ *
161
+ * @default "validate"
160
162
  */
161
163
  mode?: 'validate' | 'parse'
162
164
 
@@ -173,6 +175,17 @@ export type ValidationOptions = {
173
175
  * ```
174
176
  */
175
177
  path?: readonly PropertyKey[]
178
+
179
+ /**
180
+ * Whether to enforce strict validation rules (e.g., MIME type matching, size
181
+ * limits, datetime format).
182
+ *
183
+ * This is typically useful to allow more lax validation when parsing server
184
+ * responses, while enforcing strict validation for user input.
185
+ *
186
+ * @default true
187
+ */
188
+ strict?: boolean
176
189
  }
177
190
 
178
191
  /**
@@ -221,6 +234,7 @@ export class ValidationContext {
221
234
  mode: 'parse'
222
235
  },
223
236
  ): ValidationResult<InferOutput<V>>
237
+
224
238
  /**
225
239
  * Validates input against a validator in validate mode (default).
226
240
  *
@@ -241,6 +255,7 @@ export class ValidationContext {
241
255
  mode?: 'validate'
242
256
  },
243
257
  ): ValidationResult<I & InferInput<V>>
258
+
244
259
  /**
245
260
  * Validates input against a validator with configurable options.
246
261
  *
@@ -262,6 +277,7 @@ export class ValidationContext {
262
277
  const context = new ValidationContext({
263
278
  path: options?.path ?? [],
264
279
  mode: options?.mode ?? 'validate',
280
+ strict: options?.strict ?? true,
265
281
  })
266
282
  return context.validate(input, validator)
267
283
  }
package/src/helpers.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Subscription,
10
10
  object,
11
11
  optional,
12
+ regexp,
12
13
  string,
13
14
  } from './schema.js'
14
15
 
@@ -103,7 +104,12 @@ export type InferMethodError<
103
104
  M extends Procedure | Query | Subscription = Procedure | Query | Subscription,
104
105
  > = M extends { errors: readonly (infer E extends string)[] } ? E : never
105
106
 
107
+ /**
108
+ * @see {@link https://atproto.com/specs/xrpc#error-responses}
109
+ */
106
110
  export const lexErrorDataSchema = object({
107
- error: string({ minLength: 1 }),
111
+ // type name of the error (generic ASCII constant, no whitespace)
112
+ error: regexp(/^[\w_-]+$/, 'Expected ASCII constant with no whitespace'),
113
+ // description of the error, appropriate for display to humans
108
114
  message: optional(string()),
109
115
  }) satisfies Schema<LexErrorData>
@@ -81,7 +81,7 @@ describe('ArraySchema', () => {
81
81
  success: false,
82
82
  reason: expect.objectContaining({
83
83
  message: expect.stringContaining(
84
- 'Expected array value type (got integer) at $',
84
+ 'Expected array value type (got 3) at $',
85
85
  ),
86
86
  }),
87
87
  })