@atproto/lex-document 0.0.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.
@@ -0,0 +1,345 @@
1
+ import { l } from '@atproto/lex-schema'
2
+ import {
3
+ LexiconArray,
4
+ LexiconArrayItems,
5
+ LexiconDocument,
6
+ LexiconError,
7
+ LexiconObject,
8
+ LexiconParameters,
9
+ LexiconPayload,
10
+ LexiconRef,
11
+ LexiconRefUnion,
12
+ } from './lexicon-document.js'
13
+ import { LexiconIndexer } from './lexicon-indexer.js'
14
+
15
+ /**
16
+ * Builds a validator for a given lexicon "ref" from a lexicon indexer.
17
+ *
18
+ * @example
19
+ *
20
+ * ```ts
21
+ * import { LexiconSchemaBuilder } from '@atproto/lex/doc'
22
+ * import { LexiconStreamIndexer } from '@atproto/lex/doc'
23
+ *
24
+ * const indexer = new LexiconStreamIndexer(lexiconDocs)
25
+ * const validator = await LexiconSchemaBuilder.build(indexer, 'com.example.foo#bar')
26
+ * ```
27
+ */
28
+ export class LexiconSchemaBuilder {
29
+ static async build(
30
+ indexer: LexiconIndexer,
31
+ fullRef: string,
32
+ ): Promise<l.Validator<unknown>> {
33
+ const ctx = new LexiconSchemaBuilder(indexer)
34
+ try {
35
+ const result = await ctx.buildFullRef(fullRef)
36
+ if (!(result instanceof l.Validator)) {
37
+ throw new Error(`Ref ${fullRef} is not a validator schema type`)
38
+ }
39
+ return result
40
+ } finally {
41
+ await ctx.done()
42
+ }
43
+ }
44
+
45
+ static async buildAll(indexer: LexiconIndexer) {
46
+ const builder = new LexiconSchemaBuilder(indexer)
47
+ const schemas = new Map<
48
+ string,
49
+ | l.Validator<unknown>
50
+ | l.Query
51
+ | l.Subscription
52
+ | l.Procedure
53
+ | l.PermissionSet
54
+ >()
55
+ if (!isAsyncIterableObject(indexer)) {
56
+ throw new Error('An iterable indexer is required to build all schemas')
57
+ }
58
+ try {
59
+ for await (const doc of indexer) {
60
+ for (const hash of Object.keys(doc.defs)) {
61
+ const fullRef = `${doc.id}#${hash}`
62
+ const schema = await builder.buildFullRef(fullRef)
63
+ schemas.set(fullRef, schema)
64
+ }
65
+ }
66
+ return schemas
67
+ } finally {
68
+ await builder.done()
69
+ }
70
+ }
71
+
72
+ #asyncTasks = new AsyncTasks()
73
+
74
+ constructor(protected indexer: LexiconIndexer) {}
75
+
76
+ async done(): Promise<void> {
77
+ await this.#asyncTasks.done()
78
+ }
79
+
80
+ buildFullRef = memoize(async (fullRef: string) => {
81
+ const { nsid, hash } = parseRef(fullRef)
82
+
83
+ const doc = await this.indexer.get(nsid)
84
+ if (!doc) throw new Error(`No lexicon found for NSID: ${nsid}`)
85
+
86
+ return this.compileDef(doc, hash)
87
+ })
88
+
89
+ protected buildRefGetter(fullRef: string): () => l.Validator<unknown> {
90
+ let validator: l.Validator<unknown>
91
+
92
+ this.#asyncTasks.add(
93
+ this.buildFullRef(fullRef).then((v) => {
94
+ if (!(v instanceof l.Validator)) {
95
+ throw new Error(`Only refs to validator schema types are allowed`)
96
+ }
97
+ validator = v
98
+ }),
99
+ )
100
+
101
+ return () => {
102
+ if (validator) return validator
103
+ throw new Error('Validator not yet built. Did you await done()?')
104
+ }
105
+ }
106
+
107
+ protected buildTypedRefGetter(
108
+ fullRef: string,
109
+ ): () => l.TypedObjectSchema | l.RecordSchema {
110
+ let validator: l.TypedObjectSchema | l.RecordSchema
111
+
112
+ this.#asyncTasks.add(
113
+ this.buildFullRef(fullRef).then((v) => {
114
+ if (v instanceof l.TypedObjectSchema || v instanceof l.RecordSchema) {
115
+ validator = v
116
+ } else {
117
+ throw new Error(
118
+ 'Only refs to records and object definitions are allowed',
119
+ )
120
+ }
121
+ }),
122
+ )
123
+
124
+ return () => {
125
+ if (validator) return validator
126
+ throw new Error('Validator not yet built. Did you await done()?')
127
+ }
128
+ }
129
+
130
+ protected compileDef(doc: LexiconDocument, hash: string) {
131
+ const def = Object.hasOwn(doc.defs, hash) ? doc.defs[hash] : null
132
+ if (!def) {
133
+ throw new Error(
134
+ `No definition found for hash "${JSON.stringify(hash)}" in ${doc.id}`,
135
+ )
136
+ }
137
+ switch (def.type) {
138
+ case 'permission-set':
139
+ return l.permissionSet(
140
+ doc.id,
141
+ def.permissions.map(({ resource, type, ...p }) =>
142
+ l.permission(resource, p),
143
+ ),
144
+ def,
145
+ )
146
+ case 'procedure':
147
+ return l.procedure(
148
+ doc.id,
149
+ this.compileParams(doc, def.parameters),
150
+ this.compilePayload(doc, def.input),
151
+ this.compilePayload(doc, def.output),
152
+ this.compileErrors(doc, def.errors),
153
+ )
154
+ case 'query':
155
+ return l.query(
156
+ doc.id,
157
+ this.compileParams(doc, def.parameters),
158
+ this.compilePayload(doc, def.output),
159
+ this.compileErrors(doc, def.errors),
160
+ )
161
+ case 'subscription':
162
+ return l.subscription(
163
+ doc.id,
164
+ this.compileParams(doc, def.parameters),
165
+ this.compilePayloadSchema(doc, def.message?.schema),
166
+ this.compileErrors(doc, def.errors),
167
+ )
168
+ case 'token':
169
+ return l.token(doc.id, hash)
170
+ case 'record':
171
+ return l.record(
172
+ def.key ? l.asRecordKey(def.key) : 'any',
173
+ doc.id,
174
+ this.compileObject(doc, def.record),
175
+ )
176
+ case 'object':
177
+ return l.typedObject(doc.id, hash, this.compileObject(doc, def))
178
+ default:
179
+ return this.compileLeaf(doc, def)
180
+ }
181
+ }
182
+
183
+ protected compileLeaf(
184
+ doc: LexiconDocument,
185
+ def: LexiconArray | LexiconArrayItems,
186
+ ): l.Validator<unknown> {
187
+ switch (def.type) {
188
+ case 'string':
189
+ return l.string(def)
190
+ case 'integer':
191
+ return l.integer(def)
192
+ case 'boolean':
193
+ return l.boolean(def)
194
+ case 'blob':
195
+ return l.blob(def)
196
+ case 'cid-link':
197
+ return l.cidLink()
198
+ case 'bytes':
199
+ return l.bytes(def)
200
+ case 'unknown':
201
+ return l.unknown()
202
+ case 'array':
203
+ return l.array(this.compileLeaf(doc, def.items), def)
204
+ default:
205
+ return this.compileRef(doc, def)
206
+ }
207
+ }
208
+
209
+ protected compileRef(
210
+ doc: LexiconDocument,
211
+ def: LexiconRef | LexiconRefUnion,
212
+ ) {
213
+ switch (def.type) {
214
+ case 'ref':
215
+ return l.ref(this.buildRefGetter(buildFullRef(doc, def.ref)))
216
+ case 'union':
217
+ return l.typedUnion(
218
+ def.refs.map((r) =>
219
+ l.typedRef(this.buildTypedRefGetter(buildFullRef(doc, r))),
220
+ ),
221
+ def.closed ?? false,
222
+ )
223
+ default:
224
+ // @ts-expect-error
225
+ throw new Error(`Unknown lexicon type: ${def.type}`)
226
+ }
227
+ }
228
+
229
+ protected compileObject(
230
+ doc: LexiconDocument,
231
+ def: LexiconObject,
232
+ ): l.ObjectSchema {
233
+ const props: Record<string, l.Validator> = {}
234
+ for (const [key, propDef] of Object.entries(def.properties)) {
235
+ if (propDef === undefined) continue
236
+ props[key] = this.compileLeaf(doc, propDef)
237
+ }
238
+ return l.object(props, def)
239
+ }
240
+
241
+ protected compilePayload(
242
+ doc: LexiconDocument,
243
+ def: LexiconPayload | undefined,
244
+ ): l.Payload {
245
+ return l.payload(
246
+ def?.encoding,
247
+ def?.schema ? this.compilePayloadSchema(doc, def.schema) : undefined,
248
+ )
249
+ }
250
+
251
+ protected compileErrors(
252
+ _doc: LexiconDocument,
253
+ errors?: readonly LexiconError[],
254
+ ): undefined | string[] {
255
+ return errors?.map((e) => e.name)
256
+ }
257
+
258
+ protected compilePayloadSchema(
259
+ doc: LexiconDocument,
260
+ def?: LexiconObject | LexiconRef | LexiconRefUnion,
261
+ ) {
262
+ if (!def) return undefined
263
+ switch (def.type) {
264
+ case 'object':
265
+ return this.compileObject(doc, def)
266
+ default:
267
+ return this.compileRef(doc, def)
268
+ }
269
+ }
270
+
271
+ protected compileParams(doc: LexiconDocument, def?: LexiconParameters) {
272
+ if (!def) return l.params()
273
+
274
+ const props: Record<string, l.Validator> = {}
275
+ for (const [key, propDef] of Object.entries(def.properties)) {
276
+ if (propDef === undefined) continue
277
+ props[key] = this.compileLeaf(doc, propDef)
278
+ }
279
+ return l.params(props, def)
280
+ }
281
+ }
282
+
283
+ class AsyncTasks {
284
+ /**
285
+ * A set that, eventually, contains only rejected promises.
286
+ */
287
+ #promises = new Set<Promise<void>>()
288
+
289
+ async done(): Promise<void> {
290
+ do {
291
+ // @NOTE this is going to throw on the first rejected promise (which is
292
+ // what we want)
293
+ for (const p of this.#promises) await p
294
+ // At this point, all settled promises should have been removed. If
295
+ // this.#promises is not empty, it means new promises were added during
296
+ // the awaiting process, so we loop again.
297
+ } while (this.#promises.size > 0)
298
+ }
299
+
300
+ add(p: Promise<void>) {
301
+ const promise = Promise.resolve(p).then(() => {
302
+ // No need to keep the promise any longer
303
+ this.#promises.delete(promise)
304
+ })
305
+
306
+ void promise.catch((_err) => {
307
+ // ignore errors here, they should be caught though done()
308
+ })
309
+
310
+ this.#promises.add(promise)
311
+ }
312
+ }
313
+
314
+ function parseRef(fullRef: string) {
315
+ const { length, 0: nsid, 1: hash } = fullRef.split('#')
316
+ if (length !== 2) throw new Error('Uri can only have one hash segment')
317
+ if (!nsid || !hash) throw new Error('Invalid ref, missing hash')
318
+ return { nsid, hash }
319
+ }
320
+
321
+ function buildFullRef(from: LexiconDocument, ref: string) {
322
+ if (ref.startsWith('#')) return `${from.id}${ref}`
323
+ return ref
324
+ }
325
+
326
+ export function memoize<Fn extends (arg: string) => unknown>(fn: Fn): Fn {
327
+ const cache = new Map<string, ReturnType<Fn>>()
328
+ return ((arg: string) => {
329
+ if (cache.has(arg)) return cache.get(arg)!
330
+ const result = fn(arg) as ReturnType<Fn>
331
+ cache.set(arg, result)
332
+ return result
333
+ }) as Fn
334
+ }
335
+
336
+ function isAsyncIterableObject<T>(
337
+ obj: T,
338
+ ): obj is T & object & AsyncIterable<unknown> {
339
+ return (
340
+ obj != null &&
341
+ typeof obj === 'object' &&
342
+ Symbol.asyncIterator in obj &&
343
+ typeof obj[Symbol.asyncIterator] === 'function'
344
+ )
345
+ }