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 storeObjects,13} from "../mirror/store.ts";14import { hiddenPaths, recordSync, registerRepo } from "../server/db.ts";15import { RESERVED_NAMES } from "../server/reserved.ts";16import { dropTarballs } from "../tarball/cache.ts";17import type { Bucket } from "./reader.ts";18import {19 BucketError,20 checkFormat,21 type EventRecord,22 escapePath,23 isSafePath,24 listEventIds,25 listReleaseVersions,26 listRepos,27 OversizedObject,28 type ReleaseRecord,29 readEvent,30 readHead,31 readObject,32 readRelease,33 readReleasesLatest,34 readSave,35 type SaveRecord,36} from "./repo.ts";3738const INTERVAL_MS = 2 * 60 * 1000;3940const LANDING_BYTES = 32 * 1024 * 1024;41const LANDING_SAVES = 100;4243export type SyncTarget = {44 bucket: Bucket;45 kepr: Database;46 mirrorDir: string;47 48 synced?: (repo: string) => void;49};5051let running: Promise<void> | null = null;525354export function syncAll(target: SyncTarget): Promise<void> {55 running ??= syncEach(target).finally(() => {56 running = null;57 });58 return running;59}606162export async function rebuildRepo(target: SyncTarget, repo: string): Promise<void> {63 await syncAll(target);64 clearMirror(openMirror(target.mirrorDir, repo));65 await syncAll(target);66}67686970717273export async function reapplyHidden(target: SyncTarget, rebuild: boolean): Promise<void> {74 const repos = await listRepos(target.bucket);75 await syncAll(target);76 for (const repo of repos) {77 const mirror = openMirror(target.mirrorDir, repo);78 const head = mirrorState(mirror, "head");79 if (rebuild) clearMirror(mirror);80 else if (head) {81 land(mirror, {82 head,83 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",84 saves: [],85 objects: new Map(),86 events: [],87 releases: [],88 quarantined: [],89 hidden: hiddenPaths(target.kepr, repo),90 });91 }92 }93 if (rebuild) await syncAll(target);94}9596export function startSyncLoop(target: SyncTarget): void {97 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));98 tick();99 setInterval(tick, INTERVAL_MS);100}101102async function syncEach(target: SyncTarget): Promise<void> {103 for (const repo of await listRepos(target.bucket)) {104 registerRepo(target.kepr, repo);105 let error: string | null = null;106 if (RESERVED_NAMES.has(repo)) {107 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;108 } else {109 try {110 const mirror = openMirror(target.mirrorDir, repo);111 const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));112 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);113 } catch (cause) {114 error = cause instanceof Error ? cause.message : String(cause);115 }116 }117 recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);118 if (error === null) target.synced?.(repo);119 }120}121122123124125126127128129130export async function syncRepo(131 bucket: Bucket,132 mirror: Database,133 repo: string,134 hidden: string[] = [],135 landingBytes = LANDING_BYTES,136): Promise<EventRecord[]> {137 await checkFormat(bucket, repo);138 const head = await readHead(bucket, repo);139 if (head === null) return [];140 const unknown = new Set<string>();141 for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {142 if (unknown.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);143 unknown.add(cursor);144 cursor = (await readSave(bucket, repo, cursor)).parent;145 }146 147 148 149 150 let saves: SaveRecord[] = [];151 let objects = new Map<string, StoredObject>();152 let quarantined: string[] = [];153 let fetched = 0;154 const oversized = new Set<string>();155 const landBatch = (batchHead: string) => {156 land(mirror, {157 head: batchHead,158 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",159 saves,160 objects,161 events: [],162 releases: [],163 quarantined,164 hidden,165 });166 saves = [];167 objects = new Map();168 quarantined = [];169 fetched = 0;170 };171 for (const id of [...unknown].reverse()) {172 const save = await readSave(bucket, repo, id);173 quarantined.push(...save.quarantined);174 175 176 const ahead = new Map<string, StoredObject>();177 for (const file of save.files) {178 const { hash } = file;179 if (180 objects.has(hash) ||181 ahead.has(hash) ||182 oversized.has(hash) ||183 knownObject(mirror, hash)184 ) {185 continue;186 }187 try {188 const { compressed, bytes } = await readObject(bucket, repo, hash);189 ahead.set(hash, { compressed, size: bytes.length });190 fetched += compressed.length;191 } catch (cause) {192 if (!(cause instanceof OversizedObject)) throw cause;193 oversized.add(hash);194 }195 if (fetched >= landingBytes) {196 const last = saves.at(-1);197 if (last) landBatch(last.id);198 storeObjects(mirror, ahead);199 ahead.clear();200 fetched = 0;201 }202 }203 for (const [hash, object] of ahead) objects.set(hash, object);204 quarantined.push(205 ...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),206 );207 save.files = save.files.filter((file) => !oversized.has(file.hash));208 saves.push(save);209 if (saves.length >= LANDING_SAVES && save.id !== head) landBatch(save.id);210 }211 const known = knownEventIds(mirror);212 const events: EventRecord[] = [];213 for (const id of await listEventIds(bucket, repo)) {214 if (known.has(id)) continue;215 const event = await readEvent(bucket, repo, id);216 217 if (event.kind !== "strip" || isSafePath(event.path)) events.push(event);218 }219 const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";220 const moved =221 mirrorState(mirror, "head") !== head ||222 mirrorState(mirror, "releases_latest") !== releasesLatest;223 const releases = moved ? await newReleases(bucket, mirror, repo) : [];224 if (!moved && events.length === 0) return [];225 land(mirror, {226 head,227 releasesLatest,228 saves,229 objects,230 events,231 releases,232 quarantined,233 hidden,234 });235 return events;236}237238async function newReleases(239 bucket: Bucket,240 mirror: Database,241 repo: string,242): Promise<ReleaseRecord[]> {243 const known = knownReleases(mirror);244 const releases: ReleaseRecord[] = [];245 for (const version of await listReleaseVersions(bucket, repo)) {246 if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));247 }248 return releases;249}