@lingo.dev/compiler 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -330,6 +330,58 @@ LINGO_BUILD_MODE=cache-only npm run build
330
330
  2. **CI**: Generate real translations with `buildMode: "translate"` and real API keys
331
331
  3. **Production Build**: Use `buildMode: "cache-only"` (no API keys needed)
332
332
 
333
+ ## React Client API
334
+
335
+ The compiler provides hooks and components for managing locale in your React components.
336
+
337
+ ### `useLingoContext()`
338
+
339
+ Access the translation context to get the current locale and change it.
340
+
341
+ **Returns:**
342
+ - `locale` (string): Current locale code
343
+ - `setLocale` (function): Change the locale
344
+ - `translations` (object): Translation dictionary
345
+ - `isLoading` (boolean): Whether translations are loading
346
+
347
+ ```tsx
348
+ "use client";
349
+ import { useLingoContext } from "@lingo.dev/compiler/react";
350
+
351
+ export function LanguageSwitcher() {
352
+ const { locale, setLocale } = useLingoContext();
353
+
354
+ return (
355
+ <select value={locale} onChange={(e) => setLocale(e.target.value)}>
356
+ <option value="en">English</option>
357
+ <option value="es">Español</option>
358
+ <option value="de">Deutsch</option>
359
+ </select>
360
+ );
361
+ }
362
+ ```
363
+
364
+ ### `LocaleSwitcher` Component
365
+
366
+ A pre-built dropdown component for switching locales (no hooks needed):
367
+
368
+ ```tsx
369
+ "use client";
370
+ import { LocaleSwitcher } from "@lingo.dev/compiler/react";
371
+
372
+ export function Header() {
373
+ return (
374
+ <LocaleSwitcher
375
+ locales={[
376
+ { code: "en", label: "English" },
377
+ { code: "es", label: "Español" },
378
+ { code: "de", label: "Deutsch" },
379
+ ]}
380
+ />
381
+ );
382
+ }
383
+ ```
384
+
333
385
  ## Custom Locale Resolvers
334
386
 
335
387
  Customize how locales are detected and persisted by providing custom resolver files:
@@ -395,9 +447,10 @@ The compiler is organized into several key modules:
395
447
 
396
448
  #### `src/metadata/` - Translation metadata management
397
449
 
398
- - **`manager.ts`** - CRUD operations for `.lingo/metadata.json`
399
- - Thread-safe metadata file operations with file locking
450
+ - **`manager.ts`** - CRUD operations for LMDB metadata database
451
+ - Uses LMDB for high-performance key-value storage with built-in concurrency
400
452
  - Manages translation entries with hash-based identifiers
453
+ - Stores metadata in `.lingo/metadata-dev/` (development) or `.lingo/metadata-build/` (production)
401
454
 
402
455
  #### `src/translators/` - Translation provider abstraction
403
456
 
@@ -1,131 +1,100 @@
1
1
  const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
2
2
  const require_logger = require('../utils/logger.cjs');
3
- const require_timeout = require('../utils/timeout.cjs');
4
3
  const require_path_helpers = require('../utils/path-helpers.cjs');
5
- let fs_promises = require("fs/promises");
6
- fs_promises = require_rolldown_runtime.__toESM(fs_promises);
7
4
  let path = require("path");
8
5
  path = require_rolldown_runtime.__toESM(path);
9
6
  let fs = require("fs");
10
7
  fs = require_rolldown_runtime.__toESM(fs);
11
- let proper_lockfile = require("proper-lockfile");
12
- proper_lockfile = require_rolldown_runtime.__toESM(proper_lockfile);
8
+ let lmdb = require("lmdb");
13
9
 
14
10
  //#region src/metadata/manager.ts
15
- function createEmptyMetadata() {
16
- return {
17
- entries: {},
18
- stats: {
19
- totalEntries: 0,
20
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
21
- }
22
- };
23
- }
24
- function loadMetadata(path$2) {
25
- return new MetadataManager(path$2).loadMetadata();
26
- }
27
- function cleanupExistingMetadata(metadataFilePath) {
28
- require_logger.logger.debug(`Attempting to cleanup metadata file: ${metadataFilePath}`);
11
+ const METADATA_DIR_DEV = "metadata-dev";
12
+ const METADATA_DIR_BUILD = "metadata-build";
13
+ /**
14
+ * Opens an LMDB connection for a single operation.
15
+ *
16
+ * lmdb-js deduplicates open() calls to the same path (ref-counted at C++ level),
17
+ * so this is cheap. Each open() also clears stale readers from terminated workers.
18
+ */
19
+ function openDatabaseConnection(dbPath, noSync) {
29
20
  try {
30
- fs.default.unlinkSync(metadataFilePath);
31
- require_logger.logger.info(`🧹 Cleaned up build metadata file: ${metadataFilePath}`);
21
+ fs.default.mkdirSync(dbPath, { recursive: true });
22
+ return (0, lmdb.open)({
23
+ path: dbPath,
24
+ compression: true,
25
+ noSync
26
+ });
32
27
  } catch (error) {
33
- if (error.code === "ENOENT") require_logger.logger.debug(`Metadata file already deleted or doesn't exist: ${metadataFilePath}`);
34
- else require_logger.logger.warn(`Failed to cleanup metadata file: ${error.message}`);
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ throw new Error(`Failed to open LMDB at ${dbPath}: ${message}`);
35
30
  }
36
31
  }
37
32
  /**
38
- * Get the absolute path to the metadata file
39
- *
40
- * @param config - Config with sourceRoot, lingoDir, and environment
41
- * @returns Absolute path to metadata file
33
+ * Closes the LMDB connection. Also prevents EBUSY/EPERM on Windows during
34
+ * directory cleanup.
42
35
  */
43
- function getMetadataPath(config) {
44
- const filename = config.environment === "development" ? "metadata-dev.json" : "metadata-build.json";
45
- return path.default.join(require_path_helpers.getLingoDir(config), filename);
46
- }
47
- var MetadataManager = class {
48
- constructor(filePath) {
49
- this.filePath = filePath;
50
- }
51
- /**
52
- * Load metadata from disk
53
- * Creates empty metadata if file doesn't exist
54
- * Times out after 15 seconds to prevent indefinite hangs
55
- */
56
- async loadMetadata() {
57
- try {
58
- const content = await require_timeout.withTimeout(fs_promises.default.readFile(this.filePath, "utf-8"), require_timeout.DEFAULT_TIMEOUTS.METADATA, "Load metadata");
59
- return JSON.parse(content);
60
- } catch (error) {
61
- if (error.code === "ENOENT") return createEmptyMetadata();
62
- throw error;
63
- }
36
+ async function closeDatabaseConnection(db, dbPath) {
37
+ try {
38
+ await db.close();
39
+ } catch (e) {
40
+ require_logger.logger.debug(`Error closing database at ${dbPath}: ${e}`);
64
41
  }
65
- /**
66
- * Save metadata to disk
67
- * Times out after 15 seconds to prevent indefinite hangs
68
- */
69
- async saveMetadata(metadata) {
70
- await require_timeout.withTimeout(fs_promises.default.mkdir(path.default.dirname(this.filePath), { recursive: true }), require_timeout.DEFAULT_TIMEOUTS.FILE_IO, "Create metadata directory");
71
- metadata.stats = {
72
- totalEntries: Object.keys(metadata.entries).length,
73
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
74
- };
75
- const dir = path.default.dirname(this.filePath);
76
- const base = path.default.basename(this.filePath);
77
- const tmpPath = path.default.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);
78
- const json = JSON.stringify(metadata, null, 2);
79
- await require_timeout.withTimeout(fs_promises.default.writeFile(tmpPath, json, "utf-8"), require_timeout.DEFAULT_TIMEOUTS.METADATA, "Save metadata (tmp write)");
80
- try {
81
- await require_timeout.withTimeout(fs_promises.default.rename(tmpPath, this.filePath), require_timeout.DEFAULT_TIMEOUTS.METADATA, "Save metadata (atomic rename)");
82
- } catch (error) {
83
- if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
84
- await require_timeout.withTimeout(fs_promises.default.writeFile(this.filePath, json, "utf-8"), require_timeout.DEFAULT_TIMEOUTS.METADATA, "Save metadata (EPERM fallback direct write)");
85
- return;
86
- }
87
- throw error;
88
- } finally {
89
- await fs_promises.default.unlink(tmpPath).catch(() => {});
90
- }
42
+ }
43
+ /**
44
+ * Opens a database connection, runs the callback, and ensures the connection
45
+ * is closed afterwards.
46
+ */
47
+ async function runWithDbConnection(dbPath, noSync, fn) {
48
+ const db = openDatabaseConnection(dbPath, noSync);
49
+ try {
50
+ return fn(db);
51
+ } finally {
52
+ await closeDatabaseConnection(db, dbPath);
91
53
  }
92
- /**
93
- * Thread-safe save operation that atomically updates metadata with new entries
94
- * Uses file locking to prevent concurrent write corruption
95
- *
96
- * @param entries - Translation entries to add/update
97
- * @returns The updated metadata schema
98
- */
99
- async saveMetadataWithEntries(entries) {
100
- const lockDir = path.default.dirname(this.filePath);
101
- await fs_promises.default.mkdir(lockDir, { recursive: true });
102
- try {
103
- await fs_promises.default.access(this.filePath);
104
- } catch {
105
- await fs_promises.default.writeFile(this.filePath, JSON.stringify(createEmptyMetadata(), null, 2), "utf-8");
106
- }
107
- const release = await proper_lockfile.default.lock(this.filePath, {
108
- retries: {
109
- retries: 20,
110
- minTimeout: 50,
111
- maxTimeout: 2e3
112
- },
113
- stale: 5e3
54
+ }
55
+ function readEntriesFromDb(db) {
56
+ const entries = {};
57
+ for (const { key, value } of db.getRange()) entries[key] = value;
58
+ return entries;
59
+ }
60
+ async function loadMetadata(dbPath, noSync = false) {
61
+ return runWithDbConnection(dbPath, noSync, readEntriesFromDb);
62
+ }
63
+ /**
64
+ * Persists translation entries to LMDB in a single atomic transaction.
65
+ */
66
+ async function saveMetadata(dbPath, entries, noSync = false) {
67
+ return runWithDbConnection(dbPath, noSync, (db) => {
68
+ db.transactionSync(() => {
69
+ for (const entry of entries) db.putSync(entry.hash, entry);
114
70
  });
115
- try {
116
- const currentMetadata = await this.loadMetadata();
117
- for (const entry of entries) currentMetadata.entries[entry.hash] = entry;
118
- await this.saveMetadata(currentMetadata);
119
- return currentMetadata;
120
- } finally {
121
- await release();
71
+ });
72
+ }
73
+ function cleanupExistingMetadata(metadataDbPath) {
74
+ require_logger.logger.debug(`Cleaning up metadata database: ${metadataDbPath}`);
75
+ try {
76
+ fs.default.rmSync(metadataDbPath, {
77
+ recursive: true,
78
+ force: true
79
+ });
80
+ require_logger.logger.info(`🧹 Cleaned up metadata database: ${metadataDbPath}`);
81
+ } catch (error) {
82
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ if (code === "ENOENT") {
85
+ require_logger.logger.debug(`Metadata database already deleted or doesn't exist: ${metadataDbPath}`);
86
+ return;
122
87
  }
88
+ require_logger.logger.warn(`Failed to cleanup metadata database: ${message}`);
123
89
  }
124
- };
90
+ }
91
+ function getMetadataPath(config) {
92
+ const dirname = config.environment === "development" ? METADATA_DIR_DEV : METADATA_DIR_BUILD;
93
+ return path.default.join(require_path_helpers.getLingoDir(config), dirname);
94
+ }
125
95
 
126
96
  //#endregion
127
- exports.MetadataManager = MetadataManager;
128
97
  exports.cleanupExistingMetadata = cleanupExistingMetadata;
129
- exports.createEmptyMetadata = createEmptyMetadata;
130
98
  exports.getMetadataPath = getMetadataPath;
131
- exports.loadMetadata = loadMetadata;
99
+ exports.loadMetadata = loadMetadata;
100
+ exports.saveMetadata = saveMetadata;
@@ -1,123 +1,95 @@
1
1
  import { logger } from "../utils/logger.mjs";
2
- import { DEFAULT_TIMEOUTS, withTimeout } from "../utils/timeout.mjs";
3
2
  import { getLingoDir } from "../utils/path-helpers.mjs";
4
- import fs from "fs/promises";
5
3
  import path from "path";
6
- import fs$1 from "fs";
7
- import lockfile from "proper-lockfile";
4
+ import fs from "fs";
5
+ import { open } from "lmdb";
8
6
 
9
7
  //#region src/metadata/manager.ts
10
- function createEmptyMetadata() {
11
- return {
12
- entries: {},
13
- stats: {
14
- totalEntries: 0,
15
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
16
- }
17
- };
18
- }
19
- function loadMetadata(path$1) {
20
- return new MetadataManager(path$1).loadMetadata();
21
- }
22
- function cleanupExistingMetadata(metadataFilePath) {
23
- logger.debug(`Attempting to cleanup metadata file: ${metadataFilePath}`);
8
+ const METADATA_DIR_DEV = "metadata-dev";
9
+ const METADATA_DIR_BUILD = "metadata-build";
10
+ /**
11
+ * Opens an LMDB connection for a single operation.
12
+ *
13
+ * lmdb-js deduplicates open() calls to the same path (ref-counted at C++ level),
14
+ * so this is cheap. Each open() also clears stale readers from terminated workers.
15
+ */
16
+ function openDatabaseConnection(dbPath, noSync) {
24
17
  try {
25
- fs$1.unlinkSync(metadataFilePath);
26
- logger.info(`🧹 Cleaned up build metadata file: ${metadataFilePath}`);
18
+ fs.mkdirSync(dbPath, { recursive: true });
19
+ return open({
20
+ path: dbPath,
21
+ compression: true,
22
+ noSync
23
+ });
27
24
  } catch (error) {
28
- if (error.code === "ENOENT") logger.debug(`Metadata file already deleted or doesn't exist: ${metadataFilePath}`);
29
- else logger.warn(`Failed to cleanup metadata file: ${error.message}`);
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ throw new Error(`Failed to open LMDB at ${dbPath}: ${message}`);
30
27
  }
31
28
  }
32
29
  /**
33
- * Get the absolute path to the metadata file
34
- *
35
- * @param config - Config with sourceRoot, lingoDir, and environment
36
- * @returns Absolute path to metadata file
30
+ * Closes the LMDB connection. Also prevents EBUSY/EPERM on Windows during
31
+ * directory cleanup.
37
32
  */
38
- function getMetadataPath(config) {
39
- const filename = config.environment === "development" ? "metadata-dev.json" : "metadata-build.json";
40
- return path.join(getLingoDir(config), filename);
41
- }
42
- var MetadataManager = class {
43
- constructor(filePath) {
44
- this.filePath = filePath;
45
- }
46
- /**
47
- * Load metadata from disk
48
- * Creates empty metadata if file doesn't exist
49
- * Times out after 15 seconds to prevent indefinite hangs
50
- */
51
- async loadMetadata() {
52
- try {
53
- const content = await withTimeout(fs.readFile(this.filePath, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Load metadata");
54
- return JSON.parse(content);
55
- } catch (error) {
56
- if (error.code === "ENOENT") return createEmptyMetadata();
57
- throw error;
58
- }
33
+ async function closeDatabaseConnection(db, dbPath) {
34
+ try {
35
+ await db.close();
36
+ } catch (e) {
37
+ logger.debug(`Error closing database at ${dbPath}: ${e}`);
59
38
  }
60
- /**
61
- * Save metadata to disk
62
- * Times out after 15 seconds to prevent indefinite hangs
63
- */
64
- async saveMetadata(metadata) {
65
- await withTimeout(fs.mkdir(path.dirname(this.filePath), { recursive: true }), DEFAULT_TIMEOUTS.FILE_IO, "Create metadata directory");
66
- metadata.stats = {
67
- totalEntries: Object.keys(metadata.entries).length,
68
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
69
- };
70
- const dir = path.dirname(this.filePath);
71
- const base = path.basename(this.filePath);
72
- const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);
73
- const json = JSON.stringify(metadata, null, 2);
74
- await withTimeout(fs.writeFile(tmpPath, json, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Save metadata (tmp write)");
75
- try {
76
- await withTimeout(fs.rename(tmpPath, this.filePath), DEFAULT_TIMEOUTS.METADATA, "Save metadata (atomic rename)");
77
- } catch (error) {
78
- if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
79
- await withTimeout(fs.writeFile(this.filePath, json, "utf-8"), DEFAULT_TIMEOUTS.METADATA, "Save metadata (EPERM fallback direct write)");
80
- return;
81
- }
82
- throw error;
83
- } finally {
84
- await fs.unlink(tmpPath).catch(() => {});
85
- }
39
+ }
40
+ /**
41
+ * Opens a database connection, runs the callback, and ensures the connection
42
+ * is closed afterwards.
43
+ */
44
+ async function runWithDbConnection(dbPath, noSync, fn) {
45
+ const db = openDatabaseConnection(dbPath, noSync);
46
+ try {
47
+ return fn(db);
48
+ } finally {
49
+ await closeDatabaseConnection(db, dbPath);
86
50
  }
87
- /**
88
- * Thread-safe save operation that atomically updates metadata with new entries
89
- * Uses file locking to prevent concurrent write corruption
90
- *
91
- * @param entries - Translation entries to add/update
92
- * @returns The updated metadata schema
93
- */
94
- async saveMetadataWithEntries(entries) {
95
- const lockDir = path.dirname(this.filePath);
96
- await fs.mkdir(lockDir, { recursive: true });
97
- try {
98
- await fs.access(this.filePath);
99
- } catch {
100
- await fs.writeFile(this.filePath, JSON.stringify(createEmptyMetadata(), null, 2), "utf-8");
101
- }
102
- const release = await lockfile.lock(this.filePath, {
103
- retries: {
104
- retries: 20,
105
- minTimeout: 50,
106
- maxTimeout: 2e3
107
- },
108
- stale: 5e3
51
+ }
52
+ function readEntriesFromDb(db) {
53
+ const entries = {};
54
+ for (const { key, value } of db.getRange()) entries[key] = value;
55
+ return entries;
56
+ }
57
+ async function loadMetadata(dbPath, noSync = false) {
58
+ return runWithDbConnection(dbPath, noSync, readEntriesFromDb);
59
+ }
60
+ /**
61
+ * Persists translation entries to LMDB in a single atomic transaction.
62
+ */
63
+ async function saveMetadata(dbPath, entries, noSync = false) {
64
+ return runWithDbConnection(dbPath, noSync, (db) => {
65
+ db.transactionSync(() => {
66
+ for (const entry of entries) db.putSync(entry.hash, entry);
109
67
  });
110
- try {
111
- const currentMetadata = await this.loadMetadata();
112
- for (const entry of entries) currentMetadata.entries[entry.hash] = entry;
113
- await this.saveMetadata(currentMetadata);
114
- return currentMetadata;
115
- } finally {
116
- await release();
68
+ });
69
+ }
70
+ function cleanupExistingMetadata(metadataDbPath) {
71
+ logger.debug(`Cleaning up metadata database: ${metadataDbPath}`);
72
+ try {
73
+ fs.rmSync(metadataDbPath, {
74
+ recursive: true,
75
+ force: true
76
+ });
77
+ logger.info(`🧹 Cleaned up metadata database: ${metadataDbPath}`);
78
+ } catch (error) {
79
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ if (code === "ENOENT") {
82
+ logger.debug(`Metadata database already deleted or doesn't exist: ${metadataDbPath}`);
83
+ return;
117
84
  }
85
+ logger.warn(`Failed to cleanup metadata database: ${message}`);
118
86
  }
119
- };
87
+ }
88
+ function getMetadataPath(config) {
89
+ const dirname = config.environment === "development" ? METADATA_DIR_DEV : METADATA_DIR_BUILD;
90
+ return path.join(getLingoDir(config), dirname);
91
+ }
120
92
 
121
93
  //#endregion
122
- export { MetadataManager, cleanupExistingMetadata, createEmptyMetadata, getMetadataPath, loadMetadata };
94
+ export { cleanupExistingMetadata, getMetadataPath, loadMetadata, saveMetadata };
123
95
  //# sourceMappingURL=manager.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"manager.mjs","names":["path","error: any","filePath: string","fsPromises"],"sources":["../../src/metadata/manager.ts"],"sourcesContent":["import fsPromises from \"fs/promises\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport lockfile from \"proper-lockfile\";\nimport type { MetadataSchema, PathConfig, TranslationEntry } from \"../types\";\nimport { DEFAULT_TIMEOUTS, withTimeout } from \"../utils/timeout\";\nimport { getLingoDir } from \"../utils/path-helpers\";\nimport { logger } from \"../utils/logger\";\n\nexport function createEmptyMetadata(): MetadataSchema {\n return {\n entries: {},\n stats: {\n totalEntries: 0,\n lastUpdated: new Date().toISOString(),\n },\n };\n}\n\nexport function loadMetadata(path: string) {\n return new MetadataManager(path).loadMetadata();\n}\n\nexport function cleanupExistingMetadata(metadataFilePath: string) {\n // General cleanup. Delete metadata and stop the server if any was started.\n logger.debug(`Attempting to cleanup metadata file: ${metadataFilePath}`);\n\n try {\n fs.unlinkSync(metadataFilePath);\n logger.info(`🧹 Cleaned up build metadata file: ${metadataFilePath}`);\n } catch (error: any) {\n // Ignore if file doesn't exist\n if (error.code === \"ENOENT\") {\n logger.debug(\n `Metadata file already deleted or doesn't exist: ${metadataFilePath}`,\n );\n } else {\n logger.warn(`Failed to cleanup metadata file: ${error.message}`);\n }\n }\n}\n\n/**\n * Get the absolute path to the metadata file\n *\n * @param config - Config with sourceRoot, lingoDir, and environment\n * @returns Absolute path to metadata file\n */\nexport function getMetadataPath(config: PathConfig): string {\n const filename =\n // Similar to next keeping dev build separate, let's keep the build metadata clean of any dev mode additions\n config.environment === \"development\"\n ? \"metadata-dev.json\"\n : \"metadata-build.json\";\n return path.join(getLingoDir(config), filename);\n}\n\nexport class MetadataManager {\n constructor(private readonly filePath: string) {}\n\n /**\n * Load metadata from disk\n * Creates empty metadata if file doesn't exist\n * Times out after 15 seconds to prevent indefinite hangs\n */\n async loadMetadata(): Promise<MetadataSchema> {\n try {\n const content = await withTimeout(\n fsPromises.readFile(this.filePath, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Load metadata\",\n );\n return JSON.parse(content) as MetadataSchema;\n } catch (error: any) {\n if (error.code === \"ENOENT\") {\n // File doesn't exist, create new metadata\n return createEmptyMetadata();\n }\n throw error;\n }\n }\n\n /**\n * Save metadata to disk\n * Times out after 15 seconds to prevent indefinite hangs\n */\n private async saveMetadata(metadata: MetadataSchema): Promise<void> {\n await withTimeout(\n fsPromises.mkdir(path.dirname(this.filePath), { recursive: true }),\n DEFAULT_TIMEOUTS.FILE_IO,\n \"Create metadata directory\",\n );\n\n metadata.stats = {\n totalEntries: Object.keys(metadata.entries).length,\n lastUpdated: new Date().toISOString(),\n };\n\n // Per LLM writing to a file is not an atomic operation while rename is, so nobody should get partial content.\n // Sounds reasonable.\n const dir = path.dirname(this.filePath);\n const base = path.basename(this.filePath);\n\n // Keep temp file in the same directory to maximize chance that rename is atomic\n const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);\n\n const json = JSON.stringify(metadata, null, 2);\n\n await withTimeout(\n fsPromises.writeFile(tmpPath, json, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (tmp write)\",\n );\n\n try {\n // TODO (AleksandrSl 14/12/2025): LLM says that we may want to remove older file first for windows, but it seems lo work fine as is.\n await withTimeout(\n fsPromises.rename(tmpPath, this.filePath),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (atomic rename)\",\n );\n } catch (error) {\n // On Windows, rename() can fail with EPERM if something briefly holds the file.\n // As a fallback, try writing directly to the destination (not atomic).\n if (\n error &&\n typeof error === \"object\" &&\n \"code\" in error &&\n error.code === \"EPERM\"\n ) {\n await withTimeout(\n fsPromises.writeFile(this.filePath, json, \"utf-8\"),\n DEFAULT_TIMEOUTS.METADATA,\n \"Save metadata (EPERM fallback direct write)\",\n );\n return;\n }\n throw error;\n } finally {\n // Best-effort cleanup if rename failed for some reason\n await fsPromises.unlink(tmpPath).catch(() => {});\n }\n }\n\n /**\n * Thread-safe save operation that atomically updates metadata with new entries\n * Uses file locking to prevent concurrent write corruption\n *\n * @param entries - Translation entries to add/update\n * @returns The updated metadata schema\n */\n async saveMetadataWithEntries(\n entries: TranslationEntry[],\n ): Promise<MetadataSchema> {\n const lockDir = path.dirname(this.filePath);\n\n await fsPromises.mkdir(lockDir, { recursive: true });\n\n try {\n await fsPromises.access(this.filePath);\n } catch {\n await fsPromises.writeFile(\n this.filePath,\n JSON.stringify(createEmptyMetadata(), null, 2),\n \"utf-8\",\n );\n }\n\n const release = await lockfile.lock(this.filePath, {\n retries: {\n retries: 20,\n minTimeout: 50,\n maxTimeout: 2000,\n },\n stale: 5000,\n });\n\n try {\n // Re-load metadata inside lock to get latest state\n const currentMetadata = await this.loadMetadata();\n for (const entry of entries) {\n currentMetadata.entries[entry.hash] = entry;\n }\n await this.saveMetadata(currentMetadata);\n return currentMetadata;\n } finally {\n await release();\n }\n }\n}\n"],"mappings":";;;;;;;;;AASA,SAAgB,sBAAsC;AACpD,QAAO;EACL,SAAS,EAAE;EACX,OAAO;GACL,cAAc;GACd,8BAAa,IAAI,MAAM,EAAC,aAAa;GACtC;EACF;;AAGH,SAAgB,aAAa,QAAc;AACzC,QAAO,IAAI,gBAAgBA,OAAK,CAAC,cAAc;;AAGjD,SAAgB,wBAAwB,kBAA0B;AAEhE,QAAO,MAAM,wCAAwC,mBAAmB;AAExE,KAAI;AACF,OAAG,WAAW,iBAAiB;AAC/B,SAAO,KAAK,sCAAsC,mBAAmB;UAC9DC,OAAY;AAEnB,MAAI,MAAM,SAAS,SACjB,QAAO,MACL,mDAAmD,mBACpD;MAED,QAAO,KAAK,oCAAoC,MAAM,UAAU;;;;;;;;;AAWtE,SAAgB,gBAAgB,QAA4B;CAC1D,MAAM,WAEJ,OAAO,gBAAgB,gBACnB,sBACA;AACN,QAAO,KAAK,KAAK,YAAY,OAAO,EAAE,SAAS;;AAGjD,IAAa,kBAAb,MAA6B;CAC3B,YAAY,AAAiBC,UAAkB;EAAlB;;;;;;;CAO7B,MAAM,eAAwC;AAC5C,MAAI;GACF,MAAM,UAAU,MAAM,YACpBC,GAAW,SAAS,KAAK,UAAU,QAAQ,EAC3C,iBAAiB,UACjB,gBACD;AACD,UAAO,KAAK,MAAM,QAAQ;WACnBF,OAAY;AACnB,OAAI,MAAM,SAAS,SAEjB,QAAO,qBAAqB;AAE9B,SAAM;;;;;;;CAQV,MAAc,aAAa,UAAyC;AAClE,QAAM,YACJE,GAAW,MAAM,KAAK,QAAQ,KAAK,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC,EAClE,iBAAiB,SACjB,4BACD;AAED,WAAS,QAAQ;GACf,cAAc,OAAO,KAAK,SAAS,QAAQ,CAAC;GAC5C,8BAAa,IAAI,MAAM,EAAC,aAAa;GACtC;EAID,MAAM,MAAM,KAAK,QAAQ,KAAK,SAAS;EACvC,MAAM,OAAO,KAAK,SAAS,KAAK,SAAS;EAGzC,MAAM,UAAU,KAAK,KAAK,KAAK,IAAI,KAAK,OAAO,QAAQ,IAAI,GAAG,KAAK,KAAK,GAAG;EAE3E,MAAM,OAAO,KAAK,UAAU,UAAU,MAAM,EAAE;AAE9C,QAAM,YACJA,GAAW,UAAU,SAAS,MAAM,QAAQ,EAC5C,iBAAiB,UACjB,4BACD;AAED,MAAI;AAEF,SAAM,YACJA,GAAW,OAAO,SAAS,KAAK,SAAS,EACzC,iBAAiB,UACjB,gCACD;WACM,OAAO;AAGd,OACE,SACA,OAAO,UAAU,YACjB,UAAU,SACV,MAAM,SAAS,SACf;AACA,UAAM,YACJA,GAAW,UAAU,KAAK,UAAU,MAAM,QAAQ,EAClD,iBAAiB,UACjB,8CACD;AACD;;AAEF,SAAM;YACE;AAER,SAAMA,GAAW,OAAO,QAAQ,CAAC,YAAY,GAAG;;;;;;;;;;CAWpD,MAAM,wBACJ,SACyB;EACzB,MAAM,UAAU,KAAK,QAAQ,KAAK,SAAS;AAE3C,QAAMA,GAAW,MAAM,SAAS,EAAE,WAAW,MAAM,CAAC;AAEpD,MAAI;AACF,SAAMA,GAAW,OAAO,KAAK,SAAS;UAChC;AACN,SAAMA,GAAW,UACf,KAAK,UACL,KAAK,UAAU,qBAAqB,EAAE,MAAM,EAAE,EAC9C,QACD;;EAGH,MAAM,UAAU,MAAM,SAAS,KAAK,KAAK,UAAU;GACjD,SAAS;IACP,SAAS;IACT,YAAY;IACZ,YAAY;IACb;GACD,OAAO;GACR,CAAC;AAEF,MAAI;GAEF,MAAM,kBAAkB,MAAM,KAAK,cAAc;AACjD,QAAK,MAAM,SAAS,QAClB,iBAAgB,QAAQ,MAAM,QAAQ;AAExC,SAAM,KAAK,aAAa,gBAAgB;AACxC,UAAO;YACC;AACR,SAAM,SAAS"}
1
+ {"version":3,"file":"manager.mjs","names":["entries: MetadataSchema"],"sources":["../../src/metadata/manager.ts"],"sourcesContent":["import fs from \"fs\";\nimport path from \"path\";\nimport { open, type RootDatabase } from \"lmdb\";\nimport type { MetadataSchema, PathConfig, TranslationEntry } from \"../types\";\nimport { getLingoDir } from \"../utils/path-helpers\";\nimport { logger } from \"../utils/logger\";\n\nconst METADATA_DIR_DEV = \"metadata-dev\";\nconst METADATA_DIR_BUILD = \"metadata-build\";\n\n/**\n * Opens an LMDB connection for a single operation.\n *\n * lmdb-js deduplicates open() calls to the same path (ref-counted at C++ level),\n * so this is cheap. Each open() also clears stale readers from terminated workers.\n */\nfunction openDatabaseConnection(dbPath: string, noSync: boolean): RootDatabase {\n try {\n fs.mkdirSync(dbPath, { recursive: true });\n return open({\n path: dbPath,\n compression: true,\n noSync,\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`Failed to open LMDB at ${dbPath}: ${message}`);\n }\n}\n\n/**\n * Closes the LMDB connection. Also prevents EBUSY/EPERM on Windows during\n * directory cleanup.\n */\nasync function closeDatabaseConnection(\n db: RootDatabase,\n dbPath: string,\n): Promise<void> {\n try {\n await db.close();\n } catch (e) {\n logger.debug(`Error closing database at ${dbPath}: ${e}`);\n }\n}\n\n/**\n * Opens a database connection, runs the callback, and ensures the connection\n * is closed afterwards.\n */\nasync function runWithDbConnection<T>(\n dbPath: string,\n noSync: boolean,\n fn: (db: RootDatabase) => T,\n): Promise<T> {\n const db = openDatabaseConnection(dbPath, noSync);\n try {\n return fn(db);\n } finally {\n await closeDatabaseConnection(db, dbPath);\n }\n}\n\nfunction readEntriesFromDb(db: RootDatabase): MetadataSchema {\n const entries: MetadataSchema = {};\n\n for (const { key, value } of db.getRange()) {\n entries[key as string] = value as TranslationEntry;\n }\n\n return entries;\n}\n\nexport async function loadMetadata(\n dbPath: string,\n noSync = false,\n): Promise<MetadataSchema> {\n return runWithDbConnection(dbPath, noSync, readEntriesFromDb);\n}\n\n/**\n * Persists translation entries to LMDB in a single atomic transaction.\n */\nexport async function saveMetadata(\n dbPath: string,\n entries: TranslationEntry[],\n noSync = false,\n): Promise<void> {\n return runWithDbConnection(dbPath, noSync, (db) => {\n db.transactionSync(() => {\n for (const entry of entries) {\n db.putSync(entry.hash, entry);\n }\n });\n });\n}\n\nexport function cleanupExistingMetadata(metadataDbPath: string): void {\n logger.debug(`Cleaning up metadata database: ${metadataDbPath}`);\n\n try {\n fs.rmSync(metadataDbPath, { recursive: true, force: true });\n logger.info(`🧹 Cleaned up metadata database: ${metadataDbPath}`);\n } catch (error) {\n const code =\n error instanceof Error && \"code\" in error\n ? (error as NodeJS.ErrnoException).code\n : undefined;\n const message = error instanceof Error ? error.message : String(error);\n\n if (code === \"ENOENT\") {\n logger.debug(\n `Metadata database already deleted or doesn't exist: ${metadataDbPath}`,\n );\n return;\n }\n\n logger.warn(`Failed to cleanup metadata database: ${message}`);\n }\n}\n\nexport function getMetadataPath(config: PathConfig): string {\n const dirname =\n config.environment === \"development\"\n ? METADATA_DIR_DEV\n : METADATA_DIR_BUILD;\n return path.join(getLingoDir(config), dirname);\n}\n"],"mappings":";;;;;;;AAOA,MAAM,mBAAmB;AACzB,MAAM,qBAAqB;;;;;;;AAQ3B,SAAS,uBAAuB,QAAgB,QAA+B;AAC7E,KAAI;AACF,KAAG,UAAU,QAAQ,EAAE,WAAW,MAAM,CAAC;AACzC,SAAO,KAAK;GACV,MAAM;GACN,aAAa;GACb;GACD,CAAC;UACK,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;AACtE,QAAM,IAAI,MAAM,0BAA0B,OAAO,IAAI,UAAU;;;;;;;AAQnE,eAAe,wBACb,IACA,QACe;AACf,KAAI;AACF,QAAM,GAAG,OAAO;UACT,GAAG;AACV,SAAO,MAAM,6BAA6B,OAAO,IAAI,IAAI;;;;;;;AAQ7D,eAAe,oBACb,QACA,QACA,IACY;CACZ,MAAM,KAAK,uBAAuB,QAAQ,OAAO;AACjD,KAAI;AACF,SAAO,GAAG,GAAG;WACL;AACR,QAAM,wBAAwB,IAAI,OAAO;;;AAI7C,SAAS,kBAAkB,IAAkC;CAC3D,MAAMA,UAA0B,EAAE;AAElC,MAAK,MAAM,EAAE,KAAK,WAAW,GAAG,UAAU,CACxC,SAAQ,OAAiB;AAG3B,QAAO;;AAGT,eAAsB,aACpB,QACA,SAAS,OACgB;AACzB,QAAO,oBAAoB,QAAQ,QAAQ,kBAAkB;;;;;AAM/D,eAAsB,aACpB,QACA,SACA,SAAS,OACM;AACf,QAAO,oBAAoB,QAAQ,SAAS,OAAO;AACjD,KAAG,sBAAsB;AACvB,QAAK,MAAM,SAAS,QAClB,IAAG,QAAQ,MAAM,MAAM,MAAM;IAE/B;GACF;;AAGJ,SAAgB,wBAAwB,gBAA8B;AACpE,QAAO,MAAM,kCAAkC,iBAAiB;AAEhE,KAAI;AACF,KAAG,OAAO,gBAAgB;GAAE,WAAW;GAAM,OAAO;GAAM,CAAC;AAC3D,SAAO,KAAK,oCAAoC,iBAAiB;UAC1D,OAAO;EACd,MAAM,OACJ,iBAAiB,SAAS,UAAU,QAC/B,MAAgC,OACjC;EACN,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;AAEtE,MAAI,SAAS,UAAU;AACrB,UAAO,MACL,uDAAuD,iBACxD;AACD;;AAGF,SAAO,KAAK,wCAAwC,UAAU;;;AAIlE,SAAgB,gBAAgB,QAA4B;CAC1D,MAAM,UACJ,OAAO,gBAAgB,gBACnB,mBACA;AACN,QAAO,KAAK,KAAK,YAAY,OAAO,EAAE,QAAQ"}
@@ -28,15 +28,15 @@ async function processBuildTranslations(options) {
28
28
  const { config, publicOutputPath, metadataFilePath } = options;
29
29
  const buildMode = process.env.LINGO_BUILD_MODE || config.buildMode;
30
30
  require_logger.logger.info(`🌍 Build mode: ${buildMode}`);
31
- const metadata = await require_manager.loadMetadata(metadataFilePath);
32
- if (!metadata || Object.keys(metadata.entries).length === 0) {
31
+ const metadata = await require_manager.loadMetadata(metadataFilePath, true);
32
+ if (!metadata || Object.keys(metadata).length === 0) {
33
33
  require_logger.logger.info("No translations to process (metadata is empty)");
34
34
  return {
35
35
  success: true,
36
36
  stats: {}
37
37
  };
38
38
  }
39
- const totalEntries = Object.keys(metadata.entries).length;
39
+ const totalEntries = Object.keys(metadata).length;
40
40
  require_logger.logger.info(`📊 Found ${totalEntries} translatable entries`);
41
41
  const cache = require_cache_factory.createCache(config);
42
42
  if (buildMode === "cache-only") {
@@ -107,7 +107,7 @@ async function processBuildTranslations(options) {
107
107
  * @throws Error if cache is incomplete or missing
108
108
  */
109
109
  async function validateCache(config, metadata, cache) {
110
- const allHashes = Object.keys(metadata.entries);
110
+ const allHashes = Object.keys(metadata);
111
111
  const missingLocales = [];
112
112
  const incompleteLocales = [];
113
113
  const allLocales = config.pluralization?.enabled === true ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
@@ -138,7 +138,7 @@ async function validateCache(config, metadata, cache) {
138
138
  }
139
139
  }
140
140
  function buildCacheStats(config, metadata) {
141
- const totalEntries = Object.keys(metadata.entries).length;
141
+ const totalEntries = Object.keys(metadata).length;
142
142
  const stats = {};
143
143
  const allLocales = config.pluralization?.enabled === true ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
144
144
  for (const locale of allLocales) stats[locale] = {
@@ -151,7 +151,7 @@ function buildCacheStats(config, metadata) {
151
151
  async function copyStaticFiles(config, publicOutputPath, metadata, cache) {
152
152
  require_logger.logger.info(`📦 Generating static translation files in ${publicOutputPath}`);
153
153
  await fs_promises.default.mkdir(publicOutputPath, { recursive: true });
154
- const usedHashes = new Set(Object.keys(metadata.entries));
154
+ const usedHashes = new Set(Object.keys(metadata));
155
155
  require_logger.logger.info(`📊 Filtering translations to ${usedHashes.size} used hash(es)`);
156
156
  const allLocales = config.pluralization?.enabled === true ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
157
157
  for (const locale of allLocales) {
@@ -25,15 +25,15 @@ async function processBuildTranslations(options) {
25
25
  const { config, publicOutputPath, metadataFilePath } = options;
26
26
  const buildMode = process.env.LINGO_BUILD_MODE || config.buildMode;
27
27
  logger.info(`🌍 Build mode: ${buildMode}`);
28
- const metadata = await loadMetadata(metadataFilePath);
29
- if (!metadata || Object.keys(metadata.entries).length === 0) {
28
+ const metadata = await loadMetadata(metadataFilePath, true);
29
+ if (!metadata || Object.keys(metadata).length === 0) {
30
30
  logger.info("No translations to process (metadata is empty)");
31
31
  return {
32
32
  success: true,
33
33
  stats: {}
34
34
  };
35
35
  }
36
- const totalEntries = Object.keys(metadata.entries).length;
36
+ const totalEntries = Object.keys(metadata).length;
37
37
  logger.info(`📊 Found ${totalEntries} translatable entries`);
38
38
  const cache = createCache(config);
39
39
  if (buildMode === "cache-only") {
@@ -104,7 +104,7 @@ async function processBuildTranslations(options) {
104
104
  * @throws Error if cache is incomplete or missing
105
105
  */
106
106
  async function validateCache(config, metadata, cache) {
107
- const allHashes = Object.keys(metadata.entries);
107
+ const allHashes = Object.keys(metadata);
108
108
  const missingLocales = [];
109
109
  const incompleteLocales = [];
110
110
  const allLocales = config.pluralization?.enabled === true ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
@@ -135,7 +135,7 @@ async function validateCache(config, metadata, cache) {
135
135
  }
136
136
  }
137
137
  function buildCacheStats(config, metadata) {
138
- const totalEntries = Object.keys(metadata.entries).length;
138
+ const totalEntries = Object.keys(metadata).length;
139
139
  const stats = {};
140
140
  const allLocales = config.pluralization?.enabled === true ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
141
141
  for (const locale of allLocales) stats[locale] = {
@@ -148,7 +148,7 @@ function buildCacheStats(config, metadata) {
148
148
  async function copyStaticFiles(config, publicOutputPath, metadata, cache) {
149
149
  logger.info(`📦 Generating static translation files in ${publicOutputPath}`);
150
150
  await fs.mkdir(publicOutputPath, { recursive: true });
151
- const usedHashes = new Set(Object.keys(metadata.entries));
151
+ const usedHashes = new Set(Object.keys(metadata));
152
152
  logger.info(`📊 Filtering translations to ${usedHashes.size} used hash(es)`);
153
153
  const allLocales = config.pluralization?.enabled === true ? [config.sourceLocale, ...config.targetLocales] : config.targetLocales;
154
154
  for (const locale of allLocales) {