@atproto/lex-cli 0.5.7 → 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.
@@ -9,9 +9,9 @@ import { NSID } from '@atproto/syntax'
9
9
  import { GeneratedAPI } from '../types'
10
10
  import { gen, lexiconsTs, utilTs } from './common'
11
11
  import {
12
+ genCommonImports,
12
13
  genImports,
13
- genObjHelpers,
14
- genObject,
14
+ genRecord,
15
15
  genUserType,
16
16
  genXrpcInput,
17
17
  genXrpcOutput,
@@ -70,9 +70,9 @@ const indexTs = (
70
70
  { name: 'FetchHandler' },
71
71
  { name: 'FetchHandlerOptions' },
72
72
  ])
73
- //= import {schemas} from './lexicons'
73
+ //= import {schemas} from './lexicons.js'
74
74
  file
75
- .addImportDeclaration({ moduleSpecifier: './lexicons' })
75
+ .addImportDeclaration({ moduleSpecifier: './lexicons.js' })
76
76
  .addNamedImports([{ name: 'schemas' }])
77
77
  //= import {CID} from 'multiformats/cid'
78
78
  file
@@ -81,9 +81,14 @@ const indexTs = (
81
81
  })
82
82
  .addNamedImports([{ name: 'CID' }])
83
83
 
84
+ //= import {OmitKey} from './util.js'
85
+ file
86
+ .addImportDeclaration({ moduleSpecifier: `./util.js` })
87
+ .addNamedImports([{ name: 'OmitKey' }, { name: 'Un$Typed' }])
88
+
84
89
  // generate type imports and re-exports
85
90
  for (const lexicon of lexiconDocs) {
86
- const moduleSpecifier = `./types/${lexicon.id.split('.').join('/')}`
91
+ const moduleSpecifier = `./types/${lexicon.id.split('.').join('/')}.js`
87
92
  file
88
93
  .addImportDeclaration({ moduleSpecifier })
89
94
  .setNamespaceImport(toTitleCase(lexicon.id))
@@ -312,7 +317,7 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) {
312
317
  })
313
318
  method.addParameter({
314
319
  name: 'params',
315
- type: `Omit<${toTitleCase(ATP_METHODS.list)}.QueryParams, "collection">`,
320
+ type: `OmitKey<${toTitleCase(ATP_METHODS.list)}.QueryParams, "collection">`,
316
321
  })
317
322
  method.setBodyText(
318
323
  [
@@ -330,7 +335,7 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) {
330
335
  })
331
336
  method.addParameter({
332
337
  name: 'params',
333
- type: `Omit<${toTitleCase(ATP_METHODS.get)}.QueryParams, "collection">`,
338
+ type: `OmitKey<${toTitleCase(ATP_METHODS.get)}.QueryParams, "collection">`,
334
339
  })
335
340
  method.setBodyText(
336
341
  [
@@ -348,13 +353,13 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) {
348
353
  })
349
354
  method.addParameter({
350
355
  name: 'params',
351
- type: `Omit<${toTitleCase(
356
+ type: `OmitKey<${toTitleCase(
352
357
  ATP_METHODS.create,
353
358
  )}.InputSchema, "collection" | "record">`,
354
359
  })
355
360
  method.addParameter({
356
361
  name: 'record',
357
- type: `${typeModule}.Record`,
362
+ type: `Un$Typed<${typeModule}.Record>`,
358
363
  })
359
364
  method.addParameter({
360
365
  name: 'headers?',
@@ -365,8 +370,8 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) {
365
370
  : ''
366
371
  method.setBodyText(
367
372
  [
368
- `record.$type = '${nsid}'`,
369
- `const res = await this._client.call('${ATP_METHODS.create}', undefined, { collection: '${nsid}', ${maybeRkeyPart}...params, record }, {encoding: 'application/json', headers })`,
373
+ `const collection = '${nsid}'`,
374
+ `const res = await this._client.call('${ATP_METHODS.create}', undefined, { collection, ${maybeRkeyPart}...params, record: { ...record, $type: collection} }, {encoding: 'application/json', headers })`,
370
375
  `return res.data`,
371
376
  ].join('\n'),
372
377
  )
@@ -380,7 +385,7 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) {
380
385
  // })
381
386
  // method.addParameter({
382
387
  // name: 'params',
383
- // type: `Omit<${toTitleCase(ATP_METHODS.put)}.InputSchema, "collection" | "record">`,
388
+ // type: `OmitKey<${toTitleCase(ATP_METHODS.put)}.InputSchema, "collection" | "record">`,
384
389
  // })
385
390
  // method.addParameter({
386
391
  // name: 'record',
@@ -407,7 +412,7 @@ function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) {
407
412
  })
408
413
  method.addParameter({
409
414
  name: 'params',
410
- type: `Omit<${toTitleCase(
415
+ type: `OmitKey<${toTitleCase(
411
416
  ATP_METHODS.delete,
412
417
  )}.InputSchema, "collection">`,
413
418
  })
@@ -429,8 +434,6 @@ const lexiconTs = (project, lexicons: Lexicons, lexiconDoc: LexiconDoc) =>
429
434
  project,
430
435
  `/types/${lexiconDoc.id.split('.').join('/')}.ts`,
431
436
  async (file) => {
432
- const imports: Set<string> = new Set()
433
-
434
437
  const main = lexiconDoc.defs.main
435
438
  if (
436
439
  main?.type === 'query' ||
@@ -446,37 +449,10 @@ const lexiconTs = (project, lexicons: Lexicons, lexiconDoc: LexiconDoc) =>
446
449
  { name: 'XRPCError' },
447
450
  ])
448
451
  }
449
- //= import {ValidationResult, BlobRef} from '@atproto/lexicon'
450
- file
451
- .addImportDeclaration({
452
- moduleSpecifier: '@atproto/lexicon',
453
- })
454
- .addNamedImports([{ name: 'ValidationResult' }, { name: 'BlobRef' }])
455
- //= import {isObj, hasProp} from '../../util.ts'
456
- file
457
- .addImportDeclaration({
458
- moduleSpecifier: `${lexiconDoc.id
459
- .split('.')
460
- .map((_str) => '..')
461
- .join('/')}/util`,
462
- })
463
- .addNamedImports([{ name: 'isObj' }, { name: 'hasProp' }])
464
- //= import {lexicons} from '../../lexicons.ts'
465
- file
466
- .addImportDeclaration({
467
- moduleSpecifier: `${lexiconDoc.id
468
- .split('.')
469
- .map((_str) => '..')
470
- .join('/')}/lexicons`,
471
- })
472
- .addNamedImports([{ name: 'lexicons' }])
473
- //= import {CID} from 'multiformats/cid'
474
- file
475
- .addImportDeclaration({
476
- moduleSpecifier: 'multiformats/cid',
477
- })
478
- .addNamedImports([{ name: 'CID' }])
479
452
 
453
+ genCommonImports(file, lexiconDoc.id)
454
+
455
+ const imports: Set<string> = new Set()
480
456
  for (const defId in lexiconDoc.defs) {
481
457
  const def = lexiconDoc.defs[defId]
482
458
  const lexUri = `${lexiconDoc.id}#${defId}`
@@ -489,7 +465,7 @@ const lexiconTs = (project, lexicons: Lexicons, lexiconDoc: LexiconDoc) =>
489
465
  } else if (def.type === 'subscription') {
490
466
  continue
491
467
  } else if (def.type === 'record') {
492
- genClientRecord(file, imports, lexicons, lexUri)
468
+ genRecord(file, imports, lexicons, lexUri)
493
469
  } else {
494
470
  genUserType(file, imports, lexicons, lexUri)
495
471
  }
@@ -586,17 +562,3 @@ function genClientXrpcCommon(
586
562
  : ['return e'],
587
563
  })
588
564
  }
589
-
590
- function genClientRecord(
591
- file: SourceFile,
592
- imports: Set<string>,
593
- lexicons: Lexicons,
594
- lexUri: string,
595
- ) {
596
- const def = lexicons.getDefOrThrow(lexUri, ['record'])
597
-
598
- //= export interface Record {...}
599
- genObject(file, imports, lexUri, def.record, 'Record')
600
- //= export function isRecord(v: unknown): v is Record {...}
601
- genObjHelpers(file, lexUri, 'Record')
602
- }
@@ -1,28 +1,133 @@
1
- import { format } from 'prettier'
1
+ import { Options as PrettierOptions, format } from 'prettier'
2
2
  import { Project, SourceFile, VariableDeclarationKind } from 'ts-morph'
3
3
  import { LexiconDoc } from '@atproto/lexicon'
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 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() {