transduck 0.2.5 → 0.3.1

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  AI-powered translation for apps and websites. Fast. Cheap. No keys to manage.
4
4
 
5
- TransDuck translates your app's strings using AI, then caches them locally in DuckDB so you never pay for the same translation twice. No translation files. No key naming. No sync headaches.
5
+ TransDuck translates your app's strings using AI, then caches them locally in LMDB so you never pay for the same translation twice. No translation files. No key naming. No sync headaches.
6
6
 
7
7
  ```typescript
8
8
  import { initialize, setLanguage, ait, aitPlural } from 'transduck';
@@ -19,7 +19,7 @@ await aitPlural('{count} night', '{count} nights', 7); // → "7 Nächte"
19
19
 
20
20
  - **No keys to manage** — your source text is the key
21
21
  - **No translation files** — translations live in a single in-memory database file
22
- - **Translate once, pay once** — cached lookups take ~1.5ms, zero API costs after first call
22
+ - **Translate once, pay once** — cached lookups take ~0.015ms, zero API costs after first call
23
23
  - **AI that understands context** — project and per-string context for accurate translations
24
24
  - **Pluralization that works everywhere** — handles Russian (4 forms), Arabic (6 forms) automatically
25
25
  - **Built for vibe coding** — AI coding tools read the docs and wrap strings with `ait()` across your project
@@ -80,7 +80,7 @@ transduck stats # check your translation database
80
80
  pip install transduck
81
81
  ```
82
82
 
83
- Works with Django, Flask, FastAPI, and Jinja. Same DuckDB database format — share translations between Python and JS.
83
+ Works with Django, Flask, FastAPI, and Jinja. Same LMDB database format — share translations between Python and JS.
84
84
 
85
85
  ## Links
86
86
 
package/dist/cli.js CHANGED
@@ -18,7 +18,7 @@ export async function runInit(opts) {
18
18
  const config = {
19
19
  project: { name: opts.name, context: opts.context },
20
20
  languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
21
- storage: { path: './translations.duckdb' },
21
+ storage: { path: './translations.lmdb' },
22
22
  };
23
23
  if (providerChoice === 2) {
24
24
  config.backend = {
@@ -46,7 +46,7 @@ export async function runInit(opts) {
46
46
  }
47
47
  const configPath = join(opts.dir, 'transduck.yaml');
48
48
  writeFileSync(configPath, yamlStringify(config));
49
- const dbPath = join(opts.dir, 'translations.duckdb');
49
+ const dbPath = join(opts.dir, 'translations.lmdb');
50
50
  const store = new TranslationStore(dbPath);
51
51
  await store.initialize();
52
52
  store.close();
@@ -454,7 +454,7 @@ export async function runStats(opts) {
454
454
  }
455
455
  // CLI entry point
456
456
  const program = new Command();
457
- program.name('transduck').description('AI-native translation tool').version('0.2.5');
457
+ program.name('transduck').description('AI-native translation tool').version('0.3.1');
458
458
  program.command('init')
459
459
  .description('Initialize a new transduck project')
460
460
  .action(async () => {
package/dist/storage.d.ts CHANGED
@@ -23,18 +23,23 @@ interface Stats {
23
23
  }
24
24
  export declare class TranslationStore {
25
25
  private dbPath;
26
- private instance;
27
- private conn;
26
+ private db;
28
27
  constructor(dbPath: string);
29
- private getConn;
30
- private convertRow;
31
- query(sql: string, params?: string[]): Promise<Record<string, unknown>[]>;
32
28
  initialize(): Promise<void>;
29
+ private getDb;
33
30
  lookup(params: LookupParams): Promise<string | null>;
34
31
  insert(params: InsertParams): Promise<void>;
35
32
  lookupPlural(params: LookupParams): Promise<Record<string, string>>;
36
33
  insertPlural(params: InsertPluralParams): Promise<void>;
37
34
  stats(): Promise<Stats>;
35
+ /**
36
+ * Count entries matching optional filters.
37
+ */
38
+ count(targetLang?: string, failedOnly?: boolean): number;
39
+ /**
40
+ * Delete entries matching optional filters. Returns count deleted.
41
+ */
42
+ clear(targetLang?: string, failedOnly?: boolean): Promise<number>;
38
43
  close(): void;
39
44
  }
40
45
  export {};
package/dist/storage.js CHANGED
@@ -1,178 +1,162 @@
1
- import { DuckDBInstance, DuckDBResultReader } from '@duckdb/node-api';
2
- const SCHEMA_V2 = `
3
- CREATE TABLE IF NOT EXISTS translations (
4
- source_text TEXT NOT NULL,
5
- source_lang TEXT NOT NULL,
6
- target_lang TEXT NOT NULL,
7
- project_context_hash TEXT NOT NULL,
8
- string_context_hash TEXT NOT NULL,
9
- plural_category TEXT NOT NULL DEFAULT '',
10
- translated_text TEXT NOT NULL,
11
- model TEXT NOT NULL,
12
- status TEXT NOT NULL DEFAULT 'translated',
13
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
- PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
15
- );
16
- `;
17
- const MIGRATION_V1_TO_V2 = `
18
- CREATE TABLE translations_v2 (
19
- source_text TEXT NOT NULL,
20
- source_lang TEXT NOT NULL,
21
- target_lang TEXT NOT NULL,
22
- project_context_hash TEXT NOT NULL,
23
- string_context_hash TEXT NOT NULL,
24
- plural_category TEXT NOT NULL DEFAULT '',
25
- translated_text TEXT NOT NULL,
26
- model TEXT NOT NULL,
27
- status TEXT NOT NULL DEFAULT 'translated',
28
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
- PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
30
- );
31
- INSERT INTO translations_v2
32
- SELECT source_text, source_lang, target_lang, project_context_hash,
33
- string_context_hash, '' as plural_category, translated_text,
34
- model, status, created_at
35
- FROM translations;
36
- DROP TABLE translations;
37
- ALTER TABLE translations_v2 RENAME TO translations;
38
- `;
39
- const CHECK_PLURAL_COLUMN = `
40
- SELECT column_name FROM information_schema.columns
41
- WHERE table_name = 'translations' AND column_name = 'plural_category'
42
- `;
43
- const LOOKUP_SQL = `
44
- SELECT translated_text FROM translations
45
- WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
46
- AND project_context_hash = $4 AND string_context_hash = $5
47
- AND plural_category = '' AND status = 'translated'
48
- `;
49
- const INSERT_SQL = `
50
- INSERT INTO translations
51
- (source_text, source_lang, target_lang, project_context_hash,
52
- string_context_hash, plural_category, translated_text, model, status)
53
- VALUES ($1, $2, $3, $4, $5, '', $6, $7, $8)
54
- ON CONFLICT DO NOTHING
55
- `;
56
- const LOOKUP_PLURAL_SQL = `
57
- SELECT plural_category, translated_text FROM translations
58
- WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
59
- AND project_context_hash = $4 AND string_context_hash = $5
60
- AND plural_category != '' AND status = 'translated'
61
- `;
62
- const INSERT_PLURAL_SQL = `
63
- INSERT INTO translations
64
- (source_text, source_lang, target_lang, project_context_hash,
65
- string_context_hash, plural_category, translated_text, model, status)
66
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
67
- ON CONFLICT DO NOTHING
68
- `;
1
+ import { open } from 'lmdb';
2
+ import { createHash } from 'crypto';
3
+ // 1 GB default map size — plenty for translation caches
4
+ const DEFAULT_MAP_SIZE = 1024 * 1024 * 1024;
5
+ function contentHash(sourceText, projectContextHash, stringContextHash) {
6
+ return createHash('sha256')
7
+ .update(sourceText + '\x00' + projectContextHash + '\x00' + stringContextHash)
8
+ .digest('hex');
9
+ }
10
+ function makeKey(sourceLang, targetLang, chash, pluralCategory) {
11
+ return `${sourceLang}|${targetLang}|${chash}|${pluralCategory}`;
12
+ }
13
+ function parseKey(key) {
14
+ const parts = key.split('|');
15
+ return {
16
+ sourceLang: parts[0],
17
+ targetLang: parts[1],
18
+ contentHash: parts[2],
19
+ pluralCategory: parts[3] ?? '',
20
+ };
21
+ }
69
22
  export class TranslationStore {
70
23
  dbPath;
71
- instance = null;
72
- conn = null;
24
+ db = null;
73
25
  constructor(dbPath) {
74
26
  this.dbPath = dbPath;
75
27
  }
76
- async getConn() {
77
- if (!this.conn)
78
- throw new Error('Store not initialized');
79
- return this.conn;
80
- }
81
- convertRow(row) {
82
- const converted = {};
83
- for (const [key, value] of Object.entries(row)) {
84
- if (typeof value === 'bigint') {
85
- converted[key] = Number(value);
86
- }
87
- else {
88
- converted[key] = value;
89
- }
90
- }
91
- return converted;
92
- }
93
- async query(sql, params = []) {
94
- const conn = await this.getConn();
95
- let result;
96
- if (params.length === 0) {
97
- result = await conn.run(sql);
98
- }
99
- else {
100
- const stmt = await conn.prepare(sql);
101
- for (let i = 0; i < params.length; i++) {
102
- stmt.bindVarchar(i + 1, params[i]);
103
- }
104
- result = await stmt.run();
105
- }
106
- const reader = new DuckDBResultReader(result);
107
- await reader.readAll();
108
- return [...reader.getRowObjects()].map(row => this.convertRow(row));
109
- }
110
28
  async initialize() {
111
- this.instance = await DuckDBInstance.create(this.dbPath);
112
- this.conn = await this.instance.connect();
113
- // Check if table exists
114
- const tables = await this.query("SELECT table_name FROM information_schema.tables WHERE table_name = 'translations'");
115
- if (tables.length === 0) {
116
- // Fresh database — create v2 schema
117
- await this.conn.run(SCHEMA_V2);
118
- return;
119
- }
120
- // Table exists — check if it has plural_category (v2)
121
- const hasPlural = await this.query(CHECK_PLURAL_COLUMN);
122
- if (hasPlural.length === 0) {
123
- // Migrate from v1 to v2
124
- await this.conn.run(MIGRATION_V1_TO_V2);
125
- }
29
+ this.db = open({
30
+ path: this.dbPath,
31
+ mapSize: DEFAULT_MAP_SIZE,
32
+ // Use msgpack (default) for efficient storage
33
+ });
34
+ }
35
+ getDb() {
36
+ if (!this.db)
37
+ throw new Error('Store not initialized');
38
+ return this.db;
126
39
  }
127
40
  async lookup(params) {
128
- const rows = await this.query(LOOKUP_SQL, [
129
- params.sourceText, params.sourceLang, params.targetLang,
130
- params.projectContextHash, params.stringContextHash,
131
- ]);
132
- return rows.length > 0 ? rows[0].translated_text : null;
41
+ const db = this.getDb();
42
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
43
+ const key = makeKey(params.sourceLang, params.targetLang, chash, '');
44
+ const entry = db.get(key);
45
+ if (entry && entry.status === 'translated') {
46
+ return entry.translated_text;
47
+ }
48
+ return null;
133
49
  }
134
50
  async insert(params) {
135
- await this.query(INSERT_SQL, [
136
- params.sourceText, params.sourceLang, params.targetLang,
137
- params.projectContextHash, params.stringContextHash,
138
- params.translatedText, params.model, params.status,
139
- ]);
51
+ const db = this.getDb();
52
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
53
+ const key = makeKey(params.sourceLang, params.targetLang, chash, '');
54
+ // ON CONFLICT DO NOTHING — only insert if key doesn't exist
55
+ if (db.get(key) !== undefined)
56
+ return;
57
+ await db.put(key, {
58
+ source_text: params.sourceText,
59
+ translated_text: params.translatedText,
60
+ model: params.model,
61
+ status: params.status,
62
+ created_at: new Date().toISOString(),
63
+ project_context_hash: params.projectContextHash,
64
+ string_context_hash: params.stringContextHash,
65
+ });
140
66
  }
141
67
  async lookupPlural(params) {
142
- const rows = await this.query(LOOKUP_PLURAL_SQL, [
143
- params.sourceText, params.sourceLang, params.targetLang,
144
- params.projectContextHash, params.stringContextHash,
145
- ]);
68
+ const db = this.getDb();
69
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
70
+ const prefix = makeKey(params.sourceLang, params.targetLang, chash, '');
146
71
  const result = {};
147
- for (const row of rows) {
148
- result[row.plural_category] = row.translated_text;
72
+ for (const { key, value } of db.getRange({ start: prefix })) {
73
+ const keyStr = key;
74
+ if (!keyStr.startsWith(prefix))
75
+ break;
76
+ const parsed = parseKey(keyStr);
77
+ // Skip regular entries (empty plural_category)
78
+ if (!parsed.pluralCategory)
79
+ continue;
80
+ if (value.status === 'translated') {
81
+ result[parsed.pluralCategory] = value.translated_text;
82
+ }
149
83
  }
150
84
  return result;
151
85
  }
152
86
  async insertPlural(params) {
153
- await this.query(INSERT_PLURAL_SQL, [
154
- params.sourceText, params.sourceLang, params.targetLang,
155
- params.projectContextHash, params.stringContextHash,
156
- params.pluralCategory, params.translatedText, params.model, params.status,
157
- ]);
87
+ const db = this.getDb();
88
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
89
+ const key = makeKey(params.sourceLang, params.targetLang, chash, params.pluralCategory);
90
+ // ON CONFLICT DO NOTHING
91
+ if (db.get(key) !== undefined)
92
+ return;
93
+ await db.put(key, {
94
+ source_text: params.sourceText,
95
+ translated_text: params.translatedText,
96
+ model: params.model,
97
+ status: params.status,
98
+ created_at: new Date().toISOString(),
99
+ project_context_hash: params.projectContextHash,
100
+ string_context_hash: params.stringContextHash,
101
+ });
158
102
  }
159
103
  async stats() {
160
- const totalRows = await this.query("SELECT count(*) as c FROM translations WHERE status = 'translated'");
161
- const failedRows = await this.query("SELECT count(*) as c FROM translations WHERE status = 'failed'");
162
- const langRows = await this.query("SELECT target_lang, count(*) as c FROM translations WHERE status = 'translated' GROUP BY target_lang");
104
+ const db = this.getDb();
105
+ let totalTranslations = 0;
106
+ let totalFailed = 0;
163
107
  const byLanguage = {};
164
- for (const row of langRows) {
165
- byLanguage[row.target_lang] = Number(row.c);
108
+ for (const { key, value } of db.getRange({})) {
109
+ const parsed = parseKey(key);
110
+ if (value.status === 'translated') {
111
+ totalTranslations++;
112
+ byLanguage[parsed.targetLang] = (byLanguage[parsed.targetLang] ?? 0) + 1;
113
+ }
114
+ else if (value.status === 'failed') {
115
+ totalFailed++;
116
+ }
117
+ }
118
+ return { totalTranslations, totalFailed, byLanguage };
119
+ }
120
+ /**
121
+ * Count entries matching optional filters.
122
+ */
123
+ count(targetLang, failedOnly) {
124
+ const db = this.getDb();
125
+ let n = 0;
126
+ for (const { key, value } of db.getRange({})) {
127
+ const parsed = parseKey(key);
128
+ if (targetLang && parsed.targetLang !== targetLang)
129
+ continue;
130
+ if (failedOnly && value.status !== 'failed')
131
+ continue;
132
+ n++;
166
133
  }
167
- return {
168
- totalTranslations: Number(totalRows[0].c),
169
- totalFailed: Number(failedRows[0].c),
170
- byLanguage,
171
- };
134
+ return n;
135
+ }
136
+ /**
137
+ * Delete entries matching optional filters. Returns count deleted.
138
+ */
139
+ async clear(targetLang, failedOnly) {
140
+ const db = this.getDb();
141
+ const toDelete = [];
142
+ for (const { key, value } of db.getRange({})) {
143
+ const keyStr = key;
144
+ const parsed = parseKey(keyStr);
145
+ if (targetLang && parsed.targetLang !== targetLang)
146
+ continue;
147
+ if (failedOnly && value.status !== 'failed')
148
+ continue;
149
+ toDelete.push(keyStr);
150
+ }
151
+ for (const k of toDelete) {
152
+ await db.remove(k);
153
+ }
154
+ return toDelete.length;
172
155
  }
173
156
  close() {
174
- // @duckdb/node-api handles cleanup via GC
175
- this.conn = null;
176
- this.instance = null;
157
+ if (this.db) {
158
+ this.db.close();
159
+ this.db = null;
160
+ }
177
161
  }
178
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,16 +24,16 @@
24
24
  "test:watch": "vitest"
25
25
  },
26
26
  "dependencies": {
27
- "@duckdb/node-api": "^1.5.0-r.1",
28
27
  "commander": "^12.0.0",
28
+ "lmdb": "^3.5.2",
29
29
  "openai": "^4.0.0",
30
30
  "yaml": "^2.0.0"
31
31
  },
32
32
  "devDependencies": {
33
+ "@testing-library/react": "^16.0.0",
33
34
  "@types/node": "^22.0.0",
34
35
  "@types/react": "^19.0.0",
35
36
  "@types/react-dom": "^19.0.0",
36
- "@testing-library/react": "^16.0.0",
37
37
  "jsdom": "^25.0.0",
38
38
  "react": "^19.0.0",
39
39
  "react-dom": "^19.0.0",
@@ -41,15 +41,23 @@
41
41
  "vitest": "^2.0.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "react": ">=18.0.0",
45
- "react-dom": ">=18.0.0",
44
+ "@anthropic-ai/claude-agent-sdk": ">=0.1.0",
46
45
  "@anthropic-ai/sdk": ">=0.30.0",
47
- "@anthropic-ai/claude-agent-sdk": ">=0.1.0"
46
+ "react": ">=18.0.0",
47
+ "react-dom": ">=18.0.0"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
- "react": { "optional": true },
51
- "react-dom": { "optional": true },
52
- "@anthropic-ai/sdk": { "optional": true },
53
- "@anthropic-ai/claude-agent-sdk": { "optional": true }
50
+ "react": {
51
+ "optional": true
52
+ },
53
+ "react-dom": {
54
+ "optional": true
55
+ },
56
+ "@anthropic-ai/sdk": {
57
+ "optional": true
58
+ },
59
+ "@anthropic-ai/claude-agent-sdk": {
60
+ "optional": true
61
+ }
54
62
  }
55
63
  }
package/src/cli.ts CHANGED
@@ -31,7 +31,7 @@ export async function runInit(opts: InitOptions): Promise<string> {
31
31
  const config: Record<string, any> = {
32
32
  project: { name: opts.name, context: opts.context },
33
33
  languages: { source: opts.sourceLang.toUpperCase(), targets: opts.targetLangs.map(l => l.toUpperCase()) },
34
- storage: { path: './translations.duckdb' },
34
+ storage: { path: './translations.lmdb' },
35
35
  };
36
36
 
37
37
  if (providerChoice === 2) {
@@ -60,7 +60,7 @@ export async function runInit(opts: InitOptions): Promise<string> {
60
60
  const configPath = join(opts.dir, 'transduck.yaml');
61
61
  writeFileSync(configPath, yamlStringify(config));
62
62
 
63
- const dbPath = join(opts.dir, 'translations.duckdb');
63
+ const dbPath = join(opts.dir, 'translations.lmdb');
64
64
  const store = new TranslationStore(dbPath);
65
65
  await store.initialize();
66
66
  store.close();
@@ -562,7 +562,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
562
562
  // CLI entry point
563
563
  const program = new Command();
564
564
 
565
- program.name('transduck').description('AI-native translation tool').version('0.2.5');
565
+ program.name('transduck').description('AI-native translation tool').version('0.3.1');
566
566
 
567
567
  program.command('init')
568
568
  .description('Initialize a new transduck project')
package/src/storage.ts CHANGED
@@ -1,79 +1,8 @@
1
- import { DuckDBInstance, DuckDBResultReader } from '@duckdb/node-api';
2
- import type { DuckDBConnection } from '@duckdb/node-api';
3
-
4
- const SCHEMA_V2 = `
5
- CREATE TABLE IF NOT EXISTS translations (
6
- source_text TEXT NOT NULL,
7
- source_lang TEXT NOT NULL,
8
- target_lang TEXT NOT NULL,
9
- project_context_hash TEXT NOT NULL,
10
- string_context_hash TEXT NOT NULL,
11
- plural_category TEXT NOT NULL DEFAULT '',
12
- translated_text TEXT NOT NULL,
13
- model TEXT NOT NULL,
14
- status TEXT NOT NULL DEFAULT 'translated',
15
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
- PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
17
- );
18
- `;
19
-
20
- const MIGRATION_V1_TO_V2 = `
21
- CREATE TABLE translations_v2 (
22
- source_text TEXT NOT NULL,
23
- source_lang TEXT NOT NULL,
24
- target_lang TEXT NOT NULL,
25
- project_context_hash TEXT NOT NULL,
26
- string_context_hash TEXT NOT NULL,
27
- plural_category TEXT NOT NULL DEFAULT '',
28
- translated_text TEXT NOT NULL,
29
- model TEXT NOT NULL,
30
- status TEXT NOT NULL DEFAULT 'translated',
31
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
32
- PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash, plural_category)
33
- );
34
- INSERT INTO translations_v2
35
- SELECT source_text, source_lang, target_lang, project_context_hash,
36
- string_context_hash, '' as plural_category, translated_text,
37
- model, status, created_at
38
- FROM translations;
39
- DROP TABLE translations;
40
- ALTER TABLE translations_v2 RENAME TO translations;
41
- `;
42
-
43
- const CHECK_PLURAL_COLUMN = `
44
- SELECT column_name FROM information_schema.columns
45
- WHERE table_name = 'translations' AND column_name = 'plural_category'
46
- `;
47
-
48
- const LOOKUP_SQL = `
49
- SELECT translated_text FROM translations
50
- WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
51
- AND project_context_hash = $4 AND string_context_hash = $5
52
- AND plural_category = '' AND status = 'translated'
53
- `;
54
-
55
- const INSERT_SQL = `
56
- INSERT INTO translations
57
- (source_text, source_lang, target_lang, project_context_hash,
58
- string_context_hash, plural_category, translated_text, model, status)
59
- VALUES ($1, $2, $3, $4, $5, '', $6, $7, $8)
60
- ON CONFLICT DO NOTHING
61
- `;
62
-
63
- const LOOKUP_PLURAL_SQL = `
64
- SELECT plural_category, translated_text FROM translations
65
- WHERE source_text = $1 AND source_lang = $2 AND target_lang = $3
66
- AND project_context_hash = $4 AND string_context_hash = $5
67
- AND plural_category != '' AND status = 'translated'
68
- `;
69
-
70
- const INSERT_PLURAL_SQL = `
71
- INSERT INTO translations
72
- (source_text, source_lang, target_lang, project_context_hash,
73
- string_context_hash, plural_category, translated_text, model, status)
74
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
75
- ON CONFLICT DO NOTHING
76
- `;
1
+ import { open, type Database, type RootDatabase } from 'lmdb';
2
+ import { createHash } from 'crypto';
3
+
4
+ // 1 GB default map size — plenty for translation caches
5
+ const DEFAULT_MAP_SIZE = 1024 * 1024 * 1024;
77
6
 
78
7
  export interface LookupParams {
79
8
  sourceText: string;
@@ -96,134 +25,184 @@ export interface InsertPluralParams extends LookupParams {
96
25
  status: string;
97
26
  }
98
27
 
28
+ interface StoredEntry {
29
+ source_text: string;
30
+ translated_text: string;
31
+ model: string;
32
+ status: string;
33
+ created_at: string;
34
+ project_context_hash: string;
35
+ string_context_hash: string;
36
+ }
37
+
99
38
  interface Stats {
100
39
  totalTranslations: number;
101
40
  totalFailed: number;
102
41
  byLanguage: Record<string, number>;
103
42
  }
104
43
 
44
+ function contentHash(sourceText: string, projectContextHash: string, stringContextHash: string): string {
45
+ return createHash('sha256')
46
+ .update(sourceText + '\x00' + projectContextHash + '\x00' + stringContextHash)
47
+ .digest('hex');
48
+ }
49
+
50
+ function makeKey(sourceLang: string, targetLang: string, chash: string, pluralCategory: string): string {
51
+ return `${sourceLang}|${targetLang}|${chash}|${pluralCategory}`;
52
+ }
53
+
54
+ function parseKey(key: string): { sourceLang: string; targetLang: string; contentHash: string; pluralCategory: string } {
55
+ const parts = key.split('|');
56
+ return {
57
+ sourceLang: parts[0],
58
+ targetLang: parts[1],
59
+ contentHash: parts[2],
60
+ pluralCategory: parts[3] ?? '',
61
+ };
62
+ }
63
+
105
64
  export class TranslationStore {
106
65
  private dbPath: string;
107
- private instance: DuckDBInstance | null = null;
108
- private conn: DuckDBConnection | null = null;
66
+ private db: RootDatabase<StoredEntry, string> | null = null;
109
67
 
110
68
  constructor(dbPath: string) {
111
69
  this.dbPath = dbPath;
112
70
  }
113
71
 
114
- private async getConn(): Promise<DuckDBConnection> {
115
- if (!this.conn) throw new Error('Store not initialized');
116
- return this.conn;
117
- }
118
-
119
- private convertRow(row: Record<string, unknown>): Record<string, unknown> {
120
- const converted: Record<string, unknown> = {};
121
- for (const [key, value] of Object.entries(row)) {
122
- if (typeof value === 'bigint') {
123
- converted[key] = Number(value);
124
- } else {
125
- converted[key] = value;
126
- }
127
- }
128
- return converted;
129
- }
130
-
131
- async query(sql: string, params: string[] = []): Promise<Record<string, unknown>[]> {
132
- const conn = await this.getConn();
133
- let result;
134
- if (params.length === 0) {
135
- result = await conn.run(sql);
136
- } else {
137
- const stmt = await conn.prepare(sql);
138
- for (let i = 0; i < params.length; i++) {
139
- stmt.bindVarchar(i + 1, params[i]);
140
- }
141
- result = await stmt.run();
142
- }
143
- const reader = new DuckDBResultReader(result);
144
- await reader.readAll();
145
- return [...reader.getRowObjects()].map(row => this.convertRow(row));
146
- }
147
-
148
72
  async initialize(): Promise<void> {
149
- this.instance = await DuckDBInstance.create(this.dbPath);
150
- this.conn = await this.instance.connect();
151
-
152
- // Check if table exists
153
- const tables = await this.query(
154
- "SELECT table_name FROM information_schema.tables WHERE table_name = 'translations'"
155
- );
156
-
157
- if (tables.length === 0) {
158
- // Fresh database — create v2 schema
159
- await this.conn.run(SCHEMA_V2);
160
- return;
161
- }
73
+ this.db = open<StoredEntry, string>({
74
+ path: this.dbPath,
75
+ mapSize: DEFAULT_MAP_SIZE,
76
+ // Use msgpack (default) for efficient storage
77
+ });
78
+ }
162
79
 
163
- // Table exists check if it has plural_category (v2)
164
- const hasPlural = await this.query(CHECK_PLURAL_COLUMN);
165
- if (hasPlural.length === 0) {
166
- // Migrate from v1 to v2
167
- await this.conn.run(MIGRATION_V1_TO_V2);
168
- }
80
+ private getDb(): RootDatabase<StoredEntry, string> {
81
+ if (!this.db) throw new Error('Store not initialized');
82
+ return this.db;
169
83
  }
170
84
 
171
85
  async lookup(params: LookupParams): Promise<string | null> {
172
- const rows = await this.query(LOOKUP_SQL, [
173
- params.sourceText, params.sourceLang, params.targetLang,
174
- params.projectContextHash, params.stringContextHash,
175
- ]);
176
- return rows.length > 0 ? (rows[0].translated_text as string) : null;
86
+ const db = this.getDb();
87
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
88
+ const key = makeKey(params.sourceLang, params.targetLang, chash, '');
89
+ const entry = db.get(key);
90
+ if (entry && entry.status === 'translated') {
91
+ return entry.translated_text;
92
+ }
93
+ return null;
177
94
  }
178
95
 
179
96
  async insert(params: InsertParams): Promise<void> {
180
- await this.query(INSERT_SQL, [
181
- params.sourceText, params.sourceLang, params.targetLang,
182
- params.projectContextHash, params.stringContextHash,
183
- params.translatedText, params.model, params.status,
184
- ]);
97
+ const db = this.getDb();
98
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
99
+ const key = makeKey(params.sourceLang, params.targetLang, chash, '');
100
+ // ON CONFLICT DO NOTHING — only insert if key doesn't exist
101
+ if (db.get(key) !== undefined) return;
102
+ await db.put(key, {
103
+ source_text: params.sourceText,
104
+ translated_text: params.translatedText,
105
+ model: params.model,
106
+ status: params.status,
107
+ created_at: new Date().toISOString(),
108
+ project_context_hash: params.projectContextHash,
109
+ string_context_hash: params.stringContextHash,
110
+ });
185
111
  }
186
112
 
187
113
  async lookupPlural(params: LookupParams): Promise<Record<string, string>> {
188
- const rows = await this.query(LOOKUP_PLURAL_SQL, [
189
- params.sourceText, params.sourceLang, params.targetLang,
190
- params.projectContextHash, params.stringContextHash,
191
- ]);
114
+ const db = this.getDb();
115
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
116
+ const prefix = makeKey(params.sourceLang, params.targetLang, chash, '');
192
117
  const result: Record<string, string> = {};
193
- for (const row of rows) {
194
- result[row.plural_category as string] = row.translated_text as string;
118
+
119
+ for (const { key, value } of db.getRange({ start: prefix })) {
120
+ const keyStr = key as string;
121
+ if (!keyStr.startsWith(prefix)) break;
122
+ const parsed = parseKey(keyStr);
123
+ // Skip regular entries (empty plural_category)
124
+ if (!parsed.pluralCategory) continue;
125
+ if (value.status === 'translated') {
126
+ result[parsed.pluralCategory] = value.translated_text;
127
+ }
195
128
  }
196
129
  return result;
197
130
  }
198
131
 
199
132
  async insertPlural(params: InsertPluralParams): Promise<void> {
200
- await this.query(INSERT_PLURAL_SQL, [
201
- params.sourceText, params.sourceLang, params.targetLang,
202
- params.projectContextHash, params.stringContextHash,
203
- params.pluralCategory, params.translatedText, params.model, params.status,
204
- ]);
133
+ const db = this.getDb();
134
+ const chash = contentHash(params.sourceText, params.projectContextHash, params.stringContextHash);
135
+ const key = makeKey(params.sourceLang, params.targetLang, chash, params.pluralCategory);
136
+ // ON CONFLICT DO NOTHING
137
+ if (db.get(key) !== undefined) return;
138
+ await db.put(key, {
139
+ source_text: params.sourceText,
140
+ translated_text: params.translatedText,
141
+ model: params.model,
142
+ status: params.status,
143
+ created_at: new Date().toISOString(),
144
+ project_context_hash: params.projectContextHash,
145
+ string_context_hash: params.stringContextHash,
146
+ });
205
147
  }
206
148
 
207
149
  async stats(): Promise<Stats> {
208
- const totalRows = await this.query("SELECT count(*) as c FROM translations WHERE status = 'translated'");
209
- const failedRows = await this.query("SELECT count(*) as c FROM translations WHERE status = 'failed'");
210
- const langRows = await this.query(
211
- "SELECT target_lang, count(*) as c FROM translations WHERE status = 'translated' GROUP BY target_lang"
212
- );
150
+ const db = this.getDb();
151
+ let totalTranslations = 0;
152
+ let totalFailed = 0;
213
153
  const byLanguage: Record<string, number> = {};
214
- for (const row of langRows) {
215
- byLanguage[row.target_lang as string] = Number(row.c);
154
+
155
+ for (const { key, value } of db.getRange({})) {
156
+ const parsed = parseKey(key as string);
157
+ if (value.status === 'translated') {
158
+ totalTranslations++;
159
+ byLanguage[parsed.targetLang] = (byLanguage[parsed.targetLang] ?? 0) + 1;
160
+ } else if (value.status === 'failed') {
161
+ totalFailed++;
162
+ }
216
163
  }
217
- return {
218
- totalTranslations: Number(totalRows[0].c),
219
- totalFailed: Number(failedRows[0].c),
220
- byLanguage,
221
- };
164
+
165
+ return { totalTranslations, totalFailed, byLanguage };
166
+ }
167
+
168
+ /**
169
+ * Count entries matching optional filters.
170
+ */
171
+ count(targetLang?: string, failedOnly?: boolean): number {
172
+ const db = this.getDb();
173
+ let n = 0;
174
+ for (const { key, value } of db.getRange({})) {
175
+ const parsed = parseKey(key as string);
176
+ if (targetLang && parsed.targetLang !== targetLang) continue;
177
+ if (failedOnly && value.status !== 'failed') continue;
178
+ n++;
179
+ }
180
+ return n;
181
+ }
182
+
183
+ /**
184
+ * Delete entries matching optional filters. Returns count deleted.
185
+ */
186
+ async clear(targetLang?: string, failedOnly?: boolean): Promise<number> {
187
+ const db = this.getDb();
188
+ const toDelete: string[] = [];
189
+ for (const { key, value } of db.getRange({})) {
190
+ const keyStr = key as string;
191
+ const parsed = parseKey(keyStr);
192
+ if (targetLang && parsed.targetLang !== targetLang) continue;
193
+ if (failedOnly && value.status !== 'failed') continue;
194
+ toDelete.push(keyStr);
195
+ }
196
+ for (const k of toDelete) {
197
+ await db.remove(k);
198
+ }
199
+ return toDelete.length;
222
200
  }
223
201
 
224
202
  close(): void {
225
- // @duckdb/node-api handles cleanup via GC
226
- this.conn = null;
227
- this.instance = null;
203
+ if (this.db) {
204
+ this.db.close();
205
+ this.db = null;
206
+ }
228
207
  }
229
208
  }
package/tests/ait.test.ts CHANGED
@@ -15,7 +15,7 @@ function makeConfig(tmpDir: string): TransduckConfig {
15
15
  projectContext: 'A test site',
16
16
  sourceLang: 'EN',
17
17
  targetLangs: ['DE', 'ES'],
18
- storagePath: join(tmpDir, 'test.duckdb'),
18
+ storagePath: join(tmpDir, 'test.lmdb'),
19
19
  provider: 'openai',
20
20
  apiKeyEnv: 'OPENAI_API_KEY',
21
21
  tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
@@ -8,7 +8,7 @@ function makeConfig(): TransduckConfig {
8
8
  projectContext: 'A travel site about Mallorca',
9
9
  sourceLang: 'EN',
10
10
  targetLangs: ['DE'],
11
- storagePath: '/tmp/test.duckdb',
11
+ storagePath: '/tmp/test.lmdb',
12
12
  provider: 'openai',
13
13
  apiKeyEnv: 'OPENAI_API_KEY',
14
14
  tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
package/tests/cli.test.ts CHANGED
@@ -27,7 +27,7 @@ languages:
27
27
  targets:
28
28
  - DE
29
29
  storage:
30
- path: ./translations.duckdb
30
+ path: ./translations.lmdb
31
31
  backend:
32
32
  api_key_env: OPENAI_API_KEY
33
33
  model: gpt-4.1-mini
@@ -55,7 +55,7 @@ describe('CLI functions', () => {
55
55
  });
56
56
  const { existsSync } = await import('fs');
57
57
  expect(existsSync(join(tmpDir, 'transduck.yaml'))).toBe(true);
58
- expect(existsSync(join(tmpDir, 'translations.duckdb'))).toBe(true);
58
+ expect(existsSync(join(tmpDir, 'translations.lmdb'))).toBe(true);
59
59
  });
60
60
 
61
61
  it('stats on empty db shows zero', async () => {
@@ -68,7 +68,7 @@ describe('CLI functions', () => {
68
68
 
69
69
  it('translate returns cached with vars interpolated', async () => {
70
70
  const configPath = writeConfig(tmpDir);
71
- const dbPath = join(tmpDir, 'translations.duckdb');
71
+ const dbPath = join(tmpDir, 'translations.lmdb');
72
72
 
73
73
  // Pre-populate the DB
74
74
  const store = new TranslationStore(dbPath);
@@ -91,7 +91,7 @@ describe('CLI functions', () => {
91
91
 
92
92
  it('translate-plural returns cached plural form', async () => {
93
93
  const configPath = writeConfig(tmpDir);
94
- const dbPath = join(tmpDir, 'translations.duckdb');
94
+ const dbPath = join(tmpDir, 'translations.lmdb');
95
95
 
96
96
  // Pre-populate the DB with plural forms
97
97
  const store = new TranslationStore(dbPath);
@@ -132,7 +132,7 @@ describe('CLI functions', () => {
132
132
 
133
133
  it('warm handles plural entries', async () => {
134
134
  const configPath = writeConfig(tmpDir);
135
- const dbPath = join(tmpDir, 'translations.duckdb');
135
+ const dbPath = join(tmpDir, 'translations.lmdb');
136
136
 
137
137
  // Pre-populate some plural forms so warm skips them
138
138
  const store = new TranslationStore(dbPath);
@@ -202,7 +202,7 @@ describe('CLI functions', () => {
202
202
  writeFileSync(join(srcDir, 'app.py'), 'ait("Hello")\n');
203
203
 
204
204
  // Pre-populate the DB so warm skips
205
- const dbPath = join(tmpDir, 'translations.duckdb');
205
+ const dbPath = join(tmpDir, 'translations.lmdb');
206
206
  const store = new TranslationStore(dbPath);
207
207
  await store.initialize();
208
208
  await store.insert({
@@ -19,7 +19,7 @@ languages:
19
19
  - DE
20
20
  - ES
21
21
  storage:
22
- path: ./translations.duckdb
22
+ path: ./translations.lmdb
23
23
  backend:
24
24
  api_key_env: OPENAI_API_KEY
25
25
  model: gpt-4.1-mini
@@ -60,7 +60,7 @@ describe('loadConfig', () => {
60
60
  const configPath = join(tmpDir, 'transduck.yaml');
61
61
  writeFileSync(configPath, VALID_YAML);
62
62
  const cfg = loadConfig(configPath);
63
- expect(cfg.storagePath).toBe(join(tmpDir, 'translations.duckdb'));
63
+ expect(cfg.storagePath).toBe(join(tmpDir, 'translations.lmdb'));
64
64
  });
65
65
 
66
66
  it('discovers config from TRANSDUCK_CONFIG env var', () => {
@@ -21,7 +21,7 @@ languages:
21
21
  targets:
22
22
  - DE
23
23
  storage:
24
- path: ./translations.duckdb
24
+ path: ./translations.lmdb
25
25
  backend:
26
26
  api_key_env: OPENAI_API_KEY
27
27
  model: gpt-4.1-mini
@@ -48,7 +48,7 @@ describe('handleTranslationRequest', () => {
48
48
  const { createHash } = await import('crypto');
49
49
  const hash = (t: string) => createHash('sha256').update(t).digest('hex');
50
50
 
51
- const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
51
+ const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
52
52
  await store.initialize();
53
53
  await store.insert({
54
54
  sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
@@ -83,7 +83,7 @@ describe('handleTranslationRequest', () => {
83
83
  const { createHash } = await import('crypto');
84
84
  const hash = (t: string) => createHash('sha256').update(t).digest('hex');
85
85
 
86
- const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
86
+ const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
87
87
  await store.initialize();
88
88
  await store.insert({
89
89
  sourceText: 'Book', sourceLang: 'EN', targetLang: 'DE',
@@ -106,7 +106,7 @@ describe('handleTranslationRequest', () => {
106
106
  const { createHash } = await import('crypto');
107
107
  const hash = (t: string) => createHash('sha256').update(t).digest('hex');
108
108
 
109
- const store = new TranslationStore(join(tmpDir, 'translations.duckdb'));
109
+ const store = new TranslationStore(join(tmpDir, 'translations.lmdb'));
110
110
  await store.initialize();
111
111
  const sourceKey = '{count} item\x00{count} items';
112
112
  await store.insertPlural({
@@ -20,7 +20,7 @@ function makeConfig(overrides: Partial<TransduckConfig> = {}): TransduckConfig {
20
20
  projectContext: 'A test site',
21
21
  sourceLang: 'EN',
22
22
  targetLangs: ['DE'],
23
- storagePath: '/tmp/test.duckdb',
23
+ storagePath: '/tmp/test.lmdb',
24
24
  provider: 'openai',
25
25
  apiKeyEnv: 'OPENAI_API_KEY',
26
26
  tokenEnv: 'CLAUDE_CODE_OAUTH_TOKEN',
@@ -207,7 +207,7 @@ languages:
207
207
  source: EN
208
208
  targets: [DE]
209
209
  storage:
210
- path: ./translations.duckdb
210
+ path: ./translations.lmdb
211
211
  backend:
212
212
  api_key_env: OPENAI_API_KEY
213
213
  model: gpt-4.1-mini
@@ -230,7 +230,7 @@ languages:
230
230
  source: EN
231
231
  targets: [DE]
232
232
  storage:
233
- path: ./translations.duckdb
233
+ path: ./translations.lmdb
234
234
  backend:
235
235
  provider: claude_api
236
236
  api_key_env: ANTHROPIC_API_KEY
@@ -255,7 +255,7 @@ languages:
255
255
  source: EN
256
256
  targets: [DE]
257
257
  storage:
258
- path: ./translations.duckdb
258
+ path: ./translations.lmdb
259
259
  backend:
260
260
  provider: claude_code
261
261
  token_env: CLAUDE_CODE_OAUTH_TOKEN
@@ -277,7 +277,7 @@ languages:
277
277
  source: EN
278
278
  targets: [DE]
279
279
  storage:
280
- path: ./translations.duckdb
280
+ path: ./translations.lmdb
281
281
  backend:
282
282
  provider: claude_code
283
283
  `);
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { createHash } from 'crypto';
3
3
  import { join } from 'path';
4
4
  import { mkdtempSync } from 'fs';
@@ -14,13 +14,17 @@ describe('TranslationStore', () => {
14
14
 
15
15
  beforeEach(async () => {
16
16
  const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-test-'));
17
- store = new TranslationStore(join(tmpDir, 'test.duckdb'));
17
+ store = new TranslationStore(join(tmpDir, 'test.lmdb'));
18
18
  await store.initialize();
19
19
  });
20
20
 
21
- it('creates table on initialize', async () => {
22
- const result = await store.query('SELECT count(*) as c FROM translations');
23
- expect(result[0].c).toBe(0);
21
+ afterEach(() => {
22
+ store.close();
23
+ });
24
+
25
+ it('starts empty', async () => {
26
+ const stats = await store.stats();
27
+ expect(stats.totalTranslations).toBe(0);
24
28
  });
25
29
 
26
30
  it('inserts and looks up translation', async () => {
@@ -195,68 +199,32 @@ describe('TranslationStore', () => {
195
199
  await store.insertPlural(entry); // should not throw
196
200
  });
197
201
 
198
- // --- Migration ---
199
-
200
- it('migrates v1 schema to v2', async () => {
201
- // Create a fresh store with v1 schema
202
- const tmpDir = mkdtempSync(join(tmpdir(), 'transduck-migration-'));
203
- const dbPath = join(tmpDir, 'migrate.duckdb');
204
-
205
- // Manually create v1 schema (no plural_category column)
206
- const { DuckDBInstance } = await import('@duckdb/node-api');
207
- const instance = await DuckDBInstance.create(dbPath);
208
- const conn = await instance.connect();
209
- await conn.run(`
210
- CREATE TABLE translations (
211
- source_text TEXT NOT NULL,
212
- source_lang TEXT NOT NULL,
213
- target_lang TEXT NOT NULL,
214
- project_context_hash TEXT NOT NULL,
215
- string_context_hash TEXT NOT NULL,
216
- translated_text TEXT NOT NULL,
217
- model TEXT NOT NULL,
218
- status TEXT NOT NULL DEFAULT 'translated',
219
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
220
- PRIMARY KEY(source_text, source_lang, target_lang, project_context_hash, string_context_hash)
221
- )
222
- `);
223
- // Insert a v1 row
224
- await conn.run(`
225
- INSERT INTO translations (source_text, source_lang, target_lang, project_context_hash, string_context_hash, translated_text, model, status)
226
- VALUES ('Hello', 'EN', 'DE', '${hash('ctx')}', '${hash('')}', 'Hallo', 'gpt-4.1-mini', 'translated')
227
- `);
228
-
229
- // Close the raw connection
230
- // (DuckDB node-api handles cleanup via GC, just null out)
202
+ // --- Count and clear ---
231
203
 
232
- // Now open with TranslationStore should migrate
233
- const migratedStore = new TranslationStore(dbPath);
234
- await migratedStore.initialize();
235
-
236
- // Old data should still be accessible
237
- const result = await migratedStore.lookup({
204
+ it('counts and clears entries', async () => {
205
+ await store.insert({
238
206
  sourceText: 'Hello', sourceLang: 'EN', targetLang: 'DE',
239
207
  projectContextHash: hash('ctx'), stringContextHash: hash(''),
208
+ translatedText: 'Hallo', model: 'gpt-4.1-mini', status: 'translated',
240
209
  });
241
- expect(result).toBe('Hallo');
242
-
243
- // Plural methods should work
244
- await migratedStore.insertPlural({
245
- sourceText: 'test\x00tests',
246
- sourceLang: 'EN', targetLang: 'DE',
210
+ await store.insert({
211
+ sourceText: 'Bad', sourceLang: 'EN', targetLang: 'DE',
247
212
  projectContextHash: hash('ctx'), stringContextHash: hash(''),
248
- pluralCategory: 'other',
249
- translatedText: 'Tests',
250
- model: 'gpt-4.1-mini', status: 'translated',
213
+ translatedText: 'schlecht', model: 'gpt-4.1-mini', status: 'failed',
251
214
  });
252
-
253
- const pluralResult = await migratedStore.lookupPlural({
254
- sourceText: 'test\x00tests',
255
- sourceLang: 'EN', targetLang: 'DE',
215
+ await store.insert({
216
+ sourceText: 'Hello', sourceLang: 'EN', targetLang: 'ES',
256
217
  projectContextHash: hash('ctx'), stringContextHash: hash(''),
218
+ translatedText: 'Hola', model: 'gpt-4.1-mini', status: 'translated',
257
219
  });
258
- expect(pluralResult).toEqual({ other: 'Tests' });
259
220
 
260
- migratedStore.close();
221
+ expect(store.count()).toBe(3);
222
+ expect(store.count('DE')).toBe(2);
223
+ expect(store.count(undefined, true)).toBe(1);
224
+ expect(store.count('DE', true)).toBe(1);
225
+
226
+ const deleted = await store.clear('DE', true);
227
+ expect(deleted).toBe(1);
228
+ expect(store.count()).toBe(2);
261
229
  });
262
230
  });