birdclaw 0.3.0 → 0.4.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/src/lib/backup.ts CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
- import type Database from "better-sqlite3";
7
+ import type { Database } from "./sqlite";
8
8
  import { getBirdclawConfig } from "./config";
9
9
  import { getNativeDb } from "./db";
10
10
 
@@ -160,17 +160,13 @@ function yearFromTimestamp(value: unknown) {
160
160
  return match[1];
161
161
  }
162
162
 
163
- function rowsForQuery(
164
- db: Database.Database,
165
- sql: string,
166
- params: unknown[] = [],
167
- ) {
163
+ function rowsForQuery(db: Database, sql: string, params: unknown[] = []) {
168
164
  return (db.prepare(sql).all(...params) as Record<string, unknown>[]).map(
169
165
  toJsonRecord,
170
166
  );
171
167
  }
172
168
 
173
- function getExportRowSets(db: Database.Database) {
169
+ function getExportRowSets(db: Database) {
174
170
  const rowSets: Array<{ logicalName: string; rows: JsonRecord[] }> = [
175
171
  {
176
172
  logicalName: "accounts",
@@ -188,9 +184,49 @@ function getExportRowSets(db: Database.Database) {
188
184
  rows: rowsForQuery(
189
185
  db,
190
186
  `
191
- select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
187
+ select id, handle, display_name, bio, followers_count, following_count,
188
+ avatar_hue, avatar_url, location, url, verified_type, entities_json,
189
+ raw_json, created_at
192
190
  from profiles
193
191
  order by id
192
+ `,
193
+ ),
194
+ },
195
+ {
196
+ logicalName: "profile_affiliations",
197
+ rows: rowsForQuery(
198
+ db,
199
+ `
200
+ select subject_profile_id, organization_profile_id, organization_name,
201
+ organization_handle, badge_url, url, label, source, is_active,
202
+ first_seen_at, last_seen_at, raw_json, updated_at
203
+ from profile_affiliations
204
+ order by subject_profile_id, organization_profile_id
205
+ `,
206
+ ),
207
+ },
208
+ {
209
+ logicalName: "profile_snapshots",
210
+ rows: rowsForQuery(
211
+ db,
212
+ `
213
+ select profile_id, snapshot_hash, observed_at, last_seen_at, source,
214
+ handle, display_name, bio, location, url, verified_type,
215
+ followers_count, following_count, affiliations_json, raw_json
216
+ from profile_snapshots
217
+ order by profile_id, last_seen_at, snapshot_hash
218
+ `,
219
+ ),
220
+ },
221
+ {
222
+ logicalName: "profile_bio_entities",
223
+ rows: rowsForQuery(
224
+ db,
225
+ `
226
+ select profile_id, kind, value, source, is_active, first_seen_at,
227
+ last_seen_at, raw_json
228
+ from profile_bio_entities
229
+ order by profile_id, kind, value
194
230
  `,
195
231
  ),
196
232
  },
@@ -215,6 +251,18 @@ function getExportRowSets(db: Database.Database) {
215
251
  select account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
216
252
  from tweet_collections
217
253
  order by kind, account_id, coalesce(collected_at, ''), tweet_id
254
+ `,
255
+ ),
256
+ },
257
+ {
258
+ logicalName: "tweet_account_edges",
259
+ rows: rowsForQuery(
260
+ db,
261
+ `
262
+ select account_id, tweet_id, kind, first_seen_at, last_seen_at,
263
+ seen_count, source, raw_json, updated_at
264
+ from tweet_account_edges
265
+ order by kind, account_id, last_seen_at, tweet_id
218
266
  `,
219
267
  ),
220
268
  },
@@ -303,7 +351,7 @@ function addRows(
303
351
  shards.set(relativePath, existing);
304
352
  }
305
353
 
306
- function buildShards(db: Database.Database) {
354
+ function buildShards(db: Database) {
307
355
  const shards = new Map<string, JsonRecord[]>();
308
356
  const rowSets = getExportRowSets(db);
309
357
 
@@ -315,6 +363,15 @@ function buildShards(db: Database.Database) {
315
363
  case "profiles":
316
364
  addRows(shards, "data/profiles.jsonl", rowSet.rows);
317
365
  break;
366
+ case "profile_affiliations":
367
+ addRows(shards, "data/profile_affiliations.jsonl", rowSet.rows);
368
+ break;
369
+ case "profile_snapshots":
370
+ addRows(shards, "data/profile_snapshots.jsonl", rowSet.rows);
371
+ break;
372
+ case "profile_bio_entities":
373
+ addRows(shards, "data/profile_bio_entities.jsonl", rowSet.rows);
374
+ break;
318
375
  case "tweets":
319
376
  for (const row of rowSet.rows) {
320
377
  addRows(
@@ -333,6 +390,15 @@ function buildShards(db: Database.Database) {
333
390
  addRows(shards, `data/collections/${kind}.jsonl`, [row]);
334
391
  }
335
392
  break;
393
+ case "tweet_account_edges":
394
+ for (const row of rowSet.rows) {
395
+ const kind =
396
+ row.kind === "home" || row.kind === "mention"
397
+ ? row.kind
398
+ : "unknown";
399
+ addRows(shards, `data/timeline_edges/${kind}.jsonl`, [row]);
400
+ }
401
+ break;
336
402
  case "dm_conversations":
337
403
  addRows(shards, "data/dms/conversations.jsonl", rowSet.rows);
338
404
  break;
@@ -442,15 +508,17 @@ function computeCounts(files: BackupFileManifest[]) {
442
508
  ? "tweets"
443
509
  : second === "collections"
444
510
  ? `collections_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
445
- : second === "dms" && third === "conversations.jsonl"
446
- ? "dm_conversations"
447
- : second === "dms"
448
- ? "dm_messages"
449
- : second === "moderation"
450
- ? third?.replace(/\.jsonl$/, "") || "moderation"
451
- : second === "actions"
452
- ? third?.replace(/\.jsonl$/, "") || "actions"
453
- : second?.replace(/\.jsonl$/, "") || "unknown";
511
+ : second === "timeline_edges"
512
+ ? `timeline_edges_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
513
+ : second === "dms" && third === "conversations.jsonl"
514
+ ? "dm_conversations"
515
+ : second === "dms"
516
+ ? "dm_messages"
517
+ : second === "moderation"
518
+ ? third?.replace(/\.jsonl$/, "") || "moderation"
519
+ : second === "actions"
520
+ ? third?.replace(/\.jsonl$/, "") || "actions"
521
+ : second?.replace(/\.jsonl$/, "") || "unknown";
454
522
  counts[key] = (counts[key] ?? 0) + file.rows;
455
523
  }
456
524
  return counts;
@@ -473,8 +541,13 @@ Private text backup for Birdclaw data. The committed files are canonical JSONL s
473
541
  manifest.json
474
542
  data/accounts.jsonl
475
543
  data/profiles.jsonl
544
+ data/profile_affiliations.jsonl
545
+ data/profile_snapshots.jsonl
546
+ data/profile_bio_entities.jsonl
476
547
  data/tweets/YYYY.jsonl
477
548
  data/tweets/unknown.jsonl
549
+ data/timeline_edges/home.jsonl
550
+ data/timeline_edges/mention.jsonl
478
551
  data/collections/likes.jsonl
479
552
  data/collections/bookmarks.jsonl
480
553
  data/dms/conversations.jsonl
@@ -483,7 +556,7 @@ data/moderation/blocks.jsonl
483
556
  data/moderation/mutes.jsonl
484
557
  \`\`\`
485
558
 
486
- Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. DMs are sharded by year and keep \`conversation_id\` in each row.
559
+ Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. Timeline edges keep account-scoped home/mention membership separate from canonical tweet content. DMs are sharded by year and keep \`conversation_id\` in each row.
487
560
 
488
561
  Never commit live tokens, browser cookies, raw SQLite WAL/SHM sidecars, or temporary cache files here.
489
562
  `,
@@ -735,7 +808,7 @@ export async function exportBackup({
735
808
  validate = true,
736
809
  }: {
737
810
  repoPath: string;
738
- db?: Database.Database;
811
+ db?: Database;
739
812
  commit?: boolean;
740
813
  push?: boolean;
741
814
  message?: string;
@@ -847,7 +920,7 @@ function rowsForManifestPath(
847
920
  }
848
921
 
849
922
  function insertRows(
850
- db: Database.Database,
923
+ db: Database,
851
924
  sql: string,
852
925
  rows: JsonRecord[],
853
926
  keys: string[],
@@ -859,7 +932,7 @@ function insertRows(
859
932
  }
860
933
 
861
934
  function readFtsIds(
862
- db: Database.Database,
935
+ db: Database,
863
936
  tableName: "tweets_fts" | "dm_fts",
864
937
  idColumn: "tweet_id" | "message_id",
865
938
  ) {
@@ -870,7 +943,7 @@ function readFtsIds(
870
943
  }
871
944
 
872
945
  function insertFtsRows(
873
- db: Database.Database,
946
+ db: Database,
874
947
  tableName: "tweets_fts" | "dm_fts",
875
948
  idColumn: "tweet_id" | "message_id",
876
949
  rows: JsonRecord[],
@@ -892,10 +965,11 @@ function insertFtsRows(
892
965
  }
893
966
  }
894
967
 
895
- function clearBackupImportData(db: Database.Database) {
968
+ function clearBackupImportData(db: Database) {
896
969
  db.exec(`
897
970
  delete from ai_scores;
898
971
  delete from tweet_actions;
972
+ delete from tweet_account_edges;
899
973
  delete from tweet_collections;
900
974
  delete from blocks;
901
975
  delete from mutes;
@@ -904,6 +978,9 @@ function clearBackupImportData(db: Database.Database) {
904
978
  delete from dm_messages;
905
979
  delete from dm_conversations;
906
980
  delete from tweets;
981
+ delete from profile_bio_entities;
982
+ delete from profile_snapshots;
983
+ delete from profile_affiliations;
907
984
  delete from profiles;
908
985
  delete from accounts;
909
986
  delete from sync_cache;
@@ -917,7 +994,7 @@ export async function importBackup({
917
994
  mode = "merge",
918
995
  }: {
919
996
  repoPath: string;
920
- db?: Database.Database;
997
+ db?: Database;
921
998
  validate?: boolean;
922
999
  mode?: BackupImportMode;
923
1000
  }): Promise<BackupImportResult> {
@@ -938,8 +1015,12 @@ export async function importBackup({
938
1015
  const [
939
1016
  accounts,
940
1017
  profiles,
1018
+ profileAffiliations,
1019
+ profileSnapshots,
1020
+ profileBioEntities,
941
1021
  tweets,
942
1022
  collections,
1023
+ timelineEdges,
943
1024
  conversations,
944
1025
  messages,
945
1026
  blocks,
@@ -949,8 +1030,12 @@ export async function importBackup({
949
1030
  ] = await Promise.all([
950
1031
  readRows((file) => file === "data/accounts.jsonl"),
951
1032
  readRows((file) => file === "data/profiles.jsonl"),
1033
+ readRows((file) => file === "data/profile_affiliations.jsonl"),
1034
+ readRows((file) => file === "data/profile_snapshots.jsonl"),
1035
+ readRows((file) => file === "data/profile_bio_entities.jsonl"),
952
1036
  readRows((file) => file.startsWith("data/tweets/")),
953
1037
  readRows((file) => file.startsWith("data/collections/")),
1038
+ readRows((file) => file.startsWith("data/timeline_edges/")),
954
1039
  readRows((file) => file === "data/dms/conversations.jsonl"),
955
1040
  readRows(
956
1041
  (file) =>
@@ -1001,9 +1086,73 @@ export async function importBackup({
1001
1086
  insertRows(
1002
1087
  db,
1003
1088
  `
1089
+ insert into profile_snapshots (
1090
+ profile_id, snapshot_hash, observed_at, last_seen_at, source, handle,
1091
+ display_name, bio, location, url, verified_type, followers_count,
1092
+ following_count, affiliations_json, raw_json
1093
+ ) values (?, ?, ?, ?, coalesce(?, 'backup'), ?, ?, ?, ?, ?, ?, coalesce(?, 0), coalesce(?, 0), coalesce(?, '[]'), coalesce(?, '{}'))
1094
+ on conflict(profile_id, snapshot_hash) do update set
1095
+ last_seen_at = max(profile_snapshots.last_seen_at, excluded.last_seen_at),
1096
+ source = excluded.source,
1097
+ raw_json = case
1098
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1099
+ else profile_snapshots.raw_json
1100
+ end
1101
+ `,
1102
+ profileSnapshots,
1103
+ [
1104
+ "profile_id",
1105
+ "snapshot_hash",
1106
+ "observed_at",
1107
+ "last_seen_at",
1108
+ "source",
1109
+ "handle",
1110
+ "display_name",
1111
+ "bio",
1112
+ "location",
1113
+ "url",
1114
+ "verified_type",
1115
+ "followers_count",
1116
+ "following_count",
1117
+ "affiliations_json",
1118
+ "raw_json",
1119
+ ],
1120
+ );
1121
+ insertRows(
1122
+ db,
1123
+ `
1124
+ insert into profile_bio_entities (
1125
+ profile_id, kind, value, source, is_active, first_seen_at, last_seen_at, raw_json
1126
+ ) values (?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'))
1127
+ on conflict(profile_id, kind, value) do update set
1128
+ source = excluded.source,
1129
+ is_active = excluded.is_active,
1130
+ last_seen_at = max(profile_bio_entities.last_seen_at, excluded.last_seen_at),
1131
+ raw_json = case
1132
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1133
+ else profile_bio_entities.raw_json
1134
+ end
1135
+ `,
1136
+ profileBioEntities,
1137
+ [
1138
+ "profile_id",
1139
+ "kind",
1140
+ "value",
1141
+ "source",
1142
+ "is_active",
1143
+ "first_seen_at",
1144
+ "last_seen_at",
1145
+ "raw_json",
1146
+ ],
1147
+ );
1148
+ insertRows(
1149
+ db,
1150
+ `
1004
1151
  insert into profiles (
1005
- id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
1006
- ) values (?, ?, ?, ?, ?, coalesce(?, 0), ?, ?, ?)
1152
+ id, handle, display_name, bio, followers_count, following_count,
1153
+ avatar_hue, avatar_url, location, url, verified_type, entities_json,
1154
+ raw_json, created_at
1155
+ ) values (?, ?, ?, ?, ?, coalesce(?, 0), ?, ?, ?, ?, ?, coalesce(?, '{}'), coalesce(?, '{}'), ?)
1007
1156
  on conflict(id) do update set
1008
1157
  handle = coalesce(nullif(excluded.handle, ''), profiles.handle),
1009
1158
  display_name = coalesce(nullif(excluded.display_name, ''), profiles.display_name),
@@ -1012,6 +1161,17 @@ export async function importBackup({
1012
1161
  following_count = max(profiles.following_count, excluded.following_count),
1013
1162
  avatar_hue = case when profiles.avatar_hue = 0 then excluded.avatar_hue else profiles.avatar_hue end,
1014
1163
  avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
1164
+ location = coalesce(excluded.location, profiles.location),
1165
+ url = coalesce(excluded.url, profiles.url),
1166
+ verified_type = coalesce(excluded.verified_type, profiles.verified_type),
1167
+ entities_json = case
1168
+ when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
1169
+ else profiles.entities_json
1170
+ end,
1171
+ raw_json = case
1172
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1173
+ else profiles.raw_json
1174
+ end,
1015
1175
  created_at = min(profiles.created_at, excluded.created_at)
1016
1176
  `,
1017
1177
  profiles,
@@ -1024,12 +1184,57 @@ export async function importBackup({
1024
1184
  "following_count",
1025
1185
  "avatar_hue",
1026
1186
  "avatar_url",
1187
+ "location",
1188
+ "url",
1189
+ "verified_type",
1190
+ "entities_json",
1191
+ "raw_json",
1027
1192
  "created_at",
1028
1193
  ],
1029
1194
  );
1030
1195
  insertRows(
1031
1196
  db,
1032
1197
  `
1198
+ insert into profile_affiliations (
1199
+ subject_profile_id, organization_profile_id, organization_name,
1200
+ organization_handle, badge_url, url, label, source, is_active,
1201
+ first_seen_at, last_seen_at, raw_json, updated_at
1202
+ ) values (?, ?, ?, ?, ?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'), ?)
1203
+ on conflict(subject_profile_id, organization_profile_id) do update set
1204
+ organization_name = coalesce(excluded.organization_name, profile_affiliations.organization_name),
1205
+ organization_handle = coalesce(excluded.organization_handle, profile_affiliations.organization_handle),
1206
+ badge_url = coalesce(excluded.badge_url, profile_affiliations.badge_url),
1207
+ url = coalesce(excluded.url, profile_affiliations.url),
1208
+ label = coalesce(excluded.label, profile_affiliations.label),
1209
+ source = excluded.source,
1210
+ is_active = excluded.is_active,
1211
+ last_seen_at = max(profile_affiliations.last_seen_at, excluded.last_seen_at),
1212
+ raw_json = case
1213
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1214
+ else profile_affiliations.raw_json
1215
+ end,
1216
+ updated_at = excluded.updated_at
1217
+ `,
1218
+ profileAffiliations,
1219
+ [
1220
+ "subject_profile_id",
1221
+ "organization_profile_id",
1222
+ "organization_name",
1223
+ "organization_handle",
1224
+ "badge_url",
1225
+ "url",
1226
+ "label",
1227
+ "source",
1228
+ "is_active",
1229
+ "first_seen_at",
1230
+ "last_seen_at",
1231
+ "raw_json",
1232
+ "updated_at",
1233
+ ],
1234
+ );
1235
+ insertRows(
1236
+ db,
1237
+ `
1033
1238
  insert into tweets (
1034
1239
  id, account_id, author_profile_id, kind, text, created_at, is_replied,
1035
1240
  reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
@@ -1118,6 +1323,37 @@ export async function importBackup({
1118
1323
  insertRows(
1119
1324
  db,
1120
1325
  `
1326
+ insert into tweet_account_edges (
1327
+ account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
1328
+ source, raw_json, updated_at
1329
+ ) values (?, ?, ?, ?, ?, coalesce(?, 1), coalesce(?, 'backup'), coalesce(?, '{}'), ?)
1330
+ on conflict(account_id, tweet_id, kind) do update set
1331
+ first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
1332
+ last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
1333
+ seen_count = max(tweet_account_edges.seen_count, excluded.seen_count),
1334
+ source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
1335
+ raw_json = case
1336
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1337
+ else tweet_account_edges.raw_json
1338
+ end,
1339
+ updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
1340
+ `,
1341
+ timelineEdges,
1342
+ [
1343
+ "account_id",
1344
+ "tweet_id",
1345
+ "kind",
1346
+ "first_seen_at",
1347
+ "last_seen_at",
1348
+ "seen_count",
1349
+ "source",
1350
+ "raw_json",
1351
+ "updated_at",
1352
+ ],
1353
+ );
1354
+ insertRows(
1355
+ db,
1356
+ `
1121
1357
  insert into dm_conversations (
1122
1358
  id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
1123
1359
  ) values (?, ?, ?, ?, ?, ?, ?)
@@ -1251,7 +1487,7 @@ export async function syncBackup({
1251
1487
  }: {
1252
1488
  repoPath: string;
1253
1489
  remote?: string;
1254
- db?: Database.Database;
1490
+ db?: Database;
1255
1491
  message?: string;
1256
1492
  }): Promise<BackupSyncResult> {
1257
1493
  const resolvedRepoPath = path.resolve(repoPath);
@@ -1291,7 +1527,7 @@ export async function updateBackupFromGit({
1291
1527
  }: {
1292
1528
  repoPath: string;
1293
1529
  remote?: string;
1294
- db?: Database.Database;
1530
+ db?: Database;
1295
1531
  }): Promise<{
1296
1532
  ok: true;
1297
1533
  repoPath: string;
@@ -1322,7 +1558,7 @@ export async function updateBackupFromGit({
1322
1558
  };
1323
1559
  }
1324
1560
 
1325
- function readAutoSyncState(db: Database.Database) {
1561
+ function readAutoSyncState(db: Database) {
1326
1562
  const row = db
1327
1563
  .prepare("select value_json from sync_cache where cache_key = ?")
1328
1564
  .get(AUTO_SYNC_CACHE_KEY) as { value_json: string } | undefined;
@@ -1341,7 +1577,7 @@ function readAutoSyncState(db: Database.Database) {
1341
1577
  }
1342
1578
 
1343
1579
  function writeAutoSyncState(
1344
- db: Database.Database,
1580
+ db: Database,
1345
1581
  value: { checkedAt: string; ok: boolean; error?: string },
1346
1582
  ) {
1347
1583
  db.prepare(
@@ -1382,7 +1618,7 @@ function resolveAutoSyncConfig() {
1382
1618
  }
1383
1619
 
1384
1620
  export async function maybeAutoUpdateBackup(
1385
- db?: Database.Database,
1621
+ db?: Database,
1386
1622
  ): Promise<BackupAutoUpdateResult> {
1387
1623
  if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1388
1624
  return {
@@ -1449,7 +1685,7 @@ export async function maybeAutoUpdateBackup(
1449
1685
  }
1450
1686
 
1451
1687
  export async function maybeAutoSyncBackup(
1452
- db?: Database.Database,
1688
+ db?: Database,
1453
1689
  ): Promise<BackupAutoUpdateResult> {
1454
1690
  if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1455
1691
  return {
@@ -90,10 +90,27 @@ function toBirdLookupUser(payload: Record<string, unknown>): XurlMentionUser {
90
90
  username,
91
91
  description:
92
92
  typeof user.description === "string" ? user.description : undefined,
93
+ location: typeof user.location === "string" ? user.location : undefined,
94
+ url: typeof user.url === "string" ? user.url : undefined,
95
+ verified: typeof user.verified === "boolean" ? user.verified : undefined,
96
+ verified_type:
97
+ typeof user.verifiedType === "string"
98
+ ? user.verifiedType
99
+ : typeof user.verified_type === "string"
100
+ ? user.verified_type
101
+ : undefined,
93
102
  profile_image_url:
94
103
  typeof user.profileImageUrl === "string"
95
104
  ? user.profileImageUrl
96
105
  : undefined,
106
+ entities:
107
+ user.entities && typeof user.entities === "object"
108
+ ? (user.entities as Record<string, unknown>)
109
+ : undefined,
110
+ affiliation:
111
+ user.affiliation && typeof user.affiliation === "object"
112
+ ? (user.affiliation as Record<string, unknown>)
113
+ : undefined,
97
114
  public_metrics: {
98
115
  followers_count: Number.isFinite(followersCount) ? followersCount : 0,
99
116
  ...(Number.isFinite(followingCount)