transduck 0.2.5 → 0.4.0

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.
@@ -8,10 +8,14 @@ interface ReactState {
8
8
  language: string;
9
9
  sourceLang: string;
10
10
  endpoint: string;
11
+ projectName: string;
11
12
  cache: Map<string, string>;
12
13
  pluralCache: Map<string, Record<string, string>>;
13
14
  pendingStrings: Set<string>; // "text||context"
14
15
  pendingPlurals: Set<string>; // "one\x00other||context"
16
+ knownKeys: Set<string>;
17
+ knownPlurals: Set<string>;
18
+ isLanguageSwitch: boolean;
15
19
  triggerFetch: (() => void) | null;
16
20
  }
17
21
 
@@ -19,24 +23,62 @@ let _state: ReactState = {
19
23
  language: '',
20
24
  sourceLang: 'EN',
21
25
  endpoint: '/api/translations',
26
+ projectName: 'default',
22
27
  cache: new Map(),
23
28
  pluralCache: new Map(),
24
29
  pendingStrings: new Set(),
25
30
  pendingPlurals: new Set(),
31
+ knownKeys: new Set(),
32
+ knownPlurals: new Set(),
33
+ isLanguageSwitch: false,
26
34
  triggerFetch: null,
27
35
  };
28
36
 
29
37
  // --- Context for triggering child re-renders ---
30
- // The context value (a version counter) changes when translations are loaded.
38
+ // The context value changes when translations are loaded or language switches.
31
39
  // Components that call useTransDuck() subscribe to this and re-render automatically.
32
- const TransDuckContext = createContext<number>(0);
40
+
41
+ interface TransDuckContextValue {
42
+ version: number;
43
+ language: string;
44
+ setLanguage: (lang: string) => void;
45
+ isLoading: boolean;
46
+ }
47
+
48
+ const defaultContext: TransDuckContextValue = {
49
+ version: 0,
50
+ language: '',
51
+ setLanguage: () => {},
52
+ isLoading: false,
53
+ };
54
+
55
+ const TransDuckContext = createContext<TransDuckContextValue>(defaultContext);
33
56
 
34
57
  /**
35
- * Hook that subscribes to translation updates. Call this in any component
36
- * that uses t()/ait() to ensure it re-renders when translations are loaded.
58
+ * Hook that subscribes to translation updates and returns translation
59
+ * functions along with language state.
37
60
  */
38
- export function useTransDuck(): void {
39
- useContext(TransDuckContext);
61
+ export interface UseTransDuckReturn {
62
+ t: typeof t;
63
+ tPlural: typeof tPlural;
64
+ ait: typeof t;
65
+ aitPlural: typeof tPlural;
66
+ language: string;
67
+ setLanguage: (lang: string) => void;
68
+ isLoading: boolean;
69
+ }
70
+
71
+ export function useTransDuck(): UseTransDuckReturn {
72
+ const ctx = useContext(TransDuckContext);
73
+ return {
74
+ t,
75
+ tPlural,
76
+ ait,
77
+ aitPlural,
78
+ language: ctx.language,
79
+ setLanguage: ctx.setLanguage,
80
+ isLoading: ctx.isLoading,
81
+ };
40
82
  }
41
83
 
42
84
  export function _resetReactState(): void {
@@ -44,14 +86,22 @@ export function _resetReactState(): void {
44
86
  language: '',
45
87
  sourceLang: 'EN',
46
88
  endpoint: '/api/translations',
89
+ projectName: 'default',
47
90
  cache: new Map(),
48
91
  pluralCache: new Map(),
49
92
  pendingStrings: new Set(),
50
93
  pendingPlurals: new Set(),
94
+ knownKeys: new Set(),
95
+ knownPlurals: new Set(),
96
+ isLanguageSwitch: false,
51
97
  triggerFetch: null,
52
98
  };
53
99
  }
54
100
 
101
+ export function _getReactState(): ReactState {
102
+ return _state;
103
+ }
104
+
55
105
  function interpolateVars(text: string, vars?: Record<string, string | number> | null): string {
56
106
  if (!vars) return text;
57
107
  let result = text;
@@ -68,13 +118,14 @@ export function t(
68
118
  context?: string,
69
119
  vars?: Record<string, string | number>,
70
120
  ): string {
121
+ const key = `${sourceText}||${context ?? ''}`;
122
+ _state.knownKeys.add(key);
123
+
71
124
  // Same language — return source
72
125
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
73
126
  return interpolateVars(sourceText, vars);
74
127
  }
75
128
 
76
- const key = `${sourceText}||${context ?? ''}`;
77
-
78
129
  // Cache hit
79
130
  const cached = _state.cache.get(key);
80
131
  if (cached !== undefined) {
@@ -104,6 +155,10 @@ export function tPlural(
104
155
  vars = { ...opts.vars };
105
156
  }
106
157
 
158
+ const sourceKey = `${one}\x00${other}`;
159
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
160
+ _state.knownPlurals.add(cacheKey);
161
+
107
162
  // Same language
108
163
  if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
109
164
  const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
@@ -111,9 +166,6 @@ export function tPlural(
111
166
  return interpolateVars(form, vars);
112
167
  }
113
168
 
114
- const sourceKey = `${one}\x00${other}`;
115
- const cacheKey = `${sourceKey}||${context ?? ''}`;
116
-
117
169
  // Cache hit
118
170
  const cachedForms = _state.pluralCache.get(cacheKey);
119
171
  if (cachedForms) {
@@ -191,21 +243,62 @@ export function TransDuckProvider({
191
243
  children,
192
244
  }: TransDuckProviderProps) {
193
245
  const [version, setVersion] = useState(0);
246
+ const [languageState, setLanguageState] = useState(language.toUpperCase());
247
+ const [isLoading, setIsLoading] = useState(false);
194
248
 
195
249
  // Update module-level state synchronously so t() works during first render
196
250
  _state.endpoint = endpoint;
197
251
  _state.sourceLang = sourceLang;
252
+ _state.projectName = projectName;
198
253
 
254
+ // Synchronous first-render initialization only.
255
+ // Subsequent language changes are handled by switchLanguage / useEffect prop sync.
199
256
  const upperLang = language.toUpperCase();
200
- if (_state.language !== upperLang) {
257
+ if (!_state.language) {
201
258
  _state.language = upperLang;
202
- _state.cache.clear();
203
- _state.pluralCache.clear();
204
259
  loadFromLocalStorage(projectName, upperLang);
205
260
  }
206
261
 
262
+ const switchLanguage = useCallback((lang: string) => {
263
+ const upper = lang.toUpperCase();
264
+
265
+ // Same-language no-op
266
+ if (upper === _state.language) return;
267
+
268
+ // Update React state
269
+ setLanguageState(upper);
270
+
271
+ // Update module-level state synchronously
272
+ _state.language = upper;
273
+ _state.cache.clear();
274
+ _state.pluralCache.clear();
275
+
276
+ // Source-language guard: skip fetch, t() short-circuits
277
+ if (upper === _state.sourceLang.toUpperCase()) {
278
+ setVersion(v => v + 1);
279
+ return;
280
+ }
281
+
282
+ // Load cached translations from localStorage
283
+ loadFromLocalStorage(_state.projectName, upper);
284
+
285
+ // Mark as language switch and re-queue known keys
286
+ _state.isLanguageSwitch = true;
287
+ setIsLoading(true);
288
+ for (const key of _state.knownKeys) {
289
+ _state.pendingStrings.add(key);
290
+ }
291
+ for (const key of _state.knownPlurals) {
292
+ _state.pendingPlurals.add(key);
293
+ }
294
+
295
+ // Bump version to trigger re-render and useEffect flush
296
+ setVersion(v => v + 1);
297
+ }, []);
298
+
207
299
  // Set up trigger
208
300
  const doFetch = useCallback(async () => {
301
+ const fetchLanguage = _state.language;
209
302
  const stringsToFetch = new Set(_state.pendingStrings);
210
303
  const pluralsToFetch = new Set(_state.pendingPlurals);
211
304
  _state.pendingStrings.clear();
@@ -219,7 +312,13 @@ export function TransDuckProvider({
219
312
  if (_state.pluralCache.has(key)) pluralsToFetch.delete(key);
220
313
  }
221
314
 
222
- if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) return;
315
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) {
316
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
317
+ _state.isLanguageSwitch = false;
318
+ setIsLoading(false);
319
+ }
320
+ return;
321
+ }
223
322
 
224
323
  // Build request body
225
324
  const strings = Array.from(stringsToFetch).map(key => {
@@ -250,6 +349,9 @@ export function TransDuckProvider({
250
349
 
251
350
  const data = await response.json();
252
351
 
352
+ // Stale fetch protection: language changed while we were fetching
353
+ if (_state.language !== fetchLanguage) return;
354
+
253
355
  // Store translations
254
356
  if (data.translations) {
255
357
  for (const [key, value] of Object.entries(data.translations)) {
@@ -262,16 +364,31 @@ export function TransDuckProvider({
262
364
  }
263
365
  }
264
366
 
265
- saveToLocalStorage(projectName, _state.language);
367
+ saveToLocalStorage(_state.projectName, _state.language);
266
368
  setVersion(v => v + 1);
267
369
  } catch (err) {
268
370
  console.warn('[transduck] Translation fetch error:', err);
371
+ } finally {
372
+ if (_state.isLanguageSwitch && _state.language === fetchLanguage) {
373
+ _state.isLanguageSwitch = false;
374
+ setIsLoading(false);
375
+ }
269
376
  }
270
- }, [endpoint, projectName]);
377
+ }, [endpoint]);
271
378
 
272
379
  // Register trigger
273
380
  _state.triggerFetch = doFetch;
274
381
 
382
+ // Sync language prop changes — only when the prop itself changes
383
+ const prevPropRef = React.useRef(upperLang);
384
+ useEffect(() => {
385
+ const upper = language.toUpperCase();
386
+ if (upper !== prevPropRef.current) {
387
+ prevPropRef.current = upper;
388
+ switchLanguage(upper);
389
+ }
390
+ }, [language, switchLanguage]);
391
+
275
392
  // Flush pending after render
276
393
  useEffect(() => {
277
394
  if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
@@ -279,8 +396,15 @@ export function TransDuckProvider({
279
396
  }
280
397
  });
281
398
 
399
+ const contextValue: TransDuckContextValue = {
400
+ version,
401
+ language: languageState,
402
+ setLanguage: switchLanguage,
403
+ isLoading,
404
+ };
405
+
282
406
  return (
283
- <TransDuckContext.Provider value={version}>
407
+ <TransDuckContext.Provider value={contextValue}>
284
408
  {children}
285
409
  </TransDuckContext.Provider>
286
410
  );
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',