@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,60 +0,0 @@
1
- import { LexiconDocument } from './lexicon-document.js'
2
-
3
- /**
4
- * Interface for indexing and retrieving Lexicon documents by their NSID.
5
- *
6
- * @example
7
- * ```ts
8
- * // Using a custom indexer implementation
9
- * const networkIndexer: LexiconIndexer = {
10
- * async get(nsid: string) {
11
- * const doc = await resolveLexicon(nsid)
12
- * return doc
13
- * }
14
- * }
15
- *
16
- * const validator = await LexiconSchemaBuilder.build(networkIndexer, 'com.example.post#main')
17
- * ```
18
- */
19
- export interface LexiconIndexer {
20
- /**
21
- * Retrieves a Lexicon document by its NSID.
22
- *
23
- * @param nsid - The Namespaced Identifier of the Lexicon document to retrieve
24
- * @returns A promise that resolves to the Lexicon document
25
- * @throws When the document with the given NSID cannot be found
26
- *
27
- * @example
28
- * ```ts
29
- * const doc = await indexer.get('com.atproto.repo.createRecord')
30
- * console.log(doc.defs.main?.type) // 'procedure'
31
- * ```
32
- */
33
- get(nsid: string): Promise<LexiconDocument>
34
-
35
- /**
36
- * Optional async disposal method for cleanup.
37
- *
38
- * When implemented, allows the indexer to be used with `await using`
39
- * syntax for automatic resource cleanup.
40
- *
41
- * @returns A promise that resolves when disposal is complete
42
- */
43
- [Symbol.asyncDispose]?: () => Promise<void>
44
-
45
- /**
46
- * Optional async iterator for iterating over all available Lexicon documents.
47
- *
48
- * @returns An async iterator yielding Lexicon documents
49
- *
50
- * @example
51
- * ```ts
52
- * if (Symbol.asyncIterator in indexer) {
53
- * for await (const doc of indexer) {
54
- * console.log(doc.id)
55
- * }
56
- * }
57
- * ```
58
- */
59
- [Symbol.asyncIterator]?: () => AsyncIterator<LexiconDocument, void, unknown>
60
- }
@@ -1,132 +0,0 @@
1
- import { LexiconDocument } from './lexicon-document.js'
2
- import { LexiconIndexer } from './lexicon-indexer.js'
3
-
4
- /**
5
- * Lazily indexes Lexicon documents from an iterable source.
6
- *
7
- * This class implements `LexiconIndexer` by consuming documents from an
8
- * iterable (sync or async) and caching them for efficient retrieval.
9
- * Documents are indexed on-demand as they are requested or iterated over.
10
- *
11
- * @example
12
- * ```ts
13
- * // From an array of documents
14
- * const docs = [lexiconDoc1, lexiconDoc2, lexiconDoc3]
15
- * const indexer = new LexiconIterableIndexer(docs)
16
- *
17
- * // Documents are indexed lazily as requested
18
- * const doc = await indexer.get('com.example.post')
19
- * ```
20
- *
21
- * @example
22
- * ```ts
23
- * // From an async generator (e.g., reading from files)
24
- * async function* loadLexicons() {
25
- * for (const file of lexiconFiles) {
26
- * yield JSON.parse(await fs.readFile(file, 'utf8'))
27
- * }
28
- * }
29
- *
30
- * await using indexer = new LexiconIterableIndexer(loadLexicons())
31
- * const schemas = await LexiconSchemaBuilder.buildAll(indexer)
32
- * ```
33
- */
34
- export class LexiconIterableIndexer implements LexiconIndexer, AsyncDisposable {
35
- readonly #lexicons: Map<string, LexiconDocument> = new Map()
36
- readonly #iterator:
37
- | AsyncIterator<LexiconDocument, void, unknown>
38
- | Iterator<LexiconDocument, void, unknown>
39
-
40
- /**
41
- * Creates a new {@link LexiconIterableIndexer} from an iterable source.
42
- *
43
- * @param source - An iterable or async iterable of Lexicon documents.
44
- * The iterator is consumed lazily as documents are requested.
45
- *
46
- * @example
47
- * ```ts
48
- * // Sync iterable (array, Set, Map.values(), etc.)
49
- * const indexer = new LexiconIterableIndexer(lexiconDocuments)
50
- *
51
- * // Async iterable (async generator, ReadableStream, etc.)
52
- * const indexer = new LexiconIterableIndexer(asyncLexiconStream)
53
- * ```
54
- */
55
- constructor(
56
- readonly source: AsyncIterable<LexiconDocument> | Iterable<LexiconDocument>,
57
- ) {
58
- this.#iterator =
59
- Symbol.asyncIterator in source
60
- ? source[Symbol.asyncIterator]()
61
- : source[Symbol.iterator]()
62
- }
63
-
64
- /**
65
- * Retrieves a Lexicon document by its NSID.
66
- *
67
- * If the document has already been indexed, it is returned from cache.
68
- * Otherwise, the source iterator is consumed until the document is found.
69
- *
70
- * @see {@link LexiconIndexer.get}
71
- */
72
- async get(id: string): Promise<LexiconDocument> {
73
- const cached = this.#lexicons.get(id)
74
- if (cached) return cached
75
-
76
- for await (const doc of this) {
77
- if (doc.id === id) return doc
78
- }
79
-
80
- throw Object.assign(new Error(`Lexicon ${id} not found`), {
81
- code: 'ENOENT',
82
- })
83
- }
84
-
85
- async *[Symbol.asyncIterator](): AsyncIterator<
86
- LexiconDocument,
87
- void,
88
- undefined
89
- > {
90
- const returned = new Set<string>()
91
-
92
- for (const doc of this.#lexicons.values()) {
93
- returned.add(doc.id)
94
- yield doc
95
- }
96
-
97
- do {
98
- const { value, done } = await this.#iterator.next()
99
-
100
- if (done) break
101
-
102
- if (returned.has(value.id)) {
103
- const err = new Error(`Duplicate lexicon document id: ${value.id}`)
104
- await this.#iterator.throw?.(err)
105
- throw err // In case iterator.throw does not exist or does not throw
106
- }
107
-
108
- this.#lexicons.set(value.id, value)
109
- returned.add(value.id)
110
- yield value
111
- } while (true)
112
-
113
- // At this point, the underlying iterator is done. However, there may have
114
- // been requests (.get()) for documents that caused the iterator to yield
115
- // those documents during concurrent execution of this loop. If that was the
116
- // case, new documents may have been added to `#lexicons` that have not yet
117
- // been yielded. We need to yield those as well. Since we yield control back
118
- // to the caller, we need to repeat this process until no new documents
119
- // appear sunce we don't know what happens.
120
-
121
- for (const doc of this.#lexicons.values()) {
122
- if (!returned.has(doc.id)) {
123
- returned.add(doc.id)
124
- yield doc
125
- }
126
- }
127
- }
128
-
129
- async [Symbol.asyncDispose](): Promise<void> {
130
- await this.#iterator.return?.()
131
- }
132
- }
@@ -1,297 +0,0 @@
1
- import { beforeAll, describe, expect, it } from 'vitest'
2
- import { parseCid } from '@atproto/lex-data'
3
- import { l } from '@atproto/lex-schema'
4
- import { LexiconDocument, lexiconDocumentSchema } from './lexicon-document.js'
5
- import { LexiconIterableIndexer } from './lexicon-iterable-indexer.js'
6
- import { LexiconSchemaBuilder } from './lexicon-schema-builder.js'
7
-
8
- describe('LexiconSchemaBuilder', () => {
9
- let schemas: Map<
10
- string,
11
- | l.Validator<unknown>
12
- | l.Query
13
- | l.Subscription
14
- | l.Procedure
15
- | l.PermissionSet
16
- >
17
-
18
- const getSchema = <T extends abstract new (...args: any) => any>(
19
- ref: string,
20
- type: T,
21
- ) => {
22
- const schema = schemas.get(ref)
23
- expect(schema).toBeDefined()
24
- expect(schema).toBeInstanceOf(type)
25
- return schema as InstanceType<T>
26
- }
27
-
28
- beforeAll(async () => {
29
- const indexer = new LexiconIterableIndexer([
30
- lexiconDocumentSchema.parse({
31
- lexicon: 1,
32
- id: 'com.example.kitchenSink',
33
- defs: {
34
- main: {
35
- type: 'record',
36
- description: 'A record',
37
- key: 'tid',
38
- record: {
39
- type: 'object',
40
- required: [
41
- 'object',
42
- 'array',
43
- 'boolean',
44
- 'integer',
45
- 'string',
46
- 'bytes',
47
- 'cidLink',
48
- ],
49
- properties: {
50
- object: { type: 'ref', ref: '#object' },
51
- array: { type: 'array', items: { type: 'string' } },
52
- boolean: { type: 'boolean' },
53
- integer: { type: 'integer' },
54
- string: { type: 'string' },
55
- bytes: { type: 'bytes' },
56
- cidLink: { type: 'cid-link' },
57
- },
58
- },
59
- },
60
- object: {
61
- type: 'object',
62
- required: ['object', 'array', 'boolean', 'integer', 'string'],
63
- properties: {
64
- object: { type: 'ref', ref: '#subObject' },
65
- array: { type: 'array', items: { type: 'string' } },
66
- boolean: { type: 'boolean' },
67
- integer: { type: 'integer' },
68
- string: { type: 'string' },
69
- refToEnumWithDefault: { type: 'ref', ref: '#enumWithDefault' },
70
- },
71
- },
72
- subObject: {
73
- type: 'object',
74
- required: ['boolean'],
75
- properties: {
76
- boolean: { type: 'boolean' },
77
- },
78
- },
79
- enumWithDefault: {
80
- type: 'string',
81
- default: 'option3',
82
- enum: ['option1', 'option2', 'option3'],
83
- },
84
- },
85
- }),
86
- ])
87
- schemas = await LexiconSchemaBuilder.buildAll(indexer)
88
- })
89
-
90
- it('Validates records correctly', () => {
91
- const schema = getSchema('com.example.kitchenSink#main', l.RecordSchema)
92
-
93
- const value = {
94
- $type: 'com.example.kitchenSink',
95
- object: {
96
- object: { boolean: true },
97
- array: ['one', 'two'],
98
- boolean: true,
99
- integer: 123,
100
- string: 'string',
101
- refToEnumWithDefault: 'option3',
102
- },
103
- array: ['one', 'two'],
104
- boolean: true,
105
- integer: 123,
106
- string: 'string',
107
- datetime: new Date().toISOString(),
108
- atUri: 'at://did:web:example.com/com.example.test/self',
109
- did: 'did:web:example.com',
110
- cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
111
- bytes: new Uint8Array([0, 1, 2, 3]),
112
- cidLink: parseCid(
113
- 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
114
- ),
115
- }
116
-
117
- expect(schema.safeParse(value)).toStrictEqual({ success: true, value })
118
- })
119
-
120
- it('Validates objects correctly', () => {
121
- const schema = getSchema(
122
- 'com.example.kitchenSink#object',
123
- l.TypedObjectSchema,
124
- )
125
-
126
- const value = {
127
- object: { boolean: true },
128
- array: ['one', 'two'],
129
- boolean: true,
130
- integer: 123,
131
- string: 'string',
132
- }
133
-
134
- expect(schema.safeParse(value)).toStrictEqual({
135
- success: true,
136
- value: {
137
- object: { boolean: true },
138
- array: ['one', 'two'],
139
- boolean: true,
140
- integer: 123,
141
- string: 'string',
142
- refToEnumWithDefault: 'option3',
143
- },
144
- })
145
- })
146
-
147
- it('rejects invalid enum values', () => {
148
- const schema = getSchema(
149
- 'com.example.kitchenSink#object',
150
- l.TypedObjectSchema,
151
- )
152
-
153
- const value = {
154
- object: { boolean: true },
155
- array: ['one', 'two'],
156
- boolean: true,
157
- integer: 123,
158
- string: 'string',
159
- refToEnumWithDefault: 'invalidOption',
160
- }
161
-
162
- expect(schema.safeParse(value)).toMatchObject({
163
- success: false,
164
- reason: {
165
- issues: [
166
- {
167
- code: 'invalid_value',
168
- input: 'invalidOption',
169
- values: ['option1', 'option2', 'option3'],
170
- },
171
- ],
172
- },
173
- })
174
- })
175
-
176
- it('does not apply defaults when validating', () => {
177
- const schema = getSchema(
178
- 'com.example.kitchenSink#object',
179
- l.TypedObjectSchema,
180
- )
181
-
182
- const value = {
183
- object: { boolean: true },
184
- array: ['one', 'two'],
185
- boolean: true,
186
- integer: 123,
187
- string: 'string',
188
- }
189
-
190
- expect(schema.safeValidate(value)).toStrictEqual({
191
- success: true,
192
- value: {
193
- object: { boolean: true },
194
- array: ['one', 'two'],
195
- boolean: true,
196
- integer: 123,
197
- string: 'string',
198
- },
199
- })
200
- })
201
-
202
- it('allows missing optional record fields', () => {
203
- const schema = getSchema(
204
- 'com.example.kitchenSink#object',
205
- l.TypedObjectSchema,
206
- )
207
-
208
- expect(
209
- schema.matches({
210
- object: { boolean: true },
211
- array: ['one', 'two'],
212
- boolean: true,
213
- integer: 123,
214
- string: 'string',
215
- }),
216
- ).toBe(true)
217
- })
218
-
219
- it('Rejects missing required record fields', () => {
220
- const schema = getSchema(
221
- 'com.example.kitchenSink#object',
222
- l.TypedObjectSchema,
223
- )
224
-
225
- const value = {
226
- object: { boolean: true },
227
- // array: ['one', 'two'],
228
- boolean: true,
229
- integer: 123,
230
- string: 'string',
231
- }
232
-
233
- expect(schema.safeParse(value)).toMatchObject({
234
- success: false,
235
- reason: { issues: [{ code: 'required_key', key: 'array' }] },
236
- })
237
- })
238
-
239
- it('fails validation when ref uri has multiple hash segments', async () => {
240
- const schema: LexiconDocument = {
241
- lexicon: 1,
242
- id: 'com.example.invalid',
243
- defs: {
244
- main: {
245
- type: 'object',
246
- properties: {
247
- test: { type: 'ref', ref: 'com.example.invalid#test#test' },
248
- },
249
- },
250
- },
251
- }
252
-
253
- await expect(async () => {
254
- await LexiconSchemaBuilder.buildAll(new LexiconIterableIndexer([schema]))
255
- }).rejects.toThrow('Uri can only have one hash segment')
256
- })
257
-
258
- it('fails lexicon parsing when uri is invalid', async () => {
259
- const schema: LexiconDocument = {
260
- lexicon: 1,
261
- id: 'com.example.invalid',
262
- defs: {
263
- main: {
264
- type: 'object',
265
- properties: {
266
- test: { type: 'ref', ref: 'com.example.missing#main' },
267
- },
268
- },
269
- },
270
- }
271
-
272
- await expect(async () => {
273
- await LexiconSchemaBuilder.buildAll(new LexiconIterableIndexer([schema]))
274
- }).rejects.toThrow('Lexicon com.example.missing not found')
275
- })
276
-
277
- it('fails lexicon parsing when uri is invalid', async () => {
278
- const schema: LexiconDocument = {
279
- lexicon: 1,
280
- id: 'com.example.invalid',
281
- defs: {
282
- main: {
283
- type: 'object',
284
- properties: {
285
- test: { type: 'ref', ref: 'com.example.invalid#nonexistent' },
286
- },
287
- },
288
- },
289
- }
290
-
291
- await expect(async () => {
292
- await LexiconSchemaBuilder.buildAll(new LexiconIterableIndexer([schema]))
293
- }).rejects.toThrow(
294
- 'No definition found for hash ""nonexistent"" in com.example.invalid',
295
- )
296
- })
297
- })