import { ClientResponseError, ok } from '@atcute/client'; import type { ResolvedActor } from '@atcute/identity-resolver'; import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; import { getRpc } from './client'; export interface HydratedRecord { author: ResolvedActor; collection: Nsid; rkey: string; uri: ResourceUri; cid?: string; value: T; } export interface PaginatedResult { items: T[]; totalCount?: number; hasNext: boolean; } export interface RecordCreationResult { uri: ResourceUri; rkey: string; } export interface BacklinkRecordRef { did: Did; collection: Nsid; rkey: string; } const SORTABLE_BASE32_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz'; const TID_PATTERN = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/; export const timestampFromDate = (value?: string): number => { if (!value) { return 0; } const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; }; export const timestampFromTid = (rkey?: string): number => { if (!rkey || !TID_PATTERN.test(rkey)) { return 0; } let micros = 0; for (const char of rkey.slice(0, 11)) { const value = SORTABLE_BASE32_ALPHABET.indexOf(char); if (value < 0) { return 0; } micros = micros * 32 + value; } return Math.floor(micros / 1000); }; export const maxTimestamp = (...values: number[]): number => values.reduce((latest, value) => Math.max(latest, value), 0); export const parseAtUri = (uri: string): { did: Did; collection: Nsid; rkey: string } => { if (!uri.startsWith('at://')) { throw new Error(`Invalid at-uri: ${uri}`); } const parts = uri.slice(5).split('/'); const [did, collection, ...rkey] = parts; return { did: asDid(did), collection: asNsid(collection), rkey: rkey.join('/'), }; }; export const parseDirectRecordRef = (ref: string, collection: Nsid): ResourceUri | null => { let normalized = ref.trim(); try { normalized = decodeURIComponent(normalized); } catch { // Keep the original route param when it is not percent-encoded. } if (!normalized.startsWith('at://')) { return null; } try { return parseAtUri(normalized).collection === collection ? (normalized as ResourceUri) : null; } catch { return null; } }; export const extractBlobCid = (blob: unknown): string | null => { if (!blob || typeof blob !== 'object') { return null; } const value = blob as Record; const ref = value.ref; if (typeof ref === 'string') { return ref; } if (ref && typeof ref === 'object') { const maybeLink = (ref as Record).$link; if (typeof maybeLink === 'string') { return maybeLink; } } const maybeCid = value.cid; return typeof maybeCid === 'string' ? maybeCid : null; }; export const findNumberedRecord = ( records: T[], ref: string, ): T | undefined => { const trimmed = ref.trim(); if (/^\d+$/.test(trimmed)) { const number = Number(trimmed); return records.find((record) => record.number === number); } return records.find( (record) => record.rkey === trimmed || record.uri === trimmed || parseAtUri(record.uri).rkey === trimmed, ); }; export const optionalString = (value: string): string | undefined => { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const uniq = (values: T[]): T[] | undefined => { if (values.length === 0) { return undefined; } return [...new Set(values)]; }; export const extractDids = (input: string): Did[] | undefined => uniq( Array.from( input.matchAll(/\bdid:[a-z0-9]+:[A-Za-z0-9._:%-]+\b/gi), (match) => match[0] as Did, ), ); export const extractAtUris = (input: string): ResourceUri[] | undefined => uniq( Array.from( input.matchAll(/\bat:\/\/[^\s<>()]+/gi), (match) => match[0] as ResourceUri, ), ); export const sortByCreatedAt = (left: T, right: T): number => new Date(left.value.createdAt ?? 0).getTime() - new Date(right.value.createdAt ?? 0).getTime(); export const toNumberMap = ( items: T[], ): Map => { const ordered = [...items].sort((left, right) => { const leftTime = timestampFromDate(left.value.createdAt); const rightTime = timestampFromDate(right.value.createdAt); if (leftTime !== rightTime) { return leftTime - rightTime; } return left.rkey.localeCompare(right.rkey); }); return new Map(ordered.map((item, index) => [item.uri, index + 1])); }; export const getOptionalRecord = async ( actor: ResolvedActor, collection: Nsid, rkey: string, ): Promise => { try { const record = await ok( getRpc(actor.pds).get('com.atproto.repo.getRecord', { params: { repo: actor.did, collection, rkey, }, }), ); return record.value as T; } catch (cause) { if ( cause instanceof ClientResponseError && (cause.status === 400 || cause.status === 404) ) { return null; } throw cause; } }; export const asDid = (value: string): Did => { if (!value.startsWith('did:')) { throw new Error(`Invalid DID: ${value}`); } return value as Did; }; export const asNsid = (value: string): Nsid => { if (value.split('.').length < 3) { throw new Error(`Invalid NSID: ${value}`); } return value as Nsid; };