@atproto/bsky 0.0.248 → 0.0.250

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 (62) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/api/age-assurance/const.d.ts.map +1 -1
  3. package/dist/api/age-assurance/const.js +19 -0
  4. package/dist/api/age-assurance/const.js.map +1 -1
  5. package/dist/api/age-assurance/kws/age-verified.d.ts +20 -0
  6. package/dist/api/age-assurance/kws/age-verified.d.ts.map +1 -1
  7. package/dist/api/age-assurance/kws/age-verified.js +6 -0
  8. package/dist/api/age-assurance/kws/age-verified.js.map +1 -1
  9. package/dist/api/app/bsky/graph/muteActor.d.ts.map +1 -1
  10. package/dist/api/app/bsky/graph/muteActor.js +3 -0
  11. package/dist/api/app/bsky/graph/muteActor.js.map +1 -1
  12. package/dist/api/kws/types.d.ts +33 -0
  13. package/dist/api/kws/types.d.ts.map +1 -1
  14. package/dist/api/kws/types.js +3 -0
  15. package/dist/api/kws/types.js.map +1 -1
  16. package/dist/data-plane/index.d.ts +0 -1
  17. package/dist/data-plane/index.d.ts.map +1 -1
  18. package/dist/data-plane/index.js +0 -1
  19. package/dist/data-plane/index.js.map +1 -1
  20. package/dist/data-plane/server/bsync-subscription.d.ts +47 -0
  21. package/dist/data-plane/server/bsync-subscription.d.ts.map +1 -0
  22. package/dist/data-plane/server/bsync-subscription.js +461 -0
  23. package/dist/data-plane/server/bsync-subscription.js.map +1 -0
  24. package/dist/data-plane/server/index.d.ts +1 -0
  25. package/dist/data-plane/server/index.d.ts.map +1 -1
  26. package/dist/data-plane/server/index.js +1 -0
  27. package/dist/data-plane/server/index.js.map +1 -1
  28. package/dist/lexicons/app/bsky/ageassurance/defs.defs.d.ts +4 -0
  29. package/dist/lexicons/app/bsky/ageassurance/defs.defs.d.ts.map +1 -1
  30. package/dist/lexicons/app/bsky/ageassurance/defs.defs.js +3 -0
  31. package/dist/lexicons/app/bsky/ageassurance/defs.defs.js.map +1 -1
  32. package/dist/lexicons/chat/bsky/convo/defs.defs.js +1 -1
  33. package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
  34. package/dist/lexicons/chat/bsky/group/editGroup.defs.d.ts +4 -4
  35. package/dist/lexicons/chat/bsky/group/editGroup.defs.js +2 -2
  36. package/dist/lexicons/chat/bsky/group/editGroup.defs.js.map +1 -1
  37. package/dist/lexicons/chat/bsky/moderation/defs.defs.js +1 -1
  38. package/dist/lexicons/chat/bsky/moderation/defs.defs.js.map +1 -1
  39. package/package.json +16 -16
  40. package/src/api/age-assurance/const.ts +19 -0
  41. package/src/api/age-assurance/kws/age-verified.test.ts +98 -0
  42. package/src/api/age-assurance/kws/age-verified.ts +6 -0
  43. package/src/api/app/bsky/graph/muteActor.ts +3 -0
  44. package/src/api/kws/types.ts +11 -0
  45. package/src/api/kws/util.test.ts +50 -0
  46. package/src/data-plane/index.ts +0 -1
  47. package/src/data-plane/{bsync/index.ts → server/bsync-subscription.ts} +245 -121
  48. package/src/data-plane/server/index.ts +1 -0
  49. package/tests/data-plane/thread-mutes.test.ts +2 -0
  50. package/tests/stash.test.ts +6 -1
  51. package/tests/views/age-assurance-v2.test.ts +3 -0
  52. package/tests/views/age-assurance.test.ts +8 -0
  53. package/tests/views/bookmarks.test.ts +19 -0
  54. package/tests/views/drafts.test.ts +25 -2
  55. package/tests/views/mute-lists.test.ts +2 -0
  56. package/tests/views/mutes.test.ts +1 -0
  57. package/tests/views/notifications.test.ts +16 -0
  58. package/tsconfig.build.tsbuildinfo +1 -1
  59. package/dist/data-plane/bsync/index.d.ts +0 -11
  60. package/dist/data-plane/bsync/index.d.ts.map +0 -1
  61. package/dist/data-plane/bsync/index.js +0 -376
  62. package/dist/data-plane/bsync/index.js.map +0 -1
@@ -1,59 +1,164 @@
1
1
  import assert from 'node:assert'
2
- import events from 'node:events'
3
- import http from 'node:http'
4
- import { ConnectRouter } from '@connectrpc/connect'
5
- import { expressConnectMiddleware } from '@connectrpc/connect-express'
6
- import express from 'express'
7
- // eslint-disable-next-line import/default
8
- import httpTerminator from 'http-terminator'
9
- import { TID } from '@atproto/common'
10
2
  import { lexParse } from '@atproto/lex'
11
3
  import { AtUri } from '@atproto/syntax'
4
+ import { BsyncClient, authWithApiKey, createBsyncClient } from '../../bsync.js'
5
+ import { ServerConfig } from '../../config.js'
12
6
  import { app } from '../../lexicons/index.js'
13
- import { httpLogger } from '../../logger.js'
14
- import { Service } from '../../proto/bsync_connect.js'
7
+ import { subLogger as log } from '../../logger.js'
15
8
  import {
16
9
  Method,
10
+ MuteOperation,
17
11
  MuteOperation_Type,
18
- PutOperationRequest,
12
+ NotifOperation,
13
+ Operation,
19
14
  } from '../../proto/bsync_pb.js'
20
15
  import { Namespaces } from '../../stash.js'
21
- import { Database } from '../server/db/index.js'
22
- import { countAll, excluded } from '../server/db/util.js'
16
+ import { Database } from './db/index.js'
17
+ import { countAll, excluded } from './db/util.js'
23
18
 
24
- export class MockBsync {
25
- private terminator: httpTerminator.HttpTerminator
19
+ export type BsyncCursors = {
20
+ op?: string
21
+ mute?: string
22
+ notif?: string
23
+ }
24
+
25
+ export class BsyncSubscription {
26
+ private ac: AbortController | undefined
27
+ private pendingScans = new Set<Promise<void>>()
28
+ private bsyncClient: BsyncClient
29
+ private db: Database
30
+ // Latest cursor processed for each stream, used to wait until caught up.
31
+ private cursors: BsyncCursors = {}
32
+
33
+ constructor(public opts: { db: Database; config: ServerConfig }) {
34
+ const { config, db } = opts
35
+
36
+ this.db = db
37
+ this.bsyncClient = createBsyncClient({
38
+ baseUrl: config.bsyncUrl,
39
+ httpVersion: config.bsyncHttpVersion ?? '2',
40
+ nodeOptions: { rejectUnauthorized: !config.bsyncIgnoreBadTls },
41
+ interceptors: config.bsyncApiKey
42
+ ? [authWithApiKey(config.bsyncApiKey)]
43
+ : [],
44
+ })
45
+ }
26
46
 
27
- constructor(public server: http.Server) {
28
- this.terminator = httpTerminator.createHttpTerminator({ server })
47
+ get running() {
48
+ return !!this.ac
29
49
  }
30
50
 
31
- static async create(db: Database, port: number) {
32
- const app = express()
33
- const routes = createRoutes(db)
34
- app.use(expressConnectMiddleware({ routes }))
35
- const server = app.listen(port)
36
- await events.once(server, 'listening')
37
- return new MockBsync(server)
51
+ start() {
52
+ if (this.ac) return
53
+ this.ac = new AbortController()
54
+ this.scanMuteOperations()
55
+ this.scanNotifOperations()
56
+ this.scanOperations()
57
+ }
58
+
59
+ /**
60
+ * Waits until the subscription has processed every operation that existed in
61
+ * bsync at the time `targets` was read (one cursor per stream). Returns as
62
+ * soon as the cursors catch up - it does not wait for the bsync long-poll
63
+ * timeout. Intended for tests/dev.
64
+ */
65
+ async processAll(targets: BsyncCursors, timeout = 5000) {
66
+ if (!this.ac) return
67
+ const start = Date.now()
68
+ while (!this.isCaughtUp(targets)) {
69
+ if (Date.now() - start > timeout) {
70
+ throw new Error(
71
+ `bsync subscription was not caught up within ${timeout}ms`,
72
+ )
73
+ }
74
+ await wait(5)
75
+ }
76
+ }
77
+
78
+ private isCaughtUp(targets: BsyncCursors) {
79
+ return (
80
+ gteCursor(this.cursors.op, targets.op) &&
81
+ gteCursor(this.cursors.mute, targets.mute) &&
82
+ gteCursor(this.cursors.notif, targets.notif)
83
+ )
38
84
  }
39
85
 
40
86
  async destroy() {
41
- await this.terminator.terminate()
87
+ // Aborting cancels any in-flight long-poll scan immediately, so we don't
88
+ // have to wait for the bsync long-poll timeout to elapse.
89
+ this.ac?.abort()
90
+ this.ac = undefined
91
+ // Wait for the ongoing scans to unwind cleanly.
92
+ await Promise.all(this.pendingScans)
42
93
  }
43
94
 
44
- async [Symbol.asyncDispose]() {
45
- await this.destroy()
95
+ /**
96
+ * starts a scanner in the background, killing it when this instance is
97
+ * destroyed, and storing its promise in `this.pendingScans` to allow
98
+ * awaiting.
99
+ */
100
+ private startScanning(
101
+ scanner: (
102
+ cursor: undefined | string,
103
+ signal: AbortSignal,
104
+ ) => Promise<string | undefined>,
105
+ ) {
106
+ if (!this.ac) return
107
+ const signal = this.ac.signal
108
+
109
+ const scanPromise = this.runScanner(scanner, signal)
110
+
111
+ this.pendingScans.add(scanPromise)
112
+ void scanPromise
113
+ .catch(() => {})
114
+ .finally(() => {
115
+ this.pendingScans.delete(scanPromise)
116
+ })
46
117
  }
47
- }
48
118
 
49
- const createRoutes = (db: Database) => (router: ConnectRouter) =>
50
- router.service(Service, {
51
- async addMuteOperation(req) {
52
- const { type, actorDid, subject } = req
119
+ private async runScanner(
120
+ scanner: (
121
+ cursor: undefined | string,
122
+ signal: AbortSignal,
123
+ ) => Promise<string | undefined>,
124
+ signal: AbortSignal,
125
+ ) {
126
+ let cursor: undefined | string
127
+
128
+ while (!signal.aborted) {
129
+ try {
130
+ cursor = await scanner(cursor, signal)
131
+ } catch (err) {
132
+ if (signal.aborted) break
133
+
134
+ log.error({ err }, 'error in bsync scan')
135
+
136
+ // retry in a bit, but bail out early if we're shutting down
137
+ await wait(100, signal)
138
+ }
139
+ }
140
+ }
141
+
142
+ private scanMuteOperations() {
143
+ this.startScanning(async (cursor, signal) => {
144
+ const res = await this.bsyncClient.scanMuteOperations(
145
+ { cursor },
146
+ { signal },
147
+ )
148
+
149
+ await this.processMuteOperations(res.operations)
150
+ this.cursors.mute = res.cursor
151
+
152
+ return res.cursor
153
+ })
154
+ }
155
+
156
+ private async processMuteOperations(operations: MuteOperation[]) {
157
+ for (const op of operations) {
158
+ const { type, actorDid, subject } = op
53
159
  if (type === MuteOperation_Type.ADD) {
54
160
  if (subject.startsWith('did:')) {
55
- assert(actorDid !== subject, 'cannot mute yourself') // @TODO pass message through in http error
56
- await db.db
161
+ await this.db.db
57
162
  .insertInto('mute')
58
163
  .values({
59
164
  mutedByDid: actorDid,
@@ -65,7 +170,7 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
65
170
  } else {
66
171
  const uri = new AtUri(subject)
67
172
  if (uri.collection === app.bsky.graph.list.$type) {
68
- await db.db
173
+ await this.db.db
69
174
  .insertInto('list_mute')
70
175
  .values({
71
176
  mutedByDid: actorDid,
@@ -75,7 +180,7 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
75
180
  .onConflict((oc) => oc.doNothing())
76
181
  .execute()
77
182
  } else {
78
- await db.db
183
+ await this.db.db
79
184
  .insertInto('thread_mute')
80
185
  .values({
81
186
  mutedByDid: actorDid,
@@ -88,7 +193,7 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
88
193
  }
89
194
  } else if (type === MuteOperation_Type.REMOVE) {
90
195
  if (subject.startsWith('did:')) {
91
- await db.db
196
+ await this.db.db
92
197
  .deleteFrom('mute')
93
198
  .where('mutedByDid', '=', actorDid)
94
199
  .where('subjectDid', '=', subject)
@@ -96,13 +201,13 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
96
201
  } else {
97
202
  const uri = new AtUri(subject)
98
203
  if (uri.collection === app.bsky.graph.list.$type) {
99
- await db.db
204
+ await this.db.db
100
205
  .deleteFrom('list_mute')
101
206
  .where('mutedByDid', '=', actorDid)
102
207
  .where('listUri', '=', subject)
103
208
  .execute()
104
209
  } else {
105
- await db.db
210
+ await this.db.db
106
211
  .deleteFrom('thread_mute')
107
212
  .where('mutedByDid', '=', actorDid)
108
213
  .where('rootUri', '=', subject)
@@ -110,27 +215,37 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
110
215
  }
111
216
  }
112
217
  } else if (type === MuteOperation_Type.CLEAR) {
113
- await db.db
218
+ await this.db.db
114
219
  .deleteFrom('mute')
115
220
  .where('mutedByDid', '=', actorDid)
116
221
  .execute()
117
- await db.db
222
+ await this.db.db
118
223
  .deleteFrom('list_mute')
119
224
  .where('mutedByDid', '=', actorDid)
120
225
  .execute()
121
226
  }
227
+ }
228
+ }
229
+
230
+ private scanNotifOperations() {
231
+ this.startScanning(async (cursor, signal) => {
232
+ const res = await this.bsyncClient.scanNotifOperations(
233
+ { cursor },
234
+ { signal },
235
+ )
122
236
 
123
- return {}
124
- },
237
+ await this.processNotifOperations(res.operations)
238
+ this.cursors.notif = res.cursor
125
239
 
126
- async scanMuteOperations() {
127
- throw new Error('not implemented')
128
- },
240
+ return res.cursor
241
+ })
242
+ }
129
243
 
130
- async addNotifOperation(req) {
131
- const { actorDid, priority } = req
244
+ private async processNotifOperations(operations: NotifOperation[]) {
245
+ for (const op of operations) {
246
+ const { actorDid, priority } = op
132
247
  if (priority !== undefined) {
133
- await db.db
248
+ await this.db.db
134
249
  .insertInto('actor_state')
135
250
  .values({
136
251
  did: actorDid,
@@ -142,79 +257,70 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
142
257
  )
143
258
  .execute()
144
259
  }
145
- return {}
146
- },
147
-
148
- async scanNotifOperations() {
149
- throw new Error('not implemented')
150
- },
151
-
152
- async putOperation(req) {
153
- const { actorDid, namespace, key, method, payload } = req
154
- assert(
155
- method === Method.CREATE ||
156
- method === Method.UPDATE ||
157
- method === Method.DELETE,
158
- `Unsupported method: ${method}`,
159
- )
260
+ }
261
+ }
262
+
263
+ private scanOperations() {
264
+ this.startScanning(async (cursor, signal) => {
265
+ const res = await this.bsyncClient.scanOperations({ cursor }, { signal })
266
+
267
+ await this.processOperations(res.operations)
268
+ this.cursors.op = res.cursor
269
+
270
+ return res.cursor
271
+ })
272
+ }
273
+
274
+ private async processOperations(operations: Operation[]) {
275
+ for (const op of operations) {
276
+ const { namespace } = op
160
277
 
161
278
  const now = new Date().toISOString()
162
279
 
163
280
  // Index all items into private_data.
164
- await handleGenericOperation(db, req, now)
281
+ await handleGenericOperation(this.db, op, now)
165
282
 
166
- // Maintain bespoke indexes for certain namespaces.
283
+ // Maintain bespoke indexes for certain namespaces. A failure here must
284
+ // not stall the stream, so log and move on (matching bsync behavior).
167
285
  try {
168
286
  if (
169
287
  namespace ===
170
288
  Namespaces.AppBskyNotificationDefsSubjectActivitySubscription.$type
171
289
  ) {
172
- await handleSubjectActivitySubscriptionOperation(db, req, now)
290
+ await handleSubjectActivitySubscriptionOperation(this.db, op, now)
173
291
  } else if (
174
292
  namespace === Namespaces.AppBskyUnspeccedDefsAgeAssuranceEvent.$type
175
293
  ) {
176
- await handleAgeAssuranceEventOperation(db, req, now)
294
+ await handleAgeAssuranceEventOperation(this.db, op, now)
177
295
  } else if (
178
296
  namespace === Namespaces.AppBskyAgeassuranceDefsEvent.$type
179
297
  ) {
180
- await handleAgeAssuranceV2EventOperation(db, req, now)
298
+ await handleAgeAssuranceV2EventOperation(this.db, op, now)
181
299
  } else if (namespace === Namespaces.AppBskyBookmarkDefsBookmark.$type) {
182
- await handleBookmarkOperation(db, req, now)
300
+ await handleBookmarkOperation(this.db, op, now)
183
301
  } else if (namespace === Namespaces.AppBskyDraftDefsDraftWithId.$type) {
184
- await handleDraftOperation(db, req, now)
302
+ await handleDraftOperation(this.db, op, now)
185
303
  }
186
304
  } catch (err) {
187
- httpLogger.warn({ err, namespace }, 'mock bsync put operation failed')
305
+ log.warn({ err, namespace }, 'bsync put operation indexing failed')
188
306
  }
307
+ }
308
+ }
309
+ }
189
310
 
190
- return {
191
- operation: {
192
- id: TID.nextStr(),
193
- actorDid,
194
- namespace,
195
- key,
196
- method,
197
- payload,
198
- },
199
- }
200
- },
201
-
202
- async scanOperations() {
203
- throw new Error('not implemented')
204
- },
205
-
206
- async ping() {
207
- return {}
208
- },
209
- })
311
+ type HandleOperation = (
312
+ db: Database,
313
+ op: Operation,
314
+ now: string,
315
+ ) => Promise<void>
210
316
 
211
317
  // upsert into or remove from private_data
212
- const handleGenericOperation = async (
318
+ const handleGenericOperation: HandleOperation = async (
213
319
  db: Database,
214
- req: PutOperationRequest,
320
+ op: Operation,
215
321
  now: string,
216
322
  ) => {
217
- const { actorDid, namespace, key, method, payload } = req
323
+ const { actorDid, namespace, key, method, payload } = op
218
324
  if (method === Method.CREATE || method === Method.UPDATE) {
219
325
  await db.db
220
326
  .insertInto('private_data')
@@ -245,31 +351,33 @@ const handleGenericOperation = async (
245
351
  }
246
352
  }
247
353
 
248
- const handleSubjectActivitySubscriptionOperation = async (
354
+ const handleSubjectActivitySubscriptionOperation: HandleOperation = async (
249
355
  db: Database,
250
- req: PutOperationRequest,
356
+ op: Operation,
251
357
  now: string,
252
358
  ) => {
253
- const { actorDid, key, method, payload } = req
359
+ const { actorDid, key, method, payload } = op
254
360
 
255
361
  if (method === Method.DELETE) {
256
- return db.db
362
+ await db.db
257
363
  .deleteFrom('activity_subscription')
258
364
  .where('creator', '=', actorDid)
259
365
  .where('key', '=', key)
260
366
  .execute()
367
+ return
261
368
  }
262
369
 
263
- const json = Buffer.from(payload).toString('utf8')
264
370
  const parsed =
265
- lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(json)
371
+ lexParse<app.bsky.notification.defs.SubjectActivitySubscription>(
372
+ Buffer.from(payload).toString('utf8'),
373
+ )
266
374
  const {
267
375
  subject,
268
376
  activitySubscription: { post, reply },
269
377
  } = parsed
270
378
 
271
379
  if (method === Method.CREATE) {
272
- return db.db
380
+ await db.db
273
381
  .insertInto('activity_subscription')
274
382
  .values({
275
383
  creator: actorDid,
@@ -280,9 +388,10 @@ const handleSubjectActivitySubscriptionOperation = async (
280
388
  reply,
281
389
  })
282
390
  .execute()
391
+ return
283
392
  }
284
393
 
285
- return db.db
394
+ await db.db
286
395
  .updateTable('activity_subscription')
287
396
  .where('creator', '=', actorDid)
288
397
  .where('key', '=', key)
@@ -294,18 +403,16 @@ const handleSubjectActivitySubscriptionOperation = async (
294
403
  .execute()
295
404
  }
296
405
 
297
- const handleAgeAssuranceEventOperation = async (
406
+ const handleAgeAssuranceEventOperation: HandleOperation = async (
298
407
  db: Database,
299
- req: PutOperationRequest,
300
- _now: string,
408
+ op: Operation,
301
409
  ) => {
302
- const { actorDid, method, payload } = req
410
+ const { actorDid, method, payload } = op
303
411
  if (method !== Method.CREATE) return
304
412
 
305
413
  const parsed = lexParse<app.bsky.unspecced.defs.AgeAssuranceEvent>(
306
414
  Buffer.from(payload).toString('utf8'),
307
415
  )
308
-
309
416
  const { status, createdAt } = parsed
310
417
 
311
418
  const update = {
@@ -313,25 +420,23 @@ const handleAgeAssuranceEventOperation = async (
313
420
  ageAssuranceLastInitiatedAt: status === 'pending' ? createdAt : undefined,
314
421
  }
315
422
 
316
- return db.db
423
+ await db.db
317
424
  .updateTable('actor')
318
425
  .set(update)
319
426
  .where('did', '=', actorDid)
320
427
  .execute()
321
428
  }
322
429
 
323
- const handleAgeAssuranceV2EventOperation = async (
430
+ const handleAgeAssuranceV2EventOperation: HandleOperation = async (
324
431
  db: Database,
325
- req: PutOperationRequest,
326
- _now: string,
432
+ op: Operation,
327
433
  ) => {
328
- const { actorDid, method, payload } = req
434
+ const { actorDid, method, payload } = op
329
435
  if (method !== Method.CREATE) return
330
436
 
331
437
  const parsed = lexParse<app.bsky.ageassurance.defs.Event>(
332
438
  Buffer.from(payload).toString('utf8'),
333
439
  )
334
-
335
440
  const { status, createdAt, access, countryCode, regionCode } = parsed
336
441
 
337
442
  const update = {
@@ -342,19 +447,19 @@ const handleAgeAssuranceV2EventOperation = async (
342
447
  ageAssuranceRegionCode: regionCode,
343
448
  }
344
449
 
345
- return db.db
450
+ await db.db
346
451
  .updateTable('actor')
347
452
  .set(update)
348
453
  .where('did', '=', actorDid)
349
454
  .execute()
350
455
  }
351
456
 
352
- const handleBookmarkOperation = async (
457
+ const handleBookmarkOperation: HandleOperation = async (
353
458
  db: Database,
354
- req: PutOperationRequest,
459
+ op: Operation,
355
460
  now: string,
356
461
  ) => {
357
- const { actorDid, key, method, payload } = req
462
+ const { actorDid, key, method, payload } = op
358
463
 
359
464
  const updateAgg = (uri: string, dbTxn: Database) => {
360
465
  return dbTxn.db
@@ -420,12 +525,12 @@ const handleBookmarkOperation = async (
420
525
  }
421
526
  }
422
527
 
423
- const handleDraftOperation = async (
528
+ const handleDraftOperation: HandleOperation = async (
424
529
  db: Database,
425
- req: PutOperationRequest,
530
+ op: Operation,
426
531
  now: string,
427
532
  ) => {
428
- const { actorDid, key, method, payload } = req
533
+ const { actorDid, key, method, payload } = op
429
534
 
430
535
  if (method === Method.CREATE) {
431
536
  const payloadString = Buffer.from(payload).toString('utf8')
@@ -464,3 +569,22 @@ const handleDraftOperation = async (
464
569
  .execute()
465
570
  }
466
571
  }
572
+
573
+ // compares numeric (bigint) cursor ids; an undefined target is always satisfied
574
+ const gteCursor = (current: string | undefined, target: string | undefined) => {
575
+ if (!target) return true
576
+ return BigInt(current || '0') >= BigInt(target)
577
+ }
578
+
579
+ // resolves after `ms`, or early if `signal` aborts
580
+ const wait = (ms: number, signal?: AbortSignal) =>
581
+ new Promise<void>((resolve) => {
582
+ if (signal?.aborted) return resolve()
583
+ const done = () => {
584
+ clearTimeout(timer)
585
+ signal?.removeEventListener('abort', done)
586
+ resolve()
587
+ }
588
+ const timer = setTimeout(done, ms)
589
+ signal?.addEventListener('abort', done, { once: true })
590
+ })
@@ -10,6 +10,7 @@ import createRoutes from './routes/index.js'
10
10
 
11
11
  export type { DatabaseSchema }
12
12
 
13
+ export { BsyncSubscription } from './bsync-subscription.js'
13
14
  export { RepoSubscription } from './subscription.js'
14
15
 
15
16
  export class DataPlaneServer {
@@ -43,6 +43,7 @@ describe('thread mutes', () => {
43
43
  ),
44
44
  },
45
45
  )
46
+ await network.processAll()
46
47
  })
47
48
 
48
49
  it('notes that threads are muted in viewer state', async () => {
@@ -137,6 +138,7 @@ describe('thread mutes', () => {
137
138
  ),
138
139
  },
139
140
  )
141
+ await network.processAll()
140
142
  })
141
143
 
142
144
  it('no longer notes that threads are muted in viewer state after unmuting', async () => {
@@ -44,7 +44,12 @@ describe('private data', () => {
44
44
  })
45
45
 
46
46
  beforeEach(async () => network.processAll())
47
- afterEach(async () => clearPrivateData(network.bsky.db))
47
+ afterEach(async () => {
48
+ // Drain pending bsync ops before clearing, so a stale op can't land after
49
+ // the reset.
50
+ await network.processAll()
51
+ await clearPrivateData(network.bsky.db)
52
+ })
48
53
  afterAll(async () => network?.close())
49
54
 
50
55
  describe('create', () => {
@@ -170,6 +170,9 @@ describe('age assurance v2 views', () => {
170
170
 
171
171
  afterEach(async () => {
172
172
  vi.resetAllMocks()
173
+ // Drain in-flight bsync ops before resetting state directly, so an op from
174
+ // a test that doesn't read it back can't leak into the next test.
175
+ await network.processAll()
173
176
  await clearPrivateData(db)
174
177
  await clearActorAgeAssurance(db)
175
178
  })
@@ -97,6 +97,9 @@ describe('age assurance views', () => {
97
97
  })
98
98
 
99
99
  afterEach(async () => {
100
+ // Drain pending bsync ops before clearing, so a stale op can't land after
101
+ // the reset.
102
+ await network.processAll()
100
103
  await clearPrivateData(db)
101
104
  await clearActorAgeAssurance(db)
102
105
  })
@@ -225,6 +228,7 @@ describe('age assurance views', () => {
225
228
  externalPayload,
226
229
  status,
227
230
  })
231
+ await network.processAll()
228
232
  const finalizedState = await getAgeAssurance(actor)
229
233
  expect(finalizedState).toStrictEqual({
230
234
  status: 'assured',
@@ -264,6 +268,7 @@ describe('age assurance views', () => {
264
268
  `${redirectUrl}?actorDid=${encodeURIComponent(actorDid)}&result=success`,
265
269
  )
266
270
 
271
+ await network.processAll()
267
272
  const state2 = await getAgeAssurance(actorDid)
268
273
  expect(state2).toStrictEqual({
269
274
  status: 'assured',
@@ -288,6 +293,7 @@ describe('age assurance views', () => {
288
293
  `${redirectUrl}?result=unknown`,
289
294
  )
290
295
 
296
+ await network.processAll()
291
297
  const state = await getAgeAssurance(actorDid)
292
298
  expect(state).toStrictEqual({
293
299
  status: 'pending',
@@ -323,6 +329,7 @@ describe('age assurance views', () => {
323
329
  })
324
330
  expect(webhookRes.status).toBe(200)
325
331
 
332
+ await network.processAll()
326
333
  const state2 = await getAgeAssurance(actorDid)
327
334
  expect(state2).toStrictEqual({
328
335
  status: 'assured',
@@ -346,6 +353,7 @@ describe('age assurance views', () => {
346
353
  })
347
354
  expect(webhookRes.status).toBe(500)
348
355
 
356
+ await network.processAll()
349
357
  const state = await getAgeAssurance(actorDid)
350
358
  expect(state).toStrictEqual({
351
359
  status: 'pending',