@rocapine/community 0.3.0 → 0.3.3
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 +147 -15
- 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[] }>;
|
|
@@ -80,6 +88,14 @@ export function parseTranslationResponse(
|
|
|
80
88
|
return { sourceLocale, translations: out };
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
/** OpenAI request timeout: output grows with text length × target count, so a
|
|
92
|
+
* flat limit fails every long post (measured on Eve's Rhythm: 7 locales of a
|
|
93
|
+
* ~1,500-character post take over 20 s). 20 s base plus 5 ms per character per
|
|
94
|
+
* target, capped at 90 s (inside the edge-function wall clock). */
|
|
95
|
+
export function translationTimeoutMs(contentLength: number, targetCount: number): number {
|
|
96
|
+
return Math.min(90_000, 20_000 + 5 * contentLength * Math.max(1, targetCount));
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/** One OpenAI call. Returns null on any failure (caller writes nothing). */
|
|
84
100
|
export async function translateItem(input: {
|
|
85
101
|
content: string;
|
|
@@ -147,7 +163,7 @@ export async function translateItem(input: {
|
|
|
147
163
|
// effort=minimal shows up as sweep failures in Slack.
|
|
148
164
|
...(/^gpt-5/.test(TRANSLATION_MODEL) ? { reasoning: { effort: "minimal" } } : {}),
|
|
149
165
|
}),
|
|
150
|
-
signal: AbortSignal.timeout(
|
|
166
|
+
signal: AbortSignal.timeout(translationTimeoutMs(input.content.length, input.targets.length)),
|
|
151
167
|
});
|
|
152
168
|
if (!res.ok) {
|
|
153
169
|
console.error("translation api error", res.status, (await res.text()).slice(0, 300));
|
|
@@ -207,9 +223,9 @@ export async function runPool<T, R>(
|
|
|
207
223
|
* carries one (the attempt marker's is ""). */
|
|
208
224
|
export function missingLocales(have: TranslationRow[], targets: string[]): string[] {
|
|
209
225
|
const knownSource = have.find((h) => h.source_locale !== "")?.source_locale;
|
|
210
|
-
const
|
|
226
|
+
const real = have.filter((h) => !isMarker(h));
|
|
211
227
|
return targets.filter(
|
|
212
|
-
(l) => !
|
|
228
|
+
(l) => !real.some((h) => h.locale === l) && (!knownSource || languageOf(l) !== knownSource),
|
|
213
229
|
);
|
|
214
230
|
}
|
|
215
231
|
|
|
@@ -220,16 +236,123 @@ export function hasFreshAttempt(have: TranslationRow[], nowMs: number, maxAgeMs:
|
|
|
220
236
|
);
|
|
221
237
|
}
|
|
222
238
|
|
|
239
|
+
/** An attempt row not yet marked failed (its owner may still be translating). A
|
|
240
|
+
* failed attempt keeps its claim (and created_at) for the 6 h retry window,
|
|
241
|
+
* but nobody should wait on it. */
|
|
242
|
+
const isPendingAttempt = (h: TranslationRow) => h.locale === "attempt" && h.content !== "failed";
|
|
243
|
+
|
|
244
|
+
/** True when `have` holds an attempt young enough to still be in flight
|
|
245
|
+
* (a failed attempt never is). */
|
|
246
|
+
export function isInFlight(have: TranslationRow[], nowMs: number, inFlightMs: number): boolean {
|
|
247
|
+
return hasFreshAttempt(have.filter(isPendingAttempt), nowMs, inFlightMs);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Calls `read` until it reports `done` or `timeoutMs` has elapsed, sleeping
|
|
252
|
+
* `intervalMs` between reads (the last sleep is cut to the deadline), and
|
|
253
|
+
* returns the last value read. `sleep`/`now` are injectable for tests.
|
|
254
|
+
*/
|
|
255
|
+
export async function waitFor<T>(
|
|
256
|
+
read: () => Promise<{ done: boolean; value: T }>,
|
|
257
|
+
opts: {
|
|
258
|
+
timeoutMs: number;
|
|
259
|
+
intervalMs: number;
|
|
260
|
+
sleep?: (ms: number) => Promise<void>;
|
|
261
|
+
now?: () => number;
|
|
262
|
+
},
|
|
263
|
+
): Promise<T> {
|
|
264
|
+
const now = opts.now ?? Date.now;
|
|
265
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
266
|
+
const deadline = now() + opts.timeoutMs;
|
|
267
|
+
let r = await read();
|
|
268
|
+
while (!r.done) {
|
|
269
|
+
const left = deadline - now();
|
|
270
|
+
if (left <= 0) break;
|
|
271
|
+
await sleep(Math.min(opts.intervalMs, left));
|
|
272
|
+
r = await read();
|
|
273
|
+
}
|
|
274
|
+
return r.value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Another caller holds the attempt claim. Without `waitMs`, return what exists
|
|
279
|
+
* now (`have`); with it, wait for that caller — but only while its attempt can
|
|
280
|
+
* still be in flight (a failed attempt keeps its claim for 6 h; waiting on it
|
|
281
|
+
* would only delay the push). `claimRows` carries the attempt row's
|
|
282
|
+
* created_at; absent (23505: the claim was just taken by someone else), it is
|
|
283
|
+
* re-read. Internal export for tests; `targets`/`sleep`/`now` are injectable.
|
|
284
|
+
*/
|
|
285
|
+
export async function otherCallerResult(args: {
|
|
286
|
+
supabase: SupabaseClient;
|
|
287
|
+
tTable: string;
|
|
288
|
+
fk: string;
|
|
289
|
+
id: string;
|
|
290
|
+
have: TranslationRow[];
|
|
291
|
+
waitMs?: number;
|
|
292
|
+
claimRows?: TranslationRow[];
|
|
293
|
+
targets?: string[];
|
|
294
|
+
sleep?: (ms: number) => Promise<void>;
|
|
295
|
+
now?: () => number;
|
|
296
|
+
}): Promise<TranslationRow[]> {
|
|
297
|
+
const { supabase, tTable, fk, id, have } = args;
|
|
298
|
+
const waitMs = args.waitMs ?? 0;
|
|
299
|
+
const now = args.now ?? Date.now;
|
|
300
|
+
if (waitMs <= 0) return have;
|
|
301
|
+
let claimRows = args.claimRows;
|
|
302
|
+
if (!claimRows) {
|
|
303
|
+
const { data, error } = await supabase
|
|
304
|
+
.from(tTable)
|
|
305
|
+
.select("locale, source_locale, content, created_at")
|
|
306
|
+
.eq(fk, id)
|
|
307
|
+
.eq("locale", "attempt");
|
|
308
|
+
if (error) {
|
|
309
|
+
console.error("translation claim lookup failed", error.message);
|
|
310
|
+
return have;
|
|
311
|
+
}
|
|
312
|
+
claimRows = (data ?? []) as TranslationRow[];
|
|
313
|
+
}
|
|
314
|
+
// No attempt left means the other caller just finished: the first read
|
|
315
|
+
// below picks up its rows at once.
|
|
316
|
+
const stale =
|
|
317
|
+
claimRows.some((h) => h.locale === "attempt") && !isInFlight(claimRows, now(), IN_FLIGHT_MS);
|
|
318
|
+
if (stale) return have;
|
|
319
|
+
return await waitFor(
|
|
320
|
+
async () => {
|
|
321
|
+
const { data, error } = await supabase
|
|
322
|
+
.from(tTable)
|
|
323
|
+
.select("locale, source_locale, content")
|
|
324
|
+
.eq(fk, id);
|
|
325
|
+
if (error) {
|
|
326
|
+
console.error("translation wait lookup failed", error.message);
|
|
327
|
+
return { done: true, value: have };
|
|
328
|
+
}
|
|
329
|
+
const rows = (data ?? []) as TranslationRow[];
|
|
330
|
+
return {
|
|
331
|
+
done:
|
|
332
|
+
!rows.some(isPendingAttempt) ||
|
|
333
|
+
missingLocales(rows, args.targets ?? TARGET_LOCALES).length === 0,
|
|
334
|
+
value: realRows(rows),
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
{ timeoutMs: waitMs, intervalMs: 1500, sleep: args.sleep, now: args.now },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
223
341
|
/**
|
|
224
342
|
* Translate `kind`/`id` into every missing target locale and upsert the rows.
|
|
225
343
|
* Returns the rows now present for the item (possibly empty when the item is
|
|
226
344
|
* in a target language), or null when translation failed (nothing written).
|
|
227
345
|
* Idempotent: complete items make no API call.
|
|
346
|
+
*
|
|
347
|
+
* `waitMs` (pushes): when another caller holds the attempt claim, poll for up
|
|
348
|
+
* to `waitMs` until its claim is released or the rows cover every missing
|
|
349
|
+
* locale, instead of returning the (usually empty) rows at once.
|
|
228
350
|
*/
|
|
229
351
|
export async function ensureTranslations(
|
|
230
352
|
supabase: SupabaseClient,
|
|
231
353
|
kind: "post" | "comment",
|
|
232
354
|
id: string,
|
|
355
|
+
opts?: { waitMs?: number },
|
|
233
356
|
): Promise<TranslationRow[] | null> {
|
|
234
357
|
if (TARGET_LOCALES.length === 0) {
|
|
235
358
|
console.log("community-sdk: COMMUNITY_TRANSLATION_LOCALES not set, skipping translation");
|
|
@@ -260,9 +383,7 @@ export async function ensureTranslations(
|
|
|
260
383
|
return null;
|
|
261
384
|
}
|
|
262
385
|
const all = (existing ?? []) as TranslationRow[];
|
|
263
|
-
const have = all
|
|
264
|
-
.filter((h) => !isMarker(h))
|
|
265
|
-
.map(({ locale, source_locale, content }) => ({ locale, source_locale, content }));
|
|
386
|
+
const have = realRows(all);
|
|
266
387
|
const missing = missingLocales(all, TARGET_LOCALES);
|
|
267
388
|
if (missing.length === 0) return have;
|
|
268
389
|
|
|
@@ -272,9 +393,12 @@ export async function ensureTranslations(
|
|
|
272
393
|
// (translate-one + notify-comment, or a sweep) exactly one wins and the other
|
|
273
394
|
// gets 23505 and returns what exists. A stale claim is deleted first (only if
|
|
274
395
|
// still stale, so a claim another caller just took survives). Deleted on
|
|
275
|
-
// success, left on failure: the item is retried after 6 h.
|
|
396
|
+
// success, left (marked "failed") on failure: the item is retried after 6 h.
|
|
276
397
|
const now = Date.now();
|
|
277
|
-
|
|
398
|
+
const waitArgs = { supabase, tTable, fk, id, have, waitMs: opts?.waitMs };
|
|
399
|
+
if (hasFreshAttempt(all, now, ATTEMPT_MAX_AGE_MS)) {
|
|
400
|
+
return await otherCallerResult({ ...waitArgs, claimRows: all });
|
|
401
|
+
}
|
|
278
402
|
if (all.some((h) => h.locale === "attempt")) {
|
|
279
403
|
await supabase
|
|
280
404
|
.from(tTable)
|
|
@@ -287,10 +411,18 @@ export async function ensureTranslations(
|
|
|
287
411
|
.from(tTable)
|
|
288
412
|
.insert({ [fk]: id, locale: "attempt", source_locale: "", content: "", engine: ENGINE });
|
|
289
413
|
if (claimError) {
|
|
290
|
-
|
|
414
|
+
// Another caller holds the claim.
|
|
415
|
+
if (claimError.code === "23505") return await otherCallerResult(waitArgs);
|
|
291
416
|
console.error("translation attempt claim failed", claimError.message);
|
|
292
417
|
return null;
|
|
293
418
|
}
|
|
419
|
+
// From here on this caller holds the claim. Every failure marks it "failed"
|
|
420
|
+
// (keeping created_at, so the 6 h retry window is unchanged) so pushes
|
|
421
|
+
// waiting on it stop at once; an error marking it is ignored.
|
|
422
|
+
const fail = async (): Promise<null> => {
|
|
423
|
+
await supabase.from(tTable).update({ content: "failed" }).eq(fk, id).eq("locale", "attempt");
|
|
424
|
+
return null;
|
|
425
|
+
};
|
|
294
426
|
|
|
295
427
|
let options: { id: string; idx: number; label: string }[] = [];
|
|
296
428
|
if (kind === "post") {
|
|
@@ -302,7 +434,7 @@ export async function ensureTranslations(
|
|
|
302
434
|
// A backend without the polls module has no poll_options relation: no options.
|
|
303
435
|
if (error && error.code !== "PGRST205" && error.code !== "42P01") {
|
|
304
436
|
console.error("poll options lookup failed", error.message);
|
|
305
|
-
return
|
|
437
|
+
return await fail();
|
|
306
438
|
}
|
|
307
439
|
if (!error && data) options = data as typeof options;
|
|
308
440
|
}
|
|
@@ -312,14 +444,14 @@ export async function ensureTranslations(
|
|
|
312
444
|
options: options.map((o) => o.label),
|
|
313
445
|
targets: missing,
|
|
314
446
|
});
|
|
315
|
-
if (!parsed) return
|
|
447
|
+
if (!parsed) return await fail();
|
|
316
448
|
|
|
317
449
|
const incomplete = missing.filter(
|
|
318
450
|
(l) => languageOf(l) !== parsed.sourceLocale && !(l in parsed.translations),
|
|
319
451
|
);
|
|
320
452
|
if (incomplete.length > 0) {
|
|
321
453
|
console.error("translation response missing locales", incomplete.join(", "));
|
|
322
|
-
return
|
|
454
|
+
return await fail();
|
|
323
455
|
}
|
|
324
456
|
|
|
325
457
|
// Poll option rows are written first: if they fail, nothing lands, so the item
|
|
@@ -334,7 +466,7 @@ export async function ensureTranslations(
|
|
|
334
466
|
.upsert(optionRows, { onConflict: "option_id,locale" });
|
|
335
467
|
if (error) {
|
|
336
468
|
console.error("poll option translation upsert failed", error.message);
|
|
337
|
-
return
|
|
469
|
+
return await fail();
|
|
338
470
|
}
|
|
339
471
|
}
|
|
340
472
|
}
|
|
@@ -350,7 +482,7 @@ export async function ensureTranslations(
|
|
|
350
482
|
const { error } = await supabase.from(tTable).upsert(rows, { onConflict: `${fk},locale` });
|
|
351
483
|
if (error) {
|
|
352
484
|
console.error("translation upsert failed", error.message);
|
|
353
|
-
return
|
|
485
|
+
return await fail();
|
|
354
486
|
}
|
|
355
487
|
} else {
|
|
356
488
|
// ponytail: every missing target shares the source language, so there was
|
|
@@ -376,7 +508,7 @@ export async function ensureTranslations(
|
|
|
376
508
|
);
|
|
377
509
|
if (error) {
|
|
378
510
|
console.error("translation marker upsert failed", error.message);
|
|
379
|
-
return
|
|
511
|
+
return await fail();
|
|
380
512
|
}
|
|
381
513
|
}
|
|
382
514
|
// 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
|
);
|