@atproto/lex-installer 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.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@atproto/lex-installer",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "description": "Lexicon document packet manager for AT Lexicons",
6
+ "keywords": [
7
+ "atproto",
8
+ "lexicon",
9
+ "install",
10
+ "lex"
11
+ ],
12
+ "homepage": "https://atproto.com",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/bluesky-social/atproto",
16
+ "directory": "packages/lex/lex-installer"
17
+ },
18
+ "files": [
19
+ "./src",
20
+ "./dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "type": "commonjs",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "browser": "./dist/index.js",
29
+ "import": "./dist/index.js",
30
+ "require": "./dist/index.js",
31
+ "types": "./dist/index.d.ts"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@atproto/lex-builder": "workspace:*",
36
+ "@atproto/lex-cbor": "workspace:*",
37
+ "@atproto/lex-data": "workspace:*",
38
+ "@atproto/lex-document": "workspace:*",
39
+ "@atproto/lex-resolver": "workspace:*",
40
+ "@atproto/lex-schema": "workspace:*",
41
+ "@atproto/syntax": "workspace:*",
42
+ "tslib": "^2.8.1"
43
+ },
44
+ "devDependencies": {
45
+ "jest": "^28.1.2"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc --build tsconfig.build.json",
49
+ "test": "jest"
50
+ }
51
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
4
+ export async function readJsonFile(path: string): Promise<unknown> {
5
+ const contents = await readFile(path, 'utf8')
6
+ return JSON.parse(contents)
7
+ }
8
+
9
+ export async function writeJsonFile(
10
+ path: string,
11
+ data: unknown,
12
+ ): Promise<void> {
13
+ await mkdir(dirname(path), { recursive: true })
14
+ const contents = JSON.stringify(data, null, 2)
15
+ await writeFile(path, contents, {
16
+ encoding: 'utf8',
17
+ mode: 0o644,
18
+ flag: 'w', // override
19
+ })
20
+ }
21
+
22
+ export function isEnoentError(err: unknown): boolean {
23
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT'
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { isEnoentError, readJsonFile } from './fs.js'
2
+ import { LexInstaller, LexInstallerOptions } from './lex-installer.js'
3
+ import {
4
+ LexiconsManifest,
5
+ lexiconsManifestSchema,
6
+ } from './lexicons-manifest.js'
7
+
8
+ export type LexInstallOptions = LexInstallerOptions & {
9
+ add?: string[]
10
+ save?: boolean
11
+ ci?: boolean
12
+ }
13
+
14
+ export async function install(options: LexInstallOptions) {
15
+ const manifest: LexiconsManifest | undefined = await readJsonFile(
16
+ options.manifest,
17
+ ).then(
18
+ (json) => lexiconsManifestSchema.parse(json),
19
+ (cause: unknown) => {
20
+ if (isEnoentError(cause)) return undefined
21
+ throw new Error('Failed to read lexicons manifest', { cause })
22
+ },
23
+ )
24
+
25
+ const additions = new Set(options.add)
26
+
27
+ // Perform the installation using the existing manifest as "hint"
28
+ await using installer = new LexInstaller(options)
29
+
30
+ await installer.install({ additions, manifest })
31
+
32
+ // Verify lockfile
33
+ if (options.ci && (!manifest || !installer.matches(manifest))) {
34
+ throw new Error('Lexicons manifest is out of date')
35
+ }
36
+
37
+ // Save changes if requested
38
+ if (options.save) {
39
+ await installer.save()
40
+ }
41
+ }
@@ -0,0 +1,314 @@
1
+ import { join } from 'node:path'
2
+ import { LexiconDirectoryIndexer } from '@atproto/lex-builder'
3
+ import { cidForLex } from '@atproto/lex-cbor'
4
+ import { lexEquals } from '@atproto/lex-data'
5
+ import {
6
+ LexiconDocument,
7
+ LexiconParameters,
8
+ LexiconPermission,
9
+ LexiconRef,
10
+ LexiconRefUnion,
11
+ LexiconUnknown,
12
+ MainLexiconDefinition,
13
+ NamedLexiconDefinition,
14
+ } from '@atproto/lex-document'
15
+ import { LexResolver, LexResolverOptions } from '@atproto/lex-resolver'
16
+ import { AtUri as AtUriString, Nsid as NsidString } from '@atproto/lex-schema'
17
+ import { AtUri, NSID } from '@atproto/syntax'
18
+ import { isEnoentError, writeJsonFile } from './fs.js'
19
+ import { LexiconsManifest } from './lexicons-manifest.js'
20
+ import { NsidMap } from './nsid-map.js'
21
+ import { NsidSet } from './nsid-set.js'
22
+
23
+ export type LexInstallerOptions = LexResolverOptions & {
24
+ lexicons: string
25
+ manifest: string
26
+ update?: boolean
27
+ }
28
+
29
+ export class LexInstaller implements AsyncDisposable {
30
+ protected readonly lexiconResolver: LexResolver
31
+ protected readonly indexer: LexiconDirectoryIndexer
32
+ protected readonly documents = new NsidMap<LexiconDocument>()
33
+ protected readonly manifest: LexiconsManifest = {
34
+ version: 1,
35
+ lexicons: [],
36
+ resolutions: {},
37
+ }
38
+
39
+ constructor(protected readonly options: LexInstallerOptions) {
40
+ this.lexiconResolver = new LexResolver(options)
41
+ this.indexer = new LexiconDirectoryIndexer({
42
+ lexicons: options.lexicons,
43
+ })
44
+ }
45
+
46
+ async [Symbol.asyncDispose](): Promise<void> {
47
+ await this.indexer[Symbol.asyncDispose]()
48
+ }
49
+
50
+ matches(manifest: LexiconsManifest): boolean {
51
+ return lexEquals(this.manifest, manifest)
52
+ }
53
+
54
+ async install({
55
+ additions,
56
+ manifest,
57
+ }: {
58
+ additions?: Iterable<string>
59
+ manifest?: LexiconsManifest
60
+ } = {}): Promise<void> {
61
+ const roots = new NsidMap<AtUri | null>()
62
+
63
+ // First, process explicit additions
64
+ for (const lexicon of new Set(additions)) {
65
+ const [nsid, uri]: [NSID, AtUri | null] = lexicon.startsWith('at://')
66
+ ? ((uri) => [NSID.from(uri.rkey), uri])(new AtUri(lexicon))
67
+ : [NSID.from(lexicon), null]
68
+
69
+ if (roots.has(nsid)) {
70
+ throw new Error(
71
+ `Duplicate lexicon addition: ${nsid} (${roots.get(nsid) ?? lexicon})`,
72
+ )
73
+ }
74
+
75
+ roots.set(nsid, uri)
76
+ console.debug(`Adding new lexicon: ${nsid} (${uri ?? 'from NSID'})`)
77
+ }
78
+
79
+ // Next, restore previously existing manifest entries
80
+ if (manifest) {
81
+ for (const lexicon of manifest.lexicons) {
82
+ const nsid = NSID.from(lexicon)
83
+
84
+ // Skip entries already added explicitly
85
+ if (!roots.has(nsid)) {
86
+ const uri = manifest.resolutions[lexicon]
87
+ ? new AtUri(manifest.resolutions[lexicon].uri)
88
+ : null
89
+
90
+ roots.set(nsid, uri)
91
+
92
+ console.debug(
93
+ `Adding lexicon from manifest: ${nsid} (${uri ?? 'from NSID'})`,
94
+ )
95
+ }
96
+ }
97
+ }
98
+
99
+ // Install all root lexicons (and store them in the manifest)
100
+ await Promise.all(
101
+ Array.from(roots, async ([nsid, sourceUri]) => {
102
+ console.debug(`Installing lexicon: ${nsid}`)
103
+
104
+ const { document } = sourceUri
105
+ ? await this.installFromUri(sourceUri)
106
+ : await this.installFromNsid(nsid)
107
+
108
+ // Store the direct reference in the new manifest
109
+ this.manifest.lexicons.push(document.id)
110
+ this.manifest.lexicons.sort()
111
+ }),
112
+ )
113
+
114
+ // Then recursively install all referenced lexicons
115
+ let results: unknown[]
116
+ do {
117
+ results = await Promise.all(
118
+ Array.from(this.getMissingIds(), async (nsid) => {
119
+ console.debug(`Resolving dependency lexicon: ${nsid}`)
120
+
121
+ const nsidStr = nsid.toString() as NsidString
122
+ const resolvedUri = manifest?.resolutions[nsidStr]?.uri
123
+ ? new AtUri(manifest.resolutions[nsidStr].uri)
124
+ : null
125
+ if (resolvedUri) {
126
+ await this.installFromUri(resolvedUri)
127
+ } else {
128
+ await this.installFromNsid(nsid)
129
+ }
130
+ }),
131
+ )
132
+ } while (results.length > 0)
133
+ }
134
+
135
+ protected getMissingIds(): NsidSet {
136
+ const missing = new NsidSet()
137
+
138
+ for (const document of this.documents.values()) {
139
+ for (const nsid of listDocumentNsidRefs(document)) {
140
+ if (!this.documents.has(nsid)) {
141
+ missing.add(nsid)
142
+ }
143
+ }
144
+ }
145
+
146
+ return missing
147
+ }
148
+
149
+ protected async installFromNsid(nsid: NSID) {
150
+ const uri = await this.lexiconResolver.resolve(nsid)
151
+ return this.installFromUri(uri)
152
+ }
153
+
154
+ protected async installFromUri(uri: AtUri): Promise<{
155
+ document: LexiconDocument
156
+ uri: AtUri
157
+ }> {
158
+ const document = this.options.update
159
+ ? await this.fetch(uri)
160
+ : await this.indexer.get(uri.rkey).then(
161
+ (document) => {
162
+ console.debug(`Re-using existing lexicon ${uri.rkey} from indexer`)
163
+ return document
164
+ },
165
+ (err) => {
166
+ if (isEnoentError(err)) return this.fetch(uri)
167
+ throw err
168
+ },
169
+ )
170
+
171
+ this.documents.set(NSID.from(document.id), document)
172
+ this.manifest.resolutions[document.id] = {
173
+ cid: (await cidForLex(document)).toString(),
174
+ uri: uri.toString() as AtUriString,
175
+ }
176
+
177
+ return { document, uri }
178
+ }
179
+
180
+ async fetch(uri: AtUri): Promise<LexiconDocument> {
181
+ console.debug(`Fetching lexicon from ${uri}...`)
182
+
183
+ const document = await this.lexiconResolver.fetch(uri, {
184
+ noCache: this.options.update,
185
+ })
186
+
187
+ const basePath = join(this.options.lexicons, ...document.id.split('.'))
188
+ await writeJsonFile(`${basePath}.json`, document)
189
+
190
+ return document
191
+ }
192
+
193
+ async save(): Promise<void> {
194
+ await writeJsonFile(this.options.manifest, this.manifest)
195
+ }
196
+ }
197
+
198
+ function* listDocumentNsidRefs(doc: LexiconDocument): Iterable<NSID> {
199
+ try {
200
+ for (const def of Object.values(doc.defs)) {
201
+ if (def) {
202
+ for (const ref of defRefs(def)) {
203
+ const [nsid] = ref.split('#', 1)
204
+ if (nsid) yield NSID.from(nsid)
205
+ }
206
+ }
207
+ }
208
+ } catch (cause) {
209
+ throw new Error(`Failed to extract refs from lexicon ${doc.id}`, { cause })
210
+ }
211
+ }
212
+
213
+ function* defRefs(
214
+ def:
215
+ | MainLexiconDefinition
216
+ | NamedLexiconDefinition
217
+ | LexiconPermission
218
+ | LexiconUnknown
219
+ | LexiconParameters
220
+ | LexiconRef
221
+ | LexiconRefUnion,
222
+ ): Iterable<string> {
223
+ switch (def.type) {
224
+ case 'string':
225
+ if (def.knownValues) {
226
+ for (const val of def.knownValues) {
227
+ // Tokens ?
228
+ const { length, 0: nsid, 1: hash } = val.split('#')
229
+ if (length === 2 && hash) {
230
+ try {
231
+ NSID.from(nsid)
232
+ yield val
233
+ } catch {
234
+ // ignore invalid nsid
235
+ }
236
+ }
237
+ }
238
+ }
239
+ return
240
+ case 'array':
241
+ return yield* defRefs(def.items)
242
+ case 'params':
243
+ case 'object':
244
+ for (const prop of Object.values(def.properties)) {
245
+ yield* defRefs(prop)
246
+ }
247
+ return
248
+ case 'union':
249
+ yield* def.refs
250
+ return
251
+ case 'ref': {
252
+ yield def.ref
253
+ return
254
+ }
255
+ case 'record':
256
+ yield* defRefs(def.record)
257
+ return
258
+ case 'procedure':
259
+ if (def.input?.schema) {
260
+ yield* defRefs(def.input.schema)
261
+ }
262
+ // fallthrough
263
+ case 'query':
264
+ if (def.output?.schema) {
265
+ yield* defRefs(def.output.schema)
266
+ }
267
+ // fallthrough
268
+ case 'subscription':
269
+ if (def.parameters) {
270
+ yield* defRefs(def.parameters)
271
+ }
272
+ if ('message' in def && def.message?.schema) {
273
+ yield* defRefs(def.message.schema)
274
+ }
275
+ return
276
+ case 'permission-set':
277
+ for (const permission of def.permissions) {
278
+ yield* defRefs(permission)
279
+ }
280
+ return
281
+ case 'permission':
282
+ if (def.resource === 'rpc') {
283
+ if (Array.isArray(def.lxm)) {
284
+ for (const lxm of def.lxm) {
285
+ if (typeof lxm === 'string') {
286
+ yield lxm
287
+ }
288
+ }
289
+ }
290
+ } else if (def.resource === 'repo') {
291
+ if (Array.isArray(def.collection)) {
292
+ for (const lxm of def.collection) {
293
+ if (typeof lxm === 'string') {
294
+ yield lxm
295
+ }
296
+ }
297
+ }
298
+ }
299
+ return
300
+ case 'boolean':
301
+ case 'cid-link':
302
+ case 'token':
303
+ case 'bytes':
304
+ case 'blob':
305
+ case 'integer':
306
+ case 'unknown':
307
+ // @NOTE We explicitly list all types here to ensure exhaustiveness
308
+ // causing TS to error if a new type is added without updating this switch
309
+ return
310
+ default:
311
+ // @ts-expect-error
312
+ throw new Error(`Unknown lexicon def type: ${def.type}`)
313
+ }
314
+ }
@@ -0,0 +1,50 @@
1
+ import { lexiconsManifestSchema } from './lexicons-manifest.js'
2
+
3
+ describe('lexiconsManifestSchema', () => {
4
+ it('parses a valid manifest', () => {
5
+ expect(
6
+ lexiconsManifestSchema.parse({
7
+ version: 1,
8
+ lexicons: ['com.example.lexicon'],
9
+ resolutions: {
10
+ 'com.example.lexicon': {
11
+ uri: 'at://did:plc:foobar/com.atproto.lexicon.schema/com.example.lexicon',
12
+ cid: 'bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku',
13
+ },
14
+ },
15
+ }),
16
+ ).toEqual({
17
+ version: 1,
18
+ lexicons: ['com.example.lexicon'],
19
+ resolutions: {
20
+ 'com.example.lexicon': {
21
+ uri: 'at://did:plc:foobar/com.atproto.lexicon.schema/com.example.lexicon',
22
+ cid: 'bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku',
23
+ },
24
+ },
25
+ })
26
+ })
27
+
28
+ it('rejects an invalid manifest', () => {
29
+ expect(() =>
30
+ lexiconsManifestSchema.parse({
31
+ version: 1,
32
+ lexicons: ['com.example.lexicon'],
33
+ resolutions: {
34
+ 'com.example.lexicon': {
35
+ uri: 'invalid-uri',
36
+ cid: 'not-a-cid',
37
+ },
38
+ },
39
+ }),
40
+ ).toThrow()
41
+
42
+ expect(() =>
43
+ lexiconsManifestSchema.parse({
44
+ version: 2,
45
+ lexicons: ['com.example.lexicon'],
46
+ resolutions: {},
47
+ }),
48
+ ).toThrow()
49
+ })
50
+ })
@@ -0,0 +1,23 @@
1
+ import { l } from '@atproto/lex-schema'
2
+
3
+ export const lexiconsManifestSchema = l.object(
4
+ {
5
+ version: l.literal(1),
6
+ lexicons: l.array(l.string({ format: 'nsid' })),
7
+ resolutions: l.dict(
8
+ l.string({ format: 'nsid' }),
9
+ l.object(
10
+ {
11
+ cid: l.string({ format: 'cid' }),
12
+ uri: l.string({ format: 'at-uri' }),
13
+ },
14
+ {
15
+ required: ['cid', 'uri'],
16
+ },
17
+ ),
18
+ ),
19
+ },
20
+ { required: ['version', 'lexicons', 'resolutions'] },
21
+ )
22
+
23
+ export type LexiconsManifest = l.Infer<typeof lexiconsManifestSchema>
@@ -0,0 +1,83 @@
1
+ import { NSID } from '@atproto/syntax'
2
+
3
+ /**
4
+ * A Map implementation that maps keys of type K to an internal representation
5
+ * I. Key identity is determined by the {@link Object.is} comparison of the
6
+ * encoded keys.
7
+ *
8
+ * This is typically useful for values that can be serialized to a unique string
9
+ * representation.
10
+ */
11
+ class MappedMap<K, V, I = any> implements Map<K, V> {
12
+ private map = new Map<I, V>()
13
+
14
+ constructor(
15
+ private readonly encodeKey: (key: K) => I,
16
+ private readonly decodeKey: (enc: I) => K,
17
+ ) {}
18
+
19
+ get size(): number {
20
+ return this.map.size
21
+ }
22
+
23
+ clear(): void {
24
+ this.map.clear()
25
+ }
26
+
27
+ set(key: K, value: V): this {
28
+ this.map.set(this.encodeKey(key), value)
29
+ return this
30
+ }
31
+
32
+ get(key: K): V | undefined {
33
+ return this.map.get(this.encodeKey(key))
34
+ }
35
+
36
+ has(key: K): boolean {
37
+ return this.map.has(this.encodeKey(key))
38
+ }
39
+
40
+ delete(key: K): boolean {
41
+ return this.map.delete(this.encodeKey(key))
42
+ }
43
+
44
+ values(): IterableIterator<V> {
45
+ return this.map.values()
46
+ }
47
+
48
+ *keys(): IterableIterator<K> {
49
+ for (const key of this.map.keys()) {
50
+ yield this.decodeKey(key)
51
+ }
52
+ }
53
+
54
+ *entries(): IterableIterator<[K, V]> {
55
+ for (const [key, value] of this.map.entries()) {
56
+ yield [this.decodeKey(key), value]
57
+ }
58
+ }
59
+
60
+ forEach(
61
+ callbackfn: (value: V, key: K, map: MappedMap<K, V>) => void,
62
+ thisArg?: any,
63
+ ): void {
64
+ for (const [key, value] of this) {
65
+ callbackfn.call(thisArg, value, key, this)
66
+ }
67
+ }
68
+
69
+ [Symbol.iterator](): IterableIterator<[K, V]> {
70
+ return this.entries()
71
+ }
72
+
73
+ [Symbol.toStringTag]: string = 'MappedMap'
74
+ }
75
+
76
+ export class NsidMap<T> extends MappedMap<NSID, T, string> {
77
+ constructor() {
78
+ super(
79
+ (key) => key.toString(),
80
+ (enc) => NSID.from(enc),
81
+ )
82
+ }
83
+ }
@@ -0,0 +1,77 @@
1
+ import { NSID } from '@atproto/syntax'
2
+
3
+ /**
4
+ * A Set implementation that maps values of type K to an internal representation
5
+ * I. Value identity is determined by the {@link Object.is} comparison of the
6
+ * encoded values.
7
+ *
8
+ * This is typically useful for values that can be serialized to a unique string
9
+ * representation.
10
+ */
11
+ export class MappedSet<K, I = any> implements Set<K> {
12
+ private set = new Set<I>()
13
+
14
+ constructor(
15
+ private readonly encodeValue: (val: K) => I,
16
+ private readonly decodeValue: (enc: I) => K,
17
+ ) {}
18
+
19
+ get size(): number {
20
+ return this.set.size
21
+ }
22
+
23
+ clear(): void {
24
+ this.set.clear()
25
+ }
26
+
27
+ add(val: K): this {
28
+ this.set.add(this.encodeValue(val))
29
+ return this
30
+ }
31
+
32
+ has(val: K): boolean {
33
+ return this.set.has(this.encodeValue(val))
34
+ }
35
+
36
+ delete(val: K): boolean {
37
+ return this.set.delete(this.encodeValue(val))
38
+ }
39
+
40
+ *values(): IterableIterator<K> {
41
+ for (const val of this.set.values()) {
42
+ yield this.decodeValue(val)
43
+ }
44
+ }
45
+
46
+ keys(): SetIterator<K> {
47
+ return this.values()
48
+ }
49
+
50
+ *entries(): IterableIterator<[K, K]> {
51
+ for (const val of this) yield [val, val]
52
+ }
53
+
54
+ forEach(
55
+ callbackfn: (value: K, value2: K, set: Set<K>) => void,
56
+ thisArg?: any,
57
+ ): void {
58
+ for (const val of this) {
59
+ callbackfn.call(thisArg, val, val, this)
60
+ }
61
+ }
62
+
63
+ [Symbol.iterator](): IterableIterator<K> {
64
+ return this.values()
65
+ }
66
+
67
+ [Symbol.toStringTag]: string = 'MappedSet'
68
+ }
69
+
70
+ export class NsidSet extends MappedSet<NSID, string> {
71
+ constructor() {
72
+ super(
73
+ (val) => val.toString(),
74
+ (enc) => NSID.from(enc),
75
+ )
76
+ }
77
+ }