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<T> {
author: ResolvedActor;
collection: Nsid;
rkey: string;
uri: ResourceUri;
cid?: string;
value: T;
}
export interface PaginatedResult<T> {
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<string, unknown>;
const ref = value.ref;
if (typeof ref === 'string') {
return ref;
}
if (ref && typeof ref === 'object') {
const maybeLink = (ref as Record<string, unknown>).$link;
if (typeof maybeLink === 'string') {
return maybeLink;
}
}
const maybeCid = value.cid;
return typeof maybeCid === 'string' ? maybeCid : null;
};
export const findNumberedRecord = <T extends { number: number; rkey: string; uri: string }>(
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 = <T extends string>(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 = <T extends { value: { createdAt?: string } }>(left: T, right: T): number =>
new Date(left.value.createdAt ?? 0).getTime() - new Date(right.value.createdAt ?? 0).getTime();
export const toNumberMap = <T extends { uri: string; rkey: string; value: { createdAt?: string } }>(
items: T[],
): Map<string, number> => {
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 <T>(
actor: ResolvedActor,
collection: Nsid,
rkey: string,
): Promise<T | null> => {
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;
};