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 return running ?? holdSync(() => syncEach(target));56}575859606162export function holdSync(work: () => Promise<void>): Promise<void> {63 64 const mine = (running ?? Promise.resolve()).then(work).finally(() => {65 if (running === slot) running = null;66 });67 const slot = mine.catch(() => {});68 running = slot;69 return mine;70}717273export async function rebuildRepo(target: SyncTarget, repo: string): Promise<void> {74 await syncAll(target);75 clearMirror(openMirror(target.mirrorDir, repo));76 await syncAll(target);77}78798081828384export async function reapplyHidden(target: SyncTarget, rebuild: boolean): Promise<void> {85 const repos = await listRepos(target.bucket);86 await syncAll(target);87 for (const repo of repos) {88 const mirror = openMirror(target.mirrorDir, repo);89 const head = mirrorState(mirror, "head");90 if (rebuild) clearMirror(mirror);91 else if (head) {92 land(mirror, {93 head,94 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",95 saves: [],96 objects: new Map(),97 events: [],98 releases: [],99 quarantined: [],100 hidden: hiddenPaths(target.kepr, repo),101 });102 }103 }104 if (rebuild) await syncAll(target);105}106107export function startSyncLoop(target: SyncTarget): void {108 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));109 tick();110 setInterval(tick, INTERVAL_MS);111}112113async function syncEach(target: SyncTarget): Promise<void> {114 for (const repo of await listRepos(target.bucket)) {115 registerRepo(target.kepr, repo);116 let error: string | null = null;117 if (RESERVED_NAMES.has(repo)) {118 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;119 } else {120 try {121 const mirror = openMirror(target.mirrorDir, repo);122 const events = await syncRepo(target.bucket, mirror, repo, hiddenPaths(target.kepr, repo));123 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);124 } catch (cause) {125 error = cause instanceof Error ? cause.message : String(cause);126 }127 }128 recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);129 if (error === null) target.synced?.(repo);130 }131}132133134135136137138139140141export async function syncRepo(142 bucket: Bucket,143 mirror: Database,144 repo: string,145 hidden: string[] = [],146 landingBytes = LANDING_BYTES,147): Promise<EventRecord[]> {148 await checkFormat(bucket, repo);149 const head = await readHead(bucket, repo);150 if (head === null) return [];151 const unknown = new Set<string>();152 for (let cursor = head; cursor !== "" && !knownSave(mirror, cursor); ) {153 if (unknown.has(cursor)) throw new BucketError(`${repo}: save ${cursor} is its own ancestor`);154 unknown.add(cursor);155 cursor = (await readSave(bucket, repo, cursor)).parent;156 }157 158 159 160 161 let saves: SaveRecord[] = [];162 let objects = new Map<string, StoredObject>();163 let quarantined: string[] = [];164 let fetched = 0;165 const oversized = new Set<string>();166 const landBatch = (batchHead: string) => {167 land(mirror, {168 head: batchHead,169 releasesLatest: mirrorState(mirror, "releases_latest") ?? "",170 saves,171 objects,172 events: [],173 releases: [],174 quarantined,175 hidden,176 });177 saves = [];178 objects = new Map();179 quarantined = [];180 fetched = 0;181 };182 for (const id of [...unknown].reverse()) {183 const save = await readSave(bucket, repo, id);184 quarantined.push(...save.quarantined);185 186 187 const ahead = new Map<string, StoredObject>();188 for (const file of save.files) {189 const { hash } = file;190 if (191 objects.has(hash) ||192 ahead.has(hash) ||193 oversized.has(hash) ||194 knownObject(mirror, hash)195 ) {196 continue;197 }198 try {199 const { compressed, bytes } = await readObject(bucket, repo, hash);200 ahead.set(hash, { compressed, size: bytes.length });201 fetched += compressed.length;202 } catch (cause) {203 if (!(cause instanceof OversizedObject)) throw cause;204 oversized.add(hash);205 }206 if (fetched >= landingBytes) {207 const last = saves.at(-1);208 if (last) landBatch(last.id);209 storeObjects(mirror, ahead);210 ahead.clear();211 fetched = 0;212 }213 }214 for (const [hash, object] of ahead) objects.set(hash, object);215 quarantined.push(216 ...save.files.filter((f) => oversized.has(f.hash)).map((f) => escapePath(f.path)),217 );218 save.files = save.files.filter((file) => !oversized.has(file.hash));219 saves.push(save);220 if (saves.length >= LANDING_SAVES && save.id !== head) landBatch(save.id);221 }222 const known = knownEventIds(mirror);223 const events: EventRecord[] = [];224 for (const id of await listEventIds(bucket, repo)) {225 if (known.has(id)) continue;226 const event = await readEvent(bucket, repo, id);227 228 if (event.kind !== "strip" || isSafePath(event.path)) events.push(event);229 }230 const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";231 const moved =232 mirrorState(mirror, "head") !== head ||233 mirrorState(mirror, "releases_latest") !== releasesLatest;234 const releases = moved ? await newReleases(bucket, mirror, repo) : [];235 if (!moved && events.length === 0) return [];236 land(mirror, {237 head,238 releasesLatest,239 saves,240 objects,241 events,242 releases,243 quarantined,244 hidden,245 });246 return events;247}248249async function newReleases(250 bucket: Bucket,251 mirror: Database,252 repo: string,253): Promise<ReleaseRecord[]> {254 const known = knownReleases(mirror);255 const releases: ReleaseRecord[] = [];256 for (const version of await listReleaseVersions(bucket, repo)) {257 if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));258 }259 return releases;260}