@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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). Best-effort: push failures must
15
- // never fail the calling webhook. Projects with Enhanced Security for push
16
- // must set the EXPO_ACCESS_TOKEN function secret.
17
- export async function sendExpoPushBatch(messages: ExpoPushMessage[]): Promise<void> {
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
- for (let i = 0; i < messages.length; i += 100) {
22
- const chunk = messages.slice(i, i + 100);
23
- await fetch("https://exp.host/--/api/v2/push/send", {
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
- }).catch(() => {});
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(20_000),
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 realRows = have.filter((h) => !isMarker(h));
226
+ const real = have.filter((h) => !isMarker(h));
211
227
  return targets.filter(
212
- (l) => !realRows.some((h) => h.locale === l) && (!knownSource || languageOf(l) !== knownSource),
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
- if (hasFreshAttempt(all, now, ATTEMPT_MAX_AGE_MS)) return have;
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
- if (claimError.code === "23505") return have; // another caller holds the claim
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 null;
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 null;
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 null;
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 null;
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 null;
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 null;
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
- const { data: rows } = await supabase
48
- .from("push_tokens")
49
- .select("expo_push_token, profiles(locale)")
50
- .not("expo_push_token", "is", null);
51
- const messages = (rows ?? [])
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) => Boolean(m.to))
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
  );