Query Builder

Focus Deep-Dive

Atomic I/O operations and safe file access patterns

Every read and write in the store layer passes through these primitives. They guarantee atomicity, prevent directory traversal attacks, and provide crash-safe persistence.

📚 What is this & why does it matter?

The query builder abstracts all read/write operations behind safe, atomic interfaces. This prevents data corruption from crashes mid-write, blocks directory traversal attacks from user-supplied paths, and ensures concurrent access is serialized through async locks. Without these primitives, every caller would need to implement its own temp-file-and-rename dance and path validation logic.

💡 Real-world analogy

Think of a bank teller window. You never touch the vault directly. You hand your request through the window (API), the teller verifies your identity (path validation), prepares the transaction in a staging area (temp file), and only commits it to the ledger (rename) when everything checks out. If the power goes out mid-transaction, the staging slip is discarded and the ledger remains consistent.

Contents

Overview

Primary role

Provide crash-safe, path-validated read and write operations for JSON files, text files, SQLite databases, and archives.

Main inputs

File paths (validated against a workspace root), JSON/text payloads, archive buffers, and lock tokens from createAsyncLock().

Key decisions

Atomic write via temp-file + rename vs. direct write. Path validation rejects symlinks, paths outside the workspace root, and files exceeding size limits.

Outputs / side effects

Persisted files with mode 0o600, SafeOpenResult handles for validated reads, delivery queue entries as .json files, and extracted archive contents in staging directories.

sequenceDiagram
    participant Caller
    participant writeJsonAtomic
    participant FileSystem
    participant Lock

    Caller->>Lock: withLock(fn)
    Lock-->>Caller: lock acquired
    Caller->>writeJsonAtomic: writeJsonAtomic(path, data)
    writeJsonAtomic->>FileSystem: create temp file (UUID name)
    FileSystem-->>writeJsonAtomic: fd opened
    writeJsonAtomic->>FileSystem: write JSON + fsync
    FileSystem-->>writeJsonAtomic: bytes flushed
    writeJsonAtomic->>FileSystem: rename temp → target
    FileSystem-->>writeJsonAtomic: rename complete
    writeJsonAtomic-->>Caller: success
    Caller->>Lock: release lock

The sequence diagram above shows the atomic write flow. A UUID-named temp file is created in the same directory as the target, data is written and flushed with fsync, then a single rename() call atomically replaces the target. If any step fails, the temp file is cleaned up and the original target remains untouched.

Decision Points

DecisionWhat controls itWhy it matters
Atomic vs. direct writeAll public write functions use the atomic path; only internal helpers use direct writeFileAtomic writes prevent half-written files from being visible to concurrent readers.
Path validation strictnessSafeOpenErrorCode enum: invalid-path, not-found, outside-workspace, symlink, not-file, path-mismatch, too-largeEach error code maps to a specific security or integrity risk, enabling callers to handle failures precisely.
Lock granularitycreateAsyncLock() creates per-resource locksFine-grained locking prevents global serialization while still protecting individual files from concurrent modification.
Archive extraction limitsArchiveExtractLimits fieldsPrevents zip-bomb and tar-bomb attacks by capping total extracted size, entry count, and individual entry size.

How It Works

The query builder layer is split across six files, each responsible for a specific I/O pattern. All share the same principle: never expose a partially-written or unvalidated artifact to callers.

Simple JSON I/O (json-file.ts)

The simplest layer provides loadJsonFile and saveJsonFile for single-file reads and writes. saveJsonFile always sets mode 0o600 (owner-only read/write) to prevent accidental exposure of sensitive data.

async function loadJsonFile(
  pathname: string
): Promise<unknown | undefined> {
  try {
    const raw = await fs.readFile(pathname, "utf-8");
    return JSON.parse(raw);
  } catch {
    return undefined;
  }
}

async function saveJsonFile(
  pathname: string,
  data: unknown
): Promise<void> {
  const json = JSON.stringify(data, null, 2);
  await fs.writeFile(pathname, json, { mode: 0o600 });
}
Not crash-safe: saveJsonFile uses a direct writeFile without a temp-file rename. Use it only for non-critical data where a partial write is acceptable. For critical data, use writeJsonAtomic instead.

Atomic JSON/Text I/O (json-files.ts)

The atomic layer introduces writeJsonAtomic and writeTextAtomic, which follow the write-to-temp–fsync–rename pattern. A UUID-based temp filename prevents collisions when multiple processes target the same file.

import { randomUUID } from "node:crypto";

async function writeJsonAtomic(
  filePath: string,
  value: unknown,
  options?: { mode?: number }
): Promise<void> {
  const dir = path.dirname(filePath);
  const tmpPath = path.join(dir, `.tmp-${randomUUID()}.json`);
  const fd = await fs.open(tmpPath, "w", options?.mode ?? 0o600);
  try {
    const json = JSON.stringify(value, null, 2);
    await fd.writeFile(json);
    await fd.sync();
    await fd.close();
    await fs.rename(tmpPath, filePath);
  } catch (err) {
    await fd.close().catch(() => {});
    await fs.unlink(tmpPath).catch(() => {});
    throw err;
  }
}

async function writeTextAtomic(
  filePath: string,
  text: string,
  options?: { mode?: number }
): Promise<void> {
  const dir = path.dirname(filePath);
  const tmpPath = path.join(dir, `.tmp-${randomUUID()}.txt`);
  const fd = await fs.open(tmpPath, "w", options?.mode ?? 0o600);
  try {
    await fd.writeFile(text);
    await fd.sync();
    await fd.close();
    await fs.rename(tmpPath, filePath);
  } catch (err) {
    await fd.close().catch(() => {});
    await fs.unlink(tmpPath).catch(() => {});
    throw err;
  }
}

function createAsyncLock(): {
  withLock: <T>(fn: () => Promise<T>) => Promise<T>;
} {
  let chain = Promise.resolve();
  return {
    withLock<T>(fn: () => Promise<T>): Promise<T> {
      const next = chain.then(fn);
      chain = next.then(() => {}, () => {});
      return next;
    },
  };
}
Crash safety: On POSIX filesystems, rename() is atomic at the filesystem level. If the process crashes between fsync() and rename(), the temp file remains and the original target is untouched. On the next startup, orphaned .tmp-* files are cleaned up.

Safe File Access (fs-safe.ts)

The safe file access layer validates every path against a workspace root before opening a file descriptor. It rejects symlinks, paths that escape the root, and files that exceed size limits.

Safe Open Validation Chain

flowchart TD
    A["openFileWithinRoot(root, filePath)"] --> B{"Path contains null bytes or is empty?"}
    B -- Yes --> C["Return: invalid-path"]
    B -- No --> D["Resolve to absolute path"]
    D --> E{"Resolved path starts with root?"}
    E -- No --> F["Return: outside-workspace"]
    E -- Yes --> G["lstat the path"]
    G --> H{"Path exists?"}
    H -- No --> I["Return: not-found"]
    H -- Yes --> J{"Is symlink?"}
    J -- Yes --> K["Return: symlink"]
    J -- No --> L{"Is regular file?"}
    L -- No --> M["Return: not-file"]
    L -- Yes --> N["Open with O_RDONLY | O_NOFOLLOW"]
    N --> O["fstat the fd"]
    O --> P{"realpath matches resolved?"}
    P -- No --> Q["Close fd, return: path-mismatch"]
    P -- Yes --> R{"File size within limit?"}
    R -- No --> S["Close fd, return: too-large"]
    R -- Yes --> T["Return: SafeOpenResult"]

The validation chain above runs on every openFileWithinRoot() call. Seven distinct error codes cover every failure mode, from null-byte injection to TOCTOU race conditions (the fstat + realpath double-check after open). The O_NOFOLLOW flag prevents the kernel from silently following symlinks.

Security critical: The path-mismatch check catches TOCTOU races where a symlink is created between lstat and open. Without it, an attacker could replace a regular file with a symlink in the narrow window between validation and open.

Annotated Code Walkthrough

Walk through the safe file open and atomic write paths step by step.

Define the error codes and result type for safe file access:

type SafeOpenErrorCode =
  | "invalid-path"
  | "not-found"
  | "outside-workspace"
  | "symlink"
  | "not-file"
  | "path-mismatch"
  | "too-large";

interface SafeOpenResult {
  handle: FileHandle;
  realPath: string;
  stat: Stats;
}

type SafeOpenOutcome =
  | { ok: true; result: SafeOpenResult }
  | { ok: false; code: SafeOpenErrorCode };

Implement the validation chain with early returns for each failure mode:

import { constants } from "node:fs";

async function openFileWithinRoot(
  rootPath: string,
  filePath: string,
  options?: { maxBytes?: number }
): Promise<SafeOpenOutcome> {
  if (!filePath || filePath.includes("\0")) {
    return { ok: false, code: "invalid-path" };
  }
  const resolved = path.resolve(rootPath, filePath);
  if (!resolved.startsWith(rootPath + path.sep) &&
      resolved !== rootPath) {
    return { ok: false, code: "outside-workspace" };
  }
  let lstatResult: Stats;
  try {
    lstatResult = await fs.lstat(resolved);
  } catch {
    return { ok: false, code: "not-found" };
  }
  if (lstatResult.isSymbolicLink()) {
    return { ok: false, code: "symlink" };
  }
  if (!lstatResult.isFile()) {
    return { ok: false, code: "not-file" };
  }
  const fd = await fs.open(
    resolved,
    constants.O_RDONLY | constants.O_NOFOLLOW
  );
  const realResolved = await fs.realpath(resolved);
  if (realResolved !== resolved) {
    await fd.close();
    return { ok: false, code: "path-mismatch" };
  }
  const fdStat = await fd.stat();
  if (options?.maxBytes && fdStat.size > options.maxBytes) {
    await fd.close();
    return { ok: false, code: "too-large" };
  }
  return {
    ok: true,
    result: { handle: fd, realPath: realResolved, stat: fdStat },
  };
}
Key detail: The lstat before open is intentionally redundant with the post-open realpath check. The first catches obvious symlinks cheaply; the second catches race conditions.

The delivery queue uses atomic writes to persist each delivery as an individual JSON file, then renames or deletes the file on acknowledgement or failure:

async function enqueueDelivery(
  queueDir: string,
  payload: QueuedDeliveryPayload
): Promise<QueuedDelivery> {
  const delivery: QueuedDelivery = {
    ...payload,
    id: randomUUID(),
    enqueuedAt: Date.now(),
    retryCount: 0,
  };
  const filePath = path.join(queueDir, `${delivery.id}.json`);
  await writeJsonAtomic(filePath, delivery);
  return delivery;
}

async function ackDelivery(
  queueDir: string,
  id: string
): Promise<void> {
  const filePath = path.join(queueDir, `${id}.json`);
  await fs.unlink(filePath);
}

async function failDelivery(
  queueDir: string,
  id: string,
  error: string
): Promise<void> {
  const filePath = path.join(queueDir, `${id}.json`);
  const raw = await loadJsonFile(filePath);
  if (!raw) return;
  const delivery = raw as QueuedDelivery;
  delivery.retryCount += 1;
  delivery.lastAttemptAt = Date.now();
  delivery.lastError = error;
  const deliveredPath = path.join(
    queueDir, `${id}.delivered`
  );
  await writeJsonAtomic(filePath, delivery);
}

The SQLite integration uses a dynamic import to support environments where node:sqlite is not available:

let sqliteModule: typeof import("node:sqlite") | null = null;

async function requireNodeSqlite(): Promise<
  typeof import("node:sqlite")
> {
  if (sqliteModule) return sqliteModule;
  try {
    sqliteModule = await import("node:sqlite");
    return sqliteModule;
  } catch {
    throw new Error(
      "node:sqlite is not available in this runtime. " +
      "Ensure you are running Node.js 22.5+ with " +
      "--experimental-sqlite enabled."
    );
  }
}

Edge Cases & Error Handling

ScenarioBehaviorCode Location
Null byte in file pathopenFileWithinRoot returns invalid-path immediatelystore/fs-safe.ts:18
Symlink race (TOCTOU)Post-open realpath check catches it; fd is closed and path-mismatch returnedstore/fs-safe.ts:52
Temp file orphan after crashStartup cleanup scans for .tmp-* files older than 60 seconds and removes themstore/json-files.ts:112
Concurrent writes to same filecreateAsyncLock().withLock() serializes access; UUID temp names prevent collisionsstore/json-files.ts:78
Archive zip-bombExtraction aborts when cumulative extracted bytes exceed maxExtractedBytes; staging dir is cleaned upstore/archive.ts:45
node:sqlite unavailablerequireNodeSqlite() throws a descriptive error with runtime version requirementsstore/node-sqlite.ts:12

Configuration

Knob / ParameterDefaultDescription
mode (write functions)0o600POSIX file mode for newly created files. Owner read/write only.
maxBytes (safe open)50 * 1024 * 1024Maximum file size in bytes for openFileWithinRoot to accept.
O_NOFOLLOWAlways setKernel flag preventing symlink following on open. Non-negotiable security constraint.
orphanMaxAgeMs60000Temp files older than this are removed by the startup cleanup routine.

See also:

Directory Map

Source files for this topic
src/infra/store/
├── json-file.ts               ← loadJsonFile(), saveJsonFile() (simple I/O)
├── json-files.ts              ← readJsonFile(), writeJsonAtomic(), writeTextAtomic(), createAsyncLock()
├── node-sqlite.ts             ← requireNodeSqlite() dynamic import
├── fs-safe.ts                 ← SafeOpenErrorCode, openFileWithinRoot(), readFileWithinRoot()
├── file-lock.ts               ← File locking primitives for cross-process coordination
├── archive.ts                 ← ZIP/TAR extraction with ArchiveExtractLimits
└── delivery-queue.ts          ← enqueueDelivery(), ackDelivery(), failDelivery()