convex
Version:
Client for the Convex Cloud
554 lines (509 loc) • 16.1 kB
text/typescript
import chalk from "chalk";
import stdFs, { Dirent, Mode, ReadStream, Stats } from "fs";
import os from "os";
import path from "path";
const tmpDirOverrideVar = "CONVEX_TMPDIR";
function tmpDirPath() {
// Allow users to override the temporary directory path with an environment variable.
// This override needs to (1) be project-specific, since the user may have projects
// on different filesystems, but also (2) be device-specific and not checked in, since
// it's dependent on where the user has checked out their project. So, we don't want
// this state in the project-specific `convex.json`, which is shared across all
// devices, or in the top-level `~/.convex` directory, which is shared across all
// projects on the local machine.
//
// Therefore, just let advanced users configure this behavior with an environment
// variable that they're responsible for managing themselves for now.
const envTmpDir = process.env[tmpDirOverrideVar];
return envTmpDir ?? os.tmpdir();
}
const tmpDirRoot = tmpDirPath();
let warned = false;
function warnCrossFilesystem(dstPath: string) {
const dstDir = path.dirname(dstPath);
if (!warned) {
console.warn(
chalk.yellow(
`Temporary directory '${tmpDirRoot}' and project directory '${dstDir}' are on different filesystems.`
)
);
console.warn(
chalk.gray(
` If you're running into errors with other tools watching the project directory, override the temporary directory location with the ${chalk.bold(
tmpDirOverrideVar
)} environment variable.`
)
);
console.warn(
chalk.gray(
` Be sure to pick a temporary directory that's on the same filesystem as your project.`
)
);
warned = true;
}
}
export interface Filesystem {
listDir(dirPath: string): Dirent[];
exists(path: string): boolean;
stat(path: string): Stats;
readUtf8File(path: string): string;
createReadStream(path: string): ReadStream;
access(path: string): void;
writeUtf8File(path: string, contents: string, mode?: Mode): void;
mkdir(dirPath: string, options?: { allowExisting?: boolean }): void;
rm(path: string, options?: { force?: boolean; recursive?: boolean }): void;
rmdir(path: string): void;
unlink(path: string): void;
renameFile(fromPath: string, toPath: string): void;
registerPath(path: string, st: Stats | null): void;
invalidate(): void;
}
export interface TempDir {
tmpPath: string;
}
export async function mkdtemp(
prefix: string,
callback: (tmpDir: TempDir) => Promise<void>
): Promise<void> {
const tmpPath = stdFs.mkdtempSync(path.join(tmpDirRoot, prefix));
try {
await callback({ tmpPath });
} finally {
stdFs.rmSync(tmpPath, { force: true, recursive: true });
}
}
// 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" });
}
createReadStream(path: string): ReadStream {
return stdFs.createReadStream(path);
}
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(dirPath: string, options?: { allowExisting?: boolean }): void {
try {
stdFs.mkdirSync(dirPath);
} catch (e: any) {
if (options?.allowExisting && e.code === "EEXIST") {
return;
}
throw e;
}
}
rm(path: string, options?: { force?: boolean; recursive?: boolean }) {
stdFs.rmSync(path, options);
}
rmdir(path: string) {
stdFs.rmdirSync(path);
}
unlink(path: string) {
return stdFs.unlinkSync(path);
}
renameFile(fromPath: string, toPath: string) {
try {
return stdFs.renameSync(fromPath, toPath);
} catch (e: any) {
// Fallback to copying the file if we're on different volumes.
if (e.code === "EXDEV") {
warnCrossFilesystem(toPath);
stdFs.copyFileSync(fromPath, toPath);
return;
}
throw e;
}
}
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)) {
if (this.traceEvents) {
console.log(
"Invalidating due to directory children mismatch",
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;
}
}
createReadStream(path: string): ReadStream {
try {
const st = nodeFs.stat(path);
this.registerPath(path, st);
return nodeFs.createReadStream(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);
this.updateOnWrite(absPath);
}
mkdir(dirPath: string, options?: { allowExisting?: boolean }): void {
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;
}
this.updateOnWrite(absPath);
}
rm(entityPath: string, options?: { force?: boolean; recursive?: boolean }) {
const absPath = path.resolve(entityPath);
const isDir = this.exists(absPath) && this.stat(absPath).isDirectory();
// Handle `options.recursive` manually so that we correctly update our observations.
if (options?.recursive && isDir) {
const entries = this.listDir(entityPath);
for (const entry of entries) {
this.rm(path.join(absPath, entry.name), options);
}
}
try {
if (isDir) {
stdFs.rmdirSync(absPath);
} else {
stdFs.rmSync(absPath);
}
} catch (e: any) {
const allowed = options?.force && e.code === "ENOENT";
if (!allowed) {
throw e;
}
}
this.updateOnDelete(absPath);
}
rmdir(dirPath: string) {
const absPath = path.resolve(dirPath);
stdFs.rmdirSync(absPath);
this.updateOnDelete(absPath);
}
unlink(filePath: string) {
const absPath = path.resolve(filePath);
stdFs.unlinkSync(absPath);
this.updateOnDelete(absPath);
}
renameFile(fromPath: string, toPath: string) {
const absFromPath = path.resolve(fromPath);
const absToPath = path.resolve(toPath);
nodeFs.renameFile(absFromPath, absToPath);
this.updateOnDelete(absFromPath);
this.updateOnWrite(absToPath);
}
private updateOnWrite(absPath: string) {
// Stat the file or dir 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 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));
}
}
private updateOnDelete(absPath: string) {
// 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;
}
registerNormalized(absPath: string, observed: Stats | null): void {
const existing = this.observedFiles.get(absPath);
if (existing !== undefined) {
const stMatch = stMatches(observed, existing);
if (!stMatch.matches) {
if (this.traceEvents) {
console.log(
"Invalidating due to st mismatch",
absPath,
observed,
existing,
stMatch.reason
);
}
this.invalidated = true;
}
}
this.observedFiles.set(absPath, 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" };
}