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}64656667686970export async function reapplyHidden(target: SyncTarget, rebuild: boolean): Promise<void> {71 const repos = await listRepos(target.bucket);72 await syncAll(target);73 for (const repo of repos) {74 const mirror = openMirror(target.mirrorDir, repo);75 const head = mirrorState(mirror, "head");76 if (rebuild) clearMirror(mirror);77 else if (head) {78 land(mirror, {79 head,80 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",81 saves: [],82 objects: new Map(),83 events: [],84 releases: [],85 quarantined: [],86 hidden: hiddenPaths(target.kepr, repo),87 });88 }89 }90 if (rebuild) await syncAll(target);91}9293export function startSyncLoop(target: SyncTarget): void {94 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));95 tick();96 setInterval(tick, INTERVAL_MS);97}9899async function syncEach(target: SyncTarget): Promise<void> {100 for (const repo of await listRepos(target.bucket)) {101 registerRepo(target.kepr, repo);102 let error: string | null = null;103 if (RESERVED_NAMES.has(repo)) {104 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;105 } else {106 try {107 const mirror = openMirror(target.mirrorDir, repo);108 const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));109 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);110 } catch (cause) {111 error = cause instanceof Error ? cause.message : String(cause);112 }113 }114 recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);115 }116}117118119120121122123124125126export async function syncRepo(127 bucket: Bucket,128 mirror: Database,129 repo: string,130 hidden: string[] = [],131 landingBytes = LANDING_BYTES,132): Promise<EventRecord[]> {133 await checkFormat(bucket, repo);134 const head = await readHead(bucket, repo);135 if (head === null) return [];136 const unknown = new Set<string>();137 for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {138 if (unknown.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);139 unknown.add(cursor);140 cursor = (await readSave(bucket, repo, cursor)).parent;141 }142 143 144 145 146 let saves: SaveRecord[] = [];147 let objects = new Map<string, StoredObject>();148 let quarantined: string[] = [];149 let fetched = 0;150 const oversized = new Set<string>();151 for (const id of [...unknown].reverse()) {152 const save = await readSave(bucket, repo, id);153 quarantined.push(...save.quarantined);154 for (const file of save.files) {155 if (objects.has(file.hash) || oversized.has(file.hash) || knownObject(mirror, file.hash)) {156 continue;157 }158 try {159 const { compressed, bytes } = await readObject(bucket, repo, file.hash);160 objects.set(file.hash, { compressed, size: bytes.length });161 fetched += compressed.length;162 } catch (cause) {163 if (!(cause instanceof OversizedObject)) throw cause;164 oversized.add(file.hash);165 }166 }167 quarantined.push(168 ...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),169 );170 save.files = save.files.filter((file) => !oversized.has(file.hash));171 saves.push(save);172 if ((fetched >= landingBytes || saves.length >= LANDING_SAVES) && save.id !== head) {173 land(mirror, {174 head: save.id,175 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",176 saves,177 objects,178 events: [],179 releases: [],180 quarantined,181 hidden,182 });183 saves = [];184 objects = new Map();185 quarantined = [];186 fetched = 0;187 }188 }189 const known = knownEventIds(mirror);190 const events: EventRecord[] = [];191 for (const id of await listEventIds(bucket, repo)) {192 if (known.has(id)) continue;193 const event = await readEvent(bucket, repo, id);194 195 if (event.kind !== "strip" || isSafePath(event.path)) events.push(event);196 }197 const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";198 const moved =199 mirrorState(mirror, "head") !== head ||200 mirrorState(mirror, "releases_latest") !== releasesLatest;201 const releases = moved ? await newReleases(bucket, mirror, repo) : [];202 if (!moved && events.length === 0) return [];203 land(mirror, {204 head,205 releasesLatest,206 saves,207 objects,208 events,209 releases,210 quarantined,211 hidden,212 });213 return events;214}215216async function newReleases(217 bucket: Bucket,218 mirror: Database,219 repo: string,220): Promise<ReleaseRecord[]> {221 const known = knownReleases(mirror);222 const releases: ReleaseRecord[] = [];223 for (const version of await listReleaseVersions(bucket, repo)) {224 if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));225 }226 return releases;227}