@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,427 @@
1
+ import { l } from '@atproto/lex-schema'
2
+
3
+ // https://atproto.com/specs/lexicon
4
+
5
+ // "Concrete" Types
6
+
7
+ export const lexiconBooleanSchema = l.object(
8
+ {
9
+ type: l.literal('boolean'),
10
+ description: l.string(),
11
+ default: l.boolean(),
12
+ const: l.boolean(),
13
+ },
14
+ {
15
+ required: ['type'],
16
+ },
17
+ )
18
+ export type LexiconBoolean = l.Infer<typeof lexiconBooleanSchema>
19
+
20
+ export const lexiconIntegerSchema = l.object(
21
+ {
22
+ type: l.literal('integer'),
23
+ description: l.string(),
24
+ default: l.integer(),
25
+ minimum: l.integer(),
26
+ maximum: l.integer(),
27
+ enum: l.array(l.integer()),
28
+ const: l.integer(),
29
+ },
30
+ {
31
+ required: ['type'],
32
+ },
33
+ )
34
+ export type LexiconInteger = l.Infer<typeof lexiconIntegerSchema>
35
+
36
+ export type LexiconStringFormat = l.StringFormat
37
+ export const lexiconStringFormatSchema = l.enum<LexiconStringFormat>(
38
+ l.STRING_FORMATS,
39
+ )
40
+
41
+ export const lexiconStringSchema = l.object(
42
+ {
43
+ type: l.literal('string'),
44
+ format: lexiconStringFormatSchema,
45
+ description: l.string(),
46
+ default: l.string(),
47
+ minLength: l.integer(),
48
+ maxLength: l.integer(),
49
+ minGraphemes: l.integer(),
50
+ maxGraphemes: l.integer(),
51
+ enum: l.array(l.string()),
52
+ const: l.string(),
53
+ knownValues: l.array(l.string()),
54
+ },
55
+ {
56
+ required: ['type'],
57
+ },
58
+ )
59
+ export type LexiconString = l.Infer<typeof lexiconStringSchema>
60
+
61
+ export const lexiconBytesSchema = l.object(
62
+ {
63
+ type: l.literal('bytes'),
64
+ description: l.string(),
65
+ maxLength: l.integer(),
66
+ minLength: l.integer(),
67
+ },
68
+ {
69
+ required: ['type'],
70
+ },
71
+ )
72
+ export type LexiconBytes = l.Infer<typeof lexiconBytesSchema>
73
+
74
+ export const lexiconCidLinkSchema = l.object(
75
+ {
76
+ type: l.literal('cid-link'),
77
+ description: l.string(),
78
+ },
79
+ {
80
+ required: ['type'],
81
+ },
82
+ )
83
+ export type LexiconCid = l.Infer<typeof lexiconCidLinkSchema>
84
+
85
+ export const lexiconBlobSchema = l.object(
86
+ {
87
+ type: l.literal('blob'),
88
+ description: l.string(),
89
+ accept: l.array(l.string()),
90
+ maxSize: l.integer(),
91
+ },
92
+ {
93
+ required: ['type'],
94
+ },
95
+ )
96
+ export type LexiconBlob = l.Infer<typeof lexiconBlobSchema>
97
+
98
+ const CONCRETE_TYPES = [
99
+ lexiconBooleanSchema,
100
+ lexiconIntegerSchema,
101
+ lexiconStringSchema,
102
+ // Lexicon (DAG-CBOR)
103
+ lexiconBytesSchema,
104
+ lexiconCidLinkSchema,
105
+ // Lexicon Specific
106
+ lexiconBlobSchema,
107
+ ] as const
108
+
109
+ // Meta types
110
+
111
+ export const lexiconUnknownSchema = l.object(
112
+ {
113
+ type: l.literal('unknown'),
114
+ description: l.string(),
115
+ },
116
+ {
117
+ required: ['type'],
118
+ },
119
+ )
120
+ export type LexiconUnknown = l.Infer<typeof lexiconUnknownSchema>
121
+
122
+ export const lexiconTokenSchema = l.object(
123
+ {
124
+ type: l.literal('token'),
125
+ description: l.string(),
126
+ },
127
+ {
128
+ required: ['type'],
129
+ },
130
+ )
131
+ export type LexiconToken = l.Infer<typeof lexiconTokenSchema>
132
+
133
+ export const lexiconRefSchema = l.object(
134
+ {
135
+ type: l.literal('ref'),
136
+ description: l.string(),
137
+ ref: l.string(),
138
+ },
139
+ {
140
+ required: ['type', 'ref'],
141
+ },
142
+ )
143
+ export type LexiconRef = l.Infer<typeof lexiconRefSchema>
144
+
145
+ export const lexiconRefUnionSchema = l.object(
146
+ {
147
+ type: l.literal('union'),
148
+ description: l.string(),
149
+ refs: l.array(l.string()),
150
+ closed: l.boolean(),
151
+ },
152
+ {
153
+ required: ['type', 'refs'],
154
+ },
155
+ )
156
+ export type LexiconRefUnion = l.Infer<typeof lexiconRefUnionSchema>
157
+
158
+ // Complex Types
159
+
160
+ const ARRAY_ITEMS_SCHEMAS = [
161
+ ...CONCRETE_TYPES,
162
+ // Meta
163
+ lexiconUnknownSchema,
164
+ lexiconRefSchema,
165
+ lexiconRefUnionSchema,
166
+ ] as const
167
+
168
+ export type LexiconArrayItems = l.Infer<(typeof ARRAY_ITEMS_SCHEMAS)[number]>
169
+
170
+ export const lexiconArraySchema = l.object(
171
+ {
172
+ type: l.literal('array'),
173
+ description: l.string(),
174
+ items: l.discriminatedUnion('type', ARRAY_ITEMS_SCHEMAS),
175
+ minLength: l.integer(),
176
+ maxLength: l.integer(),
177
+ },
178
+ {
179
+ required: ['type', 'items'],
180
+ },
181
+ )
182
+ export type LexiconArray = l.Infer<typeof lexiconArraySchema>
183
+
184
+ export const lexiconObjectSchema = l.object(
185
+ {
186
+ type: l.literal('object'),
187
+ description: l.string(),
188
+ required: l.array(l.string()),
189
+ nullable: l.array(l.string()),
190
+ properties: l.dict(
191
+ l.string(),
192
+ l.discriminatedUnion('type', [
193
+ ...ARRAY_ITEMS_SCHEMAS,
194
+ lexiconArraySchema,
195
+ ]),
196
+ ),
197
+ },
198
+ {
199
+ required: ['type', 'properties'],
200
+ },
201
+ )
202
+ export type LexiconObject = l.Infer<typeof lexiconObjectSchema>
203
+
204
+ // Records
205
+
206
+ export const lexiconRecordSchema = l.object(
207
+ {
208
+ type: l.literal('record'),
209
+ description: l.string(),
210
+ key: l.string(),
211
+ record: lexiconObjectSchema,
212
+ },
213
+ { required: ['type', 'record'] },
214
+ )
215
+ export type LexiconRecord = l.Infer<typeof lexiconRecordSchema>
216
+
217
+ // XRPC Methods
218
+
219
+ export const lexiconParameters = l.object(
220
+ {
221
+ type: l.literal('params'),
222
+ description: l.string(),
223
+ required: l.array(l.string()),
224
+ properties: l.dict(
225
+ l.string(),
226
+ l.discriminatedUnion('type', [
227
+ lexiconBooleanSchema,
228
+ lexiconIntegerSchema,
229
+ lexiconStringSchema,
230
+ l.object(
231
+ {
232
+ type: l.literal('array'),
233
+ description: l.string(),
234
+ items: l.discriminatedUnion('type', [
235
+ lexiconBooleanSchema,
236
+ lexiconIntegerSchema,
237
+ lexiconStringSchema,
238
+ ]),
239
+ minLength: l.integer(),
240
+ maxLength: l.integer(),
241
+ },
242
+ {
243
+ required: ['type', 'items'],
244
+ },
245
+ ),
246
+ ]),
247
+ ),
248
+ },
249
+ {
250
+ required: ['type', 'properties'],
251
+ },
252
+ )
253
+ export type LexiconParameters = l.Infer<typeof lexiconParameters>
254
+
255
+ export const lexiconPayload = l.object(
256
+ {
257
+ description: l.string(),
258
+ encoding: l.string(),
259
+ schema: l.discriminatedUnion('type', [
260
+ lexiconRefSchema,
261
+ lexiconRefUnionSchema,
262
+ lexiconObjectSchema,
263
+ ]),
264
+ },
265
+ {
266
+ required: ['encoding'],
267
+ },
268
+ )
269
+ export type LexiconPayload = l.Infer<typeof lexiconPayload>
270
+
271
+ export const lexiconSubscriptionMessage = l.object({
272
+ description: l.string(),
273
+ schema: l.discriminatedUnion('type', [
274
+ lexiconRefSchema,
275
+ lexiconRefUnionSchema,
276
+ lexiconObjectSchema,
277
+ ]),
278
+ })
279
+ export type LexiconSubscriptionMessage = l.Infer<
280
+ typeof lexiconSubscriptionMessage
281
+ >
282
+
283
+ export const lexiconError = l.object(
284
+ {
285
+ name: l.string({ minLength: 1 }),
286
+ description: l.string(),
287
+ },
288
+ {
289
+ required: ['name'],
290
+ },
291
+ )
292
+ export type LexiconError = l.Infer<typeof lexiconError>
293
+
294
+ export const lexiconQuerySchema = l.object(
295
+ {
296
+ type: l.literal('query'),
297
+ description: l.string(),
298
+ parameters: lexiconParameters,
299
+ output: lexiconPayload,
300
+ errors: l.array(lexiconError),
301
+ },
302
+ {
303
+ required: ['type'],
304
+ },
305
+ )
306
+ export type LexiconQuery = l.Infer<typeof lexiconQuerySchema>
307
+
308
+ export const lexiconProcedureSchema = l.object(
309
+ {
310
+ type: l.literal('procedure'),
311
+ description: l.string(),
312
+ parameters: lexiconParameters,
313
+ input: lexiconPayload,
314
+ output: lexiconPayload,
315
+ errors: l.array(lexiconError),
316
+ },
317
+ {
318
+ required: ['type'],
319
+ },
320
+ )
321
+ export type LexiconProcedure = l.Infer<typeof lexiconProcedureSchema>
322
+
323
+ export const lexiconSubscriptionSchema = l.object(
324
+ {
325
+ type: l.literal('subscription'),
326
+ description: l.string(),
327
+ parameters: lexiconParameters,
328
+ message: lexiconSubscriptionMessage,
329
+ errors: l.array(lexiconError),
330
+ },
331
+ {
332
+ required: ['type'],
333
+ },
334
+ )
335
+
336
+ export type LexiconSubscription = l.Infer<typeof lexiconSubscriptionSchema>
337
+
338
+ // Permissions
339
+
340
+ const lexiconLanguageSchema = l.string({ format: 'language' })
341
+
342
+ export type LexiconLanguage = l.Infer<typeof lexiconLanguageSchema>
343
+
344
+ const lexiconLanguageDict = l.dict(lexiconLanguageSchema, l.string())
345
+
346
+ export type LexiconLanguageDict = l.Infer<typeof lexiconLanguageDict>
347
+
348
+ const lexiconPermissionSchema = l.object(
349
+ {
350
+ type: l.literal('permission'),
351
+ resource: l.string({ minLength: 1 }),
352
+ },
353
+ {
354
+ required: ['type', 'resource'],
355
+ unknownProperties: l.paramsSchema,
356
+ },
357
+ )
358
+
359
+ export type LexiconPermission = l.Infer<typeof lexiconPermissionSchema>
360
+
361
+ const lexiconPermissionSetSchema = l.object(
362
+ {
363
+ type: l.literal('permission-set'),
364
+ description: l.string(),
365
+ title: l.string(),
366
+ 'title:lang': lexiconLanguageDict,
367
+ detail: l.string(),
368
+ 'detail:lang': lexiconLanguageDict,
369
+ permissions: l.array(lexiconPermissionSchema),
370
+ },
371
+ {
372
+ required: ['type', 'permissions'],
373
+ },
374
+ )
375
+
376
+ export type LexiconPermissionSet = l.Infer<typeof lexiconPermissionSetSchema>
377
+
378
+ // Schemas that can appear anywhere in the defs
379
+ const NAMED_LEXICON_SCHEMAS = [
380
+ ...CONCRETE_TYPES,
381
+ lexiconArraySchema,
382
+ lexiconObjectSchema,
383
+ lexiconTokenSchema,
384
+ ] as const
385
+
386
+ export type NamedLexiconDefinition = l.Infer<
387
+ (typeof NAMED_LEXICON_SCHEMAS)[number]
388
+ >
389
+
390
+ // Schemas that can only appear as "main" def
391
+ const MAIN_LEXICON_SCHEMAS = [
392
+ lexiconPermissionSetSchema,
393
+ lexiconProcedureSchema,
394
+ lexiconQuerySchema,
395
+ lexiconRecordSchema,
396
+ lexiconSubscriptionSchema,
397
+ ...NAMED_LEXICON_SCHEMAS,
398
+ ] as const
399
+
400
+ export type MainLexiconDefinition = l.Infer<
401
+ (typeof MAIN_LEXICON_SCHEMAS)[number]
402
+ >
403
+
404
+ export const lexiconIdentifierSchema = l.string({ format: 'nsid' })
405
+ export type LexiconIdentifier = l.Infer<typeof lexiconIdentifierSchema>
406
+
407
+ export const lexiconDocumentSchema = l.object(
408
+ {
409
+ lexicon: l.literal(1),
410
+ id: lexiconIdentifierSchema,
411
+ revision: l.integer(),
412
+ description: l.string(),
413
+ defs: l.object(
414
+ { main: l.discriminatedUnion('type', MAIN_LEXICON_SCHEMAS) },
415
+ {
416
+ unknownProperties: l.dict(
417
+ l.string({ minLength: 1 }),
418
+ l.discriminatedUnion('type', NAMED_LEXICON_SCHEMAS),
419
+ ),
420
+ },
421
+ ),
422
+ },
423
+ {
424
+ required: ['lexicon', 'id', 'defs'],
425
+ },
426
+ )
427
+ export type LexiconDocument = l.Infer<typeof lexiconDocumentSchema>
@@ -0,0 +1,8 @@
1
+ import { LexiconDocument } from './lexicon-document.js'
2
+
3
+ export interface LexiconIndexer {
4
+ get(nsid: string): Promise<LexiconDocument>
5
+
6
+ [Symbol.asyncDispose]?: () => Promise<void>
7
+ [Symbol.asyncIterator]?: () => AsyncIterator<LexiconDocument, void, unknown>
8
+ }
@@ -0,0 +1,82 @@
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
+ export class LexiconIterableIndexer implements LexiconIndexer, AsyncDisposable {
8
+ readonly #lexicons: Map<string, LexiconDocument> = new Map()
9
+ readonly #iterator:
10
+ | AsyncIterator<LexiconDocument, void, unknown>
11
+ | Iterator<LexiconDocument, void, unknown>
12
+
13
+ constructor(
14
+ readonly source: AsyncIterable<LexiconDocument> | Iterable<LexiconDocument>,
15
+ ) {
16
+ this.#iterator =
17
+ Symbol.asyncIterator in source
18
+ ? source[Symbol.asyncIterator]()
19
+ : source[Symbol.iterator]()
20
+ }
21
+
22
+ async get(id: string): Promise<LexiconDocument> {
23
+ const cached = this.#lexicons.get(id)
24
+ if (cached) return cached
25
+
26
+ for await (const doc of this) {
27
+ if (doc.id === id) return doc
28
+ }
29
+
30
+ throw Object.assign(new Error(`Lexicon ${id} not found`), {
31
+ code: 'ENOENT',
32
+ })
33
+ }
34
+
35
+ async *[Symbol.asyncIterator](): AsyncIterator<
36
+ LexiconDocument,
37
+ void,
38
+ undefined
39
+ > {
40
+ const returned = new Set<string>()
41
+
42
+ for (const doc of this.#lexicons.values()) {
43
+ returned.add(doc.id)
44
+ yield doc
45
+ }
46
+
47
+ do {
48
+ const { value, done } = await this.#iterator.next()
49
+
50
+ if (done) break
51
+
52
+ if (returned.has(value.id)) {
53
+ const err = new Error(`Duplicate lexicon document id: ${value.id}`)
54
+ this.#iterator.throw?.(err)
55
+ throw err // In case iterator.throw does not exist or does not throw
56
+ }
57
+
58
+ this.#lexicons.set(value.id, value)
59
+ returned.add(value.id)
60
+ yield value
61
+ } while (true)
62
+
63
+ // At this point, the underlying iterator is done. However, there may have
64
+ // been requests (.get()) for documents that caused the iterator to yield
65
+ // those documents during concurrent execution of this loop. If that was the
66
+ // case, new documents may have been added to `#lexicons` that have not yet
67
+ // been yielded. We need to yield those as well. Since we yield control back
68
+ // to the caller, we need to repeat this process until no new documents
69
+ // appear sunce we don't know what happens.
70
+
71
+ for (const doc of this.#lexicons.values()) {
72
+ if (!returned.has(doc.id)) {
73
+ returned.add(doc.id)
74
+ yield doc
75
+ }
76
+ }
77
+ }
78
+
79
+ async [Symbol.asyncDispose](): Promise<void> {
80
+ await this.#iterator.return?.()
81
+ }
82
+ }
@@ -0,0 +1,206 @@
1
+ import { CID, l } from '@atproto/lex-schema'
2
+ import { LexiconDocument, lexiconDocumentSchema } from './lexicon-document.js'
3
+ import { LexiconIterableIndexer } from './lexicon-iterable-indexer.js'
4
+ import { LexiconSchemaBuilder } from './lexicon-schema-builder.js'
5
+
6
+ describe('LexiconSchemaBuilder', () => {
7
+ let schemas: Map<
8
+ string,
9
+ | l.Validator<unknown>
10
+ | l.Query
11
+ | l.Subscription
12
+ | l.Procedure
13
+ | l.PermissionSet
14
+ >
15
+
16
+ const getSchema = <T extends abstract new (...args: any) => any>(
17
+ ref: string,
18
+ type: T,
19
+ ) => {
20
+ const schema = schemas.get(ref)
21
+ expect(schema).toBeDefined()
22
+ expect(schema).toBeInstanceOf(type)
23
+ return schema as InstanceType<T>
24
+ }
25
+
26
+ beforeAll(async () => {
27
+ const indexer = new LexiconIterableIndexer([
28
+ lexiconDocumentSchema.parse({
29
+ lexicon: 1,
30
+ id: 'com.example.kitchenSink',
31
+ defs: {
32
+ main: {
33
+ type: 'record',
34
+ description: 'A record',
35
+ key: 'tid',
36
+ record: {
37
+ type: 'object',
38
+ required: [
39
+ 'object',
40
+ 'array',
41
+ 'boolean',
42
+ 'integer',
43
+ 'string',
44
+ 'bytes',
45
+ 'cidLink',
46
+ ],
47
+ properties: {
48
+ object: { type: 'ref', ref: '#object' },
49
+ array: { type: 'array', items: { type: 'string' } },
50
+ boolean: { type: 'boolean' },
51
+ integer: { type: 'integer' },
52
+ string: { type: 'string' },
53
+ bytes: { type: 'bytes' },
54
+ cidLink: { type: 'cid-link' },
55
+ },
56
+ },
57
+ },
58
+ object: {
59
+ type: 'object',
60
+ required: ['object', 'array', 'boolean', 'integer', 'string'],
61
+ properties: {
62
+ object: { type: 'ref', ref: '#subobject' },
63
+ array: { type: 'array', items: { type: 'string' } },
64
+ boolean: { type: 'boolean' },
65
+ integer: { type: 'integer' },
66
+ string: { type: 'string' },
67
+ },
68
+ },
69
+ subobject: {
70
+ type: 'object',
71
+ required: ['boolean'],
72
+ properties: {
73
+ boolean: { type: 'boolean' },
74
+ },
75
+ },
76
+ },
77
+ }),
78
+ ])
79
+ schemas = await LexiconSchemaBuilder.buildAll(indexer)
80
+ })
81
+
82
+ it('Validates records correctly', () => {
83
+ const schema = getSchema('com.example.kitchenSink#main', l.RecordSchema)
84
+
85
+ const value = {
86
+ $type: 'com.example.kitchenSink',
87
+ object: {
88
+ object: { boolean: true },
89
+ array: ['one', 'two'],
90
+ boolean: true,
91
+ integer: 123,
92
+ string: 'string',
93
+ },
94
+ array: ['one', 'two'],
95
+ boolean: true,
96
+ integer: 123,
97
+ string: 'string',
98
+ datetime: new Date().toISOString(),
99
+ atUri: 'at://did:web:example.com/com.example.test/self',
100
+ did: 'did:web:example.com',
101
+ cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
102
+ bytes: new Uint8Array([0, 1, 2, 3]),
103
+ cidLink: CID.parse(
104
+ 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
105
+ ),
106
+ }
107
+
108
+ expect(schema.validate(value)).toStrictEqual({ success: true, value })
109
+ })
110
+
111
+ it('Validates objects correctly', () => {
112
+ const schema = getSchema(
113
+ 'com.example.kitchenSink#object',
114
+ l.TypedObjectSchema,
115
+ )
116
+
117
+ const value = {
118
+ object: { boolean: true },
119
+ array: ['one', 'two'],
120
+ boolean: true,
121
+ integer: 123,
122
+ string: 'string',
123
+ }
124
+
125
+ expect(schema.validate(value)).toStrictEqual({ success: true, value })
126
+ })
127
+
128
+ it('Rejects missing required record fields', () => {
129
+ const schema = getSchema(
130
+ 'com.example.kitchenSink#object',
131
+ l.TypedObjectSchema,
132
+ )
133
+
134
+ const value = {
135
+ object: { boolean: true },
136
+ // array: ['one', 'two'],
137
+ boolean: true,
138
+ integer: 123,
139
+ string: 'string',
140
+ }
141
+
142
+ expect(schema.validate(value)).toMatchObject({
143
+ success: false,
144
+ error: { issues: [{ code: 'required_key', key: 'array' }] },
145
+ })
146
+ })
147
+
148
+ it('fails validation when ref uri has multiple hash segments', async () => {
149
+ const schema: LexiconDocument = {
150
+ lexicon: 1,
151
+ id: 'com.example.invalid',
152
+ defs: {
153
+ main: {
154
+ type: 'object',
155
+ properties: {
156
+ test: { type: 'ref', ref: 'com.example.invalid#test#test' },
157
+ },
158
+ },
159
+ },
160
+ }
161
+
162
+ await expect(async () => {
163
+ await LexiconSchemaBuilder.buildAll(new LexiconIterableIndexer([schema]))
164
+ }).rejects.toThrow('Uri can only have one hash segment')
165
+ })
166
+
167
+ it('fails lexicon parsing when uri is invalid', async () => {
168
+ const schema: LexiconDocument = {
169
+ lexicon: 1,
170
+ id: 'com.example.invalid',
171
+ defs: {
172
+ main: {
173
+ type: 'object',
174
+ properties: {
175
+ test: { type: 'ref', ref: 'com.example.missing#main' },
176
+ },
177
+ },
178
+ },
179
+ }
180
+
181
+ await expect(async () => {
182
+ await LexiconSchemaBuilder.buildAll(new LexiconIterableIndexer([schema]))
183
+ }).rejects.toThrow('Lexicon com.example.missing not found')
184
+ })
185
+
186
+ it('fails lexicon parsing when uri is invalid', async () => {
187
+ const schema: LexiconDocument = {
188
+ lexicon: 1,
189
+ id: 'com.example.invalid',
190
+ defs: {
191
+ main: {
192
+ type: 'object',
193
+ properties: {
194
+ test: { type: 'ref', ref: 'com.example.invalid#nonexistent' },
195
+ },
196
+ },
197
+ },
198
+ }
199
+
200
+ await expect(async () => {
201
+ await LexiconSchemaBuilder.buildAll(new LexiconIterableIndexer([schema]))
202
+ }).rejects.toThrow(
203
+ 'No definition found for hash ""nonexistent"" in com.example.invalid',
204
+ )
205
+ })
206
+ })