convex
Version:
Client for the Convex Cloud
447 lines (402 loc) • 12.9 kB
text/typescript
import { Dirent, Mode, Stats } from "fs";
import stdFs from "fs";
import path from "path";
export interface Filesystem {
listDir(dirPath: string): Dirent[];
exists(path: string): boolean;
stat(path: string): Stats;
readUtf8File(path: string): string;
access(path: string): void;
writeUtf8File(path: string, contents: string, mode?: Mode): void;
mkdir(path: string, options?: { allowExisting?: boolean }): void;
rm(path: string, options?: { force?: boolean; recursive?: boolean }): void;
unlink(path: string): void;
registerPath(path: string, st: Stats | null): void;
invalidate(): void;
}
// Use `nodeFs` when you just want to read and write to the local filesystem
// and don't care about collecting the paths touched. One-off commands
// should use the singleton `nodeFs`.
class NodeFs implements Filesystem {
listDir(dirPath: string) {
return stdFs.readdirSync(dirPath, { withFileTypes: true });
}
exists(path: string) {
try {
stdFs.statSync(path);
return true;
} catch (e: any) {
if (e.code === "ENOENT") {
return false;
}
throw e;
}
}
stat(path: string) {
return stdFs.statSync(path);
}
readUtf8File(path: string) {
return stdFs.readFileSync(path, { encoding: "utf-8" });
}
access(path: string) {
return stdFs.accessSync(path);
}
writeUtf8File(path: string, contents: string, mode?: Mode) {
const fd = stdFs.openSync(path, "w", mode);
try {
stdFs.writeFileSync(fd, contents, { encoding: "utf-8" });
stdFs.fsyncSync(fd);
} finally {
stdFs.closeSync(fd);
}
}
mkdir(path: string, options?: { allowExisting?: boolean }) {
try {
stdFs.mkdirSync(path);
} catch (e: any) {
if (options?.allowExisting && e.code == "EEXIST") {
return;
}
throw e;
}
}
rm(path: string, options?: { force?: boolean; recursive?: boolean }) {
stdFs.rmSync(path, options);
}
unlink(path: string) {
return stdFs.unlinkSync(path);
}
registerPath(_path: string, _st: Stats | null) {
// The node filesystem doesn't track reads, so we don't need to do anything here.
}
invalidate() {
// We don't track invalidations for the node filesystem either.
}
}
export const nodeFs: Filesystem = new NodeFs();
// Filesystem implementation that records all paths observed. This is useful
// for implementing continuous watch commands that need to manage a filesystem
// watcher and know when a command's inputs were invalidated.
export class RecordingFs implements Filesystem {
// Absolute path -> Set of observed child names
private observedDirectories: Map<string, Set<string>> = new Map();
// Absolute path -> observed stat (or null if observed nonexistent)
private observedFiles: Map<string, Stats | null> = new Map();
// Have we noticed that files have changed while recording?
private invalidated = false;
private traceEvents: boolean;
constructor(traceEvents: boolean) {
this.traceEvents = traceEvents;
}
listDir(dirPath: string): Dirent[] {
const absDirPath = path.resolve(dirPath);
// Register observing the directory itself.
const dirSt = nodeFs.stat(absDirPath);
this.registerNormalized(absDirPath, dirSt);
// List the directory and register observing all of its children.
const entries = nodeFs.listDir(dirPath);
for (const entry of entries) {
const childPath = path.join(absDirPath, entry.name);
const childSt = nodeFs.stat(childPath);
this.registerPath(childPath, childSt);
}
// Register observing the directory's children.
const observedNames = new Set(entries.map(e => e.name));
const existingNames = this.observedDirectories.get(absDirPath);
if (existingNames) {
if (!setsEqual(observedNames, existingNames)) {
this.invalidated = true;
}
}
this.observedDirectories.set(absDirPath, observedNames);
return entries;
}
exists(path: string): boolean {
try {
const st = nodeFs.stat(path);
this.registerPath(path, st);
return true;
} catch (err: any) {
if (err.code === "ENOENT") {
this.registerPath(path, null);
return false;
}
throw err;
}
}
stat(path: string): Stats {
try {
const st = nodeFs.stat(path);
this.registerPath(path, st);
return st;
} catch (err: any) {
if (err.code === "ENOENT") {
this.registerPath(path, null);
}
throw err;
}
}
readUtf8File(path: string): string {
try {
const st = nodeFs.stat(path);
this.registerPath(path, st);
return nodeFs.readUtf8File(path);
} catch (err: any) {
if (err.code === "ENOENT") {
this.registerPath(path, null);
}
throw err;
}
}
access(path: string) {
try {
const st = nodeFs.stat(path);
this.registerPath(path, st);
return nodeFs.access(path);
} catch (err: any) {
if (err.code === "ENOENT") {
this.registerPath(path, null);
}
throw err;
}
}
writeUtf8File(filePath: string, contents: string, mode?: Mode) {
const absPath = path.resolve(filePath);
nodeFs.writeUtf8File(filePath, contents, mode);
// Stat the file after writing and make it our expected observation. If we read the file after
// writing it and it doesn't match this stat (implying a subsequent write), we'll invalidate
// the current reader.
const newSt = nodeFs.stat(absPath);
// Skip invalidation checking since we don't want to conflict if we previously read this file.
this.observedFiles.set(absPath, newSt);
// If we observed the parent, add the new file to its expected directory entries.
const parentPath = path.resolve(path.dirname(absPath));
const observedParent = this.observedDirectories.get(parentPath);
if (observedParent !== undefined) {
observedParent.add(path.basename(absPath));
}
}
mkdir(dirPath: string, options?: { allowExisting?: boolean }) {
const absPath = path.resolve(dirPath);
try {
stdFs.mkdirSync(absPath);
} catch (e: any) {
if (options?.allowExisting && e.code === "EEXIST") {
const st = nodeFs.stat(absPath);
this.registerNormalized(absPath, st);
return;
}
throw e;
}
// Stat the directory we just created.
const newSt = nodeFs.stat(absPath);
this.observedFiles.set(absPath, newSt);
// If we observed the parent, add our newly created file.
const parentPath = path.resolve(path.dirname(absPath));
const observedParent = this.observedDirectories.get(parentPath);
if (observedParent !== undefined) {
observedParent.add(path.basename(absPath));
}
}
rm(entityPath: string, options?: { force?: boolean; recursive?: boolean }) {
const absPath = path.resolve(entityPath);
// Handle `options.recursive` manually so that we correctly update our observations.
if (
options?.recursive &&
this.exists(absPath) &&
this.stat(absPath).isDirectory()
) {
const entries = this.listDir(entityPath);
for (const entry of entries) {
this.rm(path.join(absPath, entry.name), options);
}
}
stdFs.rmSync(absPath, options);
// Expect this file/directory to be gone.
this.observedFiles.set(absPath, null);
// Unlink it from our parent if observed.
const parentPath = path.resolve(path.dirname(absPath));
const observedParent = this.observedDirectories.get(parentPath);
if (observedParent !== undefined) {
observedParent.delete(path.basename(absPath));
}
}
unlink(filePath: string) {
const absPath = path.resolve(filePath);
stdFs.unlinkSync(absPath);
// Expect this file to be gone.
this.observedFiles.set(absPath, null);
// Unlink it from our parent if observed.
const parentPath = path.resolve(path.dirname(absPath));
const observedParent = this.observedDirectories.get(parentPath);
if (observedParent !== undefined) {
observedParent.delete(path.basename(absPath));
}
}
registerPath(p: string, st: Stats | null) {
const absPath = path.resolve(p);
this.registerNormalized(absPath, st);
}
invalidate() {
this.invalidated = true;
}
private registerNormalized(p: string, observed: Stats | null): void {
const existing = this.observedFiles.get(p);
if (existing !== undefined) {
const stMatch = stMatches(observed, existing);
if (!stMatch.matches) {
if (this.traceEvents) {
console.log(
"Invalidating due to st mismatch",
p,
observed,
existing,
stMatch.reason
);
}
this.invalidated = true;
}
}
this.observedFiles.set(p, observed);
}
finalize(): Observations | "invalidated" {
if (this.invalidated) {
return "invalidated";
}
return new Observations(this.observedDirectories, this.observedFiles);
}
}
export type WatchEvent = {
name: "add" | "addDir" | "change" | "unlink" | "unlinkDir";
absPath: string;
};
export class Observations {
directories: Map<string, Set<string>>;
files: Map<string, Stats | null>;
constructor(
directories: Map<string, Set<string>>,
files: Map<string, Stats | null>
) {
this.directories = directories;
this.files = files;
}
paths(): string[] {
const out = [];
for (const path of this.directories.keys()) {
out.push(path);
}
for (const path of this.files.keys()) {
out.push(path);
}
return out;
}
overlaps({
absPath,
}: WatchEvent): { overlaps: false } | { overlaps: true; reason: string } {
let currentSt: null | Stats;
try {
currentSt = nodeFs.stat(absPath);
} catch (e: any) {
if (e.code === "ENOENT") {
currentSt = null;
} else {
throw e;
}
}
// First, check to see if we observed `absPath` as a file.
const observedSt = this.files.get(absPath);
if (observedSt !== undefined) {
const stMatch = stMatches(observedSt, currentSt);
if (!stMatch.matches) {
const reason = `modified (${stMatch.reason})`;
return { overlaps: true, reason };
}
}
// Second, check if we listed the directory this file is in.
const parentPath = path.resolve(path.dirname(absPath));
const observedParent = this.directories.get(parentPath);
if (observedParent !== undefined) {
const filename = path.basename(absPath);
// If the file is gone now, but we observed it in its directory, then
// it was deleted.
if (currentSt === null && observedParent.has(filename)) {
return { overlaps: true, reason: "deleted" };
}
// If the file exists now, but we didn't see it when listing its directory,
// then it was added.
if (currentSt !== null && !observedParent.has(filename)) {
return { overlaps: true, reason: "added" };
}
}
return { overlaps: false };
}
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) {
return false;
}
for (const elem of a.keys()) {
if (!b.has(elem)) {
return false;
}
}
return true;
}
export function stMatches(
a: Stats | null,
b: Stats | null
): { matches: true } | { matches: false; reason: string } {
if (a === null && b === null) {
return { matches: true };
}
if (a !== null && b !== null) {
if (a.dev !== b.dev) {
return { matches: false, reason: "device boundary" };
}
if (a.isFile() || b.isFile()) {
if (!a.isFile() || !b.isFile()) {
return { matches: false, reason: "file type" };
}
if (a.ino !== b.ino) {
return {
matches: false,
reason: `file inode (${a.ino} vs. ${b.ino})`,
};
}
if (a.size !== b.size) {
return {
matches: false,
reason: `file size (${a.size} vs. ${b.size})`,
};
}
if (a.mtimeMs !== b.mtimeMs) {
return {
matches: false,
reason: `file mtime (${a.mtimeMs} vs. ${b.mtimeMs})`,
};
}
return { matches: true };
}
if (a.isDirectory() || b.isDirectory()) {
if (!b.isDirectory() || !b.isDirectory()) {
return { matches: false, reason: "dir file type" };
}
if (a.ino !== b.ino) {
return {
matches: false,
reason: `dir inode (${a.ino} vs. ${b.ino})`,
};
}
return { matches: true };
}
// If we have something other than a file or directory, just compare inodes.
if (a.ino !== b.ino) {
return {
matches: false,
reason: `special inode (${a.ino} vs. ${b.ino})`,
};
}
return { matches: true };
}
return { matches: false, reason: "deleted mismatch" };
}