@steve31415/baselib 3.36.2 → 3.38.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/README.md CHANGED
@@ -41,7 +41,9 @@ article calls, and thread assembly through direct reply edges. It includes
41
41
  one level of quotes, article bodies and URL expansions. Use one shared
42
42
  `MirrorTweetClient(mirrorUrl, serviceFetch)` and `TwitterApiIoClient(key)`
43
43
  per run; `assembleThread(id, { api, mirror, ancestors: 'all' })` returns
44
- role-tagged tweets, source counts and warnings. Batch prefetching Mirror
44
+ role-tagged tweets (with `links`: expanded URLs and link-card text), source
45
+ counts and warnings; add `replies: { rule: 'digest', followed }` for Digest's
46
+ reply rule (root-author and followed replies with their context). Batch prefetching Mirror
45
47
  ids seeds the shared cache. Reader and Snip retain their copies for now.
46
48
 
47
49
  Releasing: bump `version`, commit, and run `npm run deploy` (Coder runs it
@@ -88,7 +88,7 @@ export function createBrowserLogger(opts = {}) {
88
88
  // keeping are often the ones an offline stretch causes: while the
89
89
  // browser reports itself offline, failed attempts don't count, so a
90
90
  // batch waits out the outage (until the page dies or the buffer cap
91
- // evicts it) instead of being dropped after ~60 s.
91
+ // evicts it) instead of being dropped after its 6 attempts (~5 min).
92
92
  isOffline: () => navigator.onLine === false,
93
93
  onGiveUp: (batch, error, reason) => console.warn(`log ship dropped a batch (${reason}): ${String(error)}`, {
94
94
  batch_id: batch.id,
@@ -54,13 +54,16 @@ export interface ShipperOptions {
54
54
  token: string;
55
55
  batchSize?: number;
56
56
  intervalMs?: number;
57
- /** Total delivery attempts per batch (default 4). */
57
+ /** Total delivery attempts per batch (default 6; see DEFAULT_RETRY_DELAYS_MS). */
58
58
  maxAttempts?: number;
59
- /** Backoff before attempt 2, 3, … (default [5s, 15s, 40s]). On an idle
60
- * Cloud Run instance the CPU is throttled between requests, so these
59
+ /** Backoff before attempt 2, 3, … (default DEFAULT_RETRY_DELAYS_MS). On an
60
+ * idle Cloud Run instance the CPU is throttled between requests, so these
61
61
  * fire at the next CPU window (next request or shutdown), not on the
62
- * wall clock. */
62
+ * wall clock — which only stretches the budget. */
63
63
  retryDelaysMs?: number[];
64
+ /** While isOffline() is true, how long a batch waits before its next
65
+ * (uncounted) attempt (default 40 s). */
66
+ offlineRetryMs?: number;
64
67
  /** Per-attempt fetch timeout (default 10s; 3s aborted most of the fleet's
65
68
  * attempts in the two weeks to 2026-08-28). */
66
69
  attemptTimeoutMs?: number;
@@ -74,8 +77,8 @@ export interface ShipperOptions {
74
77
  /** True while the platform knows it has no network (the browser passes
75
78
  * `navigator.onLine === false`). A failed attempt made while offline says
76
79
  * nothing about the batch or the ingest service, so it is not counted
77
- * toward maxAttempts and not reported: the batch waits and retries at the
78
- * longest backoff delay, or at once on resume(). The signal only decides
80
+ * toward maxAttempts and not reported: the batch waits and retries after
81
+ * offlineRetryMs, or at once on resume(). The signal only decides
79
82
  * what a failure means — attempts still run while "offline". */
80
83
  isOffline?: () => boolean;
81
84
  /** A delivery attempt failed; the batch will be retried. */
@@ -86,6 +89,21 @@ export interface ShipperOptions {
86
89
  * keep, listed in `failures`. Those events are lost; the rest landed. */
87
90
  onPartialFailure?: (batch: ShippedBatch, failed: number, failures: unknown[]) => void;
88
91
  }
92
+ /** The default retry budget: 6 attempts with 5 + 15 + 40 + 90 + 150 s =
93
+ * 5 minutes of backoff between the first and the last.
94
+ *
95
+ * Deliberate choice, 2026-10-15 (watchdog task dc78e9b2): the earlier
96
+ * 4 attempts over 5 + 15 + 40 s (~60 s) dropped a mirror batch on
97
+ * 2026-10-14, when Axiom ingest answered 503 on and off for ~150 s — 2.5×
98
+ * the budget — the only ingest outage in the 4 weeks of markers on record.
99
+ * 5 minutes covers that episode twice over. Headroom: at the fleet's peak
100
+ * five-minute volume (mirror, 1 538 events, ~0.3 MB, 2026-10-08..15) the
101
+ * events held through a 5-minute outage fill ~31% of the 5 000-event /
102
+ * 5 MB buffer cap, so the cap, not the attempt count, bounds a longer
103
+ * outage. Longer delays cost nothing on a throttled instance (they ride the
104
+ * next CPU window either way) and only matter while ingest is failing. */
105
+ export declare const DEFAULT_MAX_ATTEMPTS = 6;
106
+ export declare const DEFAULT_RETRY_DELAYS_MS: readonly number[];
89
107
  export declare class LogShipper {
90
108
  private readonly opts;
91
109
  private queue;
@@ -102,6 +120,7 @@ export declare class LogShipper {
102
120
  private readonly intervalMs;
103
121
  private readonly maxAttempts;
104
122
  private readonly retryDelaysMs;
123
+ private readonly offlineRetryMs;
105
124
  private readonly attemptTimeoutMs;
106
125
  private readonly maxBufferedEvents;
107
126
  private readonly maxBufferedBytes;
package/dist/log-core.js CHANGED
@@ -45,6 +45,21 @@ export function serializeError(err) {
45
45
  export function truncate(s, max) {
46
46
  return s.length <= max ? s : s.slice(0, max) + `…[+${s.length - max}]`;
47
47
  }
48
+ /** The default retry budget: 6 attempts with 5 + 15 + 40 + 90 + 150 s =
49
+ * 5 minutes of backoff between the first and the last.
50
+ *
51
+ * Deliberate choice, 2026-10-15 (watchdog task dc78e9b2): the earlier
52
+ * 4 attempts over 5 + 15 + 40 s (~60 s) dropped a mirror batch on
53
+ * 2026-10-14, when Axiom ingest answered 503 on and off for ~150 s — 2.5×
54
+ * the budget — the only ingest outage in the 4 weeks of markers on record.
55
+ * 5 minutes covers that episode twice over. Headroom: at the fleet's peak
56
+ * five-minute volume (mirror, 1 538 events, ~0.3 MB, 2026-10-08..15) the
57
+ * events held through a 5-minute outage fill ~31% of the 5 000-event /
58
+ * 5 MB buffer cap, so the cap, not the attempt count, bounds a longer
59
+ * outage. Longer delays cost nothing on a throttled instance (they ride the
60
+ * next CPU window either way) and only matter while ingest is failing. */
61
+ export const DEFAULT_MAX_ATTEMPTS = 6;
62
+ export const DEFAULT_RETRY_DELAYS_MS = [5_000, 15_000, 40_000, 90_000, 150_000];
48
63
  const RESPONSE_BODY_MAX = 300;
49
64
  /** A 4xx is a verdict on the bytes, not the moment: retrying the identical
50
65
  * body cannot succeed. 408 (timeout) and 429 (rate limit) are the exceptions. */
@@ -88,6 +103,7 @@ export class LogShipper {
88
103
  intervalMs;
89
104
  maxAttempts;
90
105
  retryDelaysMs;
106
+ offlineRetryMs;
91
107
  attemptTimeoutMs;
92
108
  maxBufferedEvents;
93
109
  maxBufferedBytes;
@@ -95,8 +111,9 @@ export class LogShipper {
95
111
  this.opts = opts;
96
112
  this.batchSize = opts.batchSize ?? 50;
97
113
  this.intervalMs = opts.intervalMs ?? 2000;
98
- this.maxAttempts = opts.maxAttempts ?? 4;
99
- this.retryDelaysMs = opts.retryDelaysMs ?? [5_000, 15_000, 40_000];
114
+ this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
115
+ this.retryDelaysMs = opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
116
+ this.offlineRetryMs = opts.offlineRetryMs ?? 40_000;
100
117
  this.attemptTimeoutMs = opts.attemptTimeoutMs ?? 10_000;
101
118
  this.maxBufferedEvents = opts.maxBufferedEvents ?? 5_000;
102
119
  this.maxBufferedBytes = opts.maxBufferedBytes ?? 5 * 1024 * 1024;
@@ -259,7 +276,7 @@ export class LogShipper {
259
276
  if (!outcome.ok && !outcome.terminal && this.opts.isOffline?.()) {
260
277
  // Uncounted: the budget is for failures that could be the batch's
261
278
  // or the service's, and an offline failure is neither.
262
- batch.nextAttemptAt = Date.now() + this.retryDelaysMs[this.retryDelaysMs.length - 1];
279
+ batch.nextAttemptAt = Date.now() + this.offlineRetryMs;
263
280
  this.schedulePump();
264
281
  return;
265
282
  }
@@ -4,11 +4,31 @@ export interface TweetAuthor {
4
4
  userName: string;
5
5
  name: string;
6
6
  }
7
+ /** TwitterAPI.io sends `url` (t.co) + `expanded_url`; Mirror stores the
8
+ * expansion in `url` and may add link-card `title`/`description`. */
7
9
  export interface TweetEntityUrl {
8
10
  url: string;
9
11
  expanded_url?: string;
10
12
  display_url?: string;
13
+ title?: string;
14
+ description?: string;
11
15
  }
16
+ /** A link card as TwitterAPI.io returns it: advanced_search gives the body
17
+ * directly, the by-id /tweets endpoint wraps it in `legacy` (probed
18
+ * 2026-10-15, test/fixtures/twitter-digest-live.json). String values such as
19
+ * `title`, `description` and `card_url` (the t.co token) are binding values. */
20
+ export interface TwitterApiIoCardBody {
21
+ name?: string;
22
+ binding_values?: {
23
+ key: string;
24
+ value?: {
25
+ string_value?: string;
26
+ };
27
+ }[];
28
+ }
29
+ export type TwitterApiIoCard = TwitterApiIoCardBody & {
30
+ legacy?: TwitterApiIoCardBody;
31
+ };
12
32
  export interface TwitterApiIoTweet {
13
33
  id: string;
14
34
  text: string;
@@ -20,6 +40,7 @@ export interface TwitterApiIoTweet {
20
40
  entities?: {
21
41
  urls?: TweetEntityUrl[];
22
42
  };
43
+ card?: TwitterApiIoCard | null;
23
44
  article?: unknown;
24
45
  articleBody?: {
25
46
  title: string;
@@ -17,9 +17,11 @@ export interface MirrorTweetRecord {
17
17
  is_deleted?: boolean | number;
18
18
  deleted_at?: string | null;
19
19
  }
20
+ /** `complete` is false when paging stopped early; `warnings` says why. */
20
21
  export interface MirrorConversation {
21
22
  tweets: TwitterApiIoTweet[];
22
23
  complete: boolean;
24
+ warnings?: string[];
23
25
  }
24
26
  export interface MirrorTwitterClient {
25
27
  getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
@@ -28,8 +30,13 @@ export interface MirrorTwitterClient {
28
30
  /** Mirror snapshots store URL expansions under `url`, without the original
29
31
  * t.co token. Keep both shapes; rendering handles the positional fallback. */
30
32
  export declare function tweetFromMirror(r: MirrorTweetRecord): TwitterApiIoTweet;
33
+ /** Rows per Mirror page (Mirror's cap) and pages per conversation read. */
34
+ export declare const MIRROR_PAGE_LIMIT = 1000;
35
+ export declare const MIRROR_CONVERSATION_MAX_PAGES = 10;
31
36
  /** Shared per run: batch prefetch seeds the id cache, conversation requests
32
- * are coalesced, and failed requests are evicted so a later link can retry. */
37
+ * are coalesced, and failed requests are evicted so a later link can retry.
38
+ * Conversations follow Mirror's keyset cursor (record_timestamp DESC, id
39
+ * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES. */
33
40
  export declare class MirrorTweetClient implements MirrorTwitterClient {
34
41
  private readonly mirrorUrl;
35
42
  private readonly fetchFn;
@@ -39,4 +46,5 @@ export declare class MirrorTweetClient implements MirrorTwitterClient {
39
46
  private query;
40
47
  getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
41
48
  getConversation(id: string): Promise<MirrorConversation>;
49
+ private readConversation;
42
50
  }
@@ -31,8 +31,13 @@ export function tweetFromMirror(r) {
31
31
  ...(r.article_content ? { articleBody: { title: r.article_title ?? '', text: r.article_content } } : {}),
32
32
  };
33
33
  }
34
+ /** Rows per Mirror page (Mirror's cap) and pages per conversation read. */
35
+ export const MIRROR_PAGE_LIMIT = 1000;
36
+ export const MIRROR_CONVERSATION_MAX_PAGES = 10;
34
37
  /** Shared per run: batch prefetch seeds the id cache, conversation requests
35
- * are coalesced, and failed requests are evicted so a later link can retry. */
38
+ * are coalesced, and failed requests are evicted so a later link can retry.
39
+ * Conversations follow Mirror's keyset cursor (record_timestamp DESC, id
40
+ * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES. */
36
41
  export class MirrorTweetClient {
37
42
  mirrorUrl;
38
43
  fetchFn;
@@ -42,11 +47,11 @@ export class MirrorTweetClient {
42
47
  this.mirrorUrl = mirrorUrl;
43
48
  this.fetchFn = fetchFn;
44
49
  }
45
- async query(filter) {
50
+ async query(filter, cursor) {
46
51
  const response = await this.fetchFn(`${this.mirrorUrl.replace(/\/$/, '')}/api/records/tweets/query`, {
47
52
  audience: this.mirrorUrl.replace(/\/$/, ''), method: 'POST',
48
53
  headers: { 'content-type': 'application/json' },
49
- body: JSON.stringify({ filter_sql: filter, columns: '*', limit: 1000 }),
54
+ body: JSON.stringify({ filter_sql: filter, columns: '*', limit: MIRROR_PAGE_LIMIT, ...(cursor ? { cursor } : {}) }),
50
55
  signal: AbortSignal.timeout(30_000),
51
56
  });
52
57
  if (!response.ok)
@@ -64,7 +69,9 @@ export class MirrorTweetClient {
64
69
  }
65
70
  for (const [id, tweet] of tweets)
66
71
  this.byId.set(id, tweet);
67
- return { tweets: [...tweets.values()], complete: !data.has_more && data.records.length < 1000 };
72
+ // A full page without `has_more` is treated as more to come (Mirror always sends it today).
73
+ const hasMore = data.has_more ?? data.records.length >= MIRROR_PAGE_LIMIT;
74
+ return { tweets: [...tweets.values()], hasMore, cursor: data.cursor ?? null };
68
75
  }
69
76
  async getTweetsByIds(ids) {
70
77
  ids.forEach(assertTweetId);
@@ -83,7 +90,7 @@ export class MirrorTweetClient {
83
90
  assertTweetId(id);
84
91
  let pending = this.conversations.get(id);
85
92
  if (!pending) {
86
- pending = this.query(`conversation_id = '${id}'`).catch((error) => {
93
+ pending = this.readConversation(id).catch((error) => {
87
94
  this.conversations.delete(id);
88
95
  throw error;
89
96
  });
@@ -91,4 +98,29 @@ export class MirrorTweetClient {
91
98
  }
92
99
  return pending;
93
100
  }
101
+ async readConversation(id) {
102
+ const tweets = new Map();
103
+ const cursors = new Set();
104
+ const warnings = [];
105
+ let cursor;
106
+ for (let page = 1;; page++) {
107
+ const result = await this.query(`conversation_id = '${id}'`, cursor);
108
+ for (const tweet of result.tweets)
109
+ if (!tweets.has(tweet.id))
110
+ tweets.set(tweet.id, tweet);
111
+ if (!result.hasMore)
112
+ return { tweets: [...tweets.values()], complete: true, warnings };
113
+ if (!result.cursor || cursors.has(result.cursor)) {
114
+ warnings.push('Mirror conversation pagination cursor missing or repeated');
115
+ break;
116
+ }
117
+ if (page >= MIRROR_CONVERSATION_MAX_PAGES) {
118
+ warnings.push(`Mirror conversation reached the ${MIRROR_CONVERSATION_MAX_PAGES}-page limit`);
119
+ break;
120
+ }
121
+ cursors.add(result.cursor);
122
+ cursor = result.cursor;
123
+ }
124
+ return { tweets: [...tweets.values()], complete: false, warnings };
125
+ }
94
126
  }
package/dist/twitter.d.ts CHANGED
@@ -4,7 +4,18 @@ export * from './twitter-client.js';
4
4
  export * from './twitter-mirror.js';
5
5
  export declare function parseTweetId(value: string): string | null;
6
6
  export declare function isTwitterUrl(value: string): boolean;
7
- export type TweetRole = 'ancestor' | 'linked' | 'self-reply' | 'quoted';
7
+ /** `reply`: a tweet the digest rule includes that is neither the root, an
8
+ * ancestor of the linked tweet, nor one of the linked author's self-replies
9
+ * (a followed or root-author reply, or a stranger shown as its context). The
10
+ * root is always `ancestor` unless it is the linked tweet. */
11
+ export type TweetRole = 'ancestor' | 'linked' | 'self-reply' | 'reply' | 'quoted';
12
+ /** An expanded link with its link-card text, when Mirror or the API has it. */
13
+ export interface TweetLink {
14
+ url: string;
15
+ displayUrl?: string;
16
+ title?: string;
17
+ description?: string;
18
+ }
8
19
  export interface ThreadTweet extends TwitterApiIoTweet {
9
20
  url: string;
10
21
  role: TweetRole;
@@ -13,6 +24,7 @@ export interface ThreadTweet extends TwitterApiIoTweet {
13
24
  title: string;
14
25
  text: string;
15
26
  };
27
+ links: TweetLink[];
16
28
  }
17
29
  export interface TwitterThread {
18
30
  tweets: ThreadTweet[];
@@ -23,13 +35,38 @@ export interface TwitterThread {
23
35
  };
24
36
  warnings: string[];
25
37
  }
38
+ /** Digest's display rule (digest/src/shared/thread-builder.ts `buildThread`):
39
+ * the root, every tweet by the root author or a followed handle, and each of
40
+ * those tweets' ancestor chains (strangers included as context). `followed`
41
+ * holds handles, compared case-insensitively; a leading `@` is ignored. */
42
+ export interface DigestReplyRule {
43
+ rule: 'digest';
44
+ followed: Set<string>;
45
+ }
26
46
  export interface AssembleThreadOptions {
27
47
  api: TwitterClient;
28
48
  mirror?: MirrorTwitterClient;
49
+ /** Default rule only: the digest rule always includes every ancestor. */
29
50
  ancestors?: 'same-author' | 'all';
51
+ /** Omitted: ancestors plus the linked author's contiguous self-replies. */
52
+ replies?: DigestReplyRule;
30
53
  }
54
+ /** Bounds: ancestor chain length, API search pages, and for the digest rule
55
+ * the parent ids looked up and the tweets kept (each with a warning). */
56
+ export declare const THREAD_CHAIN_LIMIT = 50;
57
+ export declare const THREAD_SEARCH_PAGE_LIMIT = 5;
58
+ export declare const DIGEST_PARENT_LOOKUP_LIMIT = 200;
59
+ export declare const DIGEST_THREAD_TWEET_LIMIT = 300;
60
+ /** Snowflake order: a longer id is newer; equal lengths compare as strings
61
+ * (ids exceed Number.MAX_SAFE_INTEGER). Same as Digest's compareTweetIds. */
62
+ export declare function compareTweetIds(a: string, b: string): number;
31
63
  export declare function expandTweetUrls(tweet: TwitterApiIoTweet): string;
64
+ /** A tweet's URL entities, expanded, with link-card title and description:
65
+ * from the entity itself (Mirror's stored shape) or else from the tweet's
66
+ * TwitterAPI.io card, matched to the entity by its t.co `card_url`. */
67
+ export declare function tweetLinks(tweet: TwitterApiIoTweet): TweetLink[];
32
68
  /** Conversation search discovers candidates; only direct parent edges decide
33
- * membership. The linked author's replies to strangers can never become
34
- * descendants. Missing ancestors degrade visibly and preserve known text. */
69
+ * membership. By default the linked author's replies to strangers can never
70
+ * become descendants; `replies: { rule: 'digest' }` applies Digest's rule
71
+ * instead. Missing ancestors degrade visibly and preserve known text. */
35
72
  export declare function assembleThread(id: string, options: AssembleThreadOptions): Promise<TwitterThread>;
package/dist/twitter.js CHANGED
@@ -13,6 +13,19 @@ export function parseTweetId(value) {
13
13
  }
14
14
  }
15
15
  export function isTwitterUrl(value) { return parseTweetId(value) !== null; }
16
+ /** Bounds: ancestor chain length, API search pages, and for the digest rule
17
+ * the parent ids looked up and the tweets kept (each with a warning). */
18
+ export const THREAD_CHAIN_LIMIT = 50;
19
+ export const THREAD_SEARCH_PAGE_LIMIT = 5;
20
+ export const DIGEST_PARENT_LOOKUP_LIMIT = 200;
21
+ export const DIGEST_THREAD_TWEET_LIMIT = 300;
22
+ /** Snowflake order: a longer id is newer; equal lengths compare as strings
23
+ * (ids exceed Number.MAX_SAFE_INTEGER). Same as Digest's compareTweetIds. */
24
+ export function compareTweetIds(a, b) {
25
+ if (a.length !== b.length)
26
+ return a.length - b.length;
27
+ return a.localeCompare(b);
28
+ }
16
29
  const sameAuthor = (a, b) => !!(a.author.id && b.author.id && a.author.id === b.author.id) ||
17
30
  !!(a.author.userName && b.author.userName && a.author.userName.toLowerCase() === b.author.userName.toLowerCase());
18
31
  export function expandTweetUrls(tweet) {
@@ -33,11 +46,38 @@ export function expandTweetUrls(tweet) {
33
46
  text += `\nLinks: ${unmatched.join(' ')}`;
34
47
  return text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
35
48
  }
49
+ /** A tweet's URL entities, expanded, with link-card title and description:
50
+ * from the entity itself (Mirror's stored shape) or else from the tweet's
51
+ * TwitterAPI.io card, matched to the entity by its t.co `card_url`. */
52
+ export function tweetLinks(tweet) {
53
+ const card = tweet.card?.legacy ?? tweet.card;
54
+ const values = new Map();
55
+ for (const binding of Array.isArray(card?.binding_values) ? card.binding_values : []) {
56
+ if (typeof binding?.key === 'string' && typeof binding.value?.string_value === 'string')
57
+ values.set(binding.key, binding.value.string_value);
58
+ }
59
+ const cardUrl = values.get('card_url');
60
+ const text = (value) => typeof value === 'string' && value.trim() ? value : undefined;
61
+ return (tweet.entities?.urls ?? []).filter((u) => typeof u?.url === 'string').map((u) => {
62
+ const fromCard = !!cardUrl && u.url === cardUrl;
63
+ const title = text(u.title) ?? (fromCard ? text(values.get('title')) : undefined);
64
+ const description = text(u.description) ?? (fromCard ? text(values.get('description')) : undefined);
65
+ return {
66
+ url: u.expanded_url || u.url,
67
+ ...(text(u.display_url) ? { displayUrl: u.display_url } : {}),
68
+ ...(title ? { title } : {}),
69
+ ...(description ? { description } : {}),
70
+ };
71
+ });
72
+ }
36
73
  /** Conversation search discovers candidates; only direct parent edges decide
37
- * membership. The linked author's replies to strangers can never become
38
- * descendants. Missing ancestors degrade visibly and preserve known text. */
74
+ * membership. By default the linked author's replies to strangers can never
75
+ * become descendants; `replies: { rule: 'digest' }` applies Digest's rule
76
+ * instead. Missing ancestors degrade visibly and preserve known text. */
39
77
  export async function assembleThread(id, options) {
40
78
  assertTweetId(id);
79
+ if (options.replies && options.replies.rule !== 'digest')
80
+ throw new Error('Unknown thread reply rule');
41
81
  const { api, mirror } = options;
42
82
  const result = { tweets: [], stats: { mirror2: 0, twitterApi: 0, apiCalls: 0 }, warnings: [] };
43
83
  const records = new Map();
@@ -46,7 +86,11 @@ export async function assembleThread(id, options) {
46
86
  if (!records.has(tweet.id) || source === 'mirror2')
47
87
  records.set(tweet.id, { tweet, source });
48
88
  };
49
- const warning = (message, error) => result.warnings.push(`${message}${error instanceof Error ? `: ${error.message}` : ''}`);
89
+ const warning = (message, error) => {
90
+ const text = `${message}${error instanceof Error ? `: ${error.message}` : ''}`;
91
+ if (!result.warnings.includes(text))
92
+ result.warnings.push(text); // one warning per cause
93
+ };
50
94
  const callApi = async (run) => {
51
95
  try {
52
96
  const response = await run();
@@ -73,37 +117,25 @@ export async function assembleThread(id, options) {
73
117
  if (absent.length)
74
118
  add((await callApi(() => api.getTweetsByIds(absent))).tweets, 'twitterapi');
75
119
  };
76
- await fetchIds([id]);
77
- const linked = records.get(id)?.tweet;
78
- if (!linked)
79
- throw new TweetNotFoundError(id);
80
- const cid = linked.conversationId ?? linked.id;
81
- let archivedAuthor = false;
82
- if (mirror)
83
- try {
84
- const conversation = await mirror.getConversation(cid);
85
- add(conversation.tweets, 'mirror2');
86
- archivedAuthor = conversation.complete && conversation.tweets.some((tweet) => sameAuthor(tweet, linked));
87
- if (!conversation.complete)
88
- warning('Mirror conversation reached its row limit; searching the author via API');
89
- }
90
- catch (error) {
91
- warning('Mirror conversation failed', error);
120
+ /** `conversation_id:<cid> from:<author>` — the author's replies Mirror may lack. */
121
+ const searchAuthor = async (author, cid) => {
122
+ if (!/^\w+$/.test(author.author.userName)) {
123
+ warning('Twitter conversation author handle missing or invalid');
124
+ return;
92
125
  }
93
- if (!archivedAuthor && /^\w+$/.test(linked.author.userName)) {
94
126
  let cursor;
95
127
  const cursors = new Set();
96
- for (let page = 0; page < 5; page++) {
128
+ for (let page = 0; page < THREAD_SEARCH_PAGE_LIMIT; page++) {
97
129
  try {
98
- const search = await callApi(() => api.advancedSearch(`conversation_id:${cid} from:${linked.author.userName}`, cursor));
99
- add(search.tweets.filter((tweet) => sameAuthor(tweet, linked) && (!tweet.conversationId || tweet.conversationId === cid)), 'twitterapi');
130
+ const search = await callApi(() => api.advancedSearch(`conversation_id:${cid} from:${author.author.userName}`, cursor));
131
+ add(search.tweets.filter((tweet) => sameAuthor(tweet, author) && (!tweet.conversationId || tweet.conversationId === cid)), 'twitterapi');
100
132
  if (!search.tweets.length || !search.has_next_page)
101
133
  break;
102
134
  if (!search.next_cursor || cursors.has(search.next_cursor)) {
103
135
  warning('Twitter conversation pagination cursor missing or repeated');
104
136
  break;
105
137
  }
106
- if (page === 4)
138
+ if (page === THREAD_SEARCH_PAGE_LIMIT - 1)
107
139
  warning('Twitter conversation reached the five-page limit');
108
140
  cursors.add(search.next_cursor);
109
141
  cursor = search.next_cursor;
@@ -113,70 +145,256 @@ export async function assembleThread(id, options) {
113
145
  break;
114
146
  }
115
147
  }
116
- }
117
- else if (!archivedAuthor)
118
- warning('Twitter conversation author handle missing or invalid');
119
- const seen = new Set([id]);
120
- const ancestors = [];
121
- let current = linked;
122
- while (current.inReplyToId && ancestors.length < 50) {
123
- if (seen.has(current.inReplyToId)) {
124
- warning('Tweet ancestor cycle detected');
125
- break;
148
+ };
149
+ await fetchIds([id]);
150
+ const linked = records.get(id)?.tweet;
151
+ if (!linked)
152
+ throw new TweetNotFoundError(id);
153
+ /** Digest rule: the linked tweet's ancestors are fetched first, one parent
154
+ * at a time and outside the shared lookup budget, so a busy thread cannot
155
+ * crowd them out. Returns the top of the chain; gaps go in `linkedGaps`. */
156
+ const linkedGaps = new Set();
157
+ const fillLinkedChain = async () => {
158
+ let current = linked;
159
+ const seen = new Set([linked.id]);
160
+ for (let depth = 0; current.inReplyToId && depth < THREAD_CHAIN_LIMIT; depth++) {
161
+ const parentId = current.inReplyToId;
162
+ if (seen.has(parentId) || linkedGaps.has(parentId))
163
+ break;
164
+ try {
165
+ await fetchIds([parentId]);
166
+ }
167
+ catch (error) {
168
+ warning(`Parent ${parentId} unavailable`, error);
169
+ linkedGaps.add(parentId);
170
+ break;
171
+ }
172
+ const parent = records.get(parentId)?.tweet;
173
+ if (!parent) {
174
+ warning(`Parent ${parentId} not found`);
175
+ linkedGaps.add(parentId);
176
+ break;
177
+ }
178
+ seen.add(parentId);
179
+ current = parent;
126
180
  }
181
+ return current;
182
+ };
183
+ let cid = linked.conversationId || linked.id;
184
+ if (options.replies && !linked.conversationId && linked.inReplyToId) {
185
+ // A reply without a conversation id: its root is the top of its chain.
186
+ const top = await fillLinkedChain();
187
+ cid = top.id;
188
+ if (top.inReplyToId)
189
+ warning(`Tweet ${id} has no conversation id and its root was not found; using ${cid}`);
190
+ }
191
+ let conversation;
192
+ if (mirror)
127
193
  try {
128
- await fetchIds([current.inReplyToId]);
194
+ conversation = await mirror.getConversation(cid);
195
+ add(conversation.tweets, 'mirror2');
196
+ for (const message of conversation.warnings ?? [])
197
+ warning(message);
129
198
  }
130
199
  catch (error) {
131
- warning(`Parent ${current.inReplyToId} unavailable`, error);
132
- break;
133
- }
134
- const parent = records.get(current.inReplyToId)?.tweet;
135
- if (!parent) {
136
- warning(`Parent ${current.inReplyToId} not found`);
137
- break;
138
- }
139
- if ((options.ancestors ?? 'same-author') === 'same-author' && !sameAuthor(parent, linked))
140
- break;
141
- seen.add(parent.id);
142
- ancestors.unshift(parent);
143
- current = parent;
144
- }
145
- if (ancestors.length === 50 && current.inReplyToId)
146
- warning('Tweet ancestors reached the 50-tweet limit');
147
- const descendants = [];
148
- current = linked;
149
- while (descendants.length < 50) {
150
- const candidates = [...records.values()].map((r) => r.tweet)
151
- .filter((tweet) => tweet.inReplyToId === current.id && sameAuthor(tweet, linked) && !seen.has(tweet.id))
152
- .sort((a, b) => a.id.length - b.id.length || a.id.localeCompare(b.id));
153
- if (!candidates.length)
154
- break;
155
- if (candidates.length > 1)
156
- warning(`Multiple self-replies to ${current.id}; selected earliest branch`);
157
- current = candidates[0];
158
- seen.add(current.id);
159
- descendants.push(current);
160
- }
161
- if (descendants.length === 50)
162
- warning('Tweet self-replies reached the 50-tweet limit');
163
- const members = [...ancestors, linked, ...descendants];
200
+ warning('Mirror conversation failed', error);
201
+ }
202
+ const incompleteWarning = (who) => {
203
+ if (conversation && !conversation.complete && !conversation.warnings?.length)
204
+ warning(`Mirror conversation incomplete; searching ${who} via API`);
205
+ };
206
+ /** The default rule: ancestors (same author unless `ancestors: 'all'`) and
207
+ * the linked author's earliest contiguous chain of direct self-replies. */
208
+ const authorThread = async () => {
209
+ const archivedAuthor = !!conversation?.complete && conversation.tweets.some((tweet) => sameAuthor(tweet, linked));
210
+ incompleteWarning('the author');
211
+ if (!archivedAuthor)
212
+ await searchAuthor(linked, cid);
213
+ const seen = new Set([id]);
214
+ const ancestors = [];
215
+ let current = linked;
216
+ while (current.inReplyToId && ancestors.length < THREAD_CHAIN_LIMIT) {
217
+ if (seen.has(current.inReplyToId)) {
218
+ warning('Tweet ancestor cycle detected');
219
+ break;
220
+ }
221
+ try {
222
+ await fetchIds([current.inReplyToId]);
223
+ }
224
+ catch (error) {
225
+ warning(`Parent ${current.inReplyToId} unavailable`, error);
226
+ break;
227
+ }
228
+ const parent = records.get(current.inReplyToId)?.tweet;
229
+ if (!parent) {
230
+ warning(`Parent ${current.inReplyToId} not found`);
231
+ break;
232
+ }
233
+ if ((options.ancestors ?? 'same-author') === 'same-author' && !sameAuthor(parent, linked))
234
+ break;
235
+ seen.add(parent.id);
236
+ ancestors.unshift(parent);
237
+ current = parent;
238
+ }
239
+ if (ancestors.length === THREAD_CHAIN_LIMIT && current.inReplyToId)
240
+ warning(`Tweet ancestors reached the ${THREAD_CHAIN_LIMIT}-tweet limit`);
241
+ const descendants = [];
242
+ current = linked;
243
+ while (descendants.length < THREAD_CHAIN_LIMIT) {
244
+ const candidates = [...records.values()].map((r) => r.tweet)
245
+ .filter((tweet) => tweet.inReplyToId === current.id && sameAuthor(tweet, linked) && !seen.has(tweet.id))
246
+ .sort((a, b) => compareTweetIds(a.id, b.id));
247
+ if (!candidates.length)
248
+ break;
249
+ if (candidates.length > 1)
250
+ warning(`Multiple self-replies to ${current.id}; selected earliest branch`);
251
+ current = candidates[0];
252
+ seen.add(current.id);
253
+ descendants.push(current);
254
+ }
255
+ if (descendants.length === THREAD_CHAIN_LIMIT)
256
+ warning(`Tweet self-replies reached the ${THREAD_CHAIN_LIMIT}-tweet limit`);
257
+ return {
258
+ members: [...ancestors, linked, ...descendants],
259
+ roleOf: (tweet) => tweet.id === id ? 'linked' : ancestors.includes(tweet) ? 'ancestor' : 'self-reply',
260
+ };
261
+ };
262
+ /** Digest's rule over Mirror's conversation plus the root author's replies
263
+ * from the API. The search is skipped only when Mirror's read is complete
264
+ * and the root author is followed (Mirror polls followed accounts; for
265
+ * others it holds only what a one-off conversation fetch found — the live
266
+ * probe's unfollowed root author had a reply Mirror lacked). */
267
+ const digestThread = async (rule) => {
268
+ const followed = new Set([...rule.followed].map((handle) => handle.replace(/^@/, '').toLowerCase()));
269
+ await fillLinkedChain();
270
+ let rootFailed = linkedGaps.has(cid);
271
+ if (!rootFailed)
272
+ try {
273
+ await fetchIds([cid]);
274
+ }
275
+ catch (error) {
276
+ rootFailed = true;
277
+ warning(`Root ${cid} unavailable`, error);
278
+ }
279
+ const root = records.get(cid)?.tweet;
280
+ // Parent lookups below skip ids in `unavailable`, so each gap is reported once.
281
+ const unavailable = new Set([...linkedGaps, ...(root ? [] : [cid])]);
282
+ if (!root && !rootFailed)
283
+ warning(`Root ${cid} not found`);
284
+ else if (root) {
285
+ const archived = !!conversation?.complete && followed.has(root.author.userName.toLowerCase());
286
+ incompleteWarning('the root author');
287
+ if (!archived)
288
+ await searchAuthor(root, cid);
289
+ }
290
+ const candidates = () => [linked, ...[...records.values()].map((r) => r.tweet)
291
+ .filter((tweet) => tweet.id !== linked.id && (tweet.conversationId || cid) === cid &&
292
+ (tweet.id === cid || (!!root && sameAuthor(tweet, root)) || followed.has(tweet.author.userName.toLowerCase())))
293
+ .sort((a, b) => compareTweetIds(a.id, b.id))];
294
+ // Fill parents missing from every candidate's chain, one batch per depth.
295
+ let lookups = 0;
296
+ for (let round = 0;; round++) {
297
+ const missing = new Set();
298
+ const visited = new Set();
299
+ for (const candidate of candidates()) {
300
+ let tweet = candidate;
301
+ while (tweet?.inReplyToId && !visited.has(tweet.id)) {
302
+ visited.add(tweet.id);
303
+ const parent = records.get(tweet.inReplyToId)?.tweet;
304
+ if (!parent && !unavailable.has(tweet.inReplyToId))
305
+ missing.add(tweet.inReplyToId);
306
+ tweet = parent;
307
+ }
308
+ }
309
+ if (!missing.size)
310
+ break;
311
+ if (round === THREAD_CHAIN_LIMIT || lookups === DIGEST_PARENT_LOOKUP_LIMIT) {
312
+ warning(round === THREAD_CHAIN_LIMIT ? `Tweet ancestors reached the ${THREAD_CHAIN_LIMIT}-tweet limit` : `Thread parent lookups reached the ${DIGEST_PARENT_LOOKUP_LIMIT}-tweet limit`);
313
+ break;
314
+ }
315
+ const batch = [...missing].slice(0, DIGEST_PARENT_LOOKUP_LIMIT - lookups);
316
+ lookups += batch.length;
317
+ let failed = false;
318
+ try {
319
+ await fetchIds(batch);
320
+ }
321
+ catch (error) {
322
+ failed = true;
323
+ warning(`Parent lookup of ${batch.length} tweets failed`, error);
324
+ }
325
+ for (const parentId of batch) {
326
+ if (records.has(parentId))
327
+ continue;
328
+ unavailable.add(parentId);
329
+ if (!failed)
330
+ warning(`Parent ${parentId} not found`);
331
+ }
332
+ }
333
+ // Each candidate brings its chain; the linked chain always, others while they fit.
334
+ const include = new Map();
335
+ const linkedChain = new Set();
336
+ for (const candidate of candidates()) {
337
+ const chain = [];
338
+ const local = new Set();
339
+ let tweet = candidate;
340
+ while (tweet && !include.has(tweet.id)) {
341
+ if (local.has(tweet.id)) {
342
+ warning('Tweet ancestor cycle detected');
343
+ break;
344
+ }
345
+ if (chain.length > THREAD_CHAIN_LIMIT) {
346
+ warning(`Tweet ancestors reached the ${THREAD_CHAIN_LIMIT}-tweet limit`);
347
+ break;
348
+ }
349
+ local.add(tweet.id);
350
+ chain.push(tweet);
351
+ tweet = tweet.inReplyToId ? records.get(tweet.inReplyToId)?.tweet : undefined;
352
+ }
353
+ if (candidate === linked)
354
+ for (const member of chain)
355
+ linkedChain.add(member.id);
356
+ else if (include.size + chain.length > DIGEST_THREAD_TWEET_LIMIT) {
357
+ warning(`Thread reached the ${DIGEST_THREAD_TWEET_LIMIT}-tweet limit; later replies omitted`);
358
+ break;
359
+ }
360
+ for (const member of chain)
361
+ include.set(member.id, member);
362
+ }
363
+ const members = [...include.values()]
364
+ .sort((a, b) => a.id === cid ? -1 : b.id === cid ? 1 : compareTweetIds(a.id, b.id));
365
+ // Replies follow their parents in id order, so one pass finds the
366
+ // linked author's contiguous self-replies.
367
+ const selfReplies = new Set([linked.id]);
368
+ for (const tweet of members) {
369
+ if (tweet.inReplyToId && selfReplies.has(tweet.inReplyToId) && sameAuthor(tweet, linked) && !linkedChain.has(tweet.id))
370
+ selfReplies.add(tweet.id);
371
+ }
372
+ return {
373
+ members,
374
+ roleOf: (tweet) => tweet.id === id ? 'linked'
375
+ : linkedChain.has(tweet.id) || tweet.id === cid ? 'ancestor'
376
+ : selfReplies.has(tweet.id) ? 'self-reply' : 'reply',
377
+ };
378
+ };
379
+ const { members, roleOf } = options.replies ? await digestThread(options.replies) : await authorThread();
164
380
  const quoteSeen = new Set(members.map((t) => t.id));
165
381
  const articleIds = new Set();
382
+ const append = (item, role, source) => {
383
+ if (item.article)
384
+ articleIds.add(item.id);
385
+ const { article: _rawArticle, quoted_tweet: _nestedQuote, ...rest } = item;
386
+ result.tweets.push({
387
+ ...rest, text: expandTweetUrls(item), url: `https://x.com/${item.author.userName || 'i'}/status/${item.id}`,
388
+ role, source, links: tweetLinks(item), ...(item.articleBody ? { article: item.articleBody } : {}),
389
+ });
390
+ if (source === 'mirror2')
391
+ result.stats.mirror2++;
392
+ else
393
+ result.stats.twitterApi++;
394
+ };
166
395
  for (const tweet of members) {
167
396
  const source = records.get(tweet.id).source;
168
- const role = tweet.id === id ? 'linked' : ancestors.includes(tweet) ? 'ancestor' : 'self-reply';
169
- const append = (item, itemRole, itemSource) => {
170
- if (item.article)
171
- articleIds.add(item.id);
172
- const { article: _rawArticle, quoted_tweet: _nestedQuote, ...rest } = item;
173
- result.tweets.push({ ...rest, text: expandTweetUrls(item), url: `https://x.com/${item.author.userName || 'i'}/status/${item.id}`, role: itemRole, source: itemSource, ...(item.articleBody ? { article: item.articleBody } : {}) });
174
- if (itemSource === 'mirror2')
175
- result.stats.mirror2++;
176
- else
177
- result.stats.twitterApi++;
178
- };
179
- append(tweet, role, source);
397
+ append(tweet, roleOf(tweet), source);
180
398
  if (tweet.quoted_tweet && !quoteSeen.has(tweet.quoted_tweet.id)) {
181
399
  quoteSeen.add(tweet.quoted_tweet.id);
182
400
  const archivedQuote = records.get(tweet.quoted_tweet.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.36.2",
3
+ "version": "3.38.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",