@atproto/ozone 0.0.11 → 0.0.13

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 (54) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/api/label/queryLabels.d.ts +3 -0
  3. package/dist/api/label/subscribeLabels.d.ts +3 -0
  4. package/dist/config/config.d.ts +3 -0
  5. package/dist/config/env.d.ts +3 -0
  6. package/dist/context.d.ts +3 -0
  7. package/dist/db/index.js +3 -1
  8. package/dist/db/index.js.map +2 -2
  9. package/dist/db/schema/label.d.ts +4 -0
  10. package/dist/index.js +875 -454
  11. package/dist/index.js.map +3 -3
  12. package/dist/lexicon/index.d.ts +2 -0
  13. package/dist/lexicon/lexicons.d.ts +27 -0
  14. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +1 -1
  15. package/dist/lexicon/types/com/atproto/admin/updateAccountPassword.d.ts +26 -0
  16. package/dist/logger.d.ts +1 -0
  17. package/dist/mod-service/util.d.ts +3 -0
  18. package/dist/sequencer/index.d.ts +2 -0
  19. package/dist/sequencer/outbox.d.ts +16 -0
  20. package/dist/sequencer/sequencer.d.ts +33 -0
  21. package/package.json +10 -10
  22. package/src/api/admin/emitModerationEvent.ts +16 -10
  23. package/src/api/index.ts +4 -0
  24. package/src/api/label/queryLabels.ts +58 -0
  25. package/src/api/label/subscribeLabels.ts +25 -0
  26. package/src/api/temp/fetchLabels.ts +2 -4
  27. package/src/config/config.ts +6 -0
  28. package/src/config/env.ts +6 -0
  29. package/src/context.ts +12 -0
  30. package/src/db/migrations/20231219T205730722Z-init.ts +7 -1
  31. package/src/db/schema/label.ts +7 -0
  32. package/src/index.ts +2 -0
  33. package/src/lexicon/index.ts +12 -0
  34. package/src/lexicon/lexicons.ts +32 -1
  35. package/src/lexicon/types/app/bsky/actor/defs.ts +2 -0
  36. package/src/lexicon/types/com/atproto/admin/updateAccountPassword.ts +39 -0
  37. package/src/logger.ts +2 -0
  38. package/src/mod-service/index.ts +73 -72
  39. package/src/mod-service/status.ts +3 -0
  40. package/src/mod-service/util.ts +17 -0
  41. package/src/mod-service/views.ts +2 -5
  42. package/src/sequencer/index.ts +2 -0
  43. package/src/sequencer/outbox.ts +122 -0
  44. package/src/sequencer/sequencer.ts +143 -0
  45. package/tests/__snapshots__/moderation-events.test.ts.snap +53 -75
  46. package/tests/__snapshots__/moderation.test.ts.snap +4 -4
  47. package/tests/moderation-appeals.test.ts +19 -7
  48. package/tests/moderation-events.test.ts +7 -7
  49. package/tests/moderation-statuses.test.ts +2 -2
  50. package/tests/moderation.test.ts +14 -13
  51. package/tests/query-labels.test.ts +163 -0
  52. package/tests/repo-search.test.ts +0 -1
  53. package/tests/sequencer.test.ts +222 -0
  54. package/tests/server.test.ts +2 -0
@@ -43,6 +43,7 @@ import { BlobPushEvent } from '../db/schema/blob_push_event'
43
43
  import { BackgroundQueue } from '../background'
44
44
  import { EventPusher } from '../daemon'
45
45
  import { jsonb } from '../db/types'
46
+ import { LabelChannel } from '../db/schema/label'
46
47
 
47
48
  export type ModerationServiceCreator = (db: Database) => ModerationService
48
49
 
@@ -438,24 +439,22 @@ export class ModerationService {
438
439
  takedownRef,
439
440
  }))
440
441
 
441
- const [repoEvts] = await Promise.all([
442
- this.db.db
443
- .insertInto('repo_push_event')
444
- .values(values)
445
- .onConflict((oc) =>
446
- oc.columns(['subjectDid', 'eventType']).doUpdateSet({
447
- takedownRef,
448
- confirmedAt: null,
449
- attempts: 0,
450
- lastAttempted: null,
451
- }),
452
- )
453
- .returning('id')
454
- .execute(),
455
- this.formatAndCreateLabels(subject.did, null, {
456
- create: [UNSPECCED_TAKEDOWN_LABEL],
457
- }),
458
- ])
442
+ const repoEvts = await this.db.db
443
+ .insertInto('repo_push_event')
444
+ .values(values)
445
+ .onConflict((oc) =>
446
+ oc.columns(['subjectDid', 'eventType']).doUpdateSet({
447
+ takedownRef,
448
+ confirmedAt: null,
449
+ attempts: 0,
450
+ lastAttempted: null,
451
+ }),
452
+ )
453
+ .returning('id')
454
+ .execute()
455
+ await this.formatAndCreateLabels(subject.did, null, {
456
+ create: [UNSPECCED_TAKEDOWN_LABEL],
457
+ })
459
458
 
460
459
  this.db.onCommit(() => {
461
460
  this.backgroundQueue.add(async () => {
@@ -467,23 +466,21 @@ export class ModerationService {
467
466
  }
468
467
 
469
468
  async reverseTakedownRepo(subject: RepoSubject) {
470
- const [repoEvts] = await Promise.all([
471
- this.db.db
472
- .updateTable('repo_push_event')
473
- .where('eventType', 'in', TAKEDOWNS)
474
- .where('subjectDid', '=', subject.did)
475
- .set({
476
- takedownRef: null,
477
- confirmedAt: null,
478
- attempts: 0,
479
- lastAttempted: null,
480
- })
481
- .returning('id')
482
- .execute(),
483
- this.formatAndCreateLabels(subject.did, null, {
484
- negate: [UNSPECCED_TAKEDOWN_LABEL],
485
- }),
486
- ])
469
+ const repoEvts = await this.db.db
470
+ .updateTable('repo_push_event')
471
+ .where('eventType', 'in', TAKEDOWNS)
472
+ .where('subjectDid', '=', subject.did)
473
+ .set({
474
+ takedownRef: null,
475
+ confirmedAt: null,
476
+ attempts: 0,
477
+ lastAttempted: null,
478
+ })
479
+ .returning('id')
480
+ .execute()
481
+ await this.formatAndCreateLabels(subject.did, null, {
482
+ negate: [UNSPECCED_TAKEDOWN_LABEL],
483
+ })
487
484
 
488
485
  this.db.onCommit(() => {
489
486
  this.backgroundQueue.add(async () => {
@@ -509,22 +506,22 @@ export class ModerationService {
509
506
  if (blobCids && blobCids.length > 0) {
510
507
  labels.push(UNSPECCED_TAKEDOWN_BLOBS_LABEL)
511
508
  }
512
- const [recordEvts] = await Promise.all([
513
- this.db.db
514
- .insertInto('record_push_event')
515
- .values(values)
516
- .onConflict((oc) =>
517
- oc.columns(['subjectUri', 'eventType']).doUpdateSet({
518
- takedownRef,
519
- confirmedAt: null,
520
- attempts: 0,
521
- lastAttempted: null,
522
- }),
523
- )
524
- .returning('id')
525
- .execute(),
526
- this.formatAndCreateLabels(subject.uri, subject.cid, { create: labels }),
527
- ])
509
+ const recordEvts = await this.db.db
510
+ .insertInto('record_push_event')
511
+ .values(values)
512
+ .onConflict((oc) =>
513
+ oc.columns(['subjectUri', 'eventType']).doUpdateSet({
514
+ takedownRef,
515
+ confirmedAt: null,
516
+ attempts: 0,
517
+ lastAttempted: null,
518
+ }),
519
+ )
520
+ .returning('id')
521
+ .execute()
522
+ await this.formatAndCreateLabels(subject.uri, subject.cid, {
523
+ create: labels,
524
+ })
528
525
 
529
526
  this.db.onCommit(() => {
530
527
  this.backgroundQueue.add(async () => {
@@ -579,29 +576,31 @@ export class ModerationService {
579
576
  if (blobCids && blobCids.length > 0) {
580
577
  labels.push(UNSPECCED_TAKEDOWN_BLOBS_LABEL)
581
578
  }
582
- const [recordEvts] = await Promise.all([
583
- this.db.db
584
- .updateTable('record_push_event')
585
- .where('eventType', 'in', TAKEDOWNS)
586
- .where('subjectDid', '=', subject.did)
587
- .where('subjectUri', '=', subject.uri)
588
- .set({
589
- takedownRef: null,
590
- confirmedAt: null,
591
- attempts: 0,
592
- lastAttempted: null,
579
+ const recordEvts = await this.db.db
580
+ .updateTable('record_push_event')
581
+ .where('eventType', 'in', TAKEDOWNS)
582
+ .where('subjectDid', '=', subject.did)
583
+ .where('subjectUri', '=', subject.uri)
584
+ .set({
585
+ takedownRef: null,
586
+ confirmedAt: null,
587
+ attempts: 0,
588
+ lastAttempted: null,
589
+ })
590
+ .returning('id')
591
+ .execute()
592
+ await this.formatAndCreateLabels(subject.uri, subject.cid, {
593
+ negate: labels,
594
+ }),
595
+ this.db.onCommit(() => {
596
+ this.backgroundQueue.add(async () => {
597
+ await Promise.all(
598
+ recordEvts.map((evt) =>
599
+ this.eventPusher.attemptRecordEvent(evt.id),
600
+ ),
601
+ )
593
602
  })
594
- .returning('id')
595
- .execute(),
596
- this.formatAndCreateLabels(subject.uri, subject.cid, { negate: labels }),
597
- ])
598
- this.db.onCommit(() => {
599
- this.backgroundQueue.add(async () => {
600
- await Promise.all(
601
- recordEvts.map((evt) => this.eventPusher.attemptRecordEvent(evt.id)),
602
- )
603
603
  })
604
- })
605
604
 
606
605
  if (blobCids && blobCids.length > 0) {
607
606
  const blobEvts = await this.db.db
@@ -858,12 +857,14 @@ export class ModerationService {
858
857
  neg: !!l.neg,
859
858
  }))
860
859
  const { ref } = this.db.db.dynamic
860
+ await sql`notify ${ref(LabelChannel)}`.execute(this.db.db)
861
861
  const excluded = (col: string) => ref(`excluded.${col}`)
862
862
  await this.db.db
863
863
  .insertInto('label')
864
864
  .values(dbVals)
865
865
  .onConflict((oc) =>
866
866
  oc.columns(['src', 'uri', 'cid', 'val']).doUpdateSet({
867
+ id: sql`${excluded('id')}`,
867
868
  neg: sql`${excluded('neg')}`,
868
869
  cts: sql`${excluded('cts')}`,
869
870
  }),
@@ -179,6 +179,9 @@ export const adjustModerationSubjectStatus = async (
179
179
  subjectStatus.appealed = true
180
180
  newStatus.lastAppealedAt = createdAt
181
181
  subjectStatus.lastAppealedAt = createdAt
182
+ // Set reviewState to escalated when appeal events are emitted
183
+ subjectStatus.reviewState = REVIEWESCALATED
184
+ newStatus.reviewState = REVIEWESCALATED
182
185
  }
183
186
 
184
187
  if (
@@ -0,0 +1,17 @@
1
+ import { LabelRow } from '../db/schema/label'
2
+ import { Label } from '../lexicon/types/com/atproto/label/defs'
3
+
4
+ export const formatLabel = (row: LabelRow): Label => {
5
+ const label: Label = {
6
+ src: row.src,
7
+ uri: row.uri,
8
+ val: row.val,
9
+ neg: row.neg,
10
+ cts: row.cts,
11
+ }
12
+ if (row.cid !== '') {
13
+ // @NOTE avoiding undefined values on label, which dag-cbor chokes on when serializing.
14
+ label.cid = row.cid
15
+ }
16
+ return label
17
+ }
@@ -24,6 +24,7 @@ import {
24
24
  } from './types'
25
25
  import { REASONOTHER } from '../lexicon/types/com/atproto/moderation/defs'
26
26
  import { subjectFromEventRow, subjectFromStatusRow } from './subject'
27
+ import { formatLabel } from './util'
27
28
 
28
29
  export type AppviewAuth = () => Promise<
29
30
  | {
@@ -403,11 +404,7 @@ export class ModerationViews {
403
404
  .if(!includeNeg, (qb) => qb.where('neg', '=', false))
404
405
  .selectAll()
405
406
  .execute()
406
- return res.map((l) => ({
407
- ...l,
408
- cid: l.cid === '' ? undefined : l.cid,
409
- neg: l.neg,
410
- }))
407
+ return res.map((l) => formatLabel(l))
411
408
  }
412
409
 
413
410
  async getSubjectStatus(
@@ -0,0 +1,2 @@
1
+ export * from './sequencer'
2
+ export * from './outbox'
@@ -0,0 +1,122 @@
1
+ import { AsyncBuffer, AsyncBufferFullError } from '@atproto/common'
2
+ import { InvalidRequestError } from '@atproto/xrpc-server'
3
+ import { Sequencer, LabelsEvt } from './sequencer'
4
+
5
+ export type OutboxOpts = {
6
+ maxBufferSize: number
7
+ }
8
+
9
+ export class Outbox {
10
+ private caughtUp = false
11
+ lastSeen = -1
12
+
13
+ cutoverBuffer: LabelsEvt[]
14
+ outBuffer: AsyncBuffer<LabelsEvt>
15
+
16
+ constructor(public sequencer: Sequencer, opts: Partial<OutboxOpts> = {}) {
17
+ const { maxBufferSize = 500 } = opts
18
+ this.cutoverBuffer = []
19
+ this.outBuffer = new AsyncBuffer<LabelsEvt>(maxBufferSize)
20
+ }
21
+
22
+ // event stream occurs in 3 phases
23
+ // 1. backfill events: events that have been added to the DB since the last time a connection was open.
24
+ // The outbox is not yet listening for new events from the sequencer
25
+ // 2. cutover: the outbox has caught up with where the sequencer purports to be,
26
+ // but the sequencer might already be halfway through sending out a round of updates.
27
+ // Therefore, we start accepting the sequencer's events in a buffer, while making our own request to the
28
+ // database to ensure we're caught up. We then dedupe the query & the buffer & stream the events in order
29
+ // 3. streaming: we're all caught up on historic state, so the sequencer outputs events and we
30
+ // immediately yield them
31
+ async *events(
32
+ backfillCursor?: number,
33
+ signal?: AbortSignal,
34
+ ): AsyncGenerator<LabelsEvt> {
35
+ // catch up as much as we can
36
+ if (backfillCursor !== undefined) {
37
+ for await (const evt of this.getBackfill(backfillCursor)) {
38
+ if (signal?.aborted) return
39
+ this.lastSeen = evt.seq
40
+ yield evt
41
+ }
42
+ } else {
43
+ // if not backfill, we don't need to cutover, just start streaming
44
+ this.caughtUp = true
45
+ }
46
+
47
+ // streams updates from sequencer, but buffers them for cutover as it makes a last request
48
+
49
+ const addToBuffer = (evts) => {
50
+ if (this.caughtUp) {
51
+ this.outBuffer.pushMany(evts)
52
+ } else {
53
+ this.cutoverBuffer = [...this.cutoverBuffer, ...evts]
54
+ }
55
+ }
56
+
57
+ if (!signal?.aborted) {
58
+ this.sequencer.on('events', addToBuffer)
59
+ }
60
+ signal?.addEventListener('abort', () =>
61
+ this.sequencer.off('events', addToBuffer),
62
+ )
63
+
64
+ const cutover = async () => {
65
+ // only need to perform cutover if we've been backfilling
66
+ if (backfillCursor !== undefined) {
67
+ const cutoverEvts = await this.sequencer.requestLabelRange({
68
+ earliestId: this.lastSeen > -1 ? this.lastSeen : backfillCursor,
69
+ })
70
+ this.outBuffer.pushMany(cutoverEvts)
71
+ // dont worry about dupes, we ensure order on yield
72
+ this.outBuffer.pushMany(this.cutoverBuffer)
73
+ this.caughtUp = true
74
+ this.cutoverBuffer = []
75
+ } else {
76
+ this.caughtUp = true
77
+ }
78
+ }
79
+ cutover()
80
+
81
+ while (true) {
82
+ try {
83
+ for await (const evt of this.outBuffer.events()) {
84
+ if (signal?.aborted) return
85
+ if (evt.seq > this.lastSeen) {
86
+ this.lastSeen = evt.seq
87
+ yield evt
88
+ }
89
+ }
90
+ } catch (err) {
91
+ if (err instanceof AsyncBufferFullError) {
92
+ throw new InvalidRequestError(
93
+ 'Stream consumer too slow',
94
+ 'ConsumerTooSlow',
95
+ )
96
+ } else {
97
+ throw err
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ // yields only historical events
104
+ async *getBackfill(backfillCursor: number) {
105
+ const PAGE_SIZE = 500
106
+ while (true) {
107
+ const evts = await this.sequencer.requestLabelRange({
108
+ earliestId: this.lastSeen > -1 ? this.lastSeen : backfillCursor,
109
+ limit: PAGE_SIZE,
110
+ })
111
+ for (const evt of evts) {
112
+ yield evt
113
+ }
114
+ // if we're within half a pagesize of the sequencer, we call it good & switch to cutover
115
+ const seqCursor = this.sequencer.lastSeen ?? -1
116
+ if (seqCursor - this.lastSeen < PAGE_SIZE / 2) break
117
+ if (evts.length < 1) break
118
+ }
119
+ }
120
+ }
121
+
122
+ export default Outbox
@@ -0,0 +1,143 @@
1
+ import EventEmitter from 'events'
2
+ import TypedEmitter from 'typed-emitter'
3
+ import { seqLogger as log } from '../logger'
4
+ import Database from '../db'
5
+ import { Labels as LabelsEvt } from '../lexicon/types/com/atproto/label/subscribeLabels'
6
+ import { LabelChannel, Label as LabelTable } from '../db/schema/label'
7
+ import { Selectable } from 'kysely'
8
+ import { formatLabel } from '../mod-service/util'
9
+ import { PoolClient } from 'pg'
10
+
11
+ export type { Labels as LabelsEvt } from '../lexicon/types/com/atproto/label/subscribeLabels'
12
+ type LabelRow = Selectable<LabelTable>
13
+
14
+ export class Sequencer extends (EventEmitter as new () => SequencerEmitter) {
15
+ destroyed = false
16
+ pollPromise: Promise<void> | undefined
17
+ queued = false
18
+ conn: PoolClient | undefined
19
+
20
+ constructor(public db: Database, public lastSeen = 0) {
21
+ super()
22
+ // note: this does not err when surpassed, just prints a warning to stderr
23
+ this.setMaxListeners(100)
24
+ }
25
+
26
+ async start() {
27
+ const curr = await this.curr()
28
+ this.lastSeen = curr ?? 0
29
+ this.poll()
30
+ this.conn = await this.db.pool.connect()
31
+ this.conn.query(`listen ${LabelChannel}`) // if this errors, unhandled rejection should cause process to exit
32
+ this.conn.on('notification', (notif) => {
33
+ if (notif.channel === LabelChannel) {
34
+ this.poll()
35
+ }
36
+ })
37
+ }
38
+
39
+ async destroy() {
40
+ if (this.destroyed) return
41
+ this.destroyed = true
42
+ if (this.conn) {
43
+ this.conn.release()
44
+ this.conn = undefined
45
+ }
46
+ if (this.pollPromise) {
47
+ await this.pollPromise
48
+ }
49
+ this.emit('close')
50
+ }
51
+
52
+ async curr(): Promise<number | null> {
53
+ const got = await this.db.db
54
+ .selectFrom('label')
55
+ .selectAll()
56
+ .orderBy('id', 'desc')
57
+ .limit(1)
58
+ .executeTakeFirst()
59
+ return got?.id ?? null
60
+ }
61
+
62
+ async next(cursor: number): Promise<LabelRow | null> {
63
+ const got = await this.db.db
64
+ .selectFrom('label')
65
+ .selectAll()
66
+ .where('id', '>', cursor)
67
+ .limit(1)
68
+ .orderBy('id', 'asc')
69
+ .executeTakeFirst()
70
+ return got || null
71
+ }
72
+
73
+ async requestLabelRange(opts: {
74
+ earliestId?: number
75
+ limit?: number
76
+ }): Promise<LabelsEvt[]> {
77
+ const { earliestId, limit } = opts
78
+
79
+ let seqQb = this.db.db.selectFrom('label').selectAll().orderBy('id', 'asc')
80
+ if (earliestId !== undefined) {
81
+ seqQb = seqQb.where('id', '>', earliestId)
82
+ }
83
+ if (limit !== undefined) {
84
+ seqQb = seqQb.limit(limit)
85
+ }
86
+
87
+ const rows = await seqQb.execute()
88
+ if (rows.length < 1) {
89
+ return []
90
+ }
91
+
92
+ const evts: LabelsEvt[] = []
93
+ for (const row of rows) {
94
+ evts.push({
95
+ seq: row.id,
96
+ labels: [formatLabel(row)],
97
+ })
98
+ }
99
+
100
+ return evts
101
+ }
102
+
103
+ private poll() {
104
+ if (this.destroyed) return
105
+ if (this.pollPromise) {
106
+ this.queued = true
107
+ return
108
+ }
109
+ this.queued = false
110
+ this.pollPromise = this.requestLabelRange({
111
+ earliestId: this.lastSeen,
112
+ limit: 500,
113
+ })
114
+ .then((evts) => {
115
+ this.emit('events', evts)
116
+ this.lastSeen = evts.at(-1)?.seq ?? this.lastSeen
117
+ if (evts.length > 0) {
118
+ this.queued = true
119
+ }
120
+ })
121
+ .catch((err) => {
122
+ log.error(
123
+ { err, lastSeen: this.lastSeen },
124
+ 'sequencer failed to poll db',
125
+ )
126
+ })
127
+ .finally(() => {
128
+ this.pollPromise = undefined
129
+ if (this.queued) {
130
+ this.poll()
131
+ }
132
+ })
133
+ }
134
+ }
135
+
136
+ type SequencerEvents = {
137
+ events: (evts: LabelsEvt[]) => void
138
+ close: () => void
139
+ }
140
+
141
+ export type SequencerEmitter = TypedEmitter<SequencerEvents>
142
+
143
+ export default Sequencer