diff --git a/backend/database/migrations.ts b/backend/database/migrations.ts index e0dabb2..df36415 100644 --- a/backend/database/migrations.ts +++ b/backend/database/migrations.ts @@ -41,4 +41,13 @@ export async function runMigrations(): Promise { value TEXT NOT NULL ) `); + + // Add pds_software column (idempotent — ignore if it already exists) + try { + await sqlite.execute( + `ALTER TABLE ${SERVERS_TABLE} ADD COLUMN pds_software TEXT`, + ); + } catch { + // Column already exists + } } diff --git a/backend/database/queries.ts b/backend/database/queries.ts index 37f956b..8c912e8 100644 --- a/backend/database/queries.ts +++ b/backend/database/queries.ts @@ -1,6 +1,10 @@ import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13"; import { METADATA_TABLE, SERVERS_TABLE } from "../../shared/constants.ts"; -import type { PdsServer, ServerToEnrich } from "../../shared/types.ts"; +import type { + LatestVersionMap, + PdsServer, + ServerToEnrich, +} from "../../shared/types.ts"; // --- Server operations --- @@ -56,6 +60,7 @@ export async function updateEnrichment( countryCode?: string | null; countryName?: string | null; ipAddress?: string | null; + pdsSoftware?: string | null; }, ): Promise { const now = new Date().toISOString(); @@ -73,7 +78,8 @@ export async function updateEnrichment( user_count = COALESCE(?, user_count), country_code = COALESCE(?, country_code), country_name = COALESCE(?, country_name), - ip_address = COALESCE(?, ip_address) + ip_address = COALESCE(?, ip_address), + pds_software = COALESCE(?, pds_software) WHERE url = ? `, args: [ @@ -89,6 +95,7 @@ export async function updateEnrichment( data.countryCode ?? null, data.countryName ?? null, data.ipAddress ?? null, + data.pdsSoftware ?? null, url, ], }); @@ -123,14 +130,49 @@ export async function getServersToEnrich( } export async function getOpenServers( - latestVersion: string | null = null, + latestVersions: LatestVersionMap = {}, ): Promise { + // Build per-software version CASE expression + // Servers with known tracked software: compare against that software's latest + // Servers with link-only software (not in latestVersions): pass (benefit of the doubt) + // Servers with NULL pds_software: default to bluesky-pds comparison + const trackedIds = Object.keys(latestVersions); + const args: (string | number)[] = []; + + let versionCaseExpr: string; + if (trackedIds.length === 0) { + versionCaseExpr = "0"; + } else { + const whenClauses: string[] = []; + for (const id of trackedIds) { + // Match servers assigned to this software + whenClauses.push("WHEN pds_software = ? AND version = ? THEN 20"); + args.push(id, latestVersions[id]); + } + // NULL pds_software defaults to bluesky-pds comparison + if (latestVersions["bluesky-pds"]) { + whenClauses.push( + "WHEN pds_software IS NULL AND version = ? THEN 20", + ); + args.push(latestVersions["bluesky-pds"]); + } + // Software with no entry in latestVersions (link-only): pass + whenClauses.push( + `WHEN pds_software IS NOT NULL AND pds_software NOT IN (${ + trackedIds.map(() => "?").join(",") + }) THEN 20`, + ); + args.push(...trackedIds); + + versionCaseExpr = `CASE ${whenClauses.join(" ")} ELSE 0 END`; + } + const trustScoreExpr = `( (CASE WHEN contact_email IS NOT NULL THEN 20 ELSE 0 END) + (CASE WHEN terms_of_service IS NOT NULL THEN 20 ELSE 0 END) + (CASE WHEN privacy_policy IS NOT NULL THEN 20 ELSE 0 END) + (CASE WHEN user_count > 5 THEN 20 ELSE 0 END) + - (CASE WHEN version = ? THEN 20 ELSE 0 END) + ${versionCaseExpr} )`; const result = await sqlite.execute({ @@ -139,7 +181,7 @@ export async function getOpenServers( WHERE is_open = 1 AND last_enriched IS NOT NULL AND version IS NOT NULL ORDER BY ${trustScoreExpr} DESC, user_count DESC NULLS LAST `, - args: [latestVersion ?? ""], + args, }); return result.rows.map(rowToServer); @@ -194,6 +236,7 @@ function rowToServer(row: Record): PdsServer { last_seen: row.last_seen as string, last_enriched: (row.last_enriched as string) || null, version: (row.version as string) || null, + pds_software: (row.pds_software as string) || null, did: (row.did as string) || null, invite_code_required: Boolean(row.invite_code_required), phone_verification: Boolean(row.phone_verification), diff --git a/backend/routes/api.ts b/backend/routes/api.ts index 2365f0a..6021347 100644 --- a/backend/routes/api.ts +++ b/backend/routes/api.ts @@ -4,13 +4,23 @@ import { getMetadata, getServerCount, } from "../database/queries.ts"; +import { getTrackedSoftware } from "../../shared/constants.ts"; +import type { LatestVersionMap } from "../../shared/types.ts"; const api = new Hono(); api.get("/servers", async (c) => { - const [servers, latestVersion, counts] = await Promise.all([ + // Fetch per-software latest versions + const tracked = getTrackedSoftware(); + const latestVersions: LatestVersionMap = {}; + const versionPromises = tracked.map(async (sw) => { + const v = await getMetadata(`latest_version:${sw.id}`); + if (v) latestVersions[sw.id] = v; + }); + + const [servers, , counts] = await Promise.all([ getAllServers(), - getMetadata("latest_pds_version"), + Promise.all(versionPromises), getServerCount(), ]); @@ -20,7 +30,8 @@ api.get("/servers", async (c) => { ); return c.json({ meta: { - latest_pds_version: latestVersion, + latest_pds_version: latestVersions["bluesky-pds"] ?? null, + latest_versions: latestVersions, total_known: counts.total, total_open: counts.open, updated_at: new Date().toISOString(), diff --git a/backend/routes/pages.ts b/backend/routes/pages.ts index 02adacc..b0e7b32 100644 --- a/backend/routes/pages.ts +++ b/backend/routes/pages.ts @@ -4,40 +4,54 @@ import { getOpenServers, getServerCount, } from "../database/queries.ts"; -import { countryFlag } from "../../shared/constants.ts"; -import type { PdsServer } from "../../shared/types.ts"; +import { + countryFlag, + getSoftwareConfig, + getTrackedSoftware, +} from "../../shared/constants.ts"; +import type { LatestVersionMap, PdsServer } from "../../shared/types.ts"; import { isSafeHref } from "../../shared/url-validation.ts"; const pages = new Hono(); pages.get("/", async (c) => { - const [latestVersion, counts] = await Promise.all([ - getMetadata("latest_pds_version"), + // Fetch per-software latest versions + const tracked = getTrackedSoftware(); + const latestVersions: LatestVersionMap = {}; + const versionPromises = tracked.map(async (sw) => { + const v = await getMetadata(`latest_version:${sw.id}`); + if (v) latestVersions[sw.id] = v; + }); + const [, counts] = await Promise.all([ + Promise.all(versionPromises), getServerCount(), ]); - const servers = await getOpenServers(latestVersion); + const servers = await getOpenServers(latestVersions); c.header( "Cache-Control", "public, max-age=3600, stale-while-revalidate=3600", ); return c.html(renderPage(servers, { - latestVersion, + latestVersions, openCount: counts.open, totalCount: counts.total, })); }); type PageOpts = { - latestVersion: string | null; + latestVersions: LatestVersionMap; openCount: number; totalCount: number; }; type TrustResult = { score: number; breakdown: string }; -function trustScore(s: PdsServer, latestVersion: string | null): TrustResult { +function trustScore( + s: PdsServer, + latestVersions: LatestVersionMap, +): TrustResult { const signals: string[] = []; let score = 0; @@ -50,18 +64,35 @@ function trustScore(s: PdsServer, latestVersion: string | null): TrustResult { check(!!s.terms_of_service, "Terms of Service"); check(!!s.privacy_policy, "Privacy Policy"); check(s.user_count != null && s.user_count > 5, "Active users (>5)"); - check(s.version === latestVersion, "Latest version"); + + // Version check: compare against the server's own software latest version + const softwareId = s.pds_software ?? "bluesky-pds"; + const expectedVersion = latestVersions[softwareId]; + if (!expectedVersion) { + // Link-only software with no tracked version: benefit of the doubt + check(true, "Latest version"); + } else { + check(s.version === expectedVersion, "Latest version"); + } return { score, breakdown: signals.join("\n") }; } function renderPage(servers: PdsServer[], opts: PageOpts): string { - const { latestVersion, openCount, totalCount } = opts; + const { latestVersions, openCount, totalCount } = opts; const rows = servers - .map((s) => renderRow(s, latestVersion, trustScore(s, latestVersion))) + .map((s) => renderRow(s, latestVersions, trustScore(s, latestVersions))) .join("\n"); + // Build stats for tracked software versions + const versionStats = getTrackedSoftware() + .filter((sw) => latestVersions[sw.id]) + .map((sw) => + `${esc(sw.displayName)}: ${esc(latestVersions[sw.id])}` + ) + .join("\n "); + return ` @@ -107,6 +138,7 @@ function renderPage(servers: PdsServer[], opts: PageOpts): string { margin-bottom: 1rem; font-size: 0.85rem; color: var(--muted); + flex-wrap: wrap; } table { width: 100%; @@ -159,6 +191,13 @@ function renderPage(servers: PdsServer[], opts: PageOpts): string { .trust-mid { background: #451a03; } .trust-low { background: #1f2937; } } + .software-link { + font-size: 0.7rem; + color: var(--muted); + text-decoration: none; + margin-left: 0.3rem; + } + .software-link:hover { text-decoration: underline; } .move-btn { display: inline-block; padding: 0.2rem 0.5rem; @@ -207,7 +246,7 @@ function renderPage(servers: PdsServer[], opts: PageOpts): string {
${servers.length} servers listed ${openCount} open of ${totalCount} known - ${latestVersion ? `Latest PDS: ${esc(latestVersion)}` : ""} + ${versionStats}
${ servers.length > 0 @@ -238,7 +277,7 @@ function renderPage(servers: PdsServer[], opts: PageOpts): string {
  • Terms of Service — published terms for using the server
  • Privacy Policy — published data handling policy
  • Active users — more than 5 accounts on the server
  • -
  • Latest version — running the most recent PDS software
  • +
  • Latest version — running the latest release of its PDS software (servers are compared against their own software's latest version)
  • This is not an endorsement. Always review a server's policies before creating an account.

    @@ -253,7 +292,7 @@ function renderPage(servers: PdsServer[], opts: PageOpts): string { function renderRow( s: PdsServer, - latestVersion: string | null, + latestVersions: LatestVersionMap, trust: TrustResult, ): string { const { score, breakdown } = trust; @@ -262,9 +301,23 @@ function renderRow( const flag = s.country_code ? esc(countryFlag(s.country_code)) + " " : ""; const country = s.country_name || ""; - let versionClass = "version-outdated"; + // Determine version badge class based on per-software comparison + const softwareId = s.pds_software ?? "bluesky-pds"; + const expectedVersion = latestVersions[softwareId]; const versionText = s.version || "unknown"; - if (s.version === latestVersion) versionClass = "version-current"; + let versionClass = "version-outdated"; + if (!expectedVersion || s.version === expectedVersion) { + versionClass = "version-current"; + } + + // Software link (show name as clickable link if recognized) + let softwareHtml = ""; + const config = s.pds_software ? getSoftwareConfig(s.pds_software) : null; + if (config && config.id !== "bluesky-pds") { + softwareHtml = `${esc(config.displayName)}`; + } const users = s.user_count != null ? (s.user_count >= 1000 ? "1000+" : String(s.user_count)) @@ -294,7 +347,7 @@ function renderRow( ${flag}${esc(country)} ${ esc(versionText) - } + }${softwareHtml} ${esc(users)} ${esc(firstSeen)} { + const result = await fetchRecentVersions(software, previousEtag); + return { + version: result.latest, + etag: result.etag, + notModified: result.notModified, + error: result.error, + }; +} + +/** Fetch up to 30 recent parsed versions for a PDS software from GitHub tags. */ +export async function fetchRecentVersions( + software: PdsSoftwareConfig, + previousEtag?: string | null, +): Promise { + if (!software.githubOwner || !software.githubRepo || !software.parseVersion) { + return { + latest: null, + versions: [], + etag: null, + notModified: false, + error: true, + }; + } + const url = - `https://api.github.com/repos/${PDS_REPO_OWNER}/${PDS_REPO_NAME}/tags?per_page=5`; + `https://api.github.com/repos/${software.githubOwner}/${software.githubRepo}/tags?per_page=30`; const headers: Record = { "Accept": "application/vnd.github.v3+json", @@ -27,7 +60,8 @@ export async function fetchLatestPdsVersion( if (resp.status === 304) { return { - version: null, + latest: null, + versions: [], etag: etag ?? previousEtag ?? null, notModified: true, error: false, @@ -35,10 +69,12 @@ export async function fetchLatestPdsVersion( } if (!resp.ok) { - console.error(`GitHub API error: ${resp.status}`); - // Preserve previous etag on error so we can retry conditional request next time + console.error( + `GitHub API error for ${software.id}: ${resp.status}`, + ); return { - version: null, + latest: null, + versions: [], etag: previousEtag ?? null, notModified: false, error: true, @@ -46,16 +82,17 @@ export async function fetchLatestPdsVersion( } const tags: Array<{ name: string }> = await resp.json(); - if (tags.length === 0) { - return { - version: null, - etag: etag ?? null, - notModified: false, - error: false, - }; + const versions: string[] = []; + for (const tag of tags) { + const v = software.parseVersion(tag.name); + if (v) versions.push(v); } - // Tags are like "v0.4.74" — strip the "v" prefix to match _health output - const version = tags[0].name.replace(/^v/, ""); - return { version, etag: etag ?? null, notModified: false, error: false }; + return { + latest: versions.length > 0 ? versions[0] : null, + versions, + etag: etag ?? null, + notModified: false, + error: false, + }; } diff --git a/cron/refresh.cron.ts b/cron/refresh.cron.ts index 221f3ad..596caf7 100644 --- a/cron/refresh.cron.ts +++ b/cron/refresh.cron.ts @@ -1,4 +1,8 @@ -import { ENRICHMENT_BATCH_SIZE } from "../shared/constants.ts"; +import { + ENRICHMENT_BATCH_SIZE, + getTrackedSoftware, +} from "../shared/constants.ts"; +import type { LatestVersionMap, VersionSoftwareMap } from "../shared/types.ts"; import { runMigrations } from "../backend/database/migrations.ts"; import { getMetadata, @@ -8,7 +12,7 @@ import { upsertServer, } from "../backend/database/queries.ts"; import { fetchPdsList } from "../backend/services/pds-fetcher.ts"; -import { fetchLatestPdsVersion } from "../backend/services/version-checker.ts"; +import { fetchRecentVersions } from "../backend/services/version-checker.ts"; import { enrichBatch } from "../backend/services/pds-enricher.ts"; export default async function () { @@ -46,34 +50,91 @@ export default async function () { ); } - // 2. Fetch latest PDS version (with ETag caching) - const previousGithubEtag = await getMetadata("github_tags_etag"); - const { - version: latestVersion, - etag: githubEtag, - notModified, - error: githubError, - } = await fetchLatestPdsVersion(previousGithubEtag); - - if (githubEtag) { - await setMetadata("github_tags_etag", githubEtag); + // 2. Fetch latest versions for all tracked PDS software + const latestVersions: LatestVersionMap = {}; + const versionToSoftware: VersionSoftwareMap = {}; + const trackedSoftware = getTrackedSoftware(); + + for (const software of trackedSoftware) { + const etagKey = `github_tags_etag:${software.id}`; + const previousEtag = await getMetadata(etagKey); + const result = await fetchRecentVersions(software, previousEtag); + + if (result.etag) { + await setMetadata(etagKey, result.etag); + } + + if (result.notModified) { + console.log( + `GitHub tags [${software.id}]: 304 not modified`, + ); + // Load cached versions from metadata + const cachedLatest = await getMetadata( + `latest_version:${software.id}`, + ); + if (cachedLatest) { + latestVersions[software.id] = cachedLatest; + } + const cachedVersions = await getMetadata( + `version_map:${software.id}`, + ); + if (cachedVersions) { + for (const v of JSON.parse(cachedVersions)) { + versionToSoftware[v] = software.id; + } + } + } else if (result.latest) { + latestVersions[software.id] = result.latest; + await setMetadata(`latest_version:${software.id}`, result.latest); + await setMetadata( + `version_map:${software.id}`, + JSON.stringify(result.versions), + ); + for (const v of result.versions) { + versionToSoftware[v] = software.id; + } + console.log( + `GitHub tags [${software.id}]: latest ${result.latest} (${result.versions.length} versions)`, + ); + } else if (result.error) { + console.log( + `GitHub tags [${software.id}]: API error (using cached)`, + ); + const cachedLatest = await getMetadata( + `latest_version:${software.id}`, + ); + if (cachedLatest) { + latestVersions[software.id] = cachedLatest; + } + const cachedVersions = await getMetadata( + `version_map:${software.id}`, + ); + if (cachedVersions) { + for (const v of JSON.parse(cachedVersions)) { + versionToSoftware[v] = software.id; + } + } + } else { + console.log(`GitHub tags [${software.id}]: no versions found`); + } } - if (notModified) { - console.log( - `GitHub tags: 304 not modified (etag=${githubEtag})`, - ); - } else if (latestVersion) { - await setMetadata("latest_pds_version", latestVersion); - console.log( - `GitHub tags: new version ${latestVersion} (etag=${githubEtag})`, - ); - } else if (githubError) { - console.log("GitHub tags: API error (using cached version)"); - } else { - console.log("GitHub tags: no version found"); + // Backward compat: keep latest_pds_version for bluesky-pds + if (latestVersions["bluesky-pds"]) { + await setMetadata("latest_pds_version", latestVersions["bluesky-pds"]); + } + // Also maintain legacy github_tags_etag for backward compat + const bskyEtag = await getMetadata("github_tags_etag:bluesky-pds"); + if (bskyEtag) { + await setMetadata("github_tags_etag", bskyEtag); } + console.log( + `Version map: ${ + Object.keys(versionToSoftware).length + } known versions across ${trackedSoftware.length} software`, + ); + // 3. Enrich a batch of servers const toEnrich = await getServersToEnrich(ENRICHMENT_BATCH_SIZE); if (toEnrich.length > 0) { @@ -86,6 +147,11 @@ export default async function () { const { results: enriched, stats } = await enrichBatch(toEnrich); for (const data of enriched) { + // Detect software by matching version against known version map + if (data.version && versionToSoftware[data.version]) { + data.pdsSoftware = versionToSoftware[data.version]; + } + await updateEnrichment(data.url, { version: data.version, did: data.did, @@ -98,15 +164,22 @@ export default async function () { countryCode: data.countryCode, countryName: data.countryName, ipAddress: data.ipAddress, + pdsSoftware: data.pdsSoftware, }); } const noUserCount = enriched.filter((s) => s.userCount === null).length; + const softwareCounts: Record = {}; + for (const s of enriched) { + const sw = s.pdsSoftware ?? "unknown"; + softwareCounts[sw] = (softwareCounts[sw] ?? 0) + 1; + } console.log( `Enriched ${enriched.length} servers: ` + `DNS ${stats.cachedDns} cached/${stats.freshDns} fresh, ` + `geo ${stats.cachedGeo} cached/${stats.freshGeo} fresh` + - (noUserCount > 0 ? `, ${noUserCount} without user count` : ""), + (noUserCount > 0 ? `, ${noUserCount} without user count` : "") + + `, software: ${JSON.stringify(softwareCounts)}`, ); } diff --git a/shared/constants.ts b/shared/constants.ts index 8c15bdc..8186844 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -4,14 +4,111 @@ export const METADATA_TABLE = "openpds_metadata"; export const STATE_JSON_URL = "https://raw.githubusercontent.com/mary-ext/atproto-scraping/trunk/state.json"; -export const PDS_REPO_OWNER = "bluesky-social"; -export const PDS_REPO_NAME = "pds"; - export const ENRICHMENT_BATCH_SIZE = 75; export const PDS_REQUEST_TIMEOUT_MS = 5000; export const DNS_CACHE_TTL_DAYS = 7; export const GEO_CACHE_TTL_DAYS = 30; +/** Configuration for a known PDS software implementation */ +export type PdsSoftwareConfig = { + id: string; + displayName: string; + projectUrl: string; + /** If set, enables version tracking via GitHub tags */ + githubOwner?: string; + githubRepo?: string; + /** Extract a version string from a GitHub tag name, or null if not a valid tag */ + parseVersion?: (tag: string) => string | null; +}; + +/** Registry of known PDS software implementations */ +export const PDS_SOFTWARE_REGISTRY: PdsSoftwareConfig[] = [ + { + id: "bluesky-pds", + displayName: "Bluesky PDS", + projectUrl: "https://github.com/bluesky-social/pds", + githubOwner: "bluesky-social", + githubRepo: "pds", + parseVersion: (tag) => { + const m = tag.match(/^v(\d+\.\d+\.\d+)$/); + return m ? m[1] : null; + }, + }, + { + id: "millipds", + displayName: "millipds", + projectUrl: "https://github.com/DavidBuchanan314/millipds", + githubOwner: "DavidBuchanan314", + githubRepo: "millipds", + parseVersion: (tag) => { + const m = tag.match(/^v(\d+\.\d+\.\d+)$/); + return m ? m[1] : null; + }, + }, + { + id: "cirrus", + displayName: "Cirrus", + projectUrl: "https://github.com/ascorbic/cirrus", + githubOwner: "ascorbic", + githubRepo: "cirrus", + parseVersion: (tag) => { + const m = tag.match(/^@getcirrus\/pds@(\d+\.\d+\.\d+)$/); + return m ? m[1] : null; + }, + }, + { + id: "cocoon", + displayName: "Cocoon", + projectUrl: "https://github.com/haileyok/cocoon", + githubOwner: "haileyok", + githubRepo: "cocoon", + parseVersion: (tag) => { + const m = tag.match(/^v(\d+\.\d+\.\d+)$/); + return m ? m[1] : null; + }, + }, + // Link-only software (no version tracking) + { + id: "tranquil", + displayName: "Tranquil", + projectUrl: "https://tangled.org/tranquil.farm/tranquil-pds", + }, + { + id: "hexpds", + displayName: "hexpds", + projectUrl: "https://github.com/ovnanova/hexpds", + }, + { + id: "bluepds", + displayName: "bluepds", + projectUrl: "https://github.com/DrChat/bluepds", + }, + { + id: "rsky", + displayName: "rsky", + projectUrl: "https://github.com/blacksky-algorithms/rsky", + }, + { + id: "picopds", + displayName: "picopds", + projectUrl: "https://github.com/DavidBuchanan314/picopds", + }, +]; + +/** Look up a software config by its ID */ +export function getSoftwareConfig( + id: string, +): PdsSoftwareConfig | undefined { + return PDS_SOFTWARE_REGISTRY.find((s) => s.id === id); +} + +/** Return only software entries that have GitHub version tracking */ +export function getTrackedSoftware(): PdsSoftwareConfig[] { + return PDS_SOFTWARE_REGISTRY.filter( + (s) => s.githubOwner && s.githubRepo && s.parseVersion, + ); +} + /** Country code to flag emoji */ export function countryFlag(code: string): string { return [...code.toUpperCase()] diff --git a/shared/constants_test.ts b/shared/constants_test.ts index d848c40..d8f43a8 100644 --- a/shared/constants_test.ts +++ b/shared/constants_test.ts @@ -1,5 +1,10 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { countryFlag } from "./constants.ts"; +import { + countryFlag, + getSoftwareConfig, + getTrackedSoftware, + PDS_SOFTWARE_REGISTRY, +} from "./constants.ts"; Deno.test("countryFlag returns correct emoji for country codes", () => { assertEquals(countryFlag("US"), "\u{1F1FA}\u{1F1F8}"); @@ -10,3 +15,103 @@ Deno.test("countryFlag returns correct emoji for country codes", () => { Deno.test("countryFlag handles lowercase input", () => { assertEquals(countryFlag("us"), countryFlag("US")); }); + +// --- PDS Software Registry tests --- + +Deno.test("getSoftwareConfig returns config for known IDs", () => { + const bsky = getSoftwareConfig("bluesky-pds"); + assertEquals(bsky?.displayName, "Bluesky PDS"); + assertEquals(bsky?.githubOwner, "bluesky-social"); + + const milli = getSoftwareConfig("millipds"); + assertEquals(milli?.displayName, "millipds"); + + const tranquil = getSoftwareConfig("tranquil"); + assertEquals(tranquil?.displayName, "Tranquil"); +}); + +Deno.test("getSoftwareConfig returns undefined for unknown ID", () => { + assertEquals(getSoftwareConfig("nonexistent"), undefined); +}); + +Deno.test("getTrackedSoftware returns only entries with GitHub info", () => { + const tracked = getTrackedSoftware(); + for (const sw of tracked) { + assertEquals(typeof sw.githubOwner, "string"); + assertEquals(typeof sw.githubRepo, "string"); + assertEquals(typeof sw.parseVersion, "function"); + } + // Link-only entries should not be included + const ids = tracked.map((s) => s.id); + assertEquals(ids.includes("tranquil"), false); + assertEquals(ids.includes("hexpds"), false); + assertEquals(ids.includes("picopds"), false); + // Tracked entries should be included + assertEquals(ids.includes("bluesky-pds"), true); + assertEquals(ids.includes("millipds"), true); + assertEquals(ids.includes("cirrus"), true); + assertEquals(ids.includes("cocoon"), true); +}); + +// --- parseVersion tests --- + +Deno.test("bluesky-pds parseVersion handles valid tags", () => { + const sw = getSoftwareConfig("bluesky-pds")!; + assertEquals(sw.parseVersion!("v0.4.74"), "0.4.74"); + assertEquals(sw.parseVersion!("v1.0.0"), "1.0.0"); +}); + +Deno.test("bluesky-pds parseVersion rejects invalid tags", () => { + const sw = getSoftwareConfig("bluesky-pds")!; + assertEquals(sw.parseVersion!("0.4.74"), null); + assertEquals(sw.parseVersion!("v0.4"), null); + assertEquals(sw.parseVersion!("release-1.0"), null); + assertEquals(sw.parseVersion!("@getcirrus/pds@0.10.4"), null); +}); + +Deno.test("millipds parseVersion handles valid tags", () => { + const sw = getSoftwareConfig("millipds")!; + assertEquals(sw.parseVersion!("v0.0.5"), "0.0.5"); + assertEquals(sw.parseVersion!("v1.2.3"), "1.2.3"); +}); + +Deno.test("millipds parseVersion rejects invalid tags", () => { + const sw = getSoftwareConfig("millipds")!; + assertEquals(sw.parseVersion!("0.0.5"), null); + assertEquals(sw.parseVersion!("release-0.0.5"), null); +}); + +Deno.test("cirrus parseVersion handles scoped tags", () => { + const sw = getSoftwareConfig("cirrus")!; + assertEquals(sw.parseVersion!("@getcirrus/pds@0.10.4"), "0.10.4"); + assertEquals(sw.parseVersion!("@getcirrus/pds@1.0.0"), "1.0.0"); +}); + +Deno.test("cirrus parseVersion rejects non-matching tags", () => { + const sw = getSoftwareConfig("cirrus")!; + assertEquals(sw.parseVersion!("v0.10.4"), null); + assertEquals(sw.parseVersion!("0.10.4"), null); + assertEquals(sw.parseVersion!("@getcirrus/other@0.10.4"), null); +}); + +Deno.test("cocoon parseVersion handles valid tags", () => { + const sw = getSoftwareConfig("cocoon")!; + assertEquals(sw.parseVersion!("v0.8.5"), "0.8.5"); + assertEquals(sw.parseVersion!("v2.0.0"), "2.0.0"); +}); + +Deno.test("cocoon parseVersion rejects invalid tags", () => { + const sw = getSoftwareConfig("cocoon")!; + assertEquals(sw.parseVersion!("0.8.5"), null); + assertEquals(sw.parseVersion!("cocoon-v0.8.5"), null); +}); + +Deno.test("all registry entries have required fields", () => { + for (const sw of PDS_SOFTWARE_REGISTRY) { + assertEquals(typeof sw.id, "string"); + assertEquals(sw.id.length > 0, true); + assertEquals(typeof sw.displayName, "string"); + assertEquals(typeof sw.projectUrl, "string"); + assertEquals(sw.projectUrl.startsWith("http"), true); + } +}); diff --git a/shared/types.ts b/shared/types.ts index 615730e..92e127c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -5,6 +5,7 @@ export type PdsServer = { last_seen: string; last_enriched: string | null; version: string | null; + pds_software: string | null; did: string | null; invite_code_required: boolean; phone_verification: boolean; @@ -20,6 +21,12 @@ export type PdsServer = { error_at: number | null; }; +/** Maps software ID to its latest version string */ +export type LatestVersionMap = Record; + +/** Maps a version string to the software ID that uses it */ +export type VersionSoftwareMap = Record; + /** Raw PDS entry from mary-ext/atproto-scraping state.json */ export type StatePdsEntry = { inviteCodeRequired: boolean;