transduck 0.7.0 → 0.9.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.
package/src/handler.ts CHANGED
@@ -10,6 +10,48 @@ function hash(text: string): string {
10
10
  return createHash('sha256').update(text).digest('hex');
11
11
  }
12
12
 
13
+ /**
14
+ * Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
15
+ * then `release()` when done. When the active count is at the limit,
16
+ * further `acquire()` calls queue until a slot frees up.
17
+ */
18
+ export function createSemaphore(limit: number) {
19
+ let active = 0;
20
+ const queue: Array<() => void> = [];
21
+ return {
22
+ acquire(): Promise<void> {
23
+ return new Promise(resolve => {
24
+ if (active < limit) {
25
+ active++;
26
+ resolve();
27
+ } else {
28
+ queue.push(() => {
29
+ active++;
30
+ resolve();
31
+ });
32
+ }
33
+ });
34
+ },
35
+ release(): void {
36
+ active--;
37
+ const next = queue.shift();
38
+ if (next) next();
39
+ },
40
+ };
41
+ }
42
+
43
+ async function withSlot<T>(
44
+ sem: ReturnType<typeof createSemaphore>,
45
+ fn: () => Promise<T>,
46
+ ): Promise<T> {
47
+ await sem.acquire();
48
+ try {
49
+ return await fn();
50
+ } finally {
51
+ sem.release();
52
+ }
53
+ }
54
+
13
55
  interface TranslationRequestString {
14
56
  text: string;
15
57
  context?: string;
@@ -82,137 +124,140 @@ export async function handleTranslationRequest(
82
124
 
83
125
  const projectContextHash = hash(cfg.projectContext);
84
126
 
85
- const translations: Record<string, string> = {};
86
- const plurals: Record<string, Record<string, string>> = {};
127
+ // Single semaphore shared across strings and plurals so total outbound
128
+ // concurrency to the backend is bounded across both categories.
129
+ const sem = createSemaphore(cfg.backendMaxConcurrency);
87
130
 
88
- // Translate regular strings
89
- for (const item of body.strings ?? []) {
90
- const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
91
- const stringContextHash = hash(item.context ?? '');
92
- const key = `${item.text}||${item.context ?? ''}`;
93
- const lookupParams = {
94
- sourceText: item.text, sourceLang, targetLang,
95
- projectContextHash, stringContextHash,
96
- };
97
-
98
- // Tier 1: local cache
99
- const cached = await store.lookup(lookupParams);
100
- if (cached !== null) {
101
- translations[key] = cached;
102
- continue;
103
- }
131
+ const stringWork = (body.strings ?? []).map(item =>
132
+ withSlot(sem, async (): Promise<[string, string]> => {
133
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
134
+ const stringContextHash = hash(item.context ?? '');
135
+ const key = `${item.text}||${item.context ?? ''}||${sourceLang}`;
136
+ const lookupParams = {
137
+ sourceText: item.text, sourceLang, targetLang,
138
+ projectContextHash, stringContextHash,
139
+ };
104
140
 
105
- // Tier 2: shared store
106
- if (shared) {
107
- const sharedCached = await shared.lookup(lookupParams);
108
- if (sharedCached !== null) {
109
- // Propagate to local
110
- await store.insert({
111
- ...lookupParams, stringContext: item.context ?? '',
112
- translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
113
- });
114
- translations[key] = sharedCached;
115
- continue;
141
+ // Tier 1: local cache
142
+ const cached = await store.lookup(lookupParams);
143
+ if (cached !== null) return [key, cached];
144
+
145
+ // Tier 2: shared store
146
+ if (shared) {
147
+ const sharedCached = await shared.lookup(lookupParams);
148
+ if (sharedCached !== null) {
149
+ await store.insert({
150
+ ...lookupParams, stringContext: item.context ?? '',
151
+ translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
152
+ });
153
+ return [key, sharedCached];
154
+ }
116
155
  }
117
- }
118
156
 
119
- // Backend call
120
- try {
121
- const translated = await backendTranslate(
122
- item.text, sourceLang, targetLang,
123
- cfg.projectContext, item.context ?? null, cfg,
124
- );
125
-
126
- if (validateTranslation(item.text, translated)) {
127
- const insertParams = {
128
- ...lookupParams, stringContext: item.context ?? '',
129
- translatedText: translated, model: cfg.backendModel, status: 'translated',
130
- };
131
- await store.insert(insertParams);
132
- if (shared) await shared.insert(insertParams);
133
- if (opts?.onTranslate) {
134
- const event: TranslationEvent = {
135
- sourceText: item.text, translatedText: translated,
136
- sourceLang, lang: targetLang, context: item.context ?? null,
137
- projectContext: cfg.projectContext, source: 'translated',
138
- plural: false,
157
+ // Backend call
158
+ try {
159
+ const translated = await backendTranslate(
160
+ item.text, sourceLang, targetLang,
161
+ cfg.projectContext, item.context ?? null, cfg,
162
+ );
163
+
164
+ if (validateTranslation(item.text, translated)) {
165
+ const insertParams = {
166
+ ...lookupParams, stringContext: item.context ?? '',
167
+ translatedText: translated, model: cfg.backendModel, status: 'translated',
139
168
  };
140
- await fireHooks([], event, opts.onTranslate);
169
+ await store.insert(insertParams);
170
+ if (shared) await shared.insert(insertParams);
171
+ if (opts?.onTranslate) {
172
+ const event: TranslationEvent = {
173
+ sourceText: item.text, translatedText: translated,
174
+ sourceLang, lang: targetLang, context: item.context ?? null,
175
+ projectContext: cfg.projectContext, source: 'translated',
176
+ plural: false,
177
+ };
178
+ await fireHooks([], event, opts.onTranslate);
179
+ }
180
+ return [key, translated];
141
181
  }
142
- translations[key] = translated;
143
- } else {
144
- translations[key] = item.text;
182
+ return [key, item.text];
183
+ } catch {
184
+ return [key, item.text];
145
185
  }
146
- } catch {
147
- translations[key] = item.text;
148
- }
149
- }
186
+ })
187
+ );
150
188
 
151
- // Translate plurals
152
- for (const item of body.plurals ?? []) {
153
- const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
154
- const stringContextHash = hash(item.context ?? '');
155
- const sourceKey = item.one + '\x00' + item.other;
156
- const responseKey = `${sourceKey}||${item.context ?? ''}`;
157
- const lookupParams = {
158
- sourceText: sourceKey, sourceLang, targetLang,
159
- projectContextHash, stringContextHash,
160
- };
161
-
162
- // Tier 1: local cache
163
- const cachedForms = await store.lookupPlural(lookupParams);
164
- if (Object.keys(cachedForms).length > 0) {
165
- plurals[responseKey] = cachedForms;
166
- continue;
167
- }
189
+ const pluralWork = (body.plurals ?? []).map(item =>
190
+ withSlot(sem, async (): Promise<[string, Record<string, string>]> => {
191
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
192
+ const stringContextHash = hash(item.context ?? '');
193
+ const sourceKey = item.one + '\x00' + item.other;
194
+ const responseKey = `${sourceKey}||${item.context ?? ''}||${sourceLang}`;
195
+ const lookupParams = {
196
+ sourceText: sourceKey, sourceLang, targetLang,
197
+ projectContextHash, stringContextHash,
198
+ };
199
+
200
+ // Tier 1: local cache
201
+ const cachedForms = await store.lookupPlural(lookupParams);
202
+ if (Object.keys(cachedForms).length > 0) return [responseKey, cachedForms];
203
+
204
+ // Tier 2: shared store
205
+ if (shared) {
206
+ const sharedForms = await shared.lookupPlural(lookupParams);
207
+ if (Object.keys(sharedForms).length > 0) {
208
+ for (const [cat, text] of Object.entries(sharedForms)) {
209
+ await store.insertPlural({
210
+ ...lookupParams, stringContext: item.context ?? '',
211
+ pluralCategory: cat, translatedText: text,
212
+ model: cfg.backendModel, status: 'translated',
213
+ });
214
+ }
215
+ return [responseKey, sharedForms];
216
+ }
217
+ }
218
+
219
+ // Backend call
220
+ try {
221
+ const forms = await backendTranslatePlural(
222
+ item.one, item.other, sourceLang, targetLang,
223
+ cfg.projectContext, item.context ?? null, cfg,
224
+ );
168
225
 
169
- // Tier 2: shared store
170
- if (shared) {
171
- const sharedForms = await shared.lookupPlural(lookupParams);
172
- if (Object.keys(sharedForms).length > 0) {
173
- // Propagate to local
174
- for (const [cat, text] of Object.entries(sharedForms)) {
175
- await store.insertPlural({
226
+ for (const [cat, translatedText] of Object.entries(forms)) {
227
+ const insertParams = {
176
228
  ...lookupParams, stringContext: item.context ?? '',
177
- pluralCategory: cat, translatedText: text,
229
+ pluralCategory: cat, translatedText: translatedText as string,
178
230
  model: cfg.backendModel, status: 'translated',
179
- });
231
+ };
232
+ await store.insertPlural(insertParams);
233
+ if (shared) await shared.insertPlural(insertParams);
180
234
  }
181
- plurals[responseKey] = sharedForms;
182
- continue;
235
+ if (opts?.onTranslate && 'other' in forms) {
236
+ const event: TranslationEvent = {
237
+ sourceText: sourceKey, translatedText: forms.other ?? '',
238
+ sourceLang, lang: targetLang, context: item.context ?? null,
239
+ projectContext: cfg.projectContext, source: 'translated',
240
+ plural: true, pluralForms: forms,
241
+ };
242
+ await fireHooks([], event, opts.onTranslate);
243
+ }
244
+ return [responseKey, forms];
245
+ } catch {
246
+ return [responseKey, { one: item.one, other: item.other }];
183
247
  }
184
- }
248
+ })
249
+ );
185
250
 
186
- // Backend call
187
- try {
188
- const forms = await backendTranslatePlural(
189
- item.one, item.other, sourceLang, targetLang,
190
- cfg.projectContext, item.context ?? null, cfg,
191
- );
192
-
193
- for (const [cat, translatedText] of Object.entries(forms)) {
194
- const insertParams = {
195
- ...lookupParams, stringContext: item.context ?? '',
196
- pluralCategory: cat, translatedText: translatedText as string,
197
- model: cfg.backendModel, status: 'translated',
198
- };
199
- await store.insertPlural(insertParams);
200
- if (shared) await shared.insertPlural(insertParams);
201
- }
202
- if (opts?.onTranslate && 'other' in forms) {
203
- const event: TranslationEvent = {
204
- sourceText: sourceKey, translatedText: forms.other ?? '',
205
- sourceLang, lang: targetLang, context: item.context ?? null,
206
- projectContext: cfg.projectContext, source: 'translated',
207
- plural: true, pluralForms: forms,
208
- };
209
- await fireHooks([], event, opts.onTranslate);
210
- }
211
- plurals[responseKey] = forms;
212
- } catch {
213
- plurals[responseKey] = { one: item.one, other: item.other };
214
- }
215
- }
251
+ const [stringResults, pluralResults] = await Promise.all([
252
+ Promise.all(stringWork),
253
+ Promise.all(pluralWork),
254
+ ]);
255
+
256
+ const translations: Record<string, string> = {};
257
+ for (const [key, value] of stringResults) translations[key] = value;
258
+
259
+ const plurals: Record<string, Record<string, string>> = {};
260
+ for (const [key, value] of pluralResults) plurals[key] = value;
216
261
 
217
262
  return { translations, plurals };
218
263
  }
@@ -130,19 +130,50 @@ function schedulePendingFlush() {
130
130
 
131
131
  // --- Stable exported functions ---
132
132
 
133
+ export interface TOptions {
134
+ context?: string;
135
+ vars?: Record<string, string | number>;
136
+ /** Override the provider's source language for this call. */
137
+ sourceLang?: string;
138
+ }
139
+
140
+ export interface TPluralOptions extends TOptions {}
141
+
142
+ /**
143
+ * Build the client-side cache / pending-set key.
144
+ * Format: text || context || sourceLang (last two always present)
145
+ */
146
+ function buildKey(text: string, context: string | undefined, sourceLang: string): string {
147
+ return `${text}||${context ?? ''}||${sourceLang}`;
148
+ }
149
+
133
150
  export function t(
134
151
  sourceText: string,
135
- context?: string,
152
+ contextOrOpts?: string | TOptions,
136
153
  vars?: Record<string, string | number>,
137
154
  ): TranslationResult {
138
- const key = `${sourceText}||${context ?? ''}`;
155
+ let context: string | undefined;
156
+ let resolvedVars: Record<string, string | number> | undefined;
157
+ let sourceLangOverride: string | undefined;
158
+
159
+ if (typeof contextOrOpts === 'string' || contextOrOpts === undefined) {
160
+ context = contextOrOpts;
161
+ resolvedVars = vars;
162
+ } else {
163
+ context = contextOrOpts.context;
164
+ resolvedVars = contextOrOpts.vars;
165
+ sourceLangOverride = contextOrOpts.sourceLang;
166
+ }
167
+
168
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
169
+ const key = buildKey(sourceText, context, effectiveSourceLang);
139
170
  _state.knownKeys.add(key);
140
171
 
141
172
  // Same language — return source
142
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
143
- return new TranslationResult(interpolateVars(sourceText, vars), {
173
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
174
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
144
175
  pending: false,
145
- sourceLang: _state.sourceLang,
176
+ sourceLang: effectiveSourceLang,
146
177
  lang: _state.language,
147
178
  });
148
179
  }
@@ -150,9 +181,9 @@ export function t(
150
181
  // Cache hit
151
182
  const cached = _state.cache.get(key);
152
183
  if (cached !== undefined) {
153
- return new TranslationResult(interpolateVars(cached, vars), {
184
+ return new TranslationResult(interpolateVars(cached, resolvedVars), {
154
185
  pending: false,
155
- sourceLang: _state.sourceLang,
186
+ sourceLang: effectiveSourceLang,
156
187
  lang: _state.language,
157
188
  source: 'cache',
158
189
  });
@@ -163,9 +194,9 @@ export function t(
163
194
  schedulePendingFlush();
164
195
 
165
196
  // Return source text as fallback, marked pending
166
- return new TranslationResult(interpolateVars(sourceText, vars), {
197
+ return new TranslationResult(interpolateVars(sourceText, resolvedVars), {
167
198
  pending: true,
168
- sourceLang: _state.sourceLang,
199
+ sourceLang: effectiveSourceLang,
169
200
  lang: _state.language,
170
201
  });
171
202
  }
@@ -174,9 +205,12 @@ export function tPlural(
174
205
  one: string,
175
206
  other: string,
176
207
  count: number,
177
- opts?: { context?: string; vars?: Record<string, string | number> },
208
+ opts?: TPluralOptions,
178
209
  ): TranslationResult {
179
210
  const context = opts?.context;
211
+ const sourceLangOverride = opts?.sourceLang;
212
+ const effectiveSourceLang = (sourceLangOverride ?? _state.sourceLang).toUpperCase();
213
+
180
214
  let vars: Record<string, string | number>;
181
215
  if (!opts?.vars) {
182
216
  vars = { count };
@@ -187,16 +221,16 @@ export function tPlural(
187
221
  }
188
222
 
189
223
  const sourceKey = `${one}\x00${other}`;
190
- const cacheKey = `${sourceKey}||${context ?? ''}`;
224
+ const cacheKey = buildKey(sourceKey, context, effectiveSourceLang);
191
225
  _state.knownPlurals.add(cacheKey);
192
226
 
193
227
  // Same language
194
- if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
195
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
228
+ if (_state.language.toUpperCase() === effectiveSourceLang) {
229
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
196
230
  const form = rules.select(count) === 'one' ? one : other;
197
231
  return new TranslationResult(interpolateVars(form, vars), {
198
232
  pending: false,
199
- sourceLang: _state.sourceLang,
233
+ sourceLang: effectiveSourceLang,
200
234
  lang: _state.language,
201
235
  });
202
236
  }
@@ -209,7 +243,7 @@ export function tPlural(
209
243
  const form = cachedForms[category] ?? cachedForms['other'] ?? other;
210
244
  return new TranslationResult(interpolateVars(form, vars), {
211
245
  pending: false,
212
- sourceLang: _state.sourceLang,
246
+ sourceLang: effectiveSourceLang,
213
247
  lang: _state.language,
214
248
  source: 'cache',
215
249
  });
@@ -220,11 +254,11 @@ export function tPlural(
220
254
  schedulePendingFlush();
221
255
 
222
256
  // Fallback to source form, marked pending
223
- const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
257
+ const rules = new Intl.PluralRules(effectiveSourceLang.toLowerCase());
224
258
  const form = rules.select(count) === 'one' ? one : other;
225
259
  return new TranslationResult(interpolateVars(form, vars), {
226
260
  pending: true,
227
- sourceLang: _state.sourceLang,
261
+ sourceLang: effectiveSourceLang,
228
262
  lang: _state.language,
229
263
  });
230
264
  }
@@ -365,15 +399,23 @@ export function TransDuckProvider({
365
399
  return;
366
400
  }
367
401
 
368
- // Build request body
402
+ // Build request body.
403
+ // Key format: text || context || sourceLang. Pop the tail segments so text
404
+ // can contain "||" safely.
369
405
  const strings = Array.from(stringsToFetch).map(key => {
370
- const [text, context] = key.split('||');
371
- return { text, context: context || undefined };
406
+ const parts = key.split('||');
407
+ const sourceLang = parts.pop()!;
408
+ const context = parts.pop();
409
+ const text = parts.join('||');
410
+ return { text, context: context || undefined, sourceLang };
372
411
  });
373
412
  const plurals = Array.from(pluralsToFetch).map(key => {
374
- const [sourceKey, context] = key.split('||');
413
+ const parts = key.split('||');
414
+ const sourceLang = parts.pop()!;
415
+ const context = parts.pop();
416
+ const sourceKey = parts.join('||');
375
417
  const [one, other] = sourceKey.split('\x00');
376
- return { one, other, context: context || undefined };
418
+ return { one, other, context: context || undefined, sourceLang };
377
419
  });
378
420
 
379
421
  try {
package/tests/ait.test.ts CHANGED
@@ -22,6 +22,7 @@ function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): Trans
22
22
  backendModel: 'gpt-4.1-mini',
23
23
  backendTimeout: 10,
24
24
  backendMaxRetries: 2,
25
+ backendMaxConcurrency: 10,
25
26
  sharedUrl: null,
26
27
  readOnly: false,
27
28
  ...overrides,
@@ -15,6 +15,7 @@ function makeConfig(): TransduckConfig {
15
15
  backendModel: 'gpt-4.1-mini',
16
16
  backendTimeout: 10,
17
17
  backendMaxRetries: 2,
18
+ backendMaxConcurrency: 10,
18
19
  sharedUrl: null,
19
20
  readOnly: false,
20
21
  };
@@ -72,6 +72,32 @@ describe('loadConfig', () => {
72
72
  delete process.env.TRANSDUCK_CONFIG;
73
73
  });
74
74
 
75
+ it('defaults backendMaxConcurrency to 10 when absent', () => {
76
+ const configPath = join(tmpDir, 'transduck.yaml');
77
+ writeFileSync(configPath, VALID_YAML);
78
+ const cfg = loadConfig(configPath);
79
+ expect(cfg.backendMaxConcurrency).toBe(10);
80
+ });
81
+
82
+ it('reads backendMaxConcurrency from backend.max_concurrency', () => {
83
+ const configPath = join(tmpDir, 'transduck.yaml');
84
+ writeFileSync(configPath, VALID_YAML + ' max_concurrency: 25\n');
85
+ const cfg = loadConfig(configPath);
86
+ expect(cfg.backendMaxConcurrency).toBe(25);
87
+ });
88
+
89
+ it('rejects zero or negative backend.max_concurrency', () => {
90
+ const configPath = join(tmpDir, 'transduck.yaml');
91
+ writeFileSync(configPath, VALID_YAML + ' max_concurrency: 0\n');
92
+ expect(() => loadConfig(configPath)).toThrow(/max_concurrency/);
93
+ });
94
+
95
+ it('rejects non-integer backend.max_concurrency', () => {
96
+ const configPath = join(tmpDir, 'transduck.yaml');
97
+ writeFileSync(configPath, VALID_YAML + ' max_concurrency: 2.5\n');
98
+ expect(() => loadConfig(configPath)).toThrow(/max_concurrency/);
99
+ });
100
+
75
101
  it('defaults shared_url to null', () => {
76
102
  const configPath = join(tmpDir, 'transduck.yaml');
77
103
  writeFileSync(configPath, VALID_YAML);