@rocapine/community 0.3.0 → 0.3.2
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/package.json +1 -1
- package/templates/functions/_shared/client.ts +25 -0
- package/templates/functions/_shared/push.ts +19 -9
- package/templates/functions/_shared/translation.ts +138 -14
- package/templates/functions/broadcast-post/index.ts +26 -8
- package/templates/functions/notify-comment/index.ts +4 -2
package/package.json
CHANGED
|
@@ -11,6 +11,31 @@ export function adminClient() {
|
|
|
11
11
|
return createClient(SUPABASE_URL, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Reads every row of a query page by page: PostgREST caps one response at the
|
|
15
|
+
// project's max_rows (Supabase default 1000), so a single select silently
|
|
16
|
+
// truncates. `page` must apply a deterministic order and `.range(from, to)`.
|
|
17
|
+
// Stops on the first short page. A failed page logs and returns what was read
|
|
18
|
+
// so far — for a broadcast, a partial send beats none (re-running would
|
|
19
|
+
// double-send to the pages already pushed).
|
|
20
|
+
export async function fetchAllRows<T>(
|
|
21
|
+
page: (
|
|
22
|
+
from: number,
|
|
23
|
+
to: number,
|
|
24
|
+
) => PromiseLike<{ data: T[] | null; error: { message: string } | null }>,
|
|
25
|
+
pageSize = 1000,
|
|
26
|
+
): Promise<T[]> {
|
|
27
|
+
const rows: T[] = [];
|
|
28
|
+
for (let from = 0; ; from += pageSize) {
|
|
29
|
+
const { data, error } = await page(from, from + pageSize - 1);
|
|
30
|
+
if (error) {
|
|
31
|
+
console.error(`fetchAllRows: page failed, keeping ${rows.length} rows`, error.message);
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
rows.push(...(data ?? []));
|
|
35
|
+
if ((data?.length ?? 0) < pageSize) return rows;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
// The gateway (verify_jwt) has already validated the bearer's signature, so
|
|
15
40
|
// only the role claim needs checking here. An exact string match against the
|
|
16
41
|
// injected SUPABASE_SERVICE_ROLE_KEY broke when a project migrated to the new
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Shared Expo push sender. Used by notify-comment, notify-like,
|
|
2
2
|
// notify-reaction and broadcast-post (push module).
|
|
3
3
|
|
|
4
|
+
import { runPool } from "./translation.ts";
|
|
5
|
+
|
|
4
6
|
export interface ExpoPushMessage {
|
|
5
7
|
to: string;
|
|
6
8
|
title: string;
|
|
@@ -11,21 +13,29 @@ export interface ExpoPushMessage {
|
|
|
11
13
|
badge?: number;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
// Sends in chunks of 100 (Expo API limit)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// Sends in chunks of 100 (Expo API limit), 4 requests in flight at a time, each
|
|
17
|
+
// aborted after 10 s so one hung request can't stall a large broadcast past the
|
|
18
|
+
// function's wall-clock limit. Best-effort: push failures must never fail the
|
|
19
|
+
// calling webhook. Projects with Enhanced Security for push must set the
|
|
20
|
+
// EXPO_ACCESS_TOKEN function secret. `fetchImpl`/`concurrency` are for tests.
|
|
21
|
+
export async function sendExpoPushBatch(
|
|
22
|
+
messages: ExpoPushMessage[],
|
|
23
|
+
opts: { fetchImpl?: typeof fetch; concurrency?: number } = {},
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
18
26
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
19
27
|
const accessToken = Deno.env.get("EXPO_ACCESS_TOKEN");
|
|
20
28
|
if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
const chunks: ExpoPushMessage[][] = [];
|
|
30
|
+
for (let i = 0; i < messages.length; i += 100) chunks.push(messages.slice(i, i + 100));
|
|
31
|
+
await runPool(chunks, opts.concurrency ?? 4, (chunk) =>
|
|
32
|
+
fetchImpl("https://exp.host/--/api/v2/push/send", {
|
|
24
33
|
method: "POST",
|
|
25
34
|
headers,
|
|
26
35
|
body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
signal: AbortSignal.timeout(10_000),
|
|
37
|
+
}).catch(() => {}),
|
|
38
|
+
);
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
|
|
@@ -32,7 +32,15 @@ export type TranslationRow = {
|
|
|
32
32
|
* with nothing to translate, "attempt" claims an in-flight/failed attempt. */
|
|
33
33
|
const MARKERS = ["source", "attempt"];
|
|
34
34
|
const isMarker = (r: TranslationRow) => MARKERS.includes(r.locale);
|
|
35
|
+
/** Real translation rows only, without created_at — what callers receive. */
|
|
36
|
+
const realRows = (rows: TranslationRow[]): TranslationRow[] =>
|
|
37
|
+
rows
|
|
38
|
+
.filter((h) => !isMarker(h))
|
|
39
|
+
.map(({ locale, source_locale, content }) => ({ locale, source_locale, content }));
|
|
35
40
|
const ATTEMPT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
41
|
+
/** An attempt younger than this may still be running (20 s OpenAI timeout plus
|
|
42
|
+
* claim/upsert time); an older one within the 6 h window has failed. */
|
|
43
|
+
export const IN_FLIGHT_MS = 45_000;
|
|
36
44
|
export type ParsedTranslation = {
|
|
37
45
|
sourceLocale: string;
|
|
38
46
|
translations: Record<string, { content: string; options: string[] }>;
|
|
@@ -207,9 +215,9 @@ export async function runPool<T, R>(
|
|
|
207
215
|
* carries one (the attempt marker's is ""). */
|
|
208
216
|
export function missingLocales(have: TranslationRow[], targets: string[]): string[] {
|
|
209
217
|
const knownSource = have.find((h) => h.source_locale !== "")?.source_locale;
|
|
210
|
-
const
|
|
218
|
+
const real = have.filter((h) => !isMarker(h));
|
|
211
219
|
return targets.filter(
|
|
212
|
-
(l) => !
|
|
220
|
+
(l) => !real.some((h) => h.locale === l) && (!knownSource || languageOf(l) !== knownSource),
|
|
213
221
|
);
|
|
214
222
|
}
|
|
215
223
|
|
|
@@ -220,16 +228,123 @@ export function hasFreshAttempt(have: TranslationRow[], nowMs: number, maxAgeMs:
|
|
|
220
228
|
);
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
/** An attempt row not yet marked failed (its owner may still be translating). A
|
|
232
|
+
* failed attempt keeps its claim (and created_at) for the 6 h retry window,
|
|
233
|
+
* but nobody should wait on it. */
|
|
234
|
+
const isPendingAttempt = (h: TranslationRow) => h.locale === "attempt" && h.content !== "failed";
|
|
235
|
+
|
|
236
|
+
/** True when `have` holds an attempt young enough to still be in flight
|
|
237
|
+
* (a failed attempt never is). */
|
|
238
|
+
export function isInFlight(have: TranslationRow[], nowMs: number, inFlightMs: number): boolean {
|
|
239
|
+
return hasFreshAttempt(have.filter(isPendingAttempt), nowMs, inFlightMs);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Calls `read` until it reports `done` or `timeoutMs` has elapsed, sleeping
|
|
244
|
+
* `intervalMs` between reads (the last sleep is cut to the deadline), and
|
|
245
|
+
* returns the last value read. `sleep`/`now` are injectable for tests.
|
|
246
|
+
*/
|
|
247
|
+
export async function waitFor<T>(
|
|
248
|
+
read: () => Promise<{ done: boolean; value: T }>,
|
|
249
|
+
opts: {
|
|
250
|
+
timeoutMs: number;
|
|
251
|
+
intervalMs: number;
|
|
252
|
+
sleep?: (ms: number) => Promise<void>;
|
|
253
|
+
now?: () => number;
|
|
254
|
+
},
|
|
255
|
+
): Promise<T> {
|
|
256
|
+
const now = opts.now ?? Date.now;
|
|
257
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
258
|
+
const deadline = now() + opts.timeoutMs;
|
|
259
|
+
let r = await read();
|
|
260
|
+
while (!r.done) {
|
|
261
|
+
const left = deadline - now();
|
|
262
|
+
if (left <= 0) break;
|
|
263
|
+
await sleep(Math.min(opts.intervalMs, left));
|
|
264
|
+
r = await read();
|
|
265
|
+
}
|
|
266
|
+
return r.value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Another caller holds the attempt claim. Without `waitMs`, return what exists
|
|
271
|
+
* now (`have`); with it, wait for that caller — but only while its attempt can
|
|
272
|
+
* still be in flight (a failed attempt keeps its claim for 6 h; waiting on it
|
|
273
|
+
* would only delay the push). `claimRows` carries the attempt row's
|
|
274
|
+
* created_at; absent (23505: the claim was just taken by someone else), it is
|
|
275
|
+
* re-read. Internal export for tests; `targets`/`sleep`/`now` are injectable.
|
|
276
|
+
*/
|
|
277
|
+
export async function otherCallerResult(args: {
|
|
278
|
+
supabase: SupabaseClient;
|
|
279
|
+
tTable: string;
|
|
280
|
+
fk: string;
|
|
281
|
+
id: string;
|
|
282
|
+
have: TranslationRow[];
|
|
283
|
+
waitMs?: number;
|
|
284
|
+
claimRows?: TranslationRow[];
|
|
285
|
+
targets?: string[];
|
|
286
|
+
sleep?: (ms: number) => Promise<void>;
|
|
287
|
+
now?: () => number;
|
|
288
|
+
}): Promise<TranslationRow[]> {
|
|
289
|
+
const { supabase, tTable, fk, id, have } = args;
|
|
290
|
+
const waitMs = args.waitMs ?? 0;
|
|
291
|
+
const now = args.now ?? Date.now;
|
|
292
|
+
if (waitMs <= 0) return have;
|
|
293
|
+
let claimRows = args.claimRows;
|
|
294
|
+
if (!claimRows) {
|
|
295
|
+
const { data, error } = await supabase
|
|
296
|
+
.from(tTable)
|
|
297
|
+
.select("locale, source_locale, content, created_at")
|
|
298
|
+
.eq(fk, id)
|
|
299
|
+
.eq("locale", "attempt");
|
|
300
|
+
if (error) {
|
|
301
|
+
console.error("translation claim lookup failed", error.message);
|
|
302
|
+
return have;
|
|
303
|
+
}
|
|
304
|
+
claimRows = (data ?? []) as TranslationRow[];
|
|
305
|
+
}
|
|
306
|
+
// No attempt left means the other caller just finished: the first read
|
|
307
|
+
// below picks up its rows at once.
|
|
308
|
+
const stale =
|
|
309
|
+
claimRows.some((h) => h.locale === "attempt") && !isInFlight(claimRows, now(), IN_FLIGHT_MS);
|
|
310
|
+
if (stale) return have;
|
|
311
|
+
return await waitFor(
|
|
312
|
+
async () => {
|
|
313
|
+
const { data, error } = await supabase
|
|
314
|
+
.from(tTable)
|
|
315
|
+
.select("locale, source_locale, content")
|
|
316
|
+
.eq(fk, id);
|
|
317
|
+
if (error) {
|
|
318
|
+
console.error("translation wait lookup failed", error.message);
|
|
319
|
+
return { done: true, value: have };
|
|
320
|
+
}
|
|
321
|
+
const rows = (data ?? []) as TranslationRow[];
|
|
322
|
+
return {
|
|
323
|
+
done:
|
|
324
|
+
!rows.some(isPendingAttempt) ||
|
|
325
|
+
missingLocales(rows, args.targets ?? TARGET_LOCALES).length === 0,
|
|
326
|
+
value: realRows(rows),
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
{ timeoutMs: waitMs, intervalMs: 1500, sleep: args.sleep, now: args.now },
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
223
333
|
/**
|
|
224
334
|
* Translate `kind`/`id` into every missing target locale and upsert the rows.
|
|
225
335
|
* Returns the rows now present for the item (possibly empty when the item is
|
|
226
336
|
* in a target language), or null when translation failed (nothing written).
|
|
227
337
|
* Idempotent: complete items make no API call.
|
|
338
|
+
*
|
|
339
|
+
* `waitMs` (pushes): when another caller holds the attempt claim, poll for up
|
|
340
|
+
* to `waitMs` until its claim is released or the rows cover every missing
|
|
341
|
+
* locale, instead of returning the (usually empty) rows at once.
|
|
228
342
|
*/
|
|
229
343
|
export async function ensureTranslations(
|
|
230
344
|
supabase: SupabaseClient,
|
|
231
345
|
kind: "post" | "comment",
|
|
232
346
|
id: string,
|
|
347
|
+
opts?: { waitMs?: number },
|
|
233
348
|
): Promise<TranslationRow[] | null> {
|
|
234
349
|
if (TARGET_LOCALES.length === 0) {
|
|
235
350
|
console.log("community-sdk: COMMUNITY_TRANSLATION_LOCALES not set, skipping translation");
|
|
@@ -260,9 +375,7 @@ export async function ensureTranslations(
|
|
|
260
375
|
return null;
|
|
261
376
|
}
|
|
262
377
|
const all = (existing ?? []) as TranslationRow[];
|
|
263
|
-
const have = all
|
|
264
|
-
.filter((h) => !isMarker(h))
|
|
265
|
-
.map(({ locale, source_locale, content }) => ({ locale, source_locale, content }));
|
|
378
|
+
const have = realRows(all);
|
|
266
379
|
const missing = missingLocales(all, TARGET_LOCALES);
|
|
267
380
|
if (missing.length === 0) return have;
|
|
268
381
|
|
|
@@ -272,9 +385,12 @@ export async function ensureTranslations(
|
|
|
272
385
|
// (translate-one + notify-comment, or a sweep) exactly one wins and the other
|
|
273
386
|
// gets 23505 and returns what exists. A stale claim is deleted first (only if
|
|
274
387
|
// still stale, so a claim another caller just took survives). Deleted on
|
|
275
|
-
// success, left on failure: the item is retried after 6 h.
|
|
388
|
+
// success, left (marked "failed") on failure: the item is retried after 6 h.
|
|
276
389
|
const now = Date.now();
|
|
277
|
-
|
|
390
|
+
const waitArgs = { supabase, tTable, fk, id, have, waitMs: opts?.waitMs };
|
|
391
|
+
if (hasFreshAttempt(all, now, ATTEMPT_MAX_AGE_MS)) {
|
|
392
|
+
return await otherCallerResult({ ...waitArgs, claimRows: all });
|
|
393
|
+
}
|
|
278
394
|
if (all.some((h) => h.locale === "attempt")) {
|
|
279
395
|
await supabase
|
|
280
396
|
.from(tTable)
|
|
@@ -287,10 +403,18 @@ export async function ensureTranslations(
|
|
|
287
403
|
.from(tTable)
|
|
288
404
|
.insert({ [fk]: id, locale: "attempt", source_locale: "", content: "", engine: ENGINE });
|
|
289
405
|
if (claimError) {
|
|
290
|
-
|
|
406
|
+
// Another caller holds the claim.
|
|
407
|
+
if (claimError.code === "23505") return await otherCallerResult(waitArgs);
|
|
291
408
|
console.error("translation attempt claim failed", claimError.message);
|
|
292
409
|
return null;
|
|
293
410
|
}
|
|
411
|
+
// From here on this caller holds the claim. Every failure marks it "failed"
|
|
412
|
+
// (keeping created_at, so the 6 h retry window is unchanged) so pushes
|
|
413
|
+
// waiting on it stop at once; an error marking it is ignored.
|
|
414
|
+
const fail = async (): Promise<null> => {
|
|
415
|
+
await supabase.from(tTable).update({ content: "failed" }).eq(fk, id).eq("locale", "attempt");
|
|
416
|
+
return null;
|
|
417
|
+
};
|
|
294
418
|
|
|
295
419
|
let options: { id: string; idx: number; label: string }[] = [];
|
|
296
420
|
if (kind === "post") {
|
|
@@ -302,7 +426,7 @@ export async function ensureTranslations(
|
|
|
302
426
|
// A backend without the polls module has no poll_options relation: no options.
|
|
303
427
|
if (error && error.code !== "PGRST205" && error.code !== "42P01") {
|
|
304
428
|
console.error("poll options lookup failed", error.message);
|
|
305
|
-
return
|
|
429
|
+
return await fail();
|
|
306
430
|
}
|
|
307
431
|
if (!error && data) options = data as typeof options;
|
|
308
432
|
}
|
|
@@ -312,14 +436,14 @@ export async function ensureTranslations(
|
|
|
312
436
|
options: options.map((o) => o.label),
|
|
313
437
|
targets: missing,
|
|
314
438
|
});
|
|
315
|
-
if (!parsed) return
|
|
439
|
+
if (!parsed) return await fail();
|
|
316
440
|
|
|
317
441
|
const incomplete = missing.filter(
|
|
318
442
|
(l) => languageOf(l) !== parsed.sourceLocale && !(l in parsed.translations),
|
|
319
443
|
);
|
|
320
444
|
if (incomplete.length > 0) {
|
|
321
445
|
console.error("translation response missing locales", incomplete.join(", "));
|
|
322
|
-
return
|
|
446
|
+
return await fail();
|
|
323
447
|
}
|
|
324
448
|
|
|
325
449
|
// Poll option rows are written first: if they fail, nothing lands, so the item
|
|
@@ -334,7 +458,7 @@ export async function ensureTranslations(
|
|
|
334
458
|
.upsert(optionRows, { onConflict: "option_id,locale" });
|
|
335
459
|
if (error) {
|
|
336
460
|
console.error("poll option translation upsert failed", error.message);
|
|
337
|
-
return
|
|
461
|
+
return await fail();
|
|
338
462
|
}
|
|
339
463
|
}
|
|
340
464
|
}
|
|
@@ -350,7 +474,7 @@ export async function ensureTranslations(
|
|
|
350
474
|
const { error } = await supabase.from(tTable).upsert(rows, { onConflict: `${fk},locale` });
|
|
351
475
|
if (error) {
|
|
352
476
|
console.error("translation upsert failed", error.message);
|
|
353
|
-
return
|
|
477
|
+
return await fail();
|
|
354
478
|
}
|
|
355
479
|
} else {
|
|
356
480
|
// ponytail: every missing target shares the source language, so there was
|
|
@@ -376,7 +500,7 @@ export async function ensureTranslations(
|
|
|
376
500
|
);
|
|
377
501
|
if (error) {
|
|
378
502
|
console.error("translation marker upsert failed", error.message);
|
|
379
|
-
return
|
|
503
|
+
return await fail();
|
|
380
504
|
}
|
|
381
505
|
}
|
|
382
506
|
// Release the claim; an error is ignored (a leftover claim only keeps the
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Guarded by a service-role check — the public anon key passes the platform's
|
|
5
5
|
// verify_jwt but must NOT be able to trigger a broadcast.
|
|
6
6
|
|
|
7
|
-
import { adminClient, isServiceCaller } from "../_shared/client.ts";
|
|
7
|
+
import { adminClient, fetchAllRows, isServiceCaller } from "../_shared/client.ts";
|
|
8
8
|
import { sendExpoPushBatch } from "../_shared/push.ts";
|
|
9
9
|
import { BROADCAST_FALLBACK_TITLE } from "../_shared/config.ts";
|
|
10
10
|
import { ensureTranslations, resolveTargetLocale, TARGET_LOCALES } from "../_shared/translation.ts";
|
|
@@ -34,21 +34,34 @@ Deno.serve(async (req) => {
|
|
|
34
34
|
const title = author?.username?.trim() || BROADCAST_FALLBACK_TITLE;
|
|
35
35
|
|
|
36
36
|
const original = post.content.length > 140 ? `${post.content.slice(0, 137)}...` : post.content;
|
|
37
|
+
// waitMs: when translate-one holds the claim (it fires on the same insert),
|
|
38
|
+
// wait for its rows instead of pushing the source language.
|
|
37
39
|
const translations =
|
|
38
40
|
TARGET_LOCALES.length === 0
|
|
39
41
|
? []
|
|
40
|
-
: ((await ensureTranslations(supabase, "post", post.id)) ?? []);
|
|
42
|
+
: ((await ensureTranslations(supabase, "post", post.id, { waitMs: 20_000 })) ?? []);
|
|
41
43
|
const excerptFor = (locale: string | null): string => {
|
|
42
44
|
if (!locale) return original;
|
|
43
45
|
const t = translations.find((r) => r.locale === locale);
|
|
44
46
|
return t ? (t.content.length > 140 ? `${t.content.slice(0, 137)}...` : t.content) : original;
|
|
45
47
|
};
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
// Paged (PostgREST max_rows caps a single select, default 1000) with a total
|
|
50
|
+
// order so pages don't overlap. Newest registration first: a reinstall
|
|
51
|
+
// creates a new anonymous user while the old row keeps the same token, and
|
|
52
|
+
// the dedupe below keeps the first (newest) row's locale. Offset paging can
|
|
53
|
+
// skip a row if one is deleted mid-broadcast — acceptable.
|
|
54
|
+
const rows = await fetchAllRows((from, to) =>
|
|
55
|
+
supabase
|
|
56
|
+
.from("push_tokens")
|
|
57
|
+
.select("user_id, expo_push_token, profiles(locale)")
|
|
58
|
+
.not("expo_push_token", "is", null)
|
|
59
|
+
.order("updated_at", { ascending: false })
|
|
60
|
+
.order("user_id")
|
|
61
|
+
.range(from, to),
|
|
62
|
+
);
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const messages = rows
|
|
52
65
|
.map((r) => {
|
|
53
66
|
const profile = Array.isArray(r.profiles) ? r.profiles[0] : r.profiles;
|
|
54
67
|
return {
|
|
@@ -56,7 +69,12 @@ Deno.serve(async (req) => {
|
|
|
56
69
|
locale: resolveTargetLocale(profile?.locale ?? null),
|
|
57
70
|
};
|
|
58
71
|
})
|
|
59
|
-
.filter((m) =>
|
|
72
|
+
.filter((m) => {
|
|
73
|
+
// A stale duplicate token must not double-push.
|
|
74
|
+
if (!m.to || seen.has(m.to)) return false;
|
|
75
|
+
seen.add(m.to);
|
|
76
|
+
return true;
|
|
77
|
+
})
|
|
60
78
|
.map(({ to, locale }) => ({
|
|
61
79
|
to,
|
|
62
80
|
title,
|
|
@@ -62,11 +62,13 @@ Deno.serve(async (req) => {
|
|
|
62
62
|
const name = actor?.username?.trim() || copy.fallbackName;
|
|
63
63
|
// Recipient-language excerpt when the translation module is on: translate
|
|
64
64
|
// now (a few seconds) rather than push the source language; on failure the
|
|
65
|
-
// original goes out and the sweep fills the rows later.
|
|
65
|
+
// original goes out and the sweep fills the rows later. translate-one fires
|
|
66
|
+
// on the same insert, so when it holds the claim wait up to 20 s (the OpenAI
|
|
67
|
+
// request timeout) for its rows.
|
|
66
68
|
let excerptSource = comment.content;
|
|
67
69
|
const targetLocale = resolveTargetLocale(recipient?.locale);
|
|
68
70
|
if (targetLocale) {
|
|
69
|
-
const rows = await ensureTranslations(supabase, "comment", comment.id);
|
|
71
|
+
const rows = await ensureTranslations(supabase, "comment", comment.id, { waitMs: 20_000 });
|
|
70
72
|
const match = rows?.find(
|
|
71
73
|
(r) => r.locale === targetLocale && languageOf(r.locale) !== r.source_locale,
|
|
72
74
|
);
|