import { ModuleFilePath, PatchId } from "@valbuild/core"; import { z } from "zod"; import type { AuthorId, BaseSha } from "./ValOps.js"; import { PatchLogEntry, PatchLogProblem } from "./patchLog.js"; /** * The on-disk shape of the local-dev patch store. * * ``` * .val/patches/ * patches.log the order, and the only place it lives * patches.repair.log what repair has done, for the person who has to know * /patch.json one plain, self-contained directory per patch * /base.json written when the patch is published * /files/… binary payloads for this patch's file ops * ``` * * A directory is named after the patch it holds, and a record references nothing * outside itself. That is the whole design, and it is a direct answer to how the * old layout failed: there, a directory was named after a record's PARENT, so * reading the store meant following links, and one absent record silently cut off * every patch written after it. * * The invariant this module exists to hold up is narrow and worth stating: **the * announced set and the delivered set are the same array.** `getStat` and * `fetchPatches` both come out of one `readPatchStore` call, so they cannot report * different numbers - which is exactly what they did when this broke, announcing * 410 patches and delivering 359 with no error in between. */ export declare const PATCH_REPAIR_LOG_FILE_NAME = "patches.repair.log"; export declare const FSPatch: z.ZodObject<{ path: z.ZodString & z.ZodType>; patch: z.ZodArray; path: z.ZodArray; value: z.ZodType>; }, z.core.$strict>, z.ZodObject<{ op: z.ZodLiteral<"remove">; path: z.ZodTuple<[z.ZodString], z.ZodString>; }, z.core.$strict>, z.ZodObject<{ op: z.ZodLiteral<"replace">; path: z.ZodArray; value: z.ZodType>; }, z.core.$strict>, z.ZodObject<{ op: z.ZodLiteral<"move">; from: z.ZodTuple<[z.ZodString], z.ZodString>; path: z.ZodArray; }, z.core.$strict>, z.ZodObject<{ op: z.ZodLiteral<"copy">; from: z.ZodArray; path: z.ZodArray; }, z.core.$strict>, z.ZodObject<{ op: z.ZodLiteral<"test">; path: z.ZodArray; value: z.ZodType>; }, z.core.$strict>, z.ZodObject<{ op: z.ZodLiteral<"file">; path: z.ZodArray; filePath: z.ZodString; value: z.ZodType>; remote: z.ZodBoolean; nestedFilePath: z.ZodOptional>; metadata: z.ZodOptional>>; }, z.core.$strict>], "op">>; patchId: z.ZodString; baseSha: z.ZodString & z.ZodType>; authorId: z.ZodNullable>>; createdAt: z.ZodString; coreVersion: z.ZodNullable; sessionId: z.ZodNullable; }, z.core.$strip>; export type FSPatchRecord = z.infer; export declare const FSPatchBase: z.ZodObject<{ baseSha: z.ZodString & z.ZodType>; timestamp: z.ZodString; }, z.core.$strip>; export type FSPatchBaseRecord = z.infer; export type PatchStoreEntry = { patchId: PatchId; record: FSPatchRecord; /** Present once the patch has been published. */ base: FSPatchBaseRecord | null; }; /** * Something wrong with the store that a reader can see. * * Every one of these used to be invisible. Reporting them is the point: the * failure that motivated this rewrite was not that the store broke, it was that * breaking looked exactly like working. */ export type PatchStoreProblem = { type: "log"; problem: PatchLogProblem; } /** * A directory that cannot be used as a patch: no record, a record that does * not parse, or one whose `patchId` is not the directory it sits in. * * That last case is what a store from before this layout looks like - its * directories are named after each record's PARENT - and it is deliberately * not special-cased. There is nothing to recover: the order lived in links * that are exactly what goes wrong, so an old store is read as a pile of * unusable directories and removed like any other. * * This is the problem the person editing is told about, because it is the one * where unpublished work disappears. */ | { type: "unreadable-patch"; /** The directory name, which for a usable patch IS the patch id. */ name: string; dir: string; message: string; } /** * A perfectly good record the log does not name. * * The benign half of a crash: a record is written before its log line, so an * interrupted append leaves this behind. Nothing ever read it, so nothing is * lost by sweeping it up, and the person editing does not need to hear about * a patch that never existed as far as they were concerned. */ | { type: "orphan-directory"; name: string; dir: string; } /** * The log was gone, and the order was recovered from the records' timestamps. * * Reported because it is a guess. Patches written inside the same millisecond * have no recoverable order - a real store had eight inside 20ms. */ | { type: "reconstructed-log"; entryCount: number; }; export type ReadPatchStoreResult = { status: "ok"; entries: PatchStoreEntry[]; problems: PatchStoreProblem[]; } | { status: "unreadable"; message: string; }; export declare function patchesLogFile(patchesDir: string): string; export declare function patchRepairLogFile(patchesDir: string): string; export declare function patchDir(patchesDir: string, patchId: PatchId): string; export declare function patchRecordFile(patchesDir: string, patchId: PatchId): string; export declare function patchBaseFile(patchesDir: string, patchId: PatchId): string; /** * Where a patch's uploaded bytes wait for the record that will reference them. * * ## Why they cannot simply be written into the patch directory * * A patch that carries a file is written in TWO requests, and the bytes go * first: the record's `file` op holds only a sha, so a record written before its * bytes would point at nothing. Uploading straight into `/files/` left * the directory holding files and no `patch.json` for the length of a round * trip — which is neither of the two shapes this store allows, so * {@link readPatchStore} read it as a patch whose contents were lost and repair * removed it, bytes and all. * * And that window is not passive: writing into the patches directory is exactly * what ends `getStat`'s long poll, so the upload summoned the read that * destroyed it. Replacing an image worked only when the two requests happened to * land close enough together. * * So the bytes are not in the store until they belong to something. * {@link appendPatch} moves them in after writing the record, under the lock, so * no reader ever sees a half-built patch directory — and the invariant that a * directory either holds a usable record or is named by the log holds again, * with nothing to tolerate and no ambiguous state to classify. * * A SIBLING of the patches directory, for two reasons: nothing that reads the * store lists it, and it is on the same filesystem, so moving into place is a * rename rather than a copy. */ export declare function uploadsDir(patchesDir: string): string; /** Where one patch's uploads wait. See {@link uploadsDir}. */ export declare function patchUploadDir(patchesDir: string, patchId: PatchId): string; export declare function patchBinaryFile(patchesDir: string, patchId: PatchId, filePath: string): string; export declare function patchBinaryFileMetadata(patchesDir: string, patchId: PatchId, filePath: string): string; /** The staged twin of {@link patchBinaryFile}. */ export declare function stagedPatchBinaryFile(patchesDir: string, patchId: PatchId, filePath: string): string; /** The staged twin of {@link patchBinaryFileMetadata}. */ export declare function stagedPatchBinaryFileMetadata(patchesDir: string, patchId: PatchId, filePath: string): string; /** Drop a patch's staging directory, whatever is left of it. */ export declare function removeStagedUploads(patchesDir: string, patchId: PatchId): void; /** * Drop staged uploads whose patch never arrived. * * A client that dies between the upload and the `PUT` leaves its bytes here. * Nothing references them — no record points at them and the log never named * them — so they are removed without a word. */ export declare function sweepStaleUploads(patchesDir: string, now?: number): void; /** * Read the whole store: the order, the records, and everything wrong with it. * * One call, one answer. Callers that need only the ids and callers that need the * ops both use this, which is what stops them disagreeing. */ export declare function readPatchStore(patchesDir: string): ReadPatchStoreResult; /** Write a patch record so that a reader sees all of it or none of it. */ export declare function writePatchRecord(patchesDir: string, patchId: PatchId, record: FSPatchRecord): void; /** * Add a patch to the store. * * Record first, then the log line, and the order is the safety property: an * interrupted append leaves a directory nothing points at, which repair sweeps * up. The reverse order would leave the log naming a patch that is not there - * the exact state this whole rewrite exists to make unreachable. * * Callers must hold the patch lock. */ export declare function appendPatch(patchesDir: string, record: FSPatchRecord): PatchLogEntry; export type RepairAction = { type: "removed-unreadable-patch"; name: string; because: string; } | { type: "removed-orphan-directory"; name: string; } | { type: "rewrote-log"; entryCount: number; }; /** * Bring the store back to a state where the log and the directories agree. * * Safe in a way the old layout's repair could never be: the log is a flat list, * so dropping an entry does not orphan the entries after it. There is nothing to * re-link, which is why this can run unattended where re-parenting a chain could * not. * * A patch that cannot be read is removed rather than kept around to fail again * on every load. That does discard unpublished work, which is why * {@link RepairAction} carries the reason, why it is written to * `patches.repair.log`, and why the caller is expected to tell the person * editing. * * Callers must hold the patch lock. */ export declare function repairPatchStore(patchesDir: string, read: Extract): RepairAction[]; /** * Last resort: move the whole store aside and start empty. * * A rename, never a delete. What is being given up on here is someone's * unpublished work, and the least this can do is say where it went. * * Callers must hold the patch lock. */ export declare function resetPatchStore(patchesDir: string, reason: string): { movedTo: string; } | { error: string; }; /** One line per problem, for a log line or an API error a person has to act on. */ export declare function describePatchStoreProblems(problems: readonly PatchStoreProblem[]): string[];