@atproto/ozone 0.1.69 → 0.1.71

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/api/moderation/queryStatuses.d.ts.map +1 -1
  3. package/dist/api/moderation/queryStatuses.js +1 -33
  4. package/dist/api/moderation/queryStatuses.js.map +1 -1
  5. package/dist/background.d.ts +49 -6
  6. package/dist/background.d.ts.map +1 -1
  7. package/dist/background.js +149 -14
  8. package/dist/background.js.map +1 -1
  9. package/dist/config/config.d.ts +1 -0
  10. package/dist/config/config.d.ts.map +1 -1
  11. package/dist/config/config.js +1 -0
  12. package/dist/config/config.js.map +1 -1
  13. package/dist/config/env.d.ts +1 -0
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +1 -0
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/daemon/context.d.ts +9 -3
  18. package/dist/daemon/context.d.ts.map +1 -1
  19. package/dist/daemon/context.js +33 -3
  20. package/dist/daemon/context.js.map +1 -1
  21. package/dist/daemon/index.d.ts.map +1 -1
  22. package/dist/daemon/index.js +3 -6
  23. package/dist/daemon/index.js.map +1 -1
  24. package/dist/daemon/materialized-view-refresher.d.ts +5 -0
  25. package/dist/daemon/materialized-view-refresher.d.ts.map +1 -0
  26. package/dist/daemon/materialized-view-refresher.js +29 -0
  27. package/dist/daemon/materialized-view-refresher.js.map +1 -0
  28. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.d.ts +5 -0
  29. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.d.ts.map +1 -0
  30. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.js +158 -0
  31. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.js.map +1 -0
  32. package/dist/db/migrations/index.d.ts +1 -0
  33. package/dist/db/migrations/index.d.ts.map +1 -1
  34. package/dist/db/migrations/index.js +2 -1
  35. package/dist/db/migrations/index.js.map +1 -1
  36. package/dist/db/schema/account_events_stats.d.ts +15 -0
  37. package/dist/db/schema/account_events_stats.d.ts.map +1 -0
  38. package/dist/db/schema/account_events_stats.js +5 -0
  39. package/dist/db/schema/account_events_stats.js.map +1 -0
  40. package/dist/db/schema/account_record_events_stats.d.ts +15 -0
  41. package/dist/db/schema/account_record_events_stats.d.ts.map +1 -0
  42. package/dist/db/schema/account_record_events_stats.js +5 -0
  43. package/dist/db/schema/account_record_events_stats.js.map +1 -0
  44. package/dist/db/schema/account_record_status_stats.d.ts +15 -0
  45. package/dist/db/schema/account_record_status_stats.d.ts.map +1 -0
  46. package/dist/db/schema/account_record_status_stats.js +5 -0
  47. package/dist/db/schema/account_record_status_stats.js.map +1 -0
  48. package/dist/db/schema/index.d.ts +5 -1
  49. package/dist/db/schema/index.d.ts.map +1 -1
  50. package/dist/db/schema/record_events_stats.d.ts +14 -0
  51. package/dist/db/schema/record_events_stats.d.ts.map +1 -0
  52. package/dist/db/schema/record_events_stats.js +5 -0
  53. package/dist/db/schema/record_events_stats.js.map +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/lexicon/lexicons.d.ts +174 -2
  58. package/dist/lexicon/lexicons.d.ts.map +1 -1
  59. package/dist/lexicon/lexicons.js +92 -1
  60. package/dist/lexicon/lexicons.js.map +1 -1
  61. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +40 -0
  62. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  63. package/dist/lexicon/types/tools/ozone/moderation/defs.js +20 -0
  64. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  65. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts +7 -1
  66. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts.map +1 -1
  67. package/dist/mod-service/index.d.ts +4 -62
  68. package/dist/mod-service/index.d.ts.map +1 -1
  69. package/dist/mod-service/index.js +80 -74
  70. package/dist/mod-service/index.js.map +1 -1
  71. package/dist/mod-service/status.d.ts +115 -4
  72. package/dist/mod-service/status.d.ts.map +1 -1
  73. package/dist/mod-service/status.js +51 -34
  74. package/dist/mod-service/status.js.map +1 -1
  75. package/dist/mod-service/types.d.ts +16 -1
  76. package/dist/mod-service/types.d.ts.map +1 -1
  77. package/dist/mod-service/views.d.ts.map +1 -1
  78. package/dist/mod-service/views.js +49 -41
  79. package/dist/mod-service/views.js.map +1 -1
  80. package/dist/util.d.ts +34 -0
  81. package/dist/util.d.ts.map +1 -1
  82. package/dist/util.js +132 -0
  83. package/dist/util.js.map +1 -1
  84. package/package.json +5 -5
  85. package/src/api/moderation/queryStatuses.ts +1 -63
  86. package/src/background.ts +140 -14
  87. package/src/config/config.ts +2 -0
  88. package/src/config/env.ts +4 -0
  89. package/src/daemon/context.ts +43 -5
  90. package/src/daemon/index.ts +3 -6
  91. package/src/daemon/materialized-view-refresher.ts +27 -0
  92. package/src/db/migrations/20241220T144630860Z-stats-materialized-views.ts +218 -0
  93. package/src/db/migrations/index.ts +1 -0
  94. package/src/db/schema/account_events_stats.ts +16 -0
  95. package/src/db/schema/account_record_events_stats.ts +15 -0
  96. package/src/db/schema/account_record_status_stats.ts +15 -0
  97. package/src/db/schema/index.ts +10 -1
  98. package/src/db/schema/record_events_stats.ts +15 -0
  99. package/src/index.ts +1 -7
  100. package/src/lexicon/lexicons.ts +100 -1
  101. package/src/lexicon/types/tools/ozone/moderation/defs.ts +62 -0
  102. package/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts +11 -1
  103. package/src/mod-service/index.ts +181 -118
  104. package/src/mod-service/status.ts +55 -28
  105. package/src/mod-service/types.ts +22 -1
  106. package/src/mod-service/views.ts +64 -50
  107. package/src/util.ts +145 -0
  108. package/tests/__snapshots__/get-record.test.ts.snap +28 -0
  109. package/tests/__snapshots__/get-records.test.ts.snap +14 -0
  110. package/tests/__snapshots__/get-repo.test.ts.snap +11 -0
  111. package/tests/__snapshots__/get-repos.test.ts.snap +11 -0
  112. package/tests/__snapshots__/moderation-events.test.ts.snap +19 -0
  113. package/tests/__snapshots__/moderation-statuses.test.ts.snap +114 -0
  114. package/tests/get-record.test.ts +4 -0
  115. package/tests/get-records.test.ts +4 -0
  116. package/tests/get-repo.test.ts +4 -0
  117. package/tests/get-repos.test.ts +4 -0
  118. package/tests/moderation-events.test.ts +4 -0
  119. package/tests/moderation-statuses.test.ts +4 -0
  120. package/tests/query-labels.test.ts +1 -0
  121. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,18 +1,18 @@
1
1
  // This may require better organization but for now, just dumping functions here containing DB queries for moderation status
2
2
 
3
+ import { HOUR } from '@atproto/common'
3
4
  import { AtUri } from '@atproto/syntax'
4
5
  import { Database } from '../db'
5
- import { ModerationSubjectStatus } from '../db/schema/moderation_subject_status'
6
+ import DatabaseSchema from '../db/schema'
7
+ import { jsonb } from '../db/types'
8
+ import { REASONAPPEAL } from '../lexicon/types/com/atproto/moderation/defs'
6
9
  import {
7
- REVIEWOPEN,
8
10
  REVIEWCLOSED,
9
11
  REVIEWESCALATED,
10
12
  REVIEWNONE,
13
+ REVIEWOPEN,
11
14
  } from '../lexicon/types/tools/ozone/moderation/defs'
12
15
  import { ModerationEventRow, ModerationSubjectStatusRow } from './types'
13
- import { HOUR } from '@atproto/common'
14
- import { REASONAPPEAL } from '../lexicon/types/com/atproto/moderation/defs'
15
- import { jsonb } from '../db/types'
16
16
 
17
17
  const getSubjectStatusForModerationEvent = ({
18
18
  currentStatus,
@@ -203,6 +203,56 @@ const getSubjectStatusForRecordEvent = ({
203
203
  return {}
204
204
  }
205
205
 
206
+ export const moderationSubjectStatusQueryBuilder = (db: DatabaseSchema) => {
207
+ // @NOTE: Using select() instead of selectAll() below because the materialized
208
+ // views might be incomplete, and we don't want the null `did` columns to
209
+ // interfere with the (never null) `did` column from the
210
+ // `moderation_subject_status` table in the results
211
+ return db
212
+ .selectFrom('moderation_subject_status')
213
+ .selectAll('moderation_subject_status')
214
+ .leftJoin('account_events_stats', (join) =>
215
+ join.onRef(
216
+ 'moderation_subject_status.did',
217
+ '=',
218
+ 'account_events_stats.subjectDid',
219
+ ),
220
+ )
221
+ .select([
222
+ 'account_events_stats.takedownCount',
223
+ 'account_events_stats.suspendCount',
224
+ 'account_events_stats.escalateCount',
225
+ 'account_events_stats.reportCount',
226
+ 'account_events_stats.appealCount',
227
+ ])
228
+ .leftJoin('account_record_events_stats', (join) =>
229
+ join.onRef(
230
+ 'moderation_subject_status.did',
231
+ '=',
232
+ 'account_record_events_stats.subjectDid',
233
+ ),
234
+ )
235
+ .select([
236
+ 'account_record_events_stats.totalReports',
237
+ 'account_record_events_stats.reportedCount',
238
+ 'account_record_events_stats.escalatedCount',
239
+ 'account_record_events_stats.appealedCount',
240
+ ])
241
+ .leftJoin('account_record_status_stats', (join) =>
242
+ join.onRef(
243
+ 'moderation_subject_status.did',
244
+ '=',
245
+ 'account_record_status_stats.did',
246
+ ),
247
+ )
248
+ .select([
249
+ 'account_record_status_stats.subjectCount',
250
+ 'account_record_status_stats.pendingCount',
251
+ 'account_record_status_stats.processedCount',
252
+ 'account_record_status_stats.takendownCount',
253
+ ])
254
+ }
255
+
206
256
  // Based on a given moderation action event, this function will update the moderation status of the subject
207
257
  // If there's no existing status, it will create one
208
258
  // If the action event does not affect the status, it will do nothing
@@ -393,29 +443,6 @@ export const adjustModerationSubjectStatus = async (
393
443
  return status || null
394
444
  }
395
445
 
396
- type ModerationSubjectStatusFilter =
397
- | Pick<ModerationSubjectStatus, 'did'>
398
- | Pick<ModerationSubjectStatus, 'did' | 'recordPath'>
399
- | Pick<ModerationSubjectStatus, 'did' | 'recordPath' | 'recordCid'>
400
- export const getModerationSubjectStatus = async (
401
- db: Database,
402
- filters: ModerationSubjectStatusFilter,
403
- ) => {
404
- let builder = db.db
405
- .selectFrom('moderation_subject_status')
406
- // DID will always be passed at the very least
407
- .where('did', '=', filters.did)
408
- .where('recordPath', '=', 'recordPath' in filters ? filters.recordPath : '')
409
-
410
- if ('recordCid' in filters) {
411
- builder = builder.where('recordCid', '=', filters.recordCid)
412
- } else {
413
- builder = builder.where('recordCid', 'is', null)
414
- }
415
-
416
- return builder.executeTakeFirst()
417
- }
418
-
419
446
  export const getStatusIdentifierFromSubject = (
420
447
  subject: string | AtUri,
421
448
  ): { did: string; recordPath: string } => {
@@ -18,8 +18,29 @@ export type ModerationEventRowWithHandle = ModerationEventRow & {
18
18
  creatorHandle?: string | null
19
19
  }
20
20
  export type ModerationSubjectStatusRow = Selectable<ModerationSubjectStatus>
21
+ export type ModerationSubjectStatusRowWithStats = ModerationSubjectStatusRow & {
22
+ // account_events_stats
23
+ takedownCount: number | null
24
+ suspendCount: number | null
25
+ escalateCount: number | null
26
+ reportCount: number | null
27
+ appealCount: number | null
28
+
29
+ // account_record_events_stats
30
+ totalReports: number | null
31
+ reportedCount: number | null
32
+ escalatedCount: number | null
33
+ appealedCount: number | null
34
+
35
+ // account_record_status_stats
36
+ subjectCount: number | null
37
+ pendingCount: number | null
38
+ processedCount: number | null
39
+ takendownCount: number | null
40
+ }
41
+
21
42
  export type ModerationSubjectStatusRowWithHandle =
22
- ModerationSubjectStatusRow & { handle: string | null }
43
+ ModerationSubjectStatusRowWithStats & { handle: string | null }
23
44
 
24
45
  export type ModEventType =
25
46
  | ToolsOzoneModerationDefs.ModEventTakedown
@@ -1,10 +1,6 @@
1
1
  import { sql } from 'kysely'
2
2
  import { AtUri, INVALID_HANDLE, normalizeDatetimeAlways } from '@atproto/syntax'
3
- import {
4
- AtpAgent,
5
- AppBskyFeedDefs,
6
- ToolsOzoneModerationDefs,
7
- } from '@atproto/api'
3
+ import { AtpAgent, AppBskyFeedDefs } from '@atproto/api'
8
4
  import { dedupeStrs } from '@atproto/common'
9
5
  import { BlobRef } from '@atproto/lexicon'
10
6
  import { Keypair } from '@atproto/crypto'
@@ -12,7 +8,6 @@ import { Database } from '../db'
12
8
  import {
13
9
  ModEventView,
14
10
  RepoView,
15
- RepoViewDetail,
16
11
  RecordView,
17
12
  RecordViewDetail,
18
13
  BlobView,
@@ -34,6 +29,7 @@ import { dbLogger } from '../logger'
34
29
  import { httpLogger } from '../logger'
35
30
  import { ParsedLabelers } from '../util'
36
31
  import { ids } from '../lexicon/lexicons'
32
+ import { moderationSubjectStatusQueryBuilder } from './status'
37
33
 
38
34
  export type AuthHeaders = {
39
35
  headers: {
@@ -481,8 +477,9 @@ export class ModerationViews {
481
477
  async blob(blobs: BlobRef[]): Promise<BlobView[]> {
482
478
  if (!blobs.length) return []
483
479
  const { ref } = this.db.db.dynamic
484
- const modStatusResults = await this.db.db
485
- .selectFrom('moderation_subject_status')
480
+ const modStatusResults = await moderationSubjectStatusQueryBuilder(
481
+ this.db.db,
482
+ )
486
483
  .where(
487
484
  sql<string>`${ref(
488
485
  'moderation_subject_status.blobCids',
@@ -529,10 +526,10 @@ export class ModerationViews {
529
526
  await Promise.all(
530
527
  res.map(async (labelRow) => {
531
528
  const signedLabel = await this.formatLabelAndEnsureSig(labelRow)
532
- if (!labels.has(labelRow.uri)) {
533
- labels.set(labelRow.uri, [])
534
- }
535
- labels.get(labelRow.uri)?.push(signedLabel)
529
+
530
+ const current = labels.get(labelRow.uri)
531
+ if (current) current.push(signedLabel)
532
+ else labels.set(labelRow.uri, [signedLabel])
536
533
  }),
537
534
  )
538
535
  return labels
@@ -559,51 +556,39 @@ export class ModerationViews {
559
556
  async getSubjectStatus(
560
557
  subjects: string[],
561
558
  ): Promise<Map<string, ModerationSubjectStatusRowWithHandle>> {
562
- const parsedSubjects = subjects.map((subject) => parseSubjectId(subject))
563
- const filterForSubject = (did: string, recordPath?: string) => {
564
- return (clause: any) => {
565
- clause = clause
566
- .where('moderation_subject_status.did', '=', did)
567
- .where('moderation_subject_status.recordPath', '=', recordPath || '')
568
- return clause
569
- }
570
- // TODO: Fix the typing here?
571
- }
572
-
573
- const builder = this.db.db
574
- .selectFrom('moderation_subject_status')
575
- .where((clause) => {
576
- parsedSubjects.forEach((subject, i) => {
577
- const applySubjectFilter = filterForSubject(
578
- subject.did,
579
- subject.recordPath,
559
+ if (!subjects.length) return new Map()
560
+
561
+ const parsedSubjects = subjects.map(parseSubjectId)
562
+
563
+ const builder = moderationSubjectStatusQueryBuilder(this.db.db)
564
+ //
565
+ .where((qb) => {
566
+ for (const sub of parsedSubjects) {
567
+ qb = qb.orWhere((qb) =>
568
+ qb
569
+ .where('moderation_subject_status.did', '=', sub.did)
570
+ .where(
571
+ 'moderation_subject_status.recordPath',
572
+ '=',
573
+ sub.recordPath ?? '',
574
+ ),
580
575
  )
581
- if (i === 0) {
582
- clause = clause.where(applySubjectFilter)
583
- } else {
584
- clause = clause.orWhere(applySubjectFilter)
585
- }
586
- })
587
-
588
- return clause
576
+ }
577
+ return qb
589
578
  })
590
- .selectAll()
591
579
 
592
580
  const [statusRes, accountsByDid] = await Promise.all([
593
581
  builder.execute(),
594
582
  this.getAccoutInfosByDid(parsedSubjects.map((s) => s.did)),
595
583
  ])
596
584
 
597
- return statusRes.reduce((acc, cur) => {
598
- const subject = cur.recordPath
599
- ? formatSubjectId(cur.did, cur.recordPath)
600
- : cur.did
601
- const handle = accountsByDid.get(cur.did)?.handle
602
- return acc.set(subject, {
603
- ...cur,
604
- handle: handle ?? INVALID_HANDLE,
605
- })
606
- }, new Map<string, ModerationSubjectStatusRowWithHandle>())
585
+ return new Map(
586
+ statusRes.map((row): [string, ModerationSubjectStatusRowWithHandle] => {
587
+ const subjectId = formatSubjectId(row.did, row.recordPath)
588
+ const handle = accountsByDid.get(row.did)?.handle ?? INVALID_HANDLE
589
+ return [subjectId, { ...row, handle }]
590
+ }),
591
+ )
607
592
  }
608
593
 
609
594
  formatSubjectStatus(
@@ -628,6 +613,35 @@ export class ModerationViews {
628
613
  subjectBlobCids: status.blobCids || [],
629
614
  tags: status.tags || [],
630
615
  subject: subjectFromStatusRow(status).lex(),
616
+
617
+ accountStats: {
618
+ // Explicitly typing to allow for easy manipulation (e.g. to strip from tests snapshots)
619
+ $type: 'tools.ozone.moderation.defs#accountStats',
620
+
621
+ // account_events_stats
622
+ reportCount: status.reportCount ?? undefined,
623
+ appealCount: status.appealCount ?? undefined,
624
+ suspendCount: status.suspendCount ?? undefined,
625
+ takedownCount: status.takedownCount ?? undefined,
626
+ escalateCount: status.escalateCount ?? undefined,
627
+ },
628
+
629
+ recordsStats: {
630
+ // Explicitly typing to allow for easy manipulation (e.g. to strip from tests snapshots)
631
+ $type: 'tools.ozone.moderation.defs#recordStats',
632
+
633
+ // account_record_events_stats
634
+ totalReports: status.totalReports ?? undefined,
635
+ reportedCount: status.reportedCount ?? undefined,
636
+ escalatedCount: status.escalatedCount ?? undefined,
637
+ appealedCount: status.appealedCount ?? undefined,
638
+
639
+ // account_record_status_stats
640
+ subjectCount: status.subjectCount ?? undefined,
641
+ pendingCount: status.pendingCount ?? undefined,
642
+ processedCount: status.processedCount ?? undefined,
643
+ takendownCount: status.takendownCount ?? undefined,
644
+ },
631
645
  }
632
646
 
633
647
  if (status.recordPath !== '') {
@@ -677,7 +691,7 @@ type RecordInfo = {
677
691
  indexedAt: string
678
692
  }
679
693
 
680
- function parseSubjectId(subject: string) {
694
+ function parseSubjectId(subject: string): { did: string; recordPath?: string } {
681
695
  if (subject.startsWith('did:')) {
682
696
  return { did: subject }
683
697
  }
package/src/util.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import assert from 'node:assert'
1
2
  import { createRetryable } from '@atproto/common'
2
3
  import { ResponseType, XRPCError } from '@atproto/xrpc'
3
4
  import { parseList } from 'structured-headers'
@@ -83,3 +84,147 @@ export const formatLabelerHeader = (parsed: ParsedLabelers): string => {
83
84
  )
84
85
  return parts.join(',')
85
86
  }
87
+
88
+ /**
89
+ * Utility function similar to `setInterval()`. The main difference is that the
90
+ * execution is controlled through a signal and that the function will wait for
91
+ * `interval` milliseconds *between* the end of the previous execution and the
92
+ * start of the next one (instead of starting the execution every `interval`
93
+ * milliseconds), ensuring that the function is not running concurrently.
94
+ *
95
+ * @param fn The function to execute. That function must not throw any error
96
+ * other than {@link signal}'s {@link AbortSignal.reason} or an {@link Error}
97
+ * that has the {@link signal}'s {@link AbortSignal.reason} as its
98
+ * {@link Error.cause}.
99
+ *
100
+ * @returns A promise that resolves when the signal is aborted, and the last
101
+ * execution is done.
102
+ *
103
+ * @throws {AbortSignal['reason']} if the {@link signal} is already aborted.
104
+ * @throws {TypeError} if {@link fn} throws an unexpected error (with the
105
+ * unexpected error as the {@link Error.cause}).
106
+ */
107
+ export async function startInterval(
108
+ fn: (signal: AbortSignal) => void | Promise<void>,
109
+ interval: number,
110
+ signal: AbortSignal,
111
+ runImmediately = false,
112
+ ) {
113
+ signal.throwIfAborted()
114
+
115
+ // Renaming for clarity
116
+ const inputSignal = signal
117
+
118
+ const intervalController = new AbortController()
119
+ const intervalSignal = intervalController.signal
120
+
121
+ return new Promise<void>((resolve, reject) => {
122
+ let timer: NodeJS.Timeout | undefined
123
+
124
+ const run = async () => {
125
+ // Cloning the signal for this particular run to prevent memory leaks
126
+ const runController = boundAbortController(intervalSignal)
127
+ const runSignal = runController.signal
128
+
129
+ try {
130
+ await fn(runSignal)
131
+ } catch (err) {
132
+ if (err != null && isCausedBySignal(err, runSignal)) {
133
+ // Silently ignore the error if it is caused by the signal. At this
134
+ // point, the interval controller was aborted, which will cause the
135
+ // promise to resolve in the "finally" block bellow.
136
+ } else {
137
+ // Invalid behavior: stop the interval and reject the promise.
138
+ const error = new TypeError('Unexpected error', { cause: err })
139
+
140
+ // Rejecting here will make `resolve()` in the "finally" block to be a
141
+ // no-op. Rejecting before aborting the controller to ensure the
142
+ // promise does not get resolved by the `abort` event listeners.
143
+ reject(error)
144
+
145
+ // Using `error` as abort reason to avoid creating an AbortError.
146
+ intervalController.abort(error)
147
+ }
148
+ } finally {
149
+ // Cleanup the listeners added by `boundAbortController`
150
+ runController.abort()
151
+
152
+ if (intervalSignal.aborted) resolve()
153
+ else schedule()
154
+ }
155
+ }
156
+
157
+ const schedule = () => {
158
+ assert(timer === undefined, 'unexpected state')
159
+ timer = setTimeout(() => {
160
+ timer = undefined // "running" state
161
+ void run()
162
+ }, interval)
163
+ }
164
+
165
+ inputSignal.addEventListener(
166
+ 'abort',
167
+ // This function will only be called if the `inputSignal` is aborted
168
+ // before the interval controller is aborted.
169
+ () => {
170
+ // Stop the interval, using the input signal's reason
171
+ intervalController.abort(inputSignal.reason)
172
+
173
+ if (timer === undefined) {
174
+ // `fn` is currently running; `run`'s finally block will resolve the
175
+ // promise.
176
+ } else {
177
+ // The execution was scheduled but not started yet. Clear the timer
178
+ // and resolve the promise.
179
+ clearTimeout(timer)
180
+ resolve()
181
+ }
182
+ },
183
+ // Remove the listener whenever the interval is aborted.
184
+ { signal: intervalSignal },
185
+ )
186
+
187
+ if (runImmediately) void run()
188
+ else schedule()
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Determines whether the cause of an error is a signal's reason
194
+ */
195
+ export function isCausedBySignal(err: unknown, signal: AbortSignal) {
196
+ if (!signal.aborted) return false
197
+ if (signal.reason == null) return false // Ignore nullish reasons
198
+ return (
199
+ err === signal.reason ||
200
+ (err instanceof Error && err.cause === signal.reason)
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Creates an AbortController that will be aborted when any of the given signals
206
+ * is aborted.
207
+ *
208
+ * @note Make sure to call `abortController.abort()` when you are done with
209
+ * the controller to avoid memory leaks.
210
+ *
211
+ * @throws if any of the input signals is already aborted.
212
+ */
213
+ export function boundAbortController(
214
+ ...signals: readonly (AbortSignal | undefined | null)[]
215
+ ): AbortController {
216
+ for (const signal of signals) {
217
+ signal?.throwIfAborted()
218
+ }
219
+
220
+ const abortController = new AbortController()
221
+ const abort = function (event: Event) {
222
+ abortController.abort((event.target as AbortSignal)?.reason)
223
+ }
224
+
225
+ for (const signal of signals) {
226
+ signal?.addEventListener('abort', abort, { signal: abortController.signal })
227
+ }
228
+
229
+ return abortController
230
+ }
@@ -28,6 +28,9 @@ Object {
28
28
  ],
29
29
  "moderation": Object {
30
30
  "subjectStatus": Object {
31
+ "accountStats": Object {
32
+ "$type": "tools.ozone.moderation.defs#accountStats",
33
+ },
31
34
  "createdAt": "1970-01-01T00:00:00.000Z",
32
35
  "hosting": Object {
33
36
  "$type": "tools.ozone.moderation.defs#recordHosting",
@@ -37,6 +40,17 @@ Object {
37
40
  "lastReportedAt": "1970-01-01T00:00:00.000Z",
38
41
  "lastReviewedAt": "1970-01-01T00:00:00.000Z",
39
42
  "lastReviewedBy": "user(1)",
43
+ "recordsStats": Object {
44
+ "$type": "tools.ozone.moderation.defs#recordStats",
45
+ "appealedCount": 0,
46
+ "escalatedCount": 0,
47
+ "pendingCount": 0,
48
+ "processedCount": 1,
49
+ "reportedCount": 1,
50
+ "subjectCount": 1,
51
+ "takendownCount": 1,
52
+ "totalReports": 2,
53
+ },
40
54
  "reviewState": "tools.ozone.moderation.defs#reviewClosed",
41
55
  "subject": Object {
42
56
  "$type": "com.atproto.repo.strongRef",
@@ -134,6 +148,9 @@ Object {
134
148
  ],
135
149
  "moderation": Object {
136
150
  "subjectStatus": Object {
151
+ "accountStats": Object {
152
+ "$type": "tools.ozone.moderation.defs#accountStats",
153
+ },
137
154
  "createdAt": "1970-01-01T00:00:00.000Z",
138
155
  "hosting": Object {
139
156
  "$type": "tools.ozone.moderation.defs#recordHosting",
@@ -143,6 +160,17 @@ Object {
143
160
  "lastReportedAt": "1970-01-01T00:00:00.000Z",
144
161
  "lastReviewedAt": "1970-01-01T00:00:00.000Z",
145
162
  "lastReviewedBy": "user(1)",
163
+ "recordsStats": Object {
164
+ "$type": "tools.ozone.moderation.defs#recordStats",
165
+ "appealedCount": 0,
166
+ "escalatedCount": 0,
167
+ "pendingCount": 0,
168
+ "processedCount": 1,
169
+ "reportedCount": 1,
170
+ "subjectCount": 1,
171
+ "takendownCount": 1,
172
+ "totalReports": 2,
173
+ },
146
174
  "reviewState": "tools.ozone.moderation.defs#reviewClosed",
147
175
  "subject": Object {
148
176
  "$type": "com.atproto.repo.strongRef",
@@ -31,6 +31,9 @@ Object {
31
31
  ],
32
32
  "moderation": Object {
33
33
  "subjectStatus": Object {
34
+ "accountStats": Object {
35
+ "$type": "tools.ozone.moderation.defs#accountStats",
36
+ },
34
37
  "createdAt": "1970-01-01T00:00:00.000Z",
35
38
  "hosting": Object {
36
39
  "$type": "tools.ozone.moderation.defs#recordHosting",
@@ -40,6 +43,17 @@ Object {
40
43
  "lastReportedAt": "1970-01-01T00:00:00.000Z",
41
44
  "lastReviewedAt": "1970-01-01T00:00:00.000Z",
42
45
  "lastReviewedBy": "user(1)",
46
+ "recordsStats": Object {
47
+ "$type": "tools.ozone.moderation.defs#recordStats",
48
+ "appealedCount": 0,
49
+ "escalatedCount": 0,
50
+ "pendingCount": 0,
51
+ "processedCount": 1,
52
+ "reportedCount": 1,
53
+ "subjectCount": 1,
54
+ "takendownCount": 1,
55
+ "totalReports": 2,
56
+ },
43
57
  "reviewState": "tools.ozone.moderation.defs#reviewClosed",
44
58
  "subject": Object {
45
59
  "$type": "com.atproto.repo.strongRef",
@@ -22,6 +22,14 @@ Object {
22
22
  ],
23
23
  "moderation": Object {
24
24
  "subjectStatus": Object {
25
+ "accountStats": Object {
26
+ "$type": "tools.ozone.moderation.defs#accountStats",
27
+ "appealCount": 0,
28
+ "escalateCount": 0,
29
+ "reportCount": 2,
30
+ "suspendCount": 0,
31
+ "takedownCount": 1,
32
+ },
25
33
  "createdAt": "1970-01-01T00:00:00.000Z",
26
34
  "hosting": Object {
27
35
  "$type": "tools.ozone.moderation.defs#accountHosting",
@@ -31,6 +39,9 @@ Object {
31
39
  "lastReportedAt": "1970-01-01T00:00:00.000Z",
32
40
  "lastReviewedAt": "1970-01-01T00:00:00.000Z",
33
41
  "lastReviewedBy": "user(1)",
42
+ "recordsStats": Object {
43
+ "$type": "tools.ozone.moderation.defs#recordStats",
44
+ },
34
45
  "reviewState": "tools.ozone.moderation.defs#reviewClosed",
35
46
  "subject": Object {
36
47
  "$type": "com.atproto.admin.defs#repoRef",
@@ -25,6 +25,14 @@ Object {
25
25
  ],
26
26
  "moderation": Object {
27
27
  "subjectStatus": Object {
28
+ "accountStats": Object {
29
+ "$type": "tools.ozone.moderation.defs#accountStats",
30
+ "appealCount": 0,
31
+ "escalateCount": 0,
32
+ "reportCount": 2,
33
+ "suspendCount": 0,
34
+ "takedownCount": 1,
35
+ },
28
36
  "createdAt": "1970-01-01T00:00:00.000Z",
29
37
  "hosting": Object {
30
38
  "$type": "tools.ozone.moderation.defs#accountHosting",
@@ -34,6 +42,9 @@ Object {
34
42
  "lastReportedAt": "1970-01-01T00:00:00.000Z",
35
43
  "lastReviewedAt": "1970-01-01T00:00:00.000Z",
36
44
  "lastReviewedBy": "user(1)",
45
+ "recordsStats": Object {
46
+ "$type": "tools.ozone.moderation.defs#recordStats",
47
+ },
37
48
  "reviewState": "tools.ozone.moderation.defs#reviewClosed",
38
49
  "subject": Object {
39
50
  "$type": "com.atproto.admin.defs#repoRef",
@@ -18,6 +18,14 @@ Object {
18
18
  "indexedAt": "1970-01-01T00:00:00.000Z",
19
19
  "moderation": Object {
20
20
  "subjectStatus": Object {
21
+ "accountStats": Object {
22
+ "$type": "tools.ozone.moderation.defs#accountStats",
23
+ "appealCount": 0,
24
+ "escalateCount": 1,
25
+ "reportCount": 4,
26
+ "suspendCount": 0,
27
+ "takedownCount": 0,
28
+ },
21
29
  "createdAt": "1970-01-01T00:00:00.000Z",
22
30
  "hosting": Object {
23
31
  "$type": "tools.ozone.moderation.defs#accountHosting",
@@ -27,6 +35,17 @@ Object {
27
35
  "lastReportedAt": "1970-01-01T00:00:00.000Z",
28
36
  "lastReviewedAt": "1970-01-01T00:00:00.000Z",
29
37
  "lastReviewedBy": "user(1)",
38
+ "recordsStats": Object {
39
+ "$type": "tools.ozone.moderation.defs#recordStats",
40
+ "appealedCount": 0,
41
+ "escalatedCount": 0,
42
+ "pendingCount": 2,
43
+ "processedCount": 0,
44
+ "reportedCount": 2,
45
+ "subjectCount": 2,
46
+ "takendownCount": 0,
47
+ "totalReports": 3,
48
+ },
30
49
  "reviewState": "tools.ozone.moderation.defs#reviewEscalated",
31
50
  "subject": Object {
32
51
  "$type": "com.atproto.admin.defs#repoRef",