@atproto/lex-document 0.1.1 → 0.1.3

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,501 +0,0 @@
1
- import { LexValue } from '@atproto/lex-data'
2
- import { l } from '@atproto/lex-schema'
3
- import {
4
- LexiconArray,
5
- LexiconArrayItems,
6
- LexiconDocument,
7
- LexiconError,
8
- LexiconObject,
9
- LexiconParameters,
10
- LexiconPayload,
11
- LexiconRef,
12
- LexiconRefUnion,
13
- } from './lexicon-document.js'
14
- import { LexiconIndexer } from './lexicon-indexer.js'
15
-
16
- /**
17
- * Builds validators for Lexicon documents.
18
- *
19
- * This class converts Lexicon type definitions into runtime validators
20
- * that can validate data against the schema. It handles reference resolution,
21
- * supporting both local (`#defName`) and cross-document (`nsid#defName`) refs.
22
- *
23
- * @example
24
- * ```ts
25
- * import { LexiconSchemaBuilder, LexiconIterableIndexer } from '@atproto/lex-document'
26
- *
27
- * // Build a single validator
28
- * const indexer = new LexiconIterableIndexer(lexiconDocs)
29
- * const validator = await LexiconSchemaBuilder.build(indexer, 'com.example.post#main')
30
- *
31
- * // Validate data
32
- * const result = validator.safeParse(myPostData)
33
- * if (result.success) {
34
- * console.log('Valid:', result.value)
35
- * } else {
36
- * console.log('Invalid:', result.error)
37
- * }
38
- * ```
39
- *
40
- * @example
41
- * ```ts
42
- * // Build all validators from an iterable indexer
43
- * const indexer = new LexiconIterableIndexer(lexiconDocs)
44
- * const allSchemas = await LexiconSchemaBuilder.buildAll(indexer)
45
- *
46
- * for (const [ref, schema] of allSchemas) {
47
- * console.log(`Built validator for ${ref}`)
48
- * }
49
- * ```
50
- */
51
- export class LexiconSchemaBuilder implements AsyncDisposable {
52
- /**
53
- * Builds a validator for a single Lexicon definition reference.
54
- *
55
- * @param indexer - The Lexicon indexer to resolve documents from
56
- * @param fullRef - The full reference to build, in format "nsid#defName"
57
- * @returns A promise resolving to a validator for the referenced definition
58
- * @throws Error if the reference does not point to a schema type
59
- *
60
- * @example
61
- * ```ts
62
- * const validator = await LexiconSchemaBuilder.build(
63
- * indexer,
64
- * 'app.bsky.feed.post#main'
65
- * )
66
- *
67
- * validator.parse(postRecord) // Throws if invalid
68
- * ```
69
- */
70
- static async build(
71
- indexer: LexiconIndexer,
72
- fullRef: string,
73
- ): Promise<l.Schema<LexValue>> {
74
- const ctx = new LexiconSchemaBuilder(indexer)
75
- try {
76
- const result = await ctx.buildFullRef(fullRef)
77
- if (!(result instanceof l.Schema)) {
78
- throw new Error(`Ref ${fullRef} is not a schema type`)
79
- }
80
- return result
81
- } finally {
82
- await ctx.done()
83
- }
84
- }
85
-
86
- /**
87
- * Builds validators for all definitions in all documents from an iterable indexer.
88
- *
89
- * This method iterates over all Lexicon documents available in the indexer
90
- * and builds validators for every definition in each document.
91
- *
92
- * @param indexer - An iterable Lexicon indexer (must implement `Symbol.asyncIterator`)
93
- * @returns A promise resolving to a Map of full references to their validators.
94
- * The map values can be validators, Query, Subscription, Procedure, or PermissionSet.
95
- * @throws Error if the indexer does not support iteration
96
- *
97
- * @example
98
- * ```ts
99
- * const indexer = new LexiconIterableIndexer(allLexiconDocs)
100
- * const schemas = await LexiconSchemaBuilder.buildAll(indexer)
101
- *
102
- * // Access a specific schema
103
- * const postSchema = schemas.get('app.bsky.feed.post#main')
104
- *
105
- * // Iterate all schemas
106
- * for (const [ref, schema] of schemas) {
107
- * console.log(ref, schema)
108
- * }
109
- * ```
110
- */
111
- static async buildAll(indexer: LexiconIndexer) {
112
- const builder = new LexiconSchemaBuilder(indexer)
113
- const schemas = new Map<
114
- string,
115
- | l.Schema<LexValue>
116
- | l.Query
117
- | l.Subscription
118
- | l.Procedure
119
- | l.PermissionSet
120
- >()
121
- if (!isAsyncIterableObject(indexer)) {
122
- throw new Error('An iterable indexer is required to build all schemas')
123
- }
124
- try {
125
- for await (const doc of indexer) {
126
- for (const hash of Object.keys(doc.defs)) {
127
- const fullRef = `${doc.id}#${hash}`
128
- const schema = await builder.buildFullRef(fullRef)
129
- schemas.set(fullRef, schema)
130
- }
131
- }
132
- return schemas
133
- } finally {
134
- await builder.done()
135
- }
136
- }
137
-
138
- #asyncTasks = new AsyncTasks()
139
-
140
- /**
141
- * Creates a new LexiconSchemaBuilder instance.
142
- *
143
- * Note: For most use cases, prefer using the static `build()` or `buildAll()`
144
- * methods instead of instantiating directly.
145
- *
146
- * @param indexer - The Lexicon indexer to resolve documents from
147
- */
148
- constructor(protected indexer: LexiconIndexer) {}
149
-
150
- /**
151
- * Waits for all pending reference resolution tasks to complete.
152
- *
153
- * When building schemas with cross-references, the builder schedules
154
- * async tasks to resolve those references. This method must be called
155
- * to ensure all references are fully resolved before using the validators.
156
- *
157
- * @returns A promise that resolves when all pending tasks are complete
158
- * @throws Rethrows any errors from failed reference resolution
159
- */
160
- async done(): Promise<void> {
161
- await this.#asyncTasks.done()
162
- }
163
-
164
- async [Symbol.asyncDispose]() {
165
- await this.done()
166
- }
167
-
168
- /**
169
- * Builds a validator for a full reference (memoized).
170
- *
171
- * Results are cached, so calling with the same reference returns
172
- * the same promise/result.
173
- *
174
- * @param fullRef - The full reference in format "nsid#defName"
175
- * @returns A promise resolving to the built schema or method definition
176
- */
177
- buildFullRef = memoize(async (fullRef: string) => {
178
- const { nsid, hash } = parseRef(fullRef)
179
-
180
- const doc = await this.indexer.get(nsid)
181
-
182
- return this.compileDef(doc, hash)
183
- })
184
-
185
- protected buildRefGetter(fullRef: string): () => l.Schema<LexValue> {
186
- let schema: l.Schema<LexValue>
187
-
188
- this.#asyncTasks.add(
189
- this.buildFullRef(fullRef).then((v) => {
190
- if (!(v instanceof l.Schema)) {
191
- throw new Error(`Only refs to schema types are allowed`)
192
- }
193
- schema = v
194
- }),
195
- )
196
-
197
- return () => {
198
- if (schema) return schema
199
- throw new Error('Validator not yet built. Did you await done()?')
200
- }
201
- }
202
-
203
- protected buildTypedRefGetter(
204
- fullRef: string,
205
- ): () => l.TypedObjectSchema | l.RecordSchema {
206
- let validator: l.TypedObjectSchema | l.RecordSchema
207
-
208
- this.#asyncTasks.add(
209
- this.buildFullRef(fullRef).then((v) => {
210
- if (v instanceof l.TypedObjectSchema || v instanceof l.RecordSchema) {
211
- validator = v
212
- } else {
213
- throw new Error(
214
- 'Only refs to records and object definitions are allowed',
215
- )
216
- }
217
- }),
218
- )
219
-
220
- return () => {
221
- if (validator) return validator
222
- throw new Error('Validator not yet built. Did you await done()?')
223
- }
224
- }
225
-
226
- protected compileDef(doc: LexiconDocument, hash: string) {
227
- const def = Object.hasOwn(doc.defs, hash) ? doc.defs[hash] : null
228
- if (!def) {
229
- throw new Error(
230
- `No definition found for hash "${JSON.stringify(hash)}" in ${doc.id}`,
231
- )
232
- }
233
- switch (def.type) {
234
- case 'permission-set':
235
- return l.permissionSet(
236
- doc.id,
237
- def.permissions.map(({ resource, type, ...p }) =>
238
- l.permission(resource, p),
239
- ),
240
- def,
241
- )
242
- case 'procedure':
243
- return l.procedure(
244
- doc.id,
245
- this.compileParams(doc, def.parameters),
246
- this.compilePayload(doc, def.input),
247
- this.compilePayload(doc, def.output),
248
- this.compileErrors(doc, def.errors),
249
- )
250
- case 'query':
251
- return l.query(
252
- doc.id,
253
- this.compileParams(doc, def.parameters),
254
- this.compilePayload(doc, def.output),
255
- this.compileErrors(doc, def.errors),
256
- )
257
- case 'subscription':
258
- return l.subscription(
259
- doc.id,
260
- this.compileParams(doc, def.parameters),
261
- this.compilePayloadSchema(doc, def.message.schema),
262
- this.compileErrors(doc, def.errors),
263
- )
264
- case 'token':
265
- return l.token(doc.id, hash)
266
- case 'record':
267
- return l.record(def.key, doc.id, this.compileObject(doc, def.record))
268
- case 'object':
269
- return l.typedObject(doc.id, hash, this.compileObject(doc, def))
270
- default:
271
- return this.compileLeaf(doc, def)
272
- }
273
- }
274
-
275
- protected compileLeaf(
276
- doc: LexiconDocument,
277
- def: LexiconArray | LexiconArrayItems,
278
- ): l.Schema<LexValue> {
279
- if (
280
- 'const' in def &&
281
- 'enum' in def &&
282
- def.enum != null &&
283
- def.const !== undefined &&
284
- !(def.enum as readonly unknown[]).includes(def.const)
285
- ) {
286
- return l.never()
287
- }
288
-
289
- switch (def.type) {
290
- case 'string': {
291
- const schema = l.string(def)
292
- if (def.default != null) schema.check(def.default)
293
- if (def.const != null) schema.check(def.const)
294
- if (def.enum != null) for (const v of def.enum) schema.check(v)
295
-
296
- const result =
297
- def.const != null
298
- ? l.literal(def.const)
299
- : def.enum != null
300
- ? l.enum(def.enum)
301
- : schema
302
-
303
- return def.default != null ? l.withDefault(result, def.default) : result
304
- }
305
- case 'integer': {
306
- const schema = l.integer(def)
307
- if (def.default != null) schema.check(def.default)
308
- if (def.const != null) schema.check(def.const)
309
- if (def.enum != null) for (const v of def.enum) schema.check(v)
310
-
311
- const result =
312
- def.const != null
313
- ? l.literal(def.const)
314
- : def.enum != null
315
- ? l.enum(def.enum)
316
- : schema
317
-
318
- return def.default != null ? l.withDefault(result, def.default) : result
319
- }
320
- case 'boolean': {
321
- const result = def.const != null ? l.literal(def.const) : l.boolean()
322
-
323
- return def.default != null ? l.withDefault(result, def.default) : result
324
- }
325
- case 'blob':
326
- return l.blob(def)
327
- case 'cid-link':
328
- return l.cid()
329
- case 'bytes':
330
- return l.bytes(def)
331
- case 'unknown':
332
- return l.lexMap()
333
- case 'array':
334
- return l.array(this.compileLeaf(doc, def.items), def)
335
- default:
336
- return this.compileRef(doc, def)
337
- }
338
- }
339
-
340
- protected compileRef(
341
- doc: LexiconDocument,
342
- def: LexiconRef | LexiconRefUnion,
343
- ): l.Schema<LexValue> {
344
- switch (def.type) {
345
- case 'ref':
346
- return l.ref(this.buildRefGetter(buildFullRef(doc, def.ref)))
347
- case 'union':
348
- return l.typedUnion(
349
- def.refs.map((r) =>
350
- l.typedRef(this.buildTypedRefGetter(buildFullRef(doc, r))),
351
- ),
352
- def.closed ?? false,
353
- )
354
- default:
355
- // @ts-expect-error
356
- throw new Error(`Unknown lexicon type: ${def.type}`)
357
- }
358
- }
359
-
360
- protected compileObject(doc: LexiconDocument, def: LexiconObject) {
361
- const props: Record<string, l.Schema<undefined | LexValue>> = {}
362
- for (const [key, propDef] of Object.entries(def.properties)) {
363
- if (propDef === undefined) continue
364
-
365
- const isNullable = def.nullable?.includes(key)
366
- const isRequired = def.required?.includes(key)
367
-
368
- let schema: l.Schema<undefined | LexValue> = this.compileLeaf(
369
- doc,
370
- propDef,
371
- )
372
-
373
- if (isNullable) {
374
- schema = l.nullable(schema)
375
- }
376
-
377
- if (!isRequired) {
378
- schema = l.optional(schema)
379
- }
380
-
381
- props[key] = schema
382
- }
383
- return l.object(props)
384
- }
385
-
386
- protected compilePayload(
387
- doc: LexiconDocument,
388
- def: LexiconPayload | undefined,
389
- ) {
390
- return l.payload(
391
- def?.encoding,
392
- def?.schema ? this.compilePayloadSchema(doc, def.schema) : undefined,
393
- )
394
- }
395
-
396
- protected compileErrors(
397
- _doc: LexiconDocument,
398
- errors?: readonly LexiconError[],
399
- ): undefined | string[] {
400
- return errors?.map((e) => e.name)
401
- }
402
-
403
- protected compilePayloadSchema(
404
- doc: LexiconDocument,
405
- def: LexiconObject | LexiconRef | LexiconRefUnion,
406
- ): l.Schema<LexValue, LexValue> {
407
- switch (def.type) {
408
- case 'object':
409
- return this.compileObject(doc, def)
410
- default:
411
- return this.compileRef(doc, def)
412
- }
413
- }
414
-
415
- protected compileParams(doc: LexiconDocument, def?: LexiconParameters) {
416
- if (!def) return l.params()
417
-
418
- const shape: l.ParamsShape = {}
419
- for (const [paramName, paramDef] of Object.entries(def.properties)) {
420
- if (paramDef === undefined) continue
421
-
422
- const isRequired = def.required?.includes(paramName)
423
-
424
- const propSchema = this.compileLeaf(doc, paramDef) as
425
- | l.StringSchema
426
- | l.BooleanSchema
427
- | l.IntegerSchema
428
- | l.ArraySchema<l.StringSchema>
429
- | l.ArraySchema<l.BooleanSchema>
430
- | l.ArraySchema<l.IntegerSchema>
431
-
432
- shape[paramName] = isRequired ? propSchema : l.optional(propSchema)
433
- }
434
-
435
- return l.params(shape)
436
- }
437
- }
438
-
439
- class AsyncTasks {
440
- /**
441
- * A set that, eventually, contains only rejected promises.
442
- */
443
- #promises = new Set<Promise<void>>()
444
-
445
- async done(): Promise<void> {
446
- do {
447
- // @NOTE this is going to throw on the first rejected promise (which is
448
- // what we want)
449
- for (const p of this.#promises) await p
450
- // At this point, all settled promises should have been removed. If
451
- // this.#promises is not empty, it means new promises were added during
452
- // the awaiting process, so we loop again.
453
- } while (this.#promises.size > 0)
454
- }
455
-
456
- add(p: Promise<void>) {
457
- const promise = Promise.resolve(p).then(() => {
458
- // No need to keep the promise any longer
459
- this.#promises.delete(promise)
460
- })
461
-
462
- void promise.catch((_err) => {
463
- // ignore errors here, they should be caught though done()
464
- })
465
-
466
- this.#promises.add(promise)
467
- }
468
- }
469
-
470
- function parseRef(fullRef: string) {
471
- const { length, 0: nsid, 1: hash } = fullRef.split('#')
472
- if (length !== 2) throw new Error('Uri can only have one hash segment')
473
- if (!nsid || !hash) throw new Error('Invalid ref, missing hash')
474
- return { nsid, hash }
475
- }
476
-
477
- function buildFullRef(from: LexiconDocument, ref: string) {
478
- if (ref.startsWith('#')) return `${from.id}${ref}`
479
- return ref
480
- }
481
-
482
- export function memoize<Fn extends (arg: string) => unknown>(fn: Fn): Fn {
483
- const cache = new Map<string, ReturnType<Fn>>()
484
- return ((arg: string) => {
485
- if (cache.has(arg)) return cache.get(arg)!
486
- const result = fn(arg) as ReturnType<Fn>
487
- cache.set(arg, result)
488
- return result
489
- }) as Fn
490
- }
491
-
492
- function isAsyncIterableObject<T>(
493
- obj: T,
494
- ): obj is T & object & AsyncIterable<unknown> {
495
- return (
496
- obj != null &&
497
- typeof obj === 'object' &&
498
- Symbol.asyncIterator in obj &&
499
- typeof obj[Symbol.asyncIterator] === 'function'
500
- )
501
- }
@@ -1,12 +0,0 @@
1
- {
2
- "extends": ["../../../tsconfig/isomorphic.json"],
3
- "include": ["./src"],
4
- "exclude": ["**/*.test.ts"],
5
- "compilerOptions": {
6
- "noImplicitAny": true,
7
- "importHelpers": true,
8
- "target": "ES2023",
9
- "rootDir": "./src",
10
- "outDir": "./dist",
11
- },
12
- }
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "include": [],
3
- "references": [
4
- { "path": "./tsconfig.build.json" },
5
- { "path": "./tsconfig.tests.json" },
6
- ],
7
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig/vitest.json",
3
- "include": ["./tests", "./src/**/*.test.ts", "./src/core-js.d.ts"],
4
- "compilerOptions": {
5
- "noImplicitAny": true,
6
- "rootDir": "./",
7
- },
8
- }