birdclaw 0.3.0 → 0.4.1

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,11 +965,14 @@ 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;
974
+ delete from link_occurrences;
975
+ delete from url_expansions;
900
976
  delete from blocks;
901
977
  delete from mutes;
902
978
  delete from dm_fts;
@@ -904,6 +980,9 @@ function clearBackupImportData(db: Database.Database) {
904
980
  delete from dm_messages;
905
981
  delete from dm_conversations;
906
982
  delete from tweets;
983
+ delete from profile_bio_entities;
984
+ delete from profile_snapshots;
985
+ delete from profile_affiliations;
907
986
  delete from profiles;
908
987
  delete from accounts;
909
988
  delete from sync_cache;
@@ -917,7 +996,7 @@ export async function importBackup({
917
996
  mode = "merge",
918
997
  }: {
919
998
  repoPath: string;
920
- db?: Database.Database;
999
+ db?: Database;
921
1000
  validate?: boolean;
922
1001
  mode?: BackupImportMode;
923
1002
  }): Promise<BackupImportResult> {
@@ -938,8 +1017,12 @@ export async function importBackup({
938
1017
  const [
939
1018
  accounts,
940
1019
  profiles,
1020
+ profileAffiliations,
1021
+ profileSnapshots,
1022
+ profileBioEntities,
941
1023
  tweets,
942
1024
  collections,
1025
+ timelineEdges,
943
1026
  conversations,
944
1027
  messages,
945
1028
  blocks,
@@ -949,8 +1032,12 @@ export async function importBackup({
949
1032
  ] = await Promise.all([
950
1033
  readRows((file) => file === "data/accounts.jsonl"),
951
1034
  readRows((file) => file === "data/profiles.jsonl"),
1035
+ readRows((file) => file === "data/profile_affiliations.jsonl"),
1036
+ readRows((file) => file === "data/profile_snapshots.jsonl"),
1037
+ readRows((file) => file === "data/profile_bio_entities.jsonl"),
952
1038
  readRows((file) => file.startsWith("data/tweets/")),
953
1039
  readRows((file) => file.startsWith("data/collections/")),
1040
+ readRows((file) => file.startsWith("data/timeline_edges/")),
954
1041
  readRows((file) => file === "data/dms/conversations.jsonl"),
955
1042
  readRows(
956
1043
  (file) =>
@@ -1001,9 +1088,73 @@ export async function importBackup({
1001
1088
  insertRows(
1002
1089
  db,
1003
1090
  `
1091
+ insert into profile_snapshots (
1092
+ profile_id, snapshot_hash, observed_at, last_seen_at, source, handle,
1093
+ display_name, bio, location, url, verified_type, followers_count,
1094
+ following_count, affiliations_json, raw_json
1095
+ ) values (?, ?, ?, ?, coalesce(?, 'backup'), ?, ?, ?, ?, ?, ?, coalesce(?, 0), coalesce(?, 0), coalesce(?, '[]'), coalesce(?, '{}'))
1096
+ on conflict(profile_id, snapshot_hash) do update set
1097
+ last_seen_at = max(profile_snapshots.last_seen_at, excluded.last_seen_at),
1098
+ source = excluded.source,
1099
+ raw_json = case
1100
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1101
+ else profile_snapshots.raw_json
1102
+ end
1103
+ `,
1104
+ profileSnapshots,
1105
+ [
1106
+ "profile_id",
1107
+ "snapshot_hash",
1108
+ "observed_at",
1109
+ "last_seen_at",
1110
+ "source",
1111
+ "handle",
1112
+ "display_name",
1113
+ "bio",
1114
+ "location",
1115
+ "url",
1116
+ "verified_type",
1117
+ "followers_count",
1118
+ "following_count",
1119
+ "affiliations_json",
1120
+ "raw_json",
1121
+ ],
1122
+ );
1123
+ insertRows(
1124
+ db,
1125
+ `
1126
+ insert into profile_bio_entities (
1127
+ profile_id, kind, value, source, is_active, first_seen_at, last_seen_at, raw_json
1128
+ ) values (?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'))
1129
+ on conflict(profile_id, kind, value) do update set
1130
+ source = excluded.source,
1131
+ is_active = excluded.is_active,
1132
+ last_seen_at = max(profile_bio_entities.last_seen_at, excluded.last_seen_at),
1133
+ raw_json = case
1134
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1135
+ else profile_bio_entities.raw_json
1136
+ end
1137
+ `,
1138
+ profileBioEntities,
1139
+ [
1140
+ "profile_id",
1141
+ "kind",
1142
+ "value",
1143
+ "source",
1144
+ "is_active",
1145
+ "first_seen_at",
1146
+ "last_seen_at",
1147
+ "raw_json",
1148
+ ],
1149
+ );
1150
+ insertRows(
1151
+ db,
1152
+ `
1004
1153
  insert into profiles (
1005
- id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
1006
- ) values (?, ?, ?, ?, ?, coalesce(?, 0), ?, ?, ?)
1154
+ id, handle, display_name, bio, followers_count, following_count,
1155
+ avatar_hue, avatar_url, location, url, verified_type, entities_json,
1156
+ raw_json, created_at
1157
+ ) values (?, ?, ?, ?, ?, coalesce(?, 0), ?, ?, ?, ?, ?, coalesce(?, '{}'), coalesce(?, '{}'), ?)
1007
1158
  on conflict(id) do update set
1008
1159
  handle = coalesce(nullif(excluded.handle, ''), profiles.handle),
1009
1160
  display_name = coalesce(nullif(excluded.display_name, ''), profiles.display_name),
@@ -1012,6 +1163,17 @@ export async function importBackup({
1012
1163
  following_count = max(profiles.following_count, excluded.following_count),
1013
1164
  avatar_hue = case when profiles.avatar_hue = 0 then excluded.avatar_hue else profiles.avatar_hue end,
1014
1165
  avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
1166
+ location = coalesce(excluded.location, profiles.location),
1167
+ url = coalesce(excluded.url, profiles.url),
1168
+ verified_type = coalesce(excluded.verified_type, profiles.verified_type),
1169
+ entities_json = case
1170
+ when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
1171
+ else profiles.entities_json
1172
+ end,
1173
+ raw_json = case
1174
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1175
+ else profiles.raw_json
1176
+ end,
1015
1177
  created_at = min(profiles.created_at, excluded.created_at)
1016
1178
  `,
1017
1179
  profiles,
@@ -1024,12 +1186,57 @@ export async function importBackup({
1024
1186
  "following_count",
1025
1187
  "avatar_hue",
1026
1188
  "avatar_url",
1189
+ "location",
1190
+ "url",
1191
+ "verified_type",
1192
+ "entities_json",
1193
+ "raw_json",
1027
1194
  "created_at",
1028
1195
  ],
1029
1196
  );
1030
1197
  insertRows(
1031
1198
  db,
1032
1199
  `
1200
+ insert into profile_affiliations (
1201
+ subject_profile_id, organization_profile_id, organization_name,
1202
+ organization_handle, badge_url, url, label, source, is_active,
1203
+ first_seen_at, last_seen_at, raw_json, updated_at
1204
+ ) values (?, ?, ?, ?, ?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'), ?)
1205
+ on conflict(subject_profile_id, organization_profile_id) do update set
1206
+ organization_name = coalesce(excluded.organization_name, profile_affiliations.organization_name),
1207
+ organization_handle = coalesce(excluded.organization_handle, profile_affiliations.organization_handle),
1208
+ badge_url = coalesce(excluded.badge_url, profile_affiliations.badge_url),
1209
+ url = coalesce(excluded.url, profile_affiliations.url),
1210
+ label = coalesce(excluded.label, profile_affiliations.label),
1211
+ source = excluded.source,
1212
+ is_active = excluded.is_active,
1213
+ last_seen_at = max(profile_affiliations.last_seen_at, excluded.last_seen_at),
1214
+ raw_json = case
1215
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1216
+ else profile_affiliations.raw_json
1217
+ end,
1218
+ updated_at = excluded.updated_at
1219
+ `,
1220
+ profileAffiliations,
1221
+ [
1222
+ "subject_profile_id",
1223
+ "organization_profile_id",
1224
+ "organization_name",
1225
+ "organization_handle",
1226
+ "badge_url",
1227
+ "url",
1228
+ "label",
1229
+ "source",
1230
+ "is_active",
1231
+ "first_seen_at",
1232
+ "last_seen_at",
1233
+ "raw_json",
1234
+ "updated_at",
1235
+ ],
1236
+ );
1237
+ insertRows(
1238
+ db,
1239
+ `
1033
1240
  insert into tweets (
1034
1241
  id, account_id, author_profile_id, kind, text, created_at, is_replied,
1035
1242
  reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
@@ -1118,6 +1325,37 @@ export async function importBackup({
1118
1325
  insertRows(
1119
1326
  db,
1120
1327
  `
1328
+ insert into tweet_account_edges (
1329
+ account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
1330
+ source, raw_json, updated_at
1331
+ ) values (?, ?, ?, ?, ?, coalesce(?, 1), coalesce(?, 'backup'), coalesce(?, '{}'), ?)
1332
+ on conflict(account_id, tweet_id, kind) do update set
1333
+ first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
1334
+ last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
1335
+ seen_count = max(tweet_account_edges.seen_count, excluded.seen_count),
1336
+ source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
1337
+ raw_json = case
1338
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1339
+ else tweet_account_edges.raw_json
1340
+ end,
1341
+ updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
1342
+ `,
1343
+ timelineEdges,
1344
+ [
1345
+ "account_id",
1346
+ "tweet_id",
1347
+ "kind",
1348
+ "first_seen_at",
1349
+ "last_seen_at",
1350
+ "seen_count",
1351
+ "source",
1352
+ "raw_json",
1353
+ "updated_at",
1354
+ ],
1355
+ );
1356
+ insertRows(
1357
+ db,
1358
+ `
1121
1359
  insert into dm_conversations (
1122
1360
  id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
1123
1361
  ) values (?, ?, ?, ?, ?, ?, ?)
@@ -1251,7 +1489,7 @@ export async function syncBackup({
1251
1489
  }: {
1252
1490
  repoPath: string;
1253
1491
  remote?: string;
1254
- db?: Database.Database;
1492
+ db?: Database;
1255
1493
  message?: string;
1256
1494
  }): Promise<BackupSyncResult> {
1257
1495
  const resolvedRepoPath = path.resolve(repoPath);
@@ -1291,7 +1529,7 @@ export async function updateBackupFromGit({
1291
1529
  }: {
1292
1530
  repoPath: string;
1293
1531
  remote?: string;
1294
- db?: Database.Database;
1532
+ db?: Database;
1295
1533
  }): Promise<{
1296
1534
  ok: true;
1297
1535
  repoPath: string;
@@ -1322,7 +1560,7 @@ export async function updateBackupFromGit({
1322
1560
  };
1323
1561
  }
1324
1562
 
1325
- function readAutoSyncState(db: Database.Database) {
1563
+ function readAutoSyncState(db: Database) {
1326
1564
  const row = db
1327
1565
  .prepare("select value_json from sync_cache where cache_key = ?")
1328
1566
  .get(AUTO_SYNC_CACHE_KEY) as { value_json: string } | undefined;
@@ -1341,7 +1579,7 @@ function readAutoSyncState(db: Database.Database) {
1341
1579
  }
1342
1580
 
1343
1581
  function writeAutoSyncState(
1344
- db: Database.Database,
1582
+ db: Database,
1345
1583
  value: { checkedAt: string; ok: boolean; error?: string },
1346
1584
  ) {
1347
1585
  db.prepare(
@@ -1382,7 +1620,7 @@ function resolveAutoSyncConfig() {
1382
1620
  }
1383
1621
 
1384
1622
  export async function maybeAutoUpdateBackup(
1385
- db?: Database.Database,
1623
+ db?: Database,
1386
1624
  ): Promise<BackupAutoUpdateResult> {
1387
1625
  if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1388
1626
  return {
@@ -1449,7 +1687,7 @@ export async function maybeAutoUpdateBackup(
1449
1687
  }
1450
1688
 
1451
1689
  export async function maybeAutoSyncBackup(
1452
- db?: Database.Database,
1690
+ db?: Database,
1453
1691
  ): Promise<BackupAutoUpdateResult> {
1454
1692
  if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1455
1693
  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)