From 794181360b567c785980ebd5e6a0468675879ebc Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 23 Apr 2026 22:31:29 -0400 Subject: [PATCH] web: create reusable record guards --- web/src/lib/activity.ts | 36 +++++++++------------ web/src/lib/bbs.ts | 13 +++----- web/src/lib/bbsModeration.ts | 19 +++++------ web/src/lib/boardThreads.ts | 28 +++++++---------- web/src/lib/discovery.ts | 11 +++---- web/src/lib/mythreads.ts | 25 +++++---------- web/src/lib/news.ts | 30 +++++++----------- web/src/lib/pins.ts | 27 ++++++---------- web/src/lib/profile.ts | 13 +++----- web/src/lib/recordGuards.ts | 61 ++++++++++++++++++++++++++++++++++++ web/src/lib/replies.ts | 19 +++++------ web/src/lib/thread.ts | 8 ++--- 12 files changed, 147 insertions(+), 143 deletions(-) create mode 100644 web/src/lib/recordGuards.ts diff --git a/web/src/lib/activity.ts b/web/src/lib/activity.ts index 3de4cbb..7f0ffa4 100644 --- a/web/src/lib/activity.ts +++ b/web/src/lib/activity.ts @@ -2,9 +2,7 @@ import { fetchAndHydrate, listRecords } from "./atproto"; import { POST } from "./lexicon"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../lexicons"; +import { isPostRecord } from "./recordGuards"; export interface ActivityItem { type: "reply" | "parent_reply"; @@ -49,38 +47,32 @@ export async function fetchActivity( ): Promise { const SCAN_LIMIT = 50; const allPosts = await listRecords(pdsUrl, did, POST, SCAN_LIMIT); - const validPosts = allPosts.filter((record) => is(postSchema, record.value)); + const validPosts = allPosts.filter(isPostRecord); - const rootPosts = validPosts.filter( - (record) => !(record.value as Record).root, - ); - const replyPosts = validPosts.filter( - (record) => !!(record.value as Record).root, - ); + const rootPosts = validPosts.filter((record) => !record.value.root); + const replyPosts = validPosts.filter((record) => !!record.value.root); const results = await Promise.all([ - ...rootPosts.map((post) => { - const value = post.value as unknown as XyzAtbbsPost.Main; - return fetchBacklinkItems( + ...rootPosts.map((post) => + fetchBacklinkItems( post.uri, `${POST}:root`, did, "reply", - value.title ?? "", + post.value.title ?? "", post.uri, - ); - }), - ...replyPosts.map((reply) => { - const value = reply.value as unknown as XyzAtbbsPost.Main; - return fetchBacklinkItems( + ), + ), + ...replyPosts.map((reply) => + fetchBacklinkItems( reply.uri, `${POST}:parent`, did, "parent_reply", "", - value.root ?? "", - ); - }), + reply.value.root ?? "", + ), + ), ]); // Deduplicate — prefer "parent-reply" type when the same reply appears as both. diff --git a/web/src/lib/bbs.ts b/web/src/lib/bbs.ts index 22f4753..e5ce201 100644 --- a/web/src/lib/bbs.ts +++ b/web/src/lib/bbs.ts @@ -9,10 +9,7 @@ import { import { queryClient } from "./queryClient"; import { SITE } from "./lexicon"; import { parseAtUri } from "./util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; -import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; -import type { XyzAtbbsSite, XyzAtbbsBoard } from "../lexicons"; +import { isBoardRecord, isSiteRecord } from "./recordGuards"; export class BBSNotFoundError extends Error {} export class NoBBSError extends Error {} @@ -77,10 +74,10 @@ export async function resolveBBS(handle: string): Promise { throw new NoBBSError(`${handle} isn't running a BBS.`); } - if (!is(siteSchema, siteRecord.value)) { + if (!isSiteRecord(siteRecord)) { throw new NoBBSError(`${handle} has an invalid site record.`); } - const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; + const siteValue = siteRecord.value; const boardUris: string[] = siteValue.boards ?? []; const boardResults = await Promise.allSettled( @@ -93,8 +90,8 @@ export async function resolveBBS(handle: string): Promise { const boards: Board[] = []; boardResults.forEach((result, index) => { if (result.status !== "fulfilled") return; - if (!is(boardSchema, result.value.value)) return; - const board = result.value.value as unknown as XyzAtbbsBoard.Main; + if (!isBoardRecord(result.value)) return; + const board = result.value.value; const parsed = parseAtUri(boardUris[index]); boards.push({ slug: parsed.rkey, diff --git a/web/src/lib/bbsModeration.ts b/web/src/lib/bbsModeration.ts index 95e5142..d9103ef 100644 --- a/web/src/lib/bbsModeration.ts +++ b/web/src/lib/bbsModeration.ts @@ -4,10 +4,7 @@ import { listRecords } from "./atproto"; import { BAN, HIDE } from "./lexicon"; import { parseAtUri } from "./util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; -import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; -import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons"; +import { isBanRecord, isHideRecord } from "./recordGuards"; export interface BBSModeration { bannedDids: Set; @@ -30,19 +27,17 @@ export async function fetchBBSModeration( const bannedDids = new Set(); const banRkeys: Record = {}; for (const record of banRecs) { - if (!is(banSchema, record.value)) continue; - const value = record.value as unknown as XyzAtbbsBan.Main; - bannedDids.add(value.did); - banRkeys[value.did] = parseAtUri(record.uri).rkey; + if (!isBanRecord(record)) continue; + bannedDids.add(record.value.did); + banRkeys[record.value.did] = parseAtUri(record.uri).rkey; } const hiddenUris = new Set(); const hideRkeys: Record = {}; for (const record of hideRecs) { - if (!is(hideSchema, record.value)) continue; - const value = record.value as unknown as XyzAtbbsHide.Main; - hiddenUris.add(value.uri); - hideRkeys[value.uri] = parseAtUri(record.uri).rkey; + if (!isHideRecord(record)) continue; + hiddenUris.add(record.value.uri); + hideRkeys[record.value.uri] = parseAtUri(record.uri).rkey; } return { bannedDids, hiddenUris, banRkeys, hideRkeys }; diff --git a/web/src/lib/boardThreads.ts b/web/src/lib/boardThreads.ts index 1ff9e99..c150e0f 100644 --- a/web/src/lib/boardThreads.ts +++ b/web/src/lib/boardThreads.ts @@ -15,9 +15,7 @@ import { } from "./atproto"; import { POST, BOARD } from "./lexicon"; import { makeAtUri, parseAtUri } from "./util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../lexicons"; +import { isPostRecord } from "./recordGuards"; export interface Participant { did: string; @@ -70,11 +68,10 @@ export async function hydrateThreadPage( const records = await getRecordsBatch(backlinks.records); for (const record of records) { - if (!is(postSchema, record.value)) continue; - const value = record.value as unknown as XyzAtbbsPost.Main; - const threadUri = value.root ?? record.uri; + if (!isPostRecord(record)) continue; + const threadUri = record.value.root ?? record.uri; if (!lastActivity.has(threadUri)) { - lastActivity.set(threadUri, value.createdAt); + lastActivity.set(threadUri, record.value.createdAt); } let posters = postersByThread.get(threadUri); if (!posters) { @@ -91,11 +88,9 @@ export async function hydrateThreadPage( const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE); const rootRecords = await getRecordsByUri(threadUris); - const validRoots = rootRecords.filter((record) => { - if (!is(postSchema, record.value)) return false; - const value = record.value as unknown as XyzAtbbsPost.Main; - return value.title && !value.root; - }); + const validRoots = rootRecords + .filter(isPostRecord) + .filter((record) => record.value.title && !record.value.root); const allDids = new Set(); for (const record of validRoots) { @@ -117,7 +112,6 @@ export async function hydrateThreadPage( .filter((record) => parseAtUri(record.uri).did in identities) .map((record) => { const { did, rkey } = parseAtUri(record.uri); - const value = record.value as unknown as XyzAtbbsPost.Main; const posterDids = postersByThread.get(record.uri) ?? new Set([did]); const participants: Participant[] = [...posterDids] .filter((posterDid) => posterDid in identities) @@ -131,10 +125,10 @@ export async function hydrateThreadPage( did, rkey, handle: identities[did].handle, - title: value.title ?? "", - body: value.body, - createdAt: value.createdAt, - lastActivityAt: lastActivity.get(record.uri) ?? value.createdAt, + title: record.value.title ?? "", + body: record.value.body, + createdAt: record.value.createdAt, + lastActivityAt: lastActivity.get(record.uri) ?? record.value.createdAt, replyCount: replyCounts[record.uri] ?? 0, participants, }; diff --git a/web/src/lib/discovery.ts b/web/src/lib/discovery.ts index 8404cb9..e7c0d0e 100644 --- a/web/src/lib/discovery.ts +++ b/web/src/lib/discovery.ts @@ -3,9 +3,7 @@ import { getAvatars, getRecord, resolveIdentitiesBatch } from "./atproto"; import { SITE } from "./lexicon"; import { SERVICES } from "./shared"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; -import type { XyzAtbbsSite } from "../lexicons"; +import { isSiteRecord } from "./recordGuards"; export interface DiscoveredBBS { did: string; @@ -42,13 +40,12 @@ export async function fetchDiscovery(): Promise { if (!(repo.did in identities)) continue; try { const siteRecord = await getRecord(repo.did, SITE, "self"); - if (!is(siteSchema, siteRecord.value)) continue; - const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; + if (!isSiteRecord(siteRecord)) continue; items.push({ did: repo.did, handle: identities[repo.did].handle, - name: siteValue.name || identities[repo.did].handle, - description: siteValue.description || "", + name: siteRecord.value.name || identities[repo.did].handle, + description: siteRecord.value.description || "", }); } catch { continue; diff --git a/web/src/lib/mythreads.ts b/web/src/lib/mythreads.ts index ac8e8c9..0a8d78a 100644 --- a/web/src/lib/mythreads.ts +++ b/web/src/lib/mythreads.ts @@ -3,9 +3,7 @@ import { listRecords, resolveIdentitiesBatch } from "./atproto"; import { POST } from "./lexicon"; import { parseAtUri } from "./util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../lexicons"; +import { isPostRecord } from "./recordGuards"; export interface MyThread { uri: string; @@ -23,33 +21,26 @@ export async function fetchMyThreads( ): Promise { const records = await listRecords(pdsUrl, did, POST); const rootPosts = records - .filter((record) => is(postSchema, record.value)) - .filter((record) => { - const value = record.value as Record; - return !value.root && value.title; // root posts with titles = threads - }); + .filter(isPostRecord) + .filter((record) => !record.value.root && record.value.title); if (!rootPosts.length) return []; const bbsDids = new Set( - rootPosts.map((record) => { - const value = record.value as unknown as XyzAtbbsPost.Main; - return parseAtUri(value.scope).did; - }), + rootPosts.map((record) => parseAtUri(record.value.scope).did), ); const identities = await resolveIdentitiesBatch([...bbsDids]); const results: MyThread[] = []; for (const record of rootPosts) { - const value = record.value as unknown as XyzAtbbsPost.Main; - const bbsDid = parseAtUri(value.scope).did; + const bbsDid = parseAtUri(record.value.scope).did; const identity = identities[bbsDid]; if (!identity) continue; results.push({ uri: record.uri, rkey: parseAtUri(record.uri).rkey, - title: value.title ?? "", - body: value.body, - createdAt: value.createdAt, + title: record.value.title ?? "", + body: record.value.body, + createdAt: record.value.createdAt, bbsDid, bbsHandle: identity.handle, }); diff --git a/web/src/lib/news.ts b/web/src/lib/news.ts index 76ce6f6..184d168 100644 --- a/web/src/lib/news.ts +++ b/web/src/lib/news.ts @@ -3,9 +3,7 @@ import { getBacklinks, getRecordsBatch } from "./atproto"; import { POST, SITE } from "./lexicon"; import { makeAtUri, parseAtUri } from "./util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../lexicons"; +import { isPostRecord } from "./recordGuards"; import type { NewsPost } from "./bbs"; export async function fetchNews(bbsDid: string): Promise { @@ -19,22 +17,16 @@ export async function fetchNews(bbsDid: string): Promise { const records = await getRecordsBatch(sysopRefs); const news: NewsPost[] = records - .filter((record) => is(postSchema, record.value)) - .filter((record) => { - const value = record.value as unknown as XyzAtbbsPost.Main; - return value.title && !value.root; - }) - .map((record) => { - const value = record.value as unknown as XyzAtbbsPost.Main; - return { - uri: record.uri, - rkey: parseAtUri(record.uri).rkey, - title: value.title ?? "", - body: value.body, - createdAt: value.createdAt, - attachments: value.attachments as NewsPost["attachments"], - }; - }); + .filter(isPostRecord) + .filter((record) => record.value.title && !record.value.root) + .map((record) => ({ + uri: record.uri, + rkey: parseAtUri(record.uri).rkey, + title: record.value.title ?? "", + body: record.value.body, + createdAt: record.value.createdAt, + attachments: record.value.attachments as NewsPost["attachments"], + })); news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); return news; diff --git a/web/src/lib/pins.ts b/web/src/lib/pins.ts index e04db93..904bcf7 100644 --- a/web/src/lib/pins.ts +++ b/web/src/lib/pins.ts @@ -7,10 +7,7 @@ import { resolveIdentitiesBatch, } from "./atproto"; import { PIN, SITE } from "./lexicon"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; -import type { XyzAtbbsPin, XyzAtbbsSite } from "../lexicons"; +import { isPinRecord, isSiteRecord } from "./recordGuards"; import { parseAtUri } from "./util"; export interface PinnedBBS { @@ -27,11 +24,9 @@ export async function fetchPins( did: string, ): Promise { const records = await listRecords(pdsUrl, did, PIN); - const pinRecords = records.filter((record) => is(pinSchema, record.value)); + const pinRecords = records.filter(isPinRecord); - const pinnedDids = pinRecords.map( - (record) => (record.value as unknown as XyzAtbbsPin.Main).did, - ); + const pinnedDids = pinRecords.map((record) => record.value.did); if (!pinnedDids.length) return []; const [identities, siteResults, avatars] = await Promise.all([ @@ -45,23 +40,21 @@ export async function fetchPins( const siteNames: Record = {}; siteResults.forEach((result, index) => { if (result.status !== "fulfilled") return; - if (!is(siteSchema, result.value.value)) return; - const siteValue = result.value.value as unknown as XyzAtbbsSite.Main; - siteNames[pinnedDids[index]] = siteValue.name; + if (!isSiteRecord(result.value)) return; + siteNames[pinnedDids[index]] = result.value.value.name; }); const results: PinnedBBS[] = []; for (const record of pinRecords) { - const value = record.value as unknown as XyzAtbbsPin.Main; - const identity = identities[value.did]; + const identity = identities[record.value.did]; if (!identity) continue; results.push({ - did: value.did, + did: record.value.did, rkey: parseAtUri(record.uri).rkey, handle: identity.handle, - name: siteNames[value.did] ?? identity.handle, - createdAt: value.createdAt, - avatar: avatars[value.did], + name: siteNames[record.value.did] ?? identity.handle, + createdAt: record.value.createdAt, + avatar: avatars[record.value.did], }); } results.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); diff --git a/web/src/lib/profile.ts b/web/src/lib/profile.ts index eb4200c..24d53d1 100644 --- a/web/src/lib/profile.ts +++ b/web/src/lib/profile.ts @@ -2,10 +2,7 @@ import { getAvatar, getRecord, resolveIdentity } from "./atproto"; import { PROFILE, SITE } from "./lexicon"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; -import type { XyzAtbbsProfile, XyzAtbbsSite } from "../lexicons"; +import { isProfileRecord, isSiteRecord } from "./recordGuards"; export interface Profile { did: string; @@ -45,9 +42,9 @@ export async function fetchProfile(handle: string): Promise { if ( profileResult.status === "fulfilled" && - is(profileSchema, profileResult.value.value) + isProfileRecord(profileResult.value) ) { - const value = profileResult.value.value as unknown as XyzAtbbsProfile.Main; + const value = profileResult.value.value; profile.name = value.name; profile.pronouns = value.pronouns; profile.bio = value.bio; @@ -56,9 +53,9 @@ export async function fetchProfile(handle: string): Promise { if ( siteResult.status === "fulfilled" && - is(siteSchema, siteResult.value.value) + isSiteRecord(siteResult.value) ) { - const value = siteResult.value.value as unknown as XyzAtbbsSite.Main; + const value = siteResult.value.value; profile.bbsName = value.name; profile.bbsDescription = value.description; } diff --git a/web/src/lib/recordGuards.ts b/web/src/lib/recordGuards.ts new file mode 100644 index 0000000..461851a --- /dev/null +++ b/web/src/lib/recordGuards.ts @@ -0,0 +1,61 @@ +// Type guards for narrowing raw ATRecord.value into typed lexicon records. +// Each guard runs the schema's runtime check and, if it passes, narrows the +// record so downstream code can access typed fields without `as unknown as ...` +// casts. + +import { is } from "@atcute/lexicons/validations"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; +import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; +import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; +import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile"; +import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; +import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; +import type { + XyzAtbbsBan, + XyzAtbbsBoard, + XyzAtbbsHide, + XyzAtbbsPin, + XyzAtbbsPost, + XyzAtbbsProfile, + XyzAtbbsSite, +} from "../lexicons"; +import type { ATRecord } from "./atproto"; + +export type TypedRecord = Omit & { value: T }; + +export type PostRecord = TypedRecord; +export type BanRecord = TypedRecord; +export type HideRecord = TypedRecord; +export type PinRecord = TypedRecord; +export type ProfileRecord = TypedRecord; +export type SiteRecord = TypedRecord; +export type BoardRecord = TypedRecord; + +export function isPostRecord(record: ATRecord): record is PostRecord { + return is(postSchema, record.value); +} + +export function isBanRecord(record: ATRecord): record is BanRecord { + return is(banSchema, record.value); +} + +export function isHideRecord(record: ATRecord): record is HideRecord { + return is(hideSchema, record.value); +} + +export function isPinRecord(record: ATRecord): record is PinRecord { + return is(pinSchema, record.value); +} + +export function isProfileRecord(record: ATRecord): record is ProfileRecord { + return is(profileSchema, record.value); +} + +export function isSiteRecord(record: ATRecord): record is SiteRecord { + return is(siteSchema, record.value); +} + +export function isBoardRecord(record: ATRecord): record is BoardRecord { + return is(boardSchema, record.value); +} diff --git a/web/src/lib/replies.ts b/web/src/lib/replies.ts index 023970f..15bb105 100644 --- a/web/src/lib/replies.ts +++ b/web/src/lib/replies.ts @@ -1,10 +1,8 @@ /** Pure helpers for reply pagination and hydration. */ -import { type BacklinkRef } from "./atproto"; +import { type ATRecord, type BacklinkRef } from "./atproto"; import { parseAtUri } from "./util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../lexicons"; +import { isPostRecord } from "./recordGuards"; import type { Reply } from "../components/post/ReplyCard"; export type { BacklinkRef }; @@ -44,22 +42,21 @@ export function clampPage(page: number, totalRefs: number): number { } export function recordToReply( - record: { uri: string; value: Record }, + record: ATRecord, authors: Record, ): Reply | null { const { did, rkey } = parseAtUri(record.uri); if (!(did in authors)) return null; - if (!is(postSchema, record.value)) return null; - const value = record.value as unknown as XyzAtbbsPost.Main; + if (!isPostRecord(record)) return null; return { uri: record.uri, did, rkey, handle: authors[did].handle, pds: authors[did].pds ?? "", - body: value.body, - createdAt: value.createdAt, - parent: value.parent ?? null, - attachments: (value.attachments ?? []) as Reply["attachments"], + body: record.value.body, + createdAt: record.value.createdAt, + parent: record.value.parent ?? null, + attachments: (record.value.attachments ?? []) as Reply["attachments"], }; } diff --git a/web/src/lib/thread.ts b/web/src/lib/thread.ts index 1b660c7..865366d 100644 --- a/web/src/lib/thread.ts +++ b/web/src/lib/thread.ts @@ -12,10 +12,8 @@ import { import { POST } from "./lexicon"; import { makeAtUri, parseAtUri } from "./util"; import { recordToReply } from "./replies"; +import { isPostRecord } from "./recordGuards"; import type { Reply } from "../components/post/ReplyCard"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../lexicons"; export interface ThreadRoot { uri: string; @@ -58,11 +56,11 @@ export async function fetchThreadRoot( tid: string, ): Promise { const threadRecord = await getRecord(did, POST, tid); - if (!is(postSchema, threadRecord.value)) { + if (!isPostRecord(threadRecord)) { throw new Error("Invalid post record"); } const author = await resolveIdentity(did); - const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; + const postValue = threadRecord.value; const boardSlug = parseAtUri(postValue.scope).rkey; return { uri: threadRecord.uri, -- 2.51.2