👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

validate records off the wire

+111 -26
+66 -13
src/lib/atproto-client.ts
··· 6 6 import type {} from "@atcute/atproto"; 7 7 import { Client } from "@atcute/client"; 8 8 import type { Did } from "@atcute/lexicons"; 9 + import { 10 + type BaseSchema, 11 + type InferOutput, 12 + safeParse, 13 + } from "@atcute/lexicons/validations"; 9 14 import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 10 - import type { 15 + import { 11 16 ComDeckbelcherCollectionList, 12 17 ComDeckbelcherDeckList, 13 18 } from "./lexicons/index"; ··· 54 59 // Generic ATProto Operations 55 60 // ============================================================================ 56 61 57 - async function getRecord<T>( 62 + async function getRecord<TSchema extends BaseSchema>( 58 63 did: Did, 59 64 rkey: Rkey, 60 65 collection: Collection, 61 66 entityName: string, 62 - ): Promise<Result<RecordResponse<T>>> { 67 + schema: TSchema, 68 + ): Promise<Result<RecordResponse<InferOutput<TSchema>>>> { 63 69 try { 64 70 const url = new URL(`${SLINGSHOT_BASE}/xrpc/com.atproto.repo.getRecord`); 65 71 url.searchParams.set("repo", did); ··· 81 87 }; 82 88 } 83 89 84 - const data = (await response.json()) as RecordResponse<T>; 85 - return { success: true, data }; 90 + const json = (await response.json()) as { 91 + uri: string; 92 + cid: string; 93 + value: unknown; 94 + }; 95 + const result = safeParse(schema, json.value); 96 + if (!result.ok) { 97 + return { 98 + success: false, 99 + error: new Error(result.message), 100 + }; 101 + } 102 + 103 + return { 104 + success: true, 105 + data: { 106 + uri: json.uri as AtUri, 107 + cid: json.cid, 108 + value: result.value, 109 + }, 110 + }; 86 111 } catch (error) { 87 112 return { 88 113 success: false, ··· 180 205 } 181 206 } 182 207 183 - async function listRecords<T>( 208 + async function listRecords<TSchema extends BaseSchema>( 184 209 pdsUrl: PdsUrl, 185 210 did: Did, 186 211 collection: Collection, 187 212 entityName: string, 188 - ): Promise<Result<ListRecordsResponse<T>>> { 213 + schema: TSchema, 214 + ): Promise<Result<ListRecordsResponse<InferOutput<TSchema>>>> { 189 215 try { 190 216 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 191 217 url.searchParams.set("repo", did); ··· 206 232 }; 207 233 } 208 234 209 - const data = (await response.json()) as ListRecordsResponse<T>; 210 - return { success: true, data }; 235 + const json = (await response.json()) as { 236 + records: { uri: string; cid: string; value: unknown }[]; 237 + cursor?: string; 238 + }; 239 + 240 + const validatedRecords: RecordResponse<InferOutput<TSchema>>[] = []; 241 + for (const record of json.records) { 242 + const result = safeParse(schema, record.value); 243 + if (!result.ok) { 244 + return { 245 + success: false, 246 + error: new Error(result.message), 247 + }; 248 + } 249 + validatedRecords.push({ 250 + uri: record.uri as AtUri, 251 + cid: record.cid, 252 + value: result.value, 253 + }); 254 + } 255 + 256 + return { 257 + success: true, 258 + data: { records: validatedRecords, cursor: json.cursor }, 259 + }; 211 260 } catch (error) { 212 261 return { 213 262 success: false, ··· 257 306 export type DeckRecordResponse = RecordResponse<ComDeckbelcherDeckList.Main>; 258 307 259 308 export function getDeckRecord(did: Did, rkey: Rkey) { 260 - return getRecord<ComDeckbelcherDeckList.Main>( 309 + return getRecord( 261 310 did, 262 311 rkey, 263 312 DECK_COLLECTION, 264 313 "deck", 314 + ComDeckbelcherDeckList.mainSchema, 265 315 ); 266 316 } 267 317 ··· 281 331 } 282 332 283 333 export function listUserDecks(pdsUrl: PdsUrl, did: Did) { 284 - return listRecords<ComDeckbelcherDeckList.Main>( 334 + return listRecords( 285 335 pdsUrl, 286 336 did, 287 337 DECK_COLLECTION, 288 338 "deck", 339 + ComDeckbelcherDeckList.mainSchema, 289 340 ); 290 341 } 291 342 ··· 301 352 RecordResponse<ComDeckbelcherCollectionList.Main>; 302 353 303 354 export function getCollectionListRecord(did: Did, rkey: Rkey) { 304 - return getRecord<ComDeckbelcherCollectionList.Main>( 355 + return getRecord( 305 356 did, 306 357 rkey, 307 358 LIST_COLLECTION, 308 359 "list", 360 + ComDeckbelcherCollectionList.mainSchema, 309 361 ); 310 362 } 311 363 ··· 325 377 } 326 378 327 379 export function listUserCollectionLists(pdsUrl: PdsUrl, did: Did) { 328 - return listRecords<ComDeckbelcherCollectionList.Main>( 380 + return listRecords( 329 381 pdsUrl, 330 382 did, 331 383 LIST_COLLECTION, 332 384 "list", 385 + ComDeckbelcherCollectionList.mainSchema, 333 386 ); 334 387 } 335 388
+45 -13
src/lib/collection-list-queries.ts
··· 16 16 type Rkey, 17 17 updateCollectionListRecord, 18 18 } from "./atproto-client"; 19 - import type { 20 - CollectionList, 21 - ListCardItem, 22 - ListItem, 19 + import { 20 + type CollectionList, 21 + isCardItem, 22 + isDeckItem, 23 + type ListItem, 23 24 } from "./collection-list-types"; 24 25 import { getPdsForDid } from "./identity"; 25 26 import type { ComDeckbelcherCollectionList } from "./lexicons/index"; 26 - import { parseOracleUri, parseScryfallUri } from "./scryfall-types"; 27 + import { 28 + parseOracleUri, 29 + parseScryfallUri, 30 + toOracleUri, 31 + toScryfallUri, 32 + } from "./scryfall-types"; 27 33 import { useAuth } from "./useAuth"; 28 34 import { useMutationWithToast } from "./useMutationWithToast"; 29 35 36 + function assertHasType<T extends { $type?: string }>( 37 + item: T, 38 + ): asserts item is T & { $type: string } { 39 + if (!item.$type) throw new Error("Item missing $type discriminator"); 40 + } 41 + 30 42 /** 31 43 * Transform lexicon list record to app CollectionList type 32 44 * Parses ref URIs to typed IDs at the boundary ··· 37 49 return { 38 50 ...record, 39 51 items: record.items.map((item): ListItem => { 52 + assertHasType(item); 40 53 if (item.$type === "com.deckbelcher.collection.list#cardItem") { 41 - const cardItem = item as ComDeckbelcherCollectionList.CardItem; 42 - const scryfallId = parseScryfallUri(cardItem.ref.scryfallUri); 43 - const oracleId = parseOracleUri(cardItem.ref.oracleUri); 54 + const scryfallId = parseScryfallUri(item.ref.scryfallUri); 55 + const oracleId = parseOracleUri(item.ref.oracleUri); 44 56 45 57 if (!scryfallId || !oracleId) { 46 58 throw new Error( 47 - `Invalid card ref URIs: ${cardItem.ref.scryfallUri}, ${cardItem.ref.oracleUri}`, 59 + `Invalid card ref URIs: ${item.ref.scryfallUri}, ${item.ref.oracleUri}`, 48 60 ); 49 61 } 50 62 51 - const { ref: _ref, ...rest } = cardItem; 52 - return { ...rest, scryfallId, oracleId } as ListCardItem; 63 + const { ref: _ref, ...rest } = item; 64 + return { ...rest, scryfallId, oracleId }; 53 65 } 54 66 if (item.$type === "com.deckbelcher.collection.list#deckItem") { 55 - return item as ComDeckbelcherCollectionList.DeckItem; 67 + return item; 56 68 } 57 69 throw new Error( 58 70 `Unknown list item type: ${(item as { $type?: string }).$type}`, ··· 164 176 $type: "com.deckbelcher.collection.list", 165 177 name: list.name, 166 178 description: list.description, 167 - items: list.items as ComDeckbelcherCollectionList.Main["items"], 179 + items: list.items.map((item) => { 180 + if (isCardItem(item)) { 181 + const { scryfallId, oracleId, ...rest } = item; 182 + const result = { 183 + ...rest, 184 + ref: { 185 + scryfallUri: toScryfallUri(scryfallId), 186 + oracleUri: toOracleUri(oracleId), 187 + }, 188 + }; 189 + assertHasType(result); 190 + return result; 191 + } 192 + if (isDeckItem(item)) { 193 + assertHasType(item); 194 + return item; 195 + } 196 + throw new Error( 197 + `Unknown list item type: ${(item as { $type?: string }).$type}`, 198 + ); 199 + }), 168 200 createdAt: list.createdAt, 169 201 updatedAt: new Date().toISOString(), 170 202 });