1import type { Database } from "bun:sqlite";2import {3 clearMirror,4 knownEventIds,5 knownObject,6 knownReleases,7 knownSave,8 land,9 mirrorState,10 openMirror,11 type StoredObject,12} from "../mirror/store.ts";13import { hiddenPaths, recordSync, registerRepo } from "../server/db.ts";14import { RESERVED_NAMES } from "../server/reserved.ts";15import { dropTarballs } from "../tarball/cache.ts";16import type { Bucket } from "./reader.ts";17import {18 BucketError,19 checkFormat,20 type EventRecord,21 escapePath,22 isSafePath,23 listEventIds,24 listReleaseVersions,25 listRepos,26 OversizedObject,27 type ReleaseRecord,28 readEvent,29 readHead,30 readObject,31 readRelease,32 readReleasesLatest,33 readSave,34 type SaveRecord,35} from "./repo.ts";3637const INTERVAL_MS = 2 * 60 * 1000;3839const LANDING_BYTES = 32 * 1024 * 1024;40const LANDING_SAVES = 100;4142export type SyncTarget = {43 bucket: Bucket;44 kepr: Database;45 mirrorDir: string;46};4748let running: Promise<void> | null = null;495051export function syncAll(target: SyncTarget): Promise<void> {52 running ??= syncEach(target).finally(() => {53 running = null;54 });55 return running;56}575859export async function rebuildRepo(target: SyncTarget, repo: string): Promise<void> {60 await syncAll(target);61 clearMirror(openMirror(target.mirrorDir, repo));62 await syncAll(target);63}6465export function startSyncLoop(target: SyncTarget): void {66 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));67 tick();68 setInterval(tick, INTERVAL_MS);69}7071async function syncEach(target: SyncTarget): Promise<void> {72 for (const repo of await listRepos(target.bucket)) {73 registerRepo(target.kepr, repo);74 let error: string | null = null;75 if (RESERVED_NAMES.has(repo)) {76 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;77 } else {78 try {79 const mirror = openMirror(target.mirrorDir, repo);80 const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));81 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);82 } catch (cause) {83 error = cause instanceof Error ? cause.message : String(cause);84 }85 }86 recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);87 }88}89909192939495969798export async function syncRepo(99 bucket: Bucket,100 mirror: Database,101 repo: string,102 hidden: string[] = [],103 landingBytes = LANDING_BYTES,104): Promise<EventRecord[]> {105 await checkFormat(bucket, repo);106 const head = await readHead(bucket, repo);107 if (head === null) return [];108 const unknown = new Set<string>();109 for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {110 if (unknown.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);111 unknown.add(cursor);112 cursor = (await readSave(bucket, repo, cursor)).parent;113 }114 115 116 117 118 let saves: SaveRecord[] = [];119 let objects = new Map<string, StoredObject>();120 let quarantined: string[] = [];121 let fetched = 0;122 const oversized = new Set<string>();123 for (const id of [...unknown].reverse()) {124 const save = await readSave(bucket, repo, id);125 quarantined.push(...save.quarantined);126 for (const file of save.files) {127 if (objects.has(file.hash) || oversized.has(file.hash) || knownObject(mirror, file.hash)) {128 continue;129 }130 try {131 const { compressed, bytes } = await readObject(bucket, repo, file.hash);132 objects.set(file.hash, { compressed, size: bytes.length });133 fetched += compressed.length;134 } catch (cause) {135 if (!(cause instanceof OversizedObject)) throw cause;136 oversized.add(file.hash);137 }138 }139 quarantined.push(140 ...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),141 );142 save.files = save.files.filter((file) => !oversized.has(file.hash));143 saves.push(save);144 if ((fetched >= landingBytes || saves.length >= LANDING_SAVES) && save.id !== head) {145 land(mirror, {146 head: save.id,147 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",148 saves,149 objects,150 events: [],151 releases: [],152 quarantined,153 hidden,154 });155 saves = [];156 objects = new Map();157 quarantined = [];158 fetched = 0;159 }160 }161 const known = knownEventIds(mirror);162 const events: EventRecord[] = [];163 for (const id of await listEventIds(bucket, repo)) {164 if (known.has(id)) continue;165 const event = await readEvent(bucket, repo, id);166 167 if (event.kind !== "strip" || isSafePath(event.path)) events.push(event);168 }169 const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";170 const moved =171 mirrorState(mirror, "head") !== head ||172 mirrorState(mirror, "releases_latest") !== releasesLatest;173 const releases = moved ? await newReleases(bucket, mirror, repo) : [];174 if (!moved && events.length === 0) return [];175 land(mirror, {176 head,177 releasesLatest,178 saves,179 objects,180 events,181 releases,182 quarantined,183 hidden,184 });185 return events;186}187188async function newReleases(189 bucket: Bucket,190 mirror: Database,191 repo: string,192): Promise<ReleaseRecord[]> {193 const known = knownReleases(mirror);194 const releases: ReleaseRecord[] = [];195 for (const version of await listReleaseVersions(bucket, repo)) {196 if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));197 }198 return releases;199}