Skip to content

feat(fs,fs-lite): add dataSuffix option to prevent file/directory key collisions#767

Open
claygeo wants to merge 2 commits intounjs:mainfrom
claygeo:fix/fs-driver-key-collision
Open

feat(fs,fs-lite): add dataSuffix option to prevent file/directory key collisions#767
claygeo wants to merge 2 commits intounjs:mainfrom
claygeo:fix/fs-driver-key-collision

Conversation

@claygeo
Copy link
Copy Markdown

@claygeo claygeo commented Mar 31, 2026

Summary

When both foo and foo:bar exist as storage keys, the fs and fs-lite drivers map them to <base>/foo (file) and <base>/foo/bar (inside directory foo/). This creates an ENOTDIR collision because foo cannot be both a file and a directory on disk.

This PR adds a dataSuffix option to both drivers. When set (e.g. dataSuffix: ".data"):

  • Key foo is stored as foo.data on disk
  • Key foo:bar is stored as foo/bar.data on disk
  • No collision -- foo.data is a file, foo/ is a directory

The suffix is transparently stripped from keys returned by getKeys() and watch callbacks. The option defaults to undefined for full backward compatibility.

Context

This addresses the root cause of file/directory collisions reported in:

Per pi0's feedback, the fix belongs in the unstorage fs driver rather than in Nitro's cache key sanitization.

Changes

  • src/drivers/fs.ts -- Added dataSuffix option, split r() into r() (directory resolution) and rFile() (file resolution with suffix), updated all file operations to use rFile(), strip suffix in getKeys() and watch callbacks
  • src/drivers/fs-lite.ts -- Same changes as fs.ts
  • test/drivers/fs.test.ts -- Added test verifying foo + foo:bar coexistence with dataSuffix, and basic CRUD with suffix
  • test/drivers/fs-lite.test.ts -- Same tests as fs.test.ts

Usage

import fsDriver from "unstorage/drivers/fs";

const storage = createStorage({
  driver: fsDriver({ base: "./cache", dataSuffix: ".data" }),
});

// These now coexist without ENOTDIR errors:
await storage.setItem("foo", "value1");
await storage.setItem("foo:bar", "value2");

Summary by CodeRabbit

  • New Features

    • Added an optional dataSuffix setting for filesystem storage drivers to enable custom on-disk file naming, preventing collisions between similar keys (e.g., "foo" vs "foo:bar"). getKeys() is now asynchronous and returns logical keys without suffixes.
    • Filesystem change notifications now respect the suffix and emit logical keys.
  • Tests

    • Added tests covering dataSuffix behavior and key coexistence.

… collisions

When both `foo` and `foo:bar` exist as storage keys, the fs drivers try
to create a file at `<base>/foo` and a directory at `<base>/foo/`,
causing ENOTDIR errors.

Add a `dataSuffix` option (e.g. `".data"`) that appends a suffix to all
stored file paths on disk. With `dataSuffix: ".data"`, key `foo` is
stored as `foo.data` and key `foo:bar` as `foo/bar.data`, eliminating
the collision. The suffix is transparently stripped from keys returned
by `getKeys()` and watch callbacks.

The option defaults to `undefined` (no suffix) for full backward
compatibility -- consumers like Nitro can opt in by setting it.
@claygeo claygeo requested a review from pi0 as a code owner March 31, 2026 12:52
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 31, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5422e74f-33dc-4a2d-97b9-5b61a78ad2eb

📥 Commits

Reviewing files that changed from the base of the PR and between dc5a9f6 and 31f85d7.

📒 Files selected for processing (2)
  • src/drivers/fs-lite.ts
  • src/drivers/fs.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/drivers/fs.ts

📝 Walkthrough

Walkthrough

Filesystem drivers fs and fs-lite gain an optional dataSuffix option. Filesystem operations now map logical keys to on-disk filenames with rFile(key) (appending the suffix). getKeys became async and strips the suffix. fs watcher events ignore/strip the suffix when emitting callbacks.

Changes

Cohort / File(s) Summary
Filesystem Drivers
src/drivers/fs-lite.ts, src/drivers/fs.ts
Added dataSuffix?: string to FSStorageOptions. Introduced rFile(key) that appends dataSuffix to physical paths. Switched read/write/stat/remove operations to use rFile. Converted getKeys to async; when dataSuffix is set, filter filenames by suffix and strip it before returning logical keys.
Watcher handling (fs driver)
src/drivers/fs.ts
Updated chokidar watch handlers to ignore events for paths without the configured suffix and to strip dataSuffix from relative event paths before calling update/remove callbacks.
Tests
test/drivers/fs-lite.test.ts, test/drivers/fs.test.ts
Added test suites for dataSuffix: ".data". Verify colliding logical keys (e.g., foo and foo:bar) produce distinct on-disk files with .data suffix, getKeys() returns logical keys without suffix, and standard set/has/get/remove flows work with the suffix enabled.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant FSDriver as "FS Driver\n(r / rFile)"
    participant Disk as "Filesystem"
    participant Watcher as "Chokidar Watcher"

    Client->>FSDriver: setItem(key, value)
    FSDriver->>FSDriver: r(key) -> rFile(key) (append dataSuffix)
    FSDriver->>Disk: write rFile(key)
    Disk-->>FSDriver: write OK
    FSDriver-->>Client: ack

    Disk-->>Watcher: fs event (rFile path)
    Watcher->>FSDriver: event relPath
    Note right of FSDriver: strip dataSuffix from relPath
    FSDriver->>Client: update(key) / remove(key) callback with logical key
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I stitched a little .data tail,
Keys that tangled now set sail,
Files on disk wear suffix bright,
Logical names stay airy-light,
Hop, inspect, and read delight! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature being added: a dataSuffix option to both fs and fs-lite drivers that solves file/directory key collision issues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/drivers/fs.test.ts (1)

115-121: Consider reusing the storage instance from the test.

The afterEach hook creates a new storage instance for cleanup. Since each test already creates and disposes its own storage instance, you could track the storage instance at the describe level and reuse it for cleanup.

♻️ Suggested refactor
   describe("dataSuffix option", () => {
     const suffixDir = resolve(__dirname, "tmp/fs-suffix");
+    let storage: ReturnType<typeof createStorage>;

     afterEach(async () => {
-      const s = createStorage({
-        driver: driver({ base: suffixDir, dataSuffix: ".data" }),
-      });
-      await s.clear();
-      await s.dispose();
+      if (storage) {
+        await storage.clear();
+        await storage.dispose();
+      }
     });

     it("prevents file/directory collision with dataSuffix", async () => {
       const d = driver({ base: suffixDir, dataSuffix: ".data" });
-      const storage = createStorage({ driver: d });
+      storage = createStorage({ driver: d });
       // ... rest of test without the dispose call at the end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/drivers/fs.test.ts` around lines 115 - 121, The afterEach currently
creates a new storage with createStorage/driver to clean up; instead declare a
describe-scoped variable (e.g., let storage = null) and have each test assign
its storage instance to that variable when it creates one, then update afterEach
to check that variable and call storage.clear() and storage.dispose() if present
(and reset it to null); update references in afterEach from the temporary s to
this describe-scoped storage and ensure tests still dispose or let afterEach
handle disposal consistently (methods: createStorage, driver, afterEach, clear,
dispose).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/drivers/fs.test.ts`:
- Around line 115-121: The afterEach currently creates a new storage with
createStorage/driver to clean up; instead declare a describe-scoped variable
(e.g., let storage = null) and have each test assign its storage instance to
that variable when it creates one, then update afterEach to check that variable
and call storage.clear() and storage.dispose() if present (and reset it to
null); update references in afterEach from the temporary s to this
describe-scoped storage and ensure tests still dispose or let afterEach handle
disposal consistently (methods: createStorage, driver, afterEach, clear,
dispose).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 40e828ed-5314-4a62-93d0-75b0b27886e2

📥 Commits

Reviewing files that changed from the base of the PR and between fb7b8aa and dc5a9f6.

📒 Files selected for processing (4)
  • src/drivers/fs-lite.ts
  • src/drivers/fs.ts
  • test/drivers/fs-lite.test.ts
  • test/drivers/fs.test.ts

…ffix is set

getKeys was using map (passing through non-suffixed files as phantom keys)
instead of filter+map. Watcher was emitting events for non-suffixed files.
Both now ignore files that don't end with the configured dataSuffix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant