Files
rea/src/application/RevisionedWorkspaceStore.ts
T

191 lines
6.4 KiB
TypeScript

import {
createStoreFileLock,
removeStaleStoreFileLock,
type StoreFileLock,
} from "./StoreFileLock.js";
import { lstat, readFile, realpath } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import { isPathWithinRoot } from "../domain/localPath.js";
import writeFileAtomic from "write-file-atomic";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
import { WorkspaceStorageError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
type WorkspaceResult<Value> = Result<Value, WorkspaceStorageError>;
/** Parser, serializer, and CAS transition owned by one revisioned workspace. */
export interface RevisionedWorkspaceCodec<Document> {
readonly parse: (input: unknown) => Document;
readonly serialize: (document: Document) => string;
readonly validateNext: (
current: Document | null,
next: Document,
expectedRevision: number | null,
) => WorkspaceResult<null>;
}
/** Read one root-confined, owner-only revisioned workspace document. */
export const readRevisionedWorkspace = async <Document>(
path: string,
policy: EvidenceFilePolicy,
codec: RevisionedWorkspaceCodec<Document>,
): Promise<WorkspaceResult<Document | null>> => {
if (policy.roots.length === 0)
return err(new WorkspaceStorageError("read", "disabled"));
try {
const destination = await resolveDestination(path, policy.roots);
if (destination === null)
return err(new WorkspaceStorageError("read", "outside-root"));
return await readWorkspaceFile(destination, policy, codec);
} catch (cause: unknown) {
return err(new WorkspaceStorageError("read", "io", { cause }));
}
};
export interface RevisionedWorkspaceWriteInput<Document> {
readonly document: Document;
readonly expectedRevision: number | null;
readonly codec: RevisionedWorkspaceCodec<Document>;
}
/** Atomically append one validated CAS-linked revision under an exclusive lock. */
export const writeRevisionedWorkspace = async <Document>(
path: string,
policy: EvidenceFilePolicy,
input: RevisionedWorkspaceWriteInput<Document>,
): Promise<
WorkspaceResult<{ readonly path: string; readonly bytes: number }>
> => {
const { document, expectedRevision, codec } = input;
if (policy.roots.length === 0)
return err(new WorkspaceStorageError("update", "disabled"));
let encoded: string;
try {
encoded = codec.serialize(document);
} catch (cause: unknown) {
return err(new WorkspaceStorageError("update", "integrity", { cause }));
}
const bytes = Buffer.byteLength(encoded, "utf8");
if (bytes > policy.maxBytes)
return err(new WorkspaceStorageError("update", "too-large"));
let lock: StoreFileLock | undefined;
try {
const destination = await resolveDestination(path, policy.roots);
if (destination === null)
return err(new WorkspaceStorageError("update", "outside-root"));
const acquired = await acquireLock(destination);
if (!acquired.ok) return acquired;
lock = acquired.value;
const current = await readWorkspaceFile(destination, policy, codec);
if (!current.ok) return current;
const checked = codec.validateNext(
current.value,
document,
expectedRevision,
);
if (!checked.ok) return checked;
await writeFileAtomic(destination, encoded, {
encoding: "utf8",
mode: 0o600,
fsync: true,
});
return ok({ path: resolve(path), bytes });
} catch (cause: unknown) {
return err(new WorkspaceStorageError("update", "io", { cause }));
} finally {
if (lock !== undefined) await lock.release();
}
};
const resolveDestination = async (
path: string,
roots: readonly string[],
): Promise<string | null> => {
const requested = resolve(path);
const canonicalParent = await realpath(dirname(requested));
const destination = resolve(canonicalParent, basename(requested));
for (const root of await canonicalizeConfiguredRoots(
roots.map((configuredRoot) => resolve(configuredRoot)),
)) {
if (isPathWithinRoot(root, destination)) return destination;
}
return null;
};
const readWorkspaceFile = async <Document>(
destination: string,
policy: EvidenceFilePolicy,
codec: RevisionedWorkspaceCodec<Document>,
): Promise<WorkspaceResult<Document | null>> => {
const stats = await lstat(destination).catch((cause: unknown) => {
if (isFileNotFound(cause)) return undefined;
throw cause;
});
if (stats === undefined) return ok(null);
if (!stats.isFile() || stats.isSymbolicLink())
return err(new WorkspaceStorageError("read", "not-file"));
if (stats.size > policy.maxBytes)
return err(new WorkspaceStorageError("read", "too-large"));
const encoded = await readFile(destination);
if (encoded.byteLength > policy.maxBytes)
return err(new WorkspaceStorageError("read", "too-large"));
let decoded: unknown;
try {
decoded = JSON.parse(encoded.toString("utf8"));
} catch (cause: unknown) {
return err(new WorkspaceStorageError("read", "invalid-json", { cause }));
}
if (!isJsonWithinLimits(decoded, policy))
return err(new WorkspaceStorageError("read", "too-large"));
try {
return ok(codec.parse(decoded));
} catch (cause: unknown) {
return err(new WorkspaceStorageError("read", "integrity", { cause }));
}
};
const acquireLock = async (
destination: string,
): Promise<WorkspaceResult<StoreFileLock>> => {
const path = `${destination}.lock`;
try {
return ok(await createStoreFileLock(path));
} catch (cause: unknown) {
if (isAlreadyExists(cause) && (await removeStaleStoreFileLock(path))) {
try {
return ok(await createStoreFileLock(path));
} catch (retryCause: unknown) {
return err(
new WorkspaceStorageError(
"update",
isAlreadyExists(retryCause) ? "locked" : "io",
{ cause: retryCause },
),
);
}
}
return err(
new WorkspaceStorageError(
"update",
isAlreadyExists(cause) ? "locked" : "io",
{ cause },
),
);
}
};
const isFileNotFound = (cause: unknown): boolean =>
errorCode(cause) === "ENOENT";
const isAlreadyExists = (cause: unknown): boolean =>
errorCode(cause) === "EEXIST";
const errorCode = (cause: unknown): unknown =>
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;