Skip to content

Conversation

@AndreyHirsa
Copy link
Contributor

@AndreyHirsa AndreyHirsa commented Feb 3, 2026

Summary

Migrate metadata storage from JSON files with lockfile to LMDB database for improved concurrent access and elimination of ELOCKED errors.

Changes

  • Replace proper-lockfile with LMDB: Use lmdb-js for metadata storage with MVCC (Multi-Version Concurrency Control) for safe concurrent access across bundler workers
  • Remove proper-lockfile dependency: No longer needed with LMDB's built-in concurrency handling
  • Update related docs

Testing

Business logic tests added:

  • Basic operations with db
  • Stats accuracy tracking
  • Cleanup logic
  • Error handling
  • All tests pass locally

Visuals

N/A - No UI changes

Checklist

  • Changeset added
  • Tests cover business logic
  • No breaking changes (or documented below)

Closes #1636

Summary by CodeRabbit

  • New Features

    • Migrated metadata storage from JSON to an LMDB-backed database with separate dev and build metadata directories and short-lived, stateless access for safer multi-worker use.
  • Documentation

    • Updated compiler, architecture, and transformation pipeline docs to reflect the new metadata storage and runtime API shape.
  • Tests

    • Added comprehensive unit tests covering metadata creation, save/load, cleanup, concurrency-like access, and edge cases.
  • Dependencies

    • Replaced file-locking approach with LMDB-backed persistence.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 3, 2026

📝 Walkthrough

Walkthrough

Migrates metadata from a JSON file with file locks to LMDB-backed databases, replacing the MetadataManager class with stateless functions (loadMetadata, saveMetadata, cleanupExistingMetadata, createEmptyMetadata), adding environment-specific dirs (.lingo/metadata-dev, .lingo/metadata-build), and removing proper-lockfile.

Changes

Cohort / File(s) Summary
Metadata Storage Implementation
packages/new-compiler/src/metadata/manager.ts
Replaced JSON + file-locking approach with LMDB-backed storage; added open/close helpers, read/write transactions, STATS_KEY, createEmptyMetadata, getMetadataPath, and cleanupExistingMetadata; removed MetadataManager class.
Plugin Integration
packages/new-compiler/src/plugin/unplugin.ts, packages/new-compiler/src/plugin/next-compiler-loader.ts, packages/new-compiler/src/plugin/next.ts
Replaced MetadataManager usage with direct saveMetadata calls; adjusted imports and small cleanup formatting changes.
Docs & Architecture
packages/new-compiler/README.md, packages/new-compiler/docs/TRANSLATION_ARCHITECTURE.md, packages/new-compiler/src/plugin/transform/TRANSFORMATION_PIPELINE.md, packages/new-compiler/src/translation-server/README.md
Updated references from .lingo/metadata.json to LMDB directories; documented new API shapes (getServerTranslations changes) and LMDB storage model.
Tests & Test Docs
packages/new-compiler/src/metadata/manager.test.ts, packages/new-compiler/src/plugin/transform/TESTING.md
Added comprehensive unit tests for LMDB-backed metadata manager (concurrency, batch ops, cleanup, path resolution); updated test expectations to check metadata-dev/metadata-build dirs.
Dependencies & Changeset
.changeset/common-teeth-reply.md, packages/new-compiler/package.json
Removed proper-lockfile and its types; added lmdb dependency; added patch-level changeset.
Formatting-only
packages/new-compiler/src/plugin/build-translator.ts
Import spacing/line-break reformatting without behavioral changes.

Sequence Diagram

sequenceDiagram
    participant Compiler as Compiler (Plugin)
    participant SaveFunc as saveMetadata(dbPath, entries)
    participant LoadFunc as loadMetadata(dbPath)
    participant DB as LMDB Database

    rect rgba(100,150,200,0.5)
    Note over Compiler,SaveFunc: Save metadata flow
    Compiler->>SaveFunc: saveMetadata(dbPath, entries)
    SaveFunc->>DB: openDatabase(dbPath)
    DB-->>SaveFunc: db handle
    SaveFunc->>DB: write entries (transaction)
    SaveFunc->>DB: update __stats__ key
    DB-->>SaveFunc: commit
    SaveFunc->>DB: close connection
    SaveFunc-->>Compiler: confirmation
    end

    rect rgba(150,200,100,0.5)
    Note over Compiler,LoadFunc: Load metadata flow
    Compiler->>LoadFunc: loadMetadata(dbPath)
    LoadFunc->>DB: openDatabase(dbPath)
    DB-->>LoadFunc: db handle
    LoadFunc->>DB: read all entries & __stats__
    DB-->>LoadFunc: entries + stats
    LoadFunc->>DB: close connection
    LoadFunc-->>Compiler: MetadataSchema
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

community

Suggested reviewers

  • vrcprl
  • sumitsaurabh927

Poem

🐰 Hopped from files to LMDB delight,
Short-lived connections, transactions tight.
Pure functions run, no locks in sight,
Metadata dances in directories bright.
Translation hops forward — nimble and light! 🥕✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The PR addresses issue #1636 about 'TypeError: traverse is not a function' by migrating to LMDB storage to eliminate ELOCKED errors that can disrupt compilation, though the direct connection is indirect. Clarify in PR description or commit messages how LMDB migration resolves the 'traverse is not a function' error in #1636. The connection between lockfile-induced ELOCKED errors and the reported TypeError should be made explicit.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: migrating metadata storage from lockfile-based system to LMDB database.
Description check ✅ Passed The description follows the template with summary, changes, testing section, and checklist. Key sections are populated with specific details about the migration and test coverage.
Out of Scope Changes check ✅ Passed All changes align with the metadata storage migration objective: LMDB dependency addition, proper-lockfile removal, manager refactoring, tests, and documentation updates are all on-scope.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/metadata-lmdb-storage

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

Copy link
Contributor

@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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/new-compiler/src/metadata/manager.test.ts`:
- Around line 309-314: The test is incorrectly treating the synchronous
MetadataManager constructor as async; update the "should throw descriptive error
for invalid path" test to directly assert the synchronous throw by replacing the
await expect(async () => new MetadataManager(invalidPath)).rejects.toThrow()
pattern with a synchronous assertion such as expect(() => new
MetadataManager(invalidPath)).toThrow() (or toThrowError/... if you want to
assert message content), referencing the MetadataManager constructor in that
test.
🧹 Nitpick comments (4)
packages/new-compiler/src/metadata/manager.ts (1)

205-226: Avoid O(n) full scans when updating stats.
Counting all keys on every save scales with database size and can slow large builds. Consider incrementally updating totalEntries per write inside the transaction.

♻️ Suggested refactor
-      for (const entry of entries) {
-        this.db.put(entry.hash, entry);
-      }
-
-      // Count entries explicitly (excluding stats key) for clarity
-      let entryCount = 0;
-      for (const { key } of this.db.getRange()) {
-        if (key !== STATS_KEY) {
-          entryCount++;
-        }
-      }
-
-      const stats = {
-        totalEntries: entryCount,
-        lastUpdated: new Date().toISOString(),
-      };
+      const prevStats =
+        (this.db.get(STATS_KEY) as MetadataSchema["stats"] | undefined) ?? {
+          totalEntries: 0,
+          lastUpdated: new Date().toISOString(),
+        };
+      let entryCount = prevStats.totalEntries;
+
+      for (const entry of entries) {
+        const existed = this.db.get(entry.hash) !== undefined;
+        this.db.put(entry.hash, entry);
+        if (!existed) entryCount++;
+      }
+
+      const stats = {
+        totalEntries: entryCount,
+        lastUpdated: new Date().toISOString(),
+      };
packages/new-compiler/src/metadata/manager.test.ts (3)

106-108: Awaiting a synchronous function is unnecessary.

cleanupExistingMetadata returns void (synchronous), so await is a no-op here. While this doesn't break functionality, it's misleading and inconsistent with the function signature.

Suggested fix
   afterEach(async () => {
-    await cleanupExistingMetadata(testDbPath);
+    cleanupExistingMetadata(testDbPath);
   });

380-384: Avoid returning values from forEach callbacks.

worker.kill() returns a boolean, triggering the Biome lint warning. Use for...of loop instead for cleaner semantics when you don't need the return values.

Suggested fix
       } finally {
-        workers.forEach((w) => w.kill());
+        for (const w of workers) {
+          w.kill();
+        }
       }

237-258: Same issue: awaiting synchronous cleanupExistingMetadata calls.

Lines 243, 303, 304, and 357 also await the synchronous cleanupExistingMetadata function. Consider removing await from all these call sites for consistency.

Also applies to: 286-307

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/new-compiler/src/metadata/manager.test.ts`:
- Around line 213-217: The test "should throw descriptive error for invalid
path" is flaky because it uses a hard-coded /root/... path; change it to create
a guaranteed-failing target using the tmp directory: create a temporary file
(e.g., via fs.writeFileSync or fs.mkdtempSync under os.tmpdir()) and pass that
file path to loadMetadata so mkdirSync will deterministically fail; update the
test code around the it block (the test function invoking loadMetadata) to
create and clean up the temp file and assert that loadMetadata(tmpFilePath)
rejects.

Comment on lines +213 to +217
describe("error handling", () => {
it("should throw descriptive error for invalid path", async () => {
const invalidPath = "/root/definitely/cannot/create/this/path";
await expect(loadMetadata(invalidPath)).rejects.toThrow();
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make the invalid-path test deterministic across CI environments.

Line 215 uses a hard-coded /root/... path, which can be writable when tests run as root, so loadMetadata may not throw and the test can flake. Use a temp file path to guarantee mkdirSync fails.

🔧 Suggested fix
   describe("error handling", () => {
     it("should throw descriptive error for invalid path", async () => {
-      const invalidPath = "/root/definitely/cannot/create/this/path";
-      await expect(loadMetadata(invalidPath)).rejects.toThrow();
+      const invalidPath = path.join(
+        os.tmpdir(),
+        `lmdb-invalid-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+      );
+      fs.writeFileSync(invalidPath, "not a directory");
+      try {
+        await expect(loadMetadata(invalidPath)).rejects.toThrow();
+      } finally {
+        fs.rmSync(invalidPath, { force: true });
+      }
     });
   });

As per coding guidelines, "When you add tests, make sure they pass".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe("error handling", () => {
it("should throw descriptive error for invalid path", async () => {
const invalidPath = "/root/definitely/cannot/create/this/path";
await expect(loadMetadata(invalidPath)).rejects.toThrow();
});
describe("error handling", () => {
it("should throw descriptive error for invalid path", async () => {
const invalidPath = path.join(
os.tmpdir(),
`lmdb-invalid-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
fs.writeFileSync(invalidPath, "not a directory");
try {
await expect(loadMetadata(invalidPath)).rejects.toThrow();
} finally {
fs.rmSync(invalidPath, { force: true });
}
});
});
🤖 Prompt for AI Agents
In `@packages/new-compiler/src/metadata/manager.test.ts` around lines 213 - 217,
The test "should throw descriptive error for invalid path" is flaky because it
uses a hard-coded /root/... path; change it to create a guaranteed-failing
target using the tmp directory: create a temporary file (e.g., via
fs.writeFileSync or fs.mkdtempSync under os.tmpdir()) and pass that file path to
loadMetadata so mkdirSync will deterministically fail; update the test code
around the it block (the test function invoking loadMetadata) to create and
clean up the temp file and assert that loadMetadata(tmpFilePath) rejects.

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.

Lingo.dev compiler fails with “TypeError: traverse is not a function” in React Router v7 + Vite

2 participants