@atproto/bsync 0.0.32 → 0.0.33

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/logger.js +2 -2
  3. package/dist/logger.js.map +1 -1
  4. package/package.json +17 -13
  5. package/bin/migration-create.ts +0 -38
  6. package/buf.gen.yaml +0 -12
  7. package/jest.config.cjs +0 -21
  8. package/proto/bsync.proto +0 -134
  9. package/src/client.ts +0 -25
  10. package/src/config.ts +0 -90
  11. package/src/context.ts +0 -48
  12. package/src/db/index.ts +0 -200
  13. package/src/db/migrations/20240108T220751294Z-init.ts +0 -26
  14. package/src/db/migrations/20240717T224303472Z-notif-ops.ts +0 -24
  15. package/src/db/migrations/20250527T022203400Z-add-operation.ts +0 -20
  16. package/src/db/migrations/20250603T163446567Z-alter-operation.ts +0 -19
  17. package/src/db/migrations/index.ts +0 -8
  18. package/src/db/migrations/provider.ts +0 -8
  19. package/src/db/schema/index.ts +0 -16
  20. package/src/db/schema/mute_item.ts +0 -13
  21. package/src/db/schema/mute_op.ts +0 -18
  22. package/src/db/schema/notif_item.ts +0 -13
  23. package/src/db/schema/notif_op.ts +0 -16
  24. package/src/db/schema/operation.ts +0 -20
  25. package/src/db/types.ts +0 -19
  26. package/src/index.ts +0 -132
  27. package/src/logger.ts +0 -26
  28. package/src/routes/add-mute-operation.ts +0 -154
  29. package/src/routes/add-notif-operation.ts +0 -80
  30. package/src/routes/auth.ts +0 -15
  31. package/src/routes/delete-operations.ts +0 -45
  32. package/src/routes/index.ts +0 -28
  33. package/src/routes/put-operation.ts +0 -115
  34. package/src/routes/scan-mute-operations.ts +0 -65
  35. package/src/routes/scan-notif-operations.ts +0 -64
  36. package/src/routes/scan-operations.ts +0 -67
  37. package/src/routes/util.ts +0 -67
  38. package/tests/delete-operations.test.ts +0 -108
  39. package/tests/mutes.test.ts +0 -352
  40. package/tests/notifications.test.ts +0 -209
  41. package/tests/operations.test.ts +0 -327
  42. package/tsconfig.build.json +0 -8
  43. package/tsconfig.build.tsbuildinfo +0 -1
  44. package/tsconfig.json +0 -7
  45. package/tsconfig.tests.json +0 -7
@@ -1,45 +0,0 @@
1
- import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect'
2
- import { AppContext } from '../context.js'
3
- import { Service } from '../proto/bsync_connect.js'
4
- import { DeleteOperationsByActorAndNamespaceResponse } from '../proto/bsync_pb.js'
5
- import { authWithApiKey } from './auth.js'
6
- import { isValidDid, validateNamespace } from './util.js'
7
-
8
- export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
9
- /**
10
- * This method is responsible for deleting log rows from the bsync db, it has
11
- * no other downstream effects. This method is called from the dataplane in
12
- * response to a data deletion request initiated by a moderator in Ozone.
13
- * It's the final step of the deletion process, basically cleaning up the
14
- * breadcrumbs that resulted in the state we store in the dataplane.
15
- */
16
- async deleteOperationsByActorAndNamespace(req, handlerCtx) {
17
- authWithApiKey(ctx, handlerCtx)
18
- const { db } = ctx
19
-
20
- try {
21
- validateNamespace(req.namespace)
22
- } catch (error) {
23
- throw new ConnectError(
24
- 'requested namespace for deletion is invalid NSID',
25
- Code.InvalidArgument,
26
- )
27
- }
28
- if (!isValidDid(req.actorDid)) {
29
- throw new ConnectError(
30
- 'requested actor_did for deletion is invalid DID',
31
- Code.InvalidArgument,
32
- )
33
- }
34
-
35
- const deletedRows = await db.db
36
- .deleteFrom('operation')
37
- .where('actorDid', '=', req.actorDid)
38
- .where('namespace', '=', req.namespace)
39
- .returning('id')
40
- .execute()
41
- return new DeleteOperationsByActorAndNamespaceResponse({
42
- deletedCount: deletedRows.length,
43
- })
44
- },
45
- })
@@ -1,28 +0,0 @@
1
- import { ConnectRouter } from '@connectrpc/connect'
2
- import { sql } from 'kysely'
3
- import { AppContext } from '../context.js'
4
- import { Service } from '../proto/bsync_connect.js'
5
- import addMuteOperation from './add-mute-operation.js'
6
- import addNotifOperation from './add-notif-operation.js'
7
- import deleteOperations from './delete-operations.js'
8
- import putOperation from './put-operation.js'
9
- import scanMuteOperations from './scan-mute-operations.js'
10
- import scanNotifOperations from './scan-notif-operations.js'
11
- import scanOperations from './scan-operations.js'
12
-
13
- export default (ctx: AppContext) => (router: ConnectRouter) => {
14
- return router.service(Service, {
15
- ...addMuteOperation(ctx),
16
- ...scanMuteOperations(ctx),
17
- ...addNotifOperation(ctx),
18
- ...scanNotifOperations(ctx),
19
- ...putOperation(ctx),
20
- ...scanOperations(ctx),
21
- ...deleteOperations(ctx),
22
- async ping() {
23
- const { db } = ctx
24
- await sql`select 1`.execute(db.db)
25
- return {}
26
- },
27
- })
28
- }
@@ -1,115 +0,0 @@
1
- import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect'
2
- import { sql } from 'kysely'
3
- import { ensureValidRecordKey } from '@atproto/syntax'
4
- import { AppContext } from '../context.js'
5
- import { Database } from '../db/index.js'
6
- import {
7
- OperationMethod,
8
- createOperationChannel,
9
- } from '../db/schema/operation.js'
10
- import { Service } from '../proto/bsync_connect.js'
11
- import {
12
- Method,
13
- PutOperationRequest,
14
- PutOperationResponse,
15
- } from '../proto/bsync_pb.js'
16
- import { authWithApiKey } from './auth.js'
17
- import { isValidDid, validateNamespace } from './util.js'
18
-
19
- export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
20
- async putOperation(req, handlerCtx) {
21
- authWithApiKey(ctx, handlerCtx)
22
- const { db } = ctx
23
- const op = validateOp(req)
24
- const id = await db.transaction(async (txn) => {
25
- return putOp(txn, op)
26
- })
27
- return new PutOperationResponse({
28
- operation: {
29
- id: String(id),
30
- actorDid: op.actorDid,
31
- namespace: op.namespace,
32
- key: op.key,
33
- method: op.method,
34
- payload: op.payload,
35
- },
36
- })
37
- },
38
- })
39
-
40
- const putOp = async (db: Database, op: Operation) => {
41
- const { ref } = db.db.dynamic
42
- const { id } = await db.db
43
- .insertInto('operation')
44
- .values({
45
- actorDid: op.actorDid,
46
- namespace: op.namespace,
47
- key: op.key,
48
- method: op.method,
49
- payload: op.payload,
50
- })
51
- .returning('id')
52
- .executeTakeFirstOrThrow()
53
- await sql`notify ${ref(createOperationChannel)}`.execute(db.db) // emitted transactionally
54
- return id
55
- }
56
-
57
- const validateOp = (req: PutOperationRequest): Operation => {
58
- try {
59
- validateNamespace(req.namespace)
60
- } catch (error) {
61
- throw new ConnectError(
62
- 'operation namespace is invalid NSID',
63
- Code.InvalidArgument,
64
- )
65
- }
66
-
67
- if (!isValidDid(req.actorDid)) {
68
- throw new ConnectError(
69
- 'operation actor_did is invalid DID',
70
- Code.InvalidArgument,
71
- )
72
- }
73
-
74
- try {
75
- ensureValidRecordKey(req.key)
76
- } catch (error) {
77
- throw new ConnectError('operation key is required', Code.InvalidArgument)
78
- }
79
-
80
- if (
81
- req.method !== Method.CREATE &&
82
- req.method !== Method.UPDATE &&
83
- req.method !== Method.DELETE
84
- ) {
85
- throw new ConnectError('operation method is invalid', Code.InvalidArgument)
86
- }
87
-
88
- if (req.method === Method.CREATE || req.method === Method.UPDATE) {
89
- try {
90
- JSON.parse(new TextDecoder().decode(req.payload))
91
- } catch (error) {
92
- throw new ConnectError(
93
- 'payload must be a valid JSON when method is CREATE or UPDATE',
94
- Code.InvalidArgument,
95
- )
96
- }
97
- }
98
-
99
- if (req.method === Method.DELETE && req.payload.length > 0) {
100
- throw new ConnectError(
101
- 'cannot specify a payload when method is DELETE',
102
- Code.InvalidArgument,
103
- )
104
- }
105
-
106
- return req as Operation
107
- }
108
-
109
- type Operation = {
110
- actorDid: string
111
- namespace: string
112
- key: string
113
- payload: Uint8Array<ArrayBuffer>
114
- method: OperationMethod
115
- }
@@ -1,65 +0,0 @@
1
- import { once } from 'node:events'
2
- import { ServiceImpl } from '@connectrpc/connect'
3
- import { AppContext } from '../context.js'
4
- import { createMuteOpChannel } from '../db/schema/mute_op.js'
5
- import { Service } from '../proto/bsync_connect.js'
6
- import { ScanMuteOperationsResponse } from '../proto/bsync_pb.js'
7
- import { authWithApiKey } from './auth.js'
8
- import { combineSignals, validCursor } from './util.js'
9
-
10
- export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
11
- async scanMuteOperations(req, handlerCtx) {
12
- authWithApiKey(ctx, handlerCtx)
13
- const { db, events } = ctx
14
- const limit = req.limit || 1000
15
- const cursor = validCursor(req.cursor)
16
- const nextMuteOpPromise = once(events, createMuteOpChannel, {
17
- signal: combineSignals(
18
- ctx.shutdown,
19
- AbortSignal.timeout(ctx.cfg.service.longPollTimeoutMs),
20
- ),
21
- })
22
- nextMuteOpPromise.catch(() => null) // ensure timeout is always handled
23
-
24
- const nextMuteOpPageQb = db.db
25
- .selectFrom('mute_op')
26
- .selectAll()
27
- .where('id', '>', cursor ?? -1)
28
- .orderBy('id', 'asc')
29
- .limit(limit)
30
-
31
- let ops = await nextMuteOpPageQb.execute()
32
-
33
- if (!ops.length) {
34
- // if there were no ops on the page, wait for an event then try again.
35
- try {
36
- await nextMuteOpPromise
37
- } catch (err) {
38
- ctx.shutdown.throwIfAborted()
39
- return new ScanMuteOperationsResponse({
40
- operations: [],
41
- cursor: req.cursor,
42
- })
43
- }
44
- ops = await nextMuteOpPageQb.execute()
45
- if (!ops.length) {
46
- return new ScanMuteOperationsResponse({
47
- operations: [],
48
- cursor: req.cursor,
49
- })
50
- }
51
- }
52
-
53
- const lastOp = ops[ops.length - 1]
54
-
55
- return new ScanMuteOperationsResponse({
56
- operations: ops.map((op) => ({
57
- id: op.id.toString(),
58
- type: op.type,
59
- actorDid: op.actorDid,
60
- subject: op.subject,
61
- })),
62
- cursor: lastOp.id.toString(),
63
- })
64
- },
65
- })
@@ -1,64 +0,0 @@
1
- import { once } from 'node:events'
2
- import { ServiceImpl } from '@connectrpc/connect'
3
- import { AppContext } from '../context.js'
4
- import { createNotifOpChannel } from '../db/schema/notif_op.js'
5
- import { Service } from '../proto/bsync_connect.js'
6
- import { ScanNotifOperationsResponse } from '../proto/bsync_pb.js'
7
- import { authWithApiKey } from './auth.js'
8
- import { combineSignals, validCursor } from './util.js'
9
-
10
- export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
11
- async scanNotifOperations(req, handlerCtx) {
12
- authWithApiKey(ctx, handlerCtx)
13
- const { db, events } = ctx
14
- const limit = req.limit || 1000
15
- const cursor = validCursor(req.cursor)
16
- const nextNotifOpPromise = once(events, createNotifOpChannel, {
17
- signal: combineSignals(
18
- ctx.shutdown,
19
- AbortSignal.timeout(ctx.cfg.service.longPollTimeoutMs),
20
- ),
21
- })
22
- nextNotifOpPromise.catch(() => null) // ensure timeout is always handled
23
-
24
- const nextNotifOpPageQb = db.db
25
- .selectFrom('notif_op')
26
- .selectAll()
27
- .where('id', '>', cursor ?? -1)
28
- .orderBy('id', 'asc')
29
- .limit(limit)
30
-
31
- let ops = await nextNotifOpPageQb.execute()
32
-
33
- if (!ops.length) {
34
- // if there were no ops on the page, wait for an event then try again.
35
- try {
36
- await nextNotifOpPromise
37
- } catch (err) {
38
- ctx.shutdown.throwIfAborted()
39
- return new ScanNotifOperationsResponse({
40
- operations: [],
41
- cursor: req.cursor,
42
- })
43
- }
44
- ops = await nextNotifOpPageQb.execute()
45
- if (!ops.length) {
46
- return new ScanNotifOperationsResponse({
47
- operations: [],
48
- cursor: req.cursor,
49
- })
50
- }
51
- }
52
-
53
- const lastOp = ops[ops.length - 1]
54
-
55
- return new ScanNotifOperationsResponse({
56
- operations: ops.map((op) => ({
57
- id: op.id.toString(),
58
- actorDid: op.actorDid,
59
- priority: op.priority ?? undefined,
60
- })),
61
- cursor: lastOp.id.toString(),
62
- })
63
- },
64
- })
@@ -1,67 +0,0 @@
1
- import { once } from 'node:events'
2
- import { ServiceImpl } from '@connectrpc/connect'
3
- import { AppContext } from '../context.js'
4
- import { createOperationChannel } from '../db/schema/operation.js'
5
- import { Service } from '../proto/bsync_connect.js'
6
- import { ScanOperationsResponse } from '../proto/bsync_pb.js'
7
- import { authWithApiKey } from './auth.js'
8
- import { combineSignals, validCursor } from './util.js'
9
-
10
- export default (ctx: AppContext): Partial<ServiceImpl<typeof Service>> => ({
11
- async scanOperations(req, handlerCtx) {
12
- authWithApiKey(ctx, handlerCtx)
13
- const { db, events } = ctx
14
- const limit = req.limit || 1000
15
- const cursor = validCursor(req.cursor)
16
- const nextOpPromise = once(events, createOperationChannel, {
17
- signal: combineSignals(
18
- ctx.shutdown,
19
- AbortSignal.timeout(ctx.cfg.service.longPollTimeoutMs),
20
- ),
21
- })
22
- nextOpPromise.catch(() => null) // ensure timeout is always handled
23
-
24
- const nextOpPageQb = db.db
25
- .selectFrom('operation')
26
- .selectAll()
27
- .where('id', '>', cursor ?? -1)
28
- .orderBy('id', 'asc')
29
- .limit(limit)
30
-
31
- let ops = await nextOpPageQb.execute()
32
-
33
- if (!ops.length) {
34
- // if there were no ops on the page, wait for an event then try again.
35
- try {
36
- await nextOpPromise
37
- } catch (err) {
38
- ctx.shutdown.throwIfAborted()
39
- return new ScanOperationsResponse({
40
- operations: [],
41
- cursor: req.cursor,
42
- })
43
- }
44
- ops = await nextOpPageQb.execute()
45
- if (!ops.length) {
46
- return new ScanOperationsResponse({
47
- operations: [],
48
- cursor: req.cursor,
49
- })
50
- }
51
- }
52
-
53
- const lastOp = ops[ops.length - 1]
54
-
55
- return new ScanOperationsResponse({
56
- operations: ops.map((op) => ({
57
- id: op.id.toString(),
58
- actorDid: op.actorDid,
59
- namespace: op.namespace,
60
- key: op.key,
61
- method: op.method,
62
- payload: op.payload,
63
- })),
64
- cursor: lastOp.id.toString(),
65
- })
66
- },
67
- })
@@ -1,67 +0,0 @@
1
- import { Code, ConnectError } from '@connectrpc/connect'
2
- import {
3
- InvalidDidError,
4
- ensureValidAtUri,
5
- ensureValidDid,
6
- ensureValidNsid,
7
- } from '@atproto/syntax'
8
-
9
- export const validCursor = (cursor: string): number | null => {
10
- if (cursor === '') return null
11
- const int = parseInt(cursor, 10)
12
- if (isNaN(int) || int < 0) {
13
- throw new ConnectError('invalid cursor', Code.InvalidArgument)
14
- }
15
- return int
16
- }
17
-
18
- export const combineSignals = (a: AbortSignal, b: AbortSignal): AbortSignal => {
19
- const controller = new AbortController()
20
- for (const signal of [a, b]) {
21
- if (signal.aborted) {
22
- controller.abort()
23
- return signal
24
- }
25
- signal.addEventListener('abort', () => controller.abort(signal.reason), {
26
- // @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68625
27
- signal: controller.signal,
28
- })
29
- }
30
- return controller.signal
31
- }
32
-
33
- export const isValidDid = (did: string) => {
34
- try {
35
- ensureValidDid(did)
36
- return true
37
- } catch (err) {
38
- if (err instanceof InvalidDidError) {
39
- return false
40
- }
41
- throw err
42
- }
43
- }
44
-
45
- export const isValidAtUri = (uri: string) => {
46
- try {
47
- ensureValidAtUri(uri)
48
- return true
49
- } catch {
50
- return false
51
- }
52
- }
53
-
54
- export const validateNamespace = (namespace: string): void => {
55
- const parts = namespace.split('#')
56
-
57
- if (parts.length !== 1 && parts.length !== 2) {
58
- throw new Error('namespace must be in the format "nsid[#fragment]"')
59
- }
60
-
61
- const [nsid, fragment] = parts
62
-
63
- ensureValidNsid(nsid)
64
- if (fragment && !/^[a-zA-Z][a-zA-Z0-9]*$/.test(fragment)) {
65
- throw new Error('namespace fragment must be a valid identifier')
66
- }
67
- }
@@ -1,108 +0,0 @@
1
- import getPort from 'get-port'
2
- import {
3
- BsyncClient,
4
- BsyncService,
5
- Database,
6
- authWithApiKey,
7
- createClient,
8
- envToCfg,
9
- } from '../src/index.js'
10
- import { Method } from '../src/proto/bsync_pb.js'
11
-
12
- describe('operations', () => {
13
- let bsync: BsyncService
14
- let client: BsyncClient
15
-
16
- const validPayload0 = Buffer.from(JSON.stringify({ value: 0 }))
17
- const validPayload1 = Buffer.from(JSON.stringify({ value: 1 }))
18
-
19
- beforeAll(async () => {
20
- bsync = await BsyncService.create(
21
- envToCfg({
22
- port: await getPort(),
23
- dbUrl: process.env.DB_POSTGRES_URL,
24
- dbSchema: 'bsync_delete_operations',
25
- apiKeys: ['key-1'],
26
- longPollTimeoutMs: 500,
27
- }),
28
- )
29
- await bsync.ctx.db.migrateToLatestOrThrow()
30
- await bsync.start()
31
- client = createClient({
32
- httpVersion: '1.1',
33
- baseUrl: `http://localhost:${bsync.ctx.cfg.service.port}`,
34
- interceptors: [authWithApiKey('key-1')],
35
- })
36
- })
37
-
38
- afterAll(async () => {
39
- await bsync.destroy()
40
- })
41
-
42
- beforeEach(async () => {
43
- await clearOps(bsync.ctx.db)
44
- })
45
-
46
- it('deletes', async () => {
47
- const res1 = await client.putOperation({
48
- actorDid: 'did:example:a',
49
- namespace: 'app.bsky.some.col',
50
- key: 'key1',
51
- method: Method.CREATE,
52
- payload: validPayload0,
53
- })
54
- const res2 = await client.putOperation({
55
- actorDid: 'did:example:a',
56
- namespace: 'app.bsky.other.col#id',
57
- key: 'key1',
58
- method: Method.UPDATE,
59
- payload: validPayload1,
60
- })
61
-
62
- expect(res1.operation?.id).toBe('1')
63
- expect(res2.operation?.id).toBe('2')
64
- expect(await dumpOps(bsync.ctx.db)).toStrictEqual([
65
- {
66
- id: 1,
67
- actorDid: 'did:example:a',
68
- namespace: 'app.bsky.some.col',
69
- key: 'key1',
70
- method: Method.CREATE,
71
- payload: validPayload0,
72
- createdAt: expect.any(Date),
73
- },
74
- {
75
- id: 2,
76
- actorDid: 'did:example:a',
77
- namespace: 'app.bsky.other.col#id',
78
- key: 'key1',
79
- method: Method.UPDATE,
80
- payload: validPayload1,
81
- createdAt: expect.any(Date),
82
- },
83
- ])
84
-
85
- await client.deleteOperationsByActorAndNamespace({
86
- actorDid: 'did:example:a',
87
- namespace: 'app.bsky.some.col',
88
- })
89
- await client.deleteOperationsByActorAndNamespace({
90
- actorDid: 'did:example:a',
91
- namespace: 'app.bsky.other.col#id',
92
- })
93
-
94
- expect(await dumpOps(bsync.ctx.db)).toStrictEqual([])
95
- })
96
- })
97
-
98
- const dumpOps = async (db: Database) => {
99
- return db.db
100
- .selectFrom('operation')
101
- .selectAll()
102
- .orderBy('id', 'asc')
103
- .execute()
104
- }
105
-
106
- const clearOps = async (db: Database) => {
107
- await db.db.deleteFrom('operation').execute()
108
- }