@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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[] }>;
@@ -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 realRows = have.filter((h) => !isMarker(h));
218
+ const real = have.filter((h) => !isMarker(h));
211
219
  return targets.filter(
212
- (l) => !realRows.some((h) => h.locale === l) && (!knownSource || languageOf(l) !== knownSource),
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
- if (hasFreshAttempt(all, now, ATTEMPT_MAX_AGE_MS)) return have;
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
- if (claimError.code === "23505") return have; // another caller holds the claim
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 null;
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 null;
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 null;
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 null;
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 null;
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 null;
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
- 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
  );