@atproto/lex-cli 0.5.6 → 0.6.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.
@@ -1,28 +1,133 @@
1
+ import { Options as PrettierOptions, format } from 'prettier'
1
2
  import { Project, SourceFile, VariableDeclarationKind } from 'ts-morph'
2
3
  import { LexiconDoc } from '@atproto/lexicon'
3
- import prettier from 'prettier'
4
4
  import { GeneratedFile } from '../types'
5
5
 
6
- const PRETTIER_OPTS = {
6
+ const PRETTIER_OPTS: PrettierOptions = {
7
7
  parser: 'typescript',
8
8
  tabWidth: 2,
9
9
  semi: false,
10
10
  singleQuote: true,
11
- trailingComma: 'all' as const,
11
+ trailingComma: 'all',
12
12
  }
13
13
 
14
14
  export const utilTs = (project) =>
15
15
  gen(project, '/util.ts', async (file) => {
16
16
  file.replaceWithText(`
17
- export function isObj(v: unknown): v is Record<string, unknown> {
18
- return typeof v === 'object' && v !== null
17
+ import { ValidationResult } from '@atproto/lexicon'
18
+
19
+ export type OmitKey<T, K extends keyof T> = {
20
+ [K2 in keyof T as K2 extends K ? never : K2]: T[K2]
21
+ }
22
+
23
+ export type $Typed<V, T extends string = string> = V & { $type: T }
24
+ export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
25
+
26
+ export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
27
+ ? Id
28
+ : \`\${Id}#\${Hash}\`
29
+
30
+ function isObject<V>(v: V): v is V & object {
31
+ return v != null && typeof v === 'object'
32
+ }
33
+
34
+ function is$type<Id extends string, Hash extends string>(
35
+ $type: unknown,
36
+ id: Id,
37
+ hash: Hash,
38
+ ): $type is $Type<Id, Hash> {
39
+ return hash === 'main'
40
+ ? $type === id
41
+ : // $type === \`\${id}#\${hash}\`
42
+ typeof $type === 'string' &&
43
+ $type.length === id.length + 1 + hash.length &&
44
+ $type.charCodeAt(id.length) === 35 /* '#' */ &&
45
+ $type.startsWith(id) &&
46
+ $type.endsWith(hash)
47
+ }
48
+ ${
49
+ /**
50
+ * The construct below allows to properly distinguish open unions. Consider
51
+ * the following example:
52
+ *
53
+ * ```ts
54
+ * type Foo = { $type?: $Type<'foo', 'main'>; foo: string }
55
+ * type Bar = { $type?: $Type<'bar', 'main'>; bar: string }
56
+ * type OpenFooBarUnion = $Typed<Foo> | $Typed<Bar> | { $type: string }
57
+ * ```
58
+ *
59
+ * In the context of lexicons, when there is a open union as shown above, the
60
+ * if `$type` if either `foo` or `bar`, then the object IS of type `Foo` or
61
+ * `Bar`.
62
+ *
63
+ * ```ts
64
+ * declare const obj1: OpenFooBarUnion
65
+ * if (is$typed(obj1, 'foo', 'main')) {
66
+ * obj1.$type // $Type<'foo', 'main'>
67
+ * obj1.foo // string
68
+ * }
69
+ * ```
70
+ *
71
+ * Similarly, if an object is of type `unknown`, then the `is$typed` function
72
+ * should only return assurance about the `$type` property, which is what it
73
+ * actually checks:
74
+ *
75
+ * ```ts
76
+ * declare const obj2: unknown
77
+ * if (is$typed(obj2, 'foo', 'main')) {
78
+ * obj2.$type // $Type<'foo', 'main'>
79
+ * // @ts-expect-error
80
+ * obj2.foo
81
+ * }
82
+ * ```
83
+ *
84
+ * The construct bellow is what makes these two scenarios possible.
85
+ */
86
+ ''
19
87
  }
88
+ export type $TypedObject<V, Id extends string, Hash extends string> = V extends {
89
+ $type: $Type<Id, Hash>
90
+ }
91
+ ? V
92
+ : V extends { $type?: string }
93
+ ? V extends { $type?: infer T extends $Type<Id, Hash> }
94
+ ? V & { $type: T }
95
+ : never
96
+ : V & { $type: $Type<Id, Hash> }
97
+
98
+ export function is$typed<V, Id extends string, Hash extends string>(
99
+ v: V,
100
+ id: Id,
101
+ hash: Hash,
102
+ ): v is $TypedObject<V, Id, Hash> {
103
+ return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
104
+ }
105
+
106
+ export function maybe$typed<V, Id extends string, Hash extends string>(
107
+ v: V,
108
+ id: Id,
109
+ hash: Hash,
110
+ ): v is V & object & { $type?: $Type<Id, Hash> } {
111
+ return (
112
+ isObject(v) &&
113
+ ('$type' in v
114
+ ? v.$type === undefined || is$type(v.$type, id, hash)
115
+ : true)
116
+ )
117
+ }
118
+
119
+ export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
120
+ export type ValidatorParam<V extends Validator> =
121
+ V extends Validator<infer R> ? R : never
20
122
 
21
- export function hasProp<K extends PropertyKey>(
22
- data: object,
23
- prop: K,
24
- ): data is Record<K, unknown> {
25
- return prop in data
123
+ /**
124
+ * Utility function that allows to convert a "validate*" utility function into a
125
+ * type predicate.
126
+ */
127
+ export function asPredicate<V extends Validator>(validate: V) {
128
+ return function <T>(v: T): v is T & ValidatorParam<V> {
129
+ return validate(v).success
130
+ }
26
131
  }
27
132
  `)
28
133
  })
@@ -41,7 +146,23 @@ export const lexiconsTs = (project, lexicons: LexiconDoc[]) =>
41
146
  .addImportDeclaration({
42
147
  moduleSpecifier: '@atproto/lexicon',
43
148
  })
44
- .addNamedImports([{ name: 'LexiconDoc' }, { name: 'Lexicons' }])
149
+ .addNamedImports([
150
+ { name: 'LexiconDoc' },
151
+ { name: 'Lexicons' },
152
+ { name: 'ValidationError' },
153
+ { name: 'ValidationResult' },
154
+ ])
155
+
156
+ //= import {is$typed, maybe$typed, $Typed} from './util'
157
+ file
158
+ .addImportDeclaration({
159
+ moduleSpecifier: './util.js',
160
+ })
161
+ .addNamedImports([
162
+ { name: '$Typed' },
163
+ { name: 'is$typed' },
164
+ { name: 'maybe$typed' },
165
+ ])
45
166
 
46
167
  //= export const schemaDict = {...} as const satisfies Record<string, LexiconDoc>
47
168
  file.addVariableStatement({
@@ -66,14 +187,14 @@ export const lexiconsTs = (project, lexicons: LexiconDoc[]) =>
66
187
  ],
67
188
  })
68
189
 
69
- //= export const schemas = Object.values(schemaDict)
190
+ //= export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
70
191
  file.addVariableStatement({
71
192
  isExported: true,
72
193
  declarationKind: VariableDeclarationKind.Const,
73
194
  declarations: [
74
195
  {
75
196
  name: 'schemas',
76
- initializer: 'Object.values(schemaDict)',
197
+ initializer: 'Object.values(schemaDict) satisfies LexiconDoc[]',
77
198
  },
78
199
  ],
79
200
  })
@@ -91,6 +212,44 @@ export const lexiconsTs = (project, lexicons: LexiconDoc[]) =>
91
212
  ],
92
213
  })
93
214
 
215
+ file.addFunction({
216
+ isExported: true,
217
+ name: 'validate',
218
+ overloads: [
219
+ {
220
+ typeParameters: ['T extends { $type: string }'],
221
+ parameters: [
222
+ { name: 'v', type: 'unknown' },
223
+ { name: 'id', type: 'string' },
224
+ { name: 'hash', type: 'string' },
225
+ { name: 'requiredType', type: 'true' },
226
+ ],
227
+ returnType: 'ValidationResult<T>',
228
+ },
229
+ {
230
+ typeParameters: ['T extends { $type?: string }'],
231
+ parameters: [
232
+ { name: 'v', type: 'unknown' },
233
+ { name: 'id', type: 'string' },
234
+ { name: 'hash', type: 'string' },
235
+ { name: 'requiredType', type: 'false', hasQuestionToken: true },
236
+ ],
237
+ returnType: 'ValidationResult<T>',
238
+ },
239
+ ],
240
+ parameters: [
241
+ { name: 'v', type: 'unknown' },
242
+ { name: 'id', type: 'string' },
243
+ { name: 'hash', type: 'string' },
244
+ { name: 'requiredType', type: 'boolean', hasQuestionToken: true },
245
+ ],
246
+ statements: [
247
+ // If $type is present, make sure it is valid before validating the rest of the object
248
+ 'return (requiredType ? is$typed : maybe$typed)(v, id, hash) ? lexicons.validate(`${id}#${hash}`, v) : { success: false, error: new ValidationError(`Must be an object with "${hash === \'main\' ? id : `${id}#${hash}`}" $type property`) }',
249
+ ],
250
+ returnType: 'ValidationResult',
251
+ })
252
+
94
253
  //= export const ids = {...}
95
254
  file.addVariableStatement({
96
255
  isExported: true,
@@ -98,14 +257,11 @@ export const lexiconsTs = (project, lexicons: LexiconDoc[]) =>
98
257
  declarations: [
99
258
  {
100
259
  name: 'ids',
101
- initializer: JSON.stringify(
102
- lexicons.reduce((acc, cur) => {
103
- return {
104
- ...acc,
105
- [nsidToEnum(cur.id)]: cur.id,
106
- }
107
- }, {}),
108
- ),
260
+ initializer: `{${lexicons
261
+ .map(
262
+ (lex) => `\n ${nsidToEnum(lex.id)}: ${JSON.stringify(lex.id)},`,
263
+ )
264
+ .join('')}\n} as const`,
109
265
  },
110
266
  ],
111
267
  })
@@ -118,12 +274,11 @@ export async function gen(
118
274
  ): Promise<GeneratedFile> {
119
275
  const file = project.createSourceFile(path)
120
276
  await gen(file)
121
- file.saveSync()
122
- const src = project.getFileSystem().readFileSync(path)
123
- return {
124
- path: path,
125
- content: `${banner()}${await prettier.format(src, PRETTIER_OPTS)}`,
126
- }
277
+ await file.save() // Save in the "in memory" file system
278
+ const src = `${banner()}${file.getFullText()}`
279
+ const content = await format(src, PRETTIER_OPTS)
280
+
281
+ return { path, content }
127
282
  }
128
283
 
129
284
  function banner() {