@atproto/bsky 0.0.166 → 0.0.167

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 (148) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/api/app/bsky/notification/listActivitySubscriptions.d.ts +4 -0
  3. package/dist/api/app/bsky/notification/listActivitySubscriptions.d.ts.map +1 -0
  4. package/dist/api/app/bsky/notification/listActivitySubscriptions.js +63 -0
  5. package/dist/api/app/bsky/notification/listActivitySubscriptions.js.map +1 -0
  6. package/dist/api/app/bsky/notification/putActivitySubscription.d.ts +4 -0
  7. package/dist/api/app/bsky/notification/putActivitySubscription.d.ts.map +1 -0
  8. package/dist/api/app/bsky/notification/putActivitySubscription.js +63 -0
  9. package/dist/api/app/bsky/notification/putActivitySubscription.js.map +1 -0
  10. package/dist/api/app/bsky/notification/putPreferencesV2.d.ts.map +1 -1
  11. package/dist/api/app/bsky/notification/putPreferencesV2.js +2 -1
  12. package/dist/api/app/bsky/notification/putPreferencesV2.js.map +1 -1
  13. package/dist/api/index.d.ts.map +1 -1
  14. package/dist/api/index.js +4 -0
  15. package/dist/api/index.js.map +1 -1
  16. package/dist/data-plane/bsync/index.d.ts.map +1 -1
  17. package/dist/data-plane/bsync/index.js +52 -38
  18. package/dist/data-plane/bsync/index.js.map +1 -1
  19. package/dist/data-plane/server/db/database-schema.d.ts +2 -1
  20. package/dist/data-plane/server/db/database-schema.d.ts.map +1 -1
  21. package/dist/data-plane/server/db/migrations/20250611T140649895Z-add-activity-subscription.d.ts +4 -0
  22. package/dist/data-plane/server/db/migrations/20250611T140649895Z-add-activity-subscription.d.ts.map +1 -0
  23. package/dist/data-plane/server/db/migrations/20250611T140649895Z-add-activity-subscription.js +24 -0
  24. package/dist/data-plane/server/db/migrations/20250611T140649895Z-add-activity-subscription.js.map +1 -0
  25. package/dist/data-plane/server/db/migrations/index.d.ts +1 -0
  26. package/dist/data-plane/server/db/migrations/index.d.ts.map +1 -1
  27. package/dist/data-plane/server/db/migrations/index.js +2 -1
  28. package/dist/data-plane/server/db/migrations/index.js.map +1 -1
  29. package/dist/data-plane/server/db/pagination.d.ts +22 -0
  30. package/dist/data-plane/server/db/pagination.d.ts.map +1 -1
  31. package/dist/data-plane/server/db/pagination.js +30 -1
  32. package/dist/data-plane/server/db/pagination.js.map +1 -1
  33. package/dist/data-plane/server/db/tables/activity-subscription.d.ts +13 -0
  34. package/dist/data-plane/server/db/tables/activity-subscription.d.ts.map +1 -0
  35. package/dist/data-plane/server/db/tables/activity-subscription.js +5 -0
  36. package/dist/data-plane/server/db/tables/activity-subscription.js.map +1 -0
  37. package/dist/data-plane/server/indexing/index.d.ts +2 -0
  38. package/dist/data-plane/server/indexing/index.d.ts.map +1 -1
  39. package/dist/data-plane/server/indexing/index.js +2 -0
  40. package/dist/data-plane/server/indexing/index.js.map +1 -1
  41. package/dist/data-plane/server/indexing/plugins/notif-declaration.d.ts +7 -0
  42. package/dist/data-plane/server/indexing/plugins/notif-declaration.d.ts.map +1 -0
  43. package/dist/data-plane/server/indexing/plugins/notif-declaration.js +72 -0
  44. package/dist/data-plane/server/indexing/plugins/notif-declaration.js.map +1 -0
  45. package/dist/data-plane/server/routes/activity-subscription.d.ts +6 -0
  46. package/dist/data-plane/server/routes/activity-subscription.d.ts.map +1 -0
  47. package/dist/data-plane/server/routes/activity-subscription.js +67 -0
  48. package/dist/data-plane/server/routes/activity-subscription.js.map +1 -0
  49. package/dist/data-plane/server/routes/index.js +2 -2
  50. package/dist/data-plane/server/routes/index.js.map +1 -1
  51. package/dist/data-plane/server/routes/notifs.d.ts +3 -0
  52. package/dist/data-plane/server/routes/notifs.d.ts.map +1 -1
  53. package/dist/data-plane/server/routes/notifs.js +64 -0
  54. package/dist/data-plane/server/routes/notifs.js.map +1 -1
  55. package/dist/data-plane/server/routes/profile.d.ts.map +1 -1
  56. package/dist/data-plane/server/routes/profile.js +20 -1
  57. package/dist/data-plane/server/routes/profile.js.map +1 -1
  58. package/dist/data-plane/server/routes/records.d.ts.map +1 -1
  59. package/dist/data-plane/server/routes/records.js +1 -0
  60. package/dist/data-plane/server/routes/records.js.map +1 -1
  61. package/dist/data-plane/server/util.d.ts +6 -6
  62. package/dist/hydration/actor.d.ts +20 -6
  63. package/dist/hydration/actor.d.ts.map +1 -1
  64. package/dist/hydration/actor.js +44 -1
  65. package/dist/hydration/actor.js.map +1 -1
  66. package/dist/hydration/hydrator.d.ts +4 -3
  67. package/dist/hydration/hydrator.d.ts.map +1 -1
  68. package/dist/hydration/hydrator.js +16 -2
  69. package/dist/hydration/hydrator.js.map +1 -1
  70. package/dist/hydration/util.d.ts +4 -0
  71. package/dist/hydration/util.d.ts.map +1 -1
  72. package/dist/hydration/util.js +3 -1
  73. package/dist/hydration/util.js.map +1 -1
  74. package/dist/lexicon/lexicons.d.ts +4 -0
  75. package/dist/lexicon/lexicons.d.ts.map +1 -1
  76. package/dist/lexicon/lexicons.js +2 -0
  77. package/dist/lexicon/lexicons.js.map +1 -1
  78. package/dist/proto/bsky_connect.d.ts +28 -1
  79. package/dist/proto/bsky_connect.d.ts.map +1 -1
  80. package/dist/proto/bsky_connect.js +27 -0
  81. package/dist/proto/bsky_connect.js.map +1 -1
  82. package/dist/proto/bsky_pb.d.ts +189 -0
  83. package/dist/proto/bsky_pb.d.ts.map +1 -1
  84. package/dist/proto/bsky_pb.js +598 -5
  85. package/dist/proto/bsky_pb.js.map +1 -1
  86. package/dist/stash.d.ts +1 -0
  87. package/dist/stash.d.ts.map +1 -1
  88. package/dist/stash.js +1 -0
  89. package/dist/stash.js.map +1 -1
  90. package/dist/views/index.d.ts +5 -3
  91. package/dist/views/index.d.ts.map +1 -1
  92. package/dist/views/index.js +29 -9
  93. package/dist/views/index.js.map +1 -1
  94. package/package.json +4 -4
  95. package/proto/bsky.proto +45 -0
  96. package/src/api/app/bsky/notification/listActivitySubscriptions.ts +110 -0
  97. package/src/api/app/bsky/notification/putActivitySubscription.ts +69 -0
  98. package/src/api/app/bsky/notification/putPreferencesV2.ts +2 -1
  99. package/src/api/index.ts +4 -0
  100. package/src/data-plane/bsync/index.ts +75 -44
  101. package/src/data-plane/server/db/database-schema.ts +3 -1
  102. package/src/data-plane/server/db/migrations/20250611T140649895Z-add-activity-subscription.ts +22 -0
  103. package/src/data-plane/server/db/migrations/index.ts +1 -0
  104. package/src/data-plane/server/db/pagination.ts +37 -0
  105. package/src/data-plane/server/db/tables/activity-subscription.ts +12 -0
  106. package/src/data-plane/server/indexing/index.ts +3 -0
  107. package/src/data-plane/server/indexing/plugins/notif-declaration.ts +59 -0
  108. package/src/data-plane/server/routes/activity-subscription.ts +83 -0
  109. package/src/data-plane/server/routes/index.ts +2 -2
  110. package/src/data-plane/server/routes/notifs.ts +95 -0
  111. package/src/data-plane/server/routes/profile.ts +33 -1
  112. package/src/data-plane/server/routes/records.ts +4 -0
  113. package/src/hydration/actor.ts +97 -10
  114. package/src/hydration/hydrator.ts +32 -6
  115. package/src/hydration/util.ts +8 -0
  116. package/src/lexicon/lexicons.ts +4 -0
  117. package/src/proto/bsky_connect.ts +33 -0
  118. package/src/proto/bsky_pb.ts +648 -0
  119. package/src/stash.ts +6 -1
  120. package/src/views/index.ts +48 -11
  121. package/tests/__snapshots__/feed-generation.test.ts.snap +213 -0
  122. package/tests/data-plane/__snapshots__/indexing.test.ts.snap +88 -0
  123. package/tests/views/__snapshots__/author-feed.test.ts.snap +498 -0
  124. package/tests/views/__snapshots__/block-lists.test.ts.snap +56 -0
  125. package/tests/views/__snapshots__/blocks.test.ts.snap +28 -0
  126. package/tests/views/__snapshots__/follows.test.ts.snap +170 -0
  127. package/tests/views/__snapshots__/labeler-service.test.ts.snap +15 -0
  128. package/tests/views/__snapshots__/likes.test.ts.snap +23 -0
  129. package/tests/views/__snapshots__/list-feed.test.ts.snap +68 -0
  130. package/tests/views/__snapshots__/lists.test.ts.snap +120 -0
  131. package/tests/views/__snapshots__/mute-lists.test.ts.snap +63 -0
  132. package/tests/views/__snapshots__/mutes.test.ts.snap +55 -0
  133. package/tests/views/__snapshots__/notifications.test.ts.snap +299 -0
  134. package/tests/views/__snapshots__/posts.test.ts.snap +58 -0
  135. package/tests/views/__snapshots__/profile.test.ts.snap +74 -0
  136. package/tests/views/__snapshots__/quotes.test.ts.snap +35 -0
  137. package/tests/views/__snapshots__/reposts.test.ts.snap +26 -0
  138. package/tests/views/__snapshots__/starter-packs.test.ts.snap +113 -0
  139. package/tests/views/__snapshots__/thread-v2.test.ts.snap +115 -0
  140. package/tests/views/__snapshots__/thread.test.ts.snap +145 -0
  141. package/tests/views/__snapshots__/timeline.test.ts.snap +566 -0
  142. package/tests/views/notifications.test.ts +355 -19
  143. package/tsconfig.build.tsbuildinfo +1 -1
  144. package/dist/data-plane/server/routes/private-data.d.ts +0 -9
  145. package/dist/data-plane/server/routes/private-data.d.ts.map +0 -1
  146. package/dist/data-plane/server/routes/private-data.js +0 -65
  147. package/dist/data-plane/server/routes/private-data.js.map +0 -1
  148. package/src/data-plane/server/routes/private-data.ts +0 -95
@@ -0,0 +1,110 @@
1
+ import { mapDefined } from '@atproto/common'
2
+ import { AppContext } from '../../../../context'
3
+ import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
4
+ import { Server } from '../../../../lexicon'
5
+ import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/listActivitySubscriptions'
6
+ import {
7
+ HydrationFnInput,
8
+ PresentationFnInput,
9
+ RulesFnInput,
10
+ SkeletonFnInput,
11
+ createPipeline,
12
+ } from '../../../../pipeline'
13
+ import { Views } from '../../../../views'
14
+ import { clearlyBadCursor, resHeaders } from '../../../util'
15
+
16
+ export default function (server: Server, ctx: AppContext) {
17
+ const listActivitySubscriptions = createPipeline(
18
+ skeleton,
19
+ hydration,
20
+ noBlocks,
21
+ presentation,
22
+ )
23
+ server.app.bsky.notification.listActivitySubscriptions({
24
+ auth: ctx.authVerifier.standard,
25
+ handler: async ({ params, auth, req }) => {
26
+ const viewer = auth.credentials.iss
27
+ const labelers = ctx.reqLabelers(req)
28
+ const hydrateCtx = await ctx.hydrator.createContext({
29
+ labelers,
30
+ viewer,
31
+ })
32
+
33
+ const result = await listActivitySubscriptions(
34
+ { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) },
35
+ ctx,
36
+ )
37
+
38
+ return {
39
+ encoding: 'application/json',
40
+ body: result,
41
+ headers: resHeaders({ labelers: hydrateCtx.labelers }),
42
+ }
43
+ },
44
+ })
45
+ }
46
+
47
+ const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
48
+ const { params, ctx } = input
49
+ const actorDid = params.hydrateCtx.viewer
50
+ if (clearlyBadCursor(params.cursor)) {
51
+ return { actorDid, dids: [] }
52
+ }
53
+ const { dids, cursor } =
54
+ await ctx.hydrator.dataplane.getActivitySubscriptionDids({
55
+ actorDid: params.hydrateCtx.viewer,
56
+ limit: params.limit,
57
+ cursor: params.cursor,
58
+ })
59
+ return {
60
+ actorDid,
61
+ dids,
62
+ cursor: cursor || undefined,
63
+ }
64
+ }
65
+
66
+ const hydration = async (
67
+ input: HydrationFnInput<Context, Params, SkeletonState>,
68
+ ) => {
69
+ const { ctx, params, skeleton } = input
70
+ const { dids } = skeleton
71
+ const state = await ctx.hydrator.hydrateProfilesDetailed(
72
+ dids,
73
+ params.hydrateCtx,
74
+ )
75
+ return state
76
+ }
77
+
78
+ const noBlocks = (input: RulesFnInput<Context, Params, SkeletonState>) => {
79
+ const { skeleton, hydration, ctx } = input
80
+ skeleton.dids = skeleton.dids.filter(
81
+ (did) => !ctx.views.viewerBlockExists(did, hydration),
82
+ )
83
+ return skeleton
84
+ }
85
+
86
+ const presentation = (
87
+ input: PresentationFnInput<Context, Params, SkeletonState>,
88
+ ) => {
89
+ const { ctx, hydration, skeleton } = input
90
+ const { dids, cursor } = skeleton
91
+ const subscriptions = mapDefined(dids, (did) => {
92
+ return ctx.views.profile(did, hydration)
93
+ })
94
+ return { subscriptions, cursor }
95
+ }
96
+
97
+ type Context = {
98
+ hydrator: Hydrator
99
+ views: Views
100
+ }
101
+
102
+ type Params = QueryParams & {
103
+ hydrateCtx: HydrateCtx & { viewer: string }
104
+ }
105
+
106
+ type SkeletonState = {
107
+ actorDid: string
108
+ dids: string[]
109
+ cursor?: string
110
+ }
@@ -0,0 +1,69 @@
1
+ import { TID } from '@atproto/common'
2
+ import { InvalidRequestError } from '@atproto/xrpc-server'
3
+ import { AppContext } from '../../../../context'
4
+ import { isActivitySubscriptionEnabled } from '../../../../hydration/util'
5
+ import { Server } from '../../../../lexicon'
6
+ import { Namespaces } from '../../../../stash'
7
+
8
+ export default function (server: Server, ctx: AppContext) {
9
+ server.app.bsky.notification.putActivitySubscription({
10
+ auth: ctx.authVerifier.standard,
11
+ handler: async ({ input, auth }) => {
12
+ const actorDid = auth.credentials.iss
13
+ const { subject, activitySubscription } = input.body
14
+ if (actorDid === subject) {
15
+ throw new InvalidRequestError('Cannot subscribe to own activity')
16
+ }
17
+
18
+ const existingKey = await getExistingKey(ctx, actorDid, subject)
19
+ const enabled = isActivitySubscriptionEnabled(activitySubscription)
20
+
21
+ const stashInput = {
22
+ actorDid,
23
+ namespace:
24
+ Namespaces.AppBskyNotificationDefsSubjectActivitySubscription,
25
+ payload: {
26
+ subject,
27
+ activitySubscription,
28
+ },
29
+ key: existingKey ?? TID.nextStr(),
30
+ }
31
+
32
+ if (existingKey) {
33
+ if (enabled) {
34
+ await ctx.stashClient.update(stashInput)
35
+ } else {
36
+ await ctx.stashClient.delete(stashInput)
37
+ }
38
+ } else {
39
+ if (enabled) {
40
+ await ctx.stashClient.create(stashInput)
41
+ } else {
42
+ // no-op: subscription already doesn't exist
43
+ }
44
+ }
45
+
46
+ return {
47
+ encoding: 'application/json',
48
+ body: {
49
+ subject,
50
+ activitySubscription: enabled ? activitySubscription : undefined,
51
+ },
52
+ }
53
+ },
54
+ })
55
+ }
56
+
57
+ const getExistingKey = async (
58
+ ctx: AppContext,
59
+ actorDid: string,
60
+ subject: string,
61
+ ): Promise<string | null> => {
62
+ const res = await ctx.dataplane.getActivitySubscriptionsByActorAndSubjects({
63
+ actorDid,
64
+ subjectDids: [subject],
65
+ })
66
+ const [existing] = res.subscriptions
67
+ const key = existing.key
68
+ return key || null
69
+ }
@@ -6,6 +6,7 @@ import { Server } from '../../../../lexicon'
6
6
  import { Preferences } from '../../../../lexicon/types/app/bsky/notification/defs'
7
7
  import { HandlerInput } from '../../../../lexicon/types/app/bsky/notification/putPreferencesV2'
8
8
  import { GetNotificationPreferencesResponse } from '../../../../proto/bsky_pb'
9
+ import { Namespaces } from '../../../../stash'
9
10
  import { protobufToLex } from './util'
10
11
 
11
12
  export default function (server: Server, ctx: AppContext) {
@@ -18,7 +19,7 @@ export default function (server: Server, ctx: AppContext) {
18
19
  // Notification preferences are created automatically on the dataplane on signup, so we just update.
19
20
  await ctx.stashClient.update({
20
21
  actorDid,
21
- namespace: 'app.bsky.notification.defs#preferences',
22
+ namespace: Namespaces.AppBskyNotificationDefsPreferences,
22
23
  key: 'self',
23
24
  payload: preferences,
24
25
  })
package/src/api/index.ts CHANGED
@@ -44,7 +44,9 @@ import unmuteThread from './app/bsky/graph/unmuteThread'
44
44
  import getLabelerServices from './app/bsky/labeler/getServices'
45
45
  import getPreferences from './app/bsky/notification/getPreferences'
46
46
  import getUnreadCount from './app/bsky/notification/getUnreadCount'
47
+ import listActivitySubscriptions from './app/bsky/notification/listActivitySubscriptions'
47
48
  import listNotifications from './app/bsky/notification/listNotifications'
49
+ import putActivitySubscription from './app/bsky/notification/putActivitySubscription'
48
50
  import putPreferences from './app/bsky/notification/putPreferences'
49
51
  import putPreferencesV2 from './app/bsky/notification/putPreferencesV2'
50
52
  import registerPush from './app/bsky/notification/registerPush'
@@ -126,7 +128,9 @@ export default function (server: Server, ctx: AppContext) {
126
128
  getSuggestions(server, ctx)
127
129
  getPreferences(server, ctx)
128
130
  getUnreadCount(server, ctx)
131
+ listActivitySubscriptions(server, ctx)
129
132
  listNotifications(server, ctx)
133
+ putActivitySubscription(server, ctx)
130
134
  updateSeen(server, ctx)
131
135
  putPreferences(server, ctx)
132
136
  putPreferencesV2(server, ctx)
@@ -5,8 +5,11 @@ import { ConnectRouter } from '@connectrpc/connect'
5
5
  import { expressConnectMiddleware } from '@connectrpc/connect-express'
6
6
  import express from 'express'
7
7
  import { TID } from '@atproto/common'
8
+ import { jsonStringToLex } from '@atproto/lexicon'
8
9
  import { AtUri } from '@atproto/syntax'
9
10
  import { ids } from '../../lexicon/lexicons'
11
+ import { SubjectActivitySubscription } from '../../lexicon/types/app/bsky/notification/defs'
12
+ import { httpLogger } from '../../logger'
10
13
  import { Service } from '../../proto/bsync_connect'
11
14
  import {
12
15
  Method,
@@ -15,6 +18,7 @@ import {
15
18
  } from '../../proto/bsync_pb'
16
19
  import { Namespaces } from '../../stash'
17
20
  import { Database } from '../server/db'
21
+ import { excluded } from '../server/db/util'
18
22
 
19
23
  export class MockBsync {
20
24
  constructor(public server: http.Server) {}
@@ -146,19 +150,30 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
146
150
 
147
151
  async putOperation(req) {
148
152
  const { actorDid, namespace, key, method, payload } = req
149
- if (
150
- method !== Method.CREATE &&
151
- method !== Method.UPDATE &&
152
- method !== Method.DELETE
153
- ) {
154
- throw new Error(`Unsupported method: ${method}`)
155
- }
153
+ assert(
154
+ method === Method.CREATE ||
155
+ method === Method.UPDATE ||
156
+ method === Method.DELETE,
157
+ `Unsupported method: ${method}`,
158
+ )
156
159
 
157
160
  const now = new Date().toISOString()
158
- if (namespace === Namespaces.AppBskyNotificationDefsPreferences) {
159
- await handleNotificationPreferencesOperation(db, req, now)
160
- } else {
161
- await handleGenericOperation(db, req, now)
161
+
162
+ // index all items into private_data
163
+ await handleGenericOperation(db, req, now)
164
+
165
+ // maintain bespoke indexes for certain namespaces
166
+ if (
167
+ namespace ===
168
+ Namespaces.AppBskyNotificationDefsSubjectActivitySubscription
169
+ ) {
170
+ await handleSubjectActivitySubscriptionOperation(db, req, now).catch(
171
+ (err: unknown) =>
172
+ httpLogger.warn(
173
+ { err, namespace },
174
+ 'mock bsync put operation failed',
175
+ ),
176
+ )
162
177
  }
163
178
 
164
179
  return {
@@ -182,43 +197,64 @@ const createRoutes = (db: Database) => (router: ConnectRouter) =>
182
197
  },
183
198
  })
184
199
 
185
- const handleNotificationPreferencesOperation = async (
200
+ const handleSubjectActivitySubscriptionOperation = async (
186
201
  db: Database,
187
202
  req: PutOperationRequest,
188
203
  now: string,
189
204
  ) => {
190
- const { actorDid, namespace, key, method, payload } = req
191
- if (method === Method.CREATE || method === Method.UPDATE) {
205
+ const { actorDid, key, method, payload } = req
206
+
207
+ if (method === Method.DELETE) {
192
208
  return db.db
193
- .insertInto('private_data')
209
+ .deleteFrom('activity_subscription')
210
+ .where('creator', '=', actorDid)
211
+ .where('key', '=', key)
212
+ .execute()
213
+ }
214
+
215
+ const parsed = jsonStringToLex(
216
+ Buffer.from(payload).toString('utf8'),
217
+ ) as SubjectActivitySubscription
218
+ const {
219
+ subject,
220
+ activitySubscription: { post, reply },
221
+ } = parsed
222
+
223
+ if (method === Method.CREATE) {
224
+ return db.db
225
+ .insertInto('activity_subscription')
194
226
  .values({
195
- actorDid,
196
- namespace,
227
+ creator: actorDid,
228
+ subjectDid: subject,
197
229
  key,
198
- payload: Buffer.from(payload).toString('utf8'),
199
230
  indexedAt: now,
200
- updatedAt: now,
231
+ post,
232
+ reply,
201
233
  })
202
- .onConflict((oc) =>
203
- oc.columns(['actorDid', 'namespace', 'key']).doUpdateSet({
204
- payload: Buffer.from(payload).toString('utf8'),
205
- updatedAt: now,
206
- }),
207
- )
208
234
  .execute()
209
235
  }
210
236
 
211
- return handleGenericOperation(db, req, now)
237
+ return db.db
238
+ .updateTable('activity_subscription')
239
+ .where('creator', '=', actorDid)
240
+ .where('key', '=', key)
241
+ .set({
242
+ indexedAt: now,
243
+ post,
244
+ reply,
245
+ })
246
+ .execute()
212
247
  }
213
248
 
249
+ // upsert into or remove from private_data
214
250
  const handleGenericOperation = async (
215
251
  db: Database,
216
252
  req: PutOperationRequest,
217
253
  now: string,
218
254
  ) => {
219
255
  const { actorDid, namespace, key, method, payload } = req
220
- if (method === Method.CREATE) {
221
- return db.db
256
+ if (method === Method.CREATE || method === Method.UPDATE) {
257
+ await db.db
222
258
  .insertInto('private_data')
223
259
  .values({
224
260
  actorDid,
@@ -228,26 +264,21 @@ const handleGenericOperation = async (
228
264
  indexedAt: now,
229
265
  updatedAt: now,
230
266
  })
267
+ .onConflict((oc) =>
268
+ oc.columns(['actorDid', 'namespace', 'key']).doUpdateSet({
269
+ payload: excluded(db.db, 'payload'),
270
+ updatedAt: excluded(db.db, 'updatedAt'),
271
+ }),
272
+ )
231
273
  .execute()
232
- }
233
-
234
- if (method === Method.UPDATE) {
235
- return db.db
236
- .updateTable('private_data')
274
+ } else if (method === Method.DELETE) {
275
+ await db.db
276
+ .deleteFrom('private_data')
237
277
  .where('actorDid', '=', actorDid)
238
278
  .where('namespace', '=', namespace)
239
279
  .where('key', '=', key)
240
- .set({
241
- payload: Buffer.from(payload).toString('utf8'),
242
- updatedAt: now,
243
- })
244
280
  .execute()
281
+ } else {
282
+ assert.fail(`unexpected method ${method}`)
245
283
  }
246
-
247
- return db.db
248
- .deleteFrom('private_data')
249
- .where('actorDid', '=', actorDid)
250
- .where('namespace', '=', namespace)
251
- .where('key', '=', key)
252
- .execute()
253
284
  }
@@ -1,4 +1,5 @@
1
1
  import { Kysely } from 'kysely'
2
+ import * as activitySubscription from './tables/activity-subscription'
2
3
  import * as actor from './tables/actor'
3
4
  import * as actorBlock from './tables/actor-block'
4
5
  import * as actorState from './tables/actor-state'
@@ -79,6 +80,7 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB &
79
80
  taggedSuggestion.PartialDB &
80
81
  quote.PartialDB &
81
82
  verification.PartialDB &
82
- privateData.PartialDB
83
+ privateData.PartialDB &
84
+ activitySubscription.PartialDB
83
85
 
84
86
  export type DatabaseSchema = Kysely<DatabaseSchemaType>
@@ -0,0 +1,22 @@
1
+ import { Kysely } from 'kysely'
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ await db.schema
5
+ .createTable('activity_subscription')
6
+ .addColumn('creator', 'varchar', (col) => col.notNull())
7
+ .addColumn('subjectDid', 'varchar', (col) => col.notNull())
8
+ .addColumn('key', 'varchar', (col) => col.notNull())
9
+ .addColumn('indexedAt', 'varchar', (col) => col.notNull())
10
+ .addColumn('post', 'boolean', (col) => col.notNull())
11
+ .addColumn('reply', 'boolean', (col) => col.notNull())
12
+ .addPrimaryKeyConstraint('activity_subscription_pkey', ['creator', 'key'])
13
+ .addUniqueConstraint('activity_subscription_unique_creator_subject_did', [
14
+ 'creator',
15
+ 'subjectDid',
16
+ ])
17
+ .execute()
18
+ }
19
+
20
+ export async function down(db: Kysely<unknown>): Promise<void> {
21
+ await db.schema.dropTable('activity_subscription').execute()
22
+ }
@@ -51,3 +51,4 @@ export * as _20250404T163421487Z from './20250404T163421487Z-verifications'
51
51
  export * as _20250526T023712742Z from './20250526T023712742Z-like-repost-via'
52
52
  export * as _20250528T221913281Z from './20250528T221913281Z-add-record-tags'
53
53
  export * as _20250602T190357447Z from './20250602T190357447Z-add-private-data'
54
+ export * as _20250611T140649895Z from './20250611T140649895Z-add-activity-subscription'
@@ -1,4 +1,5 @@
1
1
  import { sql } from 'kysely'
2
+ import { ensureValidRecordKey } from '@atproto/syntax'
2
3
  import { InvalidRequestError } from '@atproto/xrpc-server'
3
4
  import { AnyQb, DbRef } from './util'
4
5
 
@@ -294,3 +295,39 @@ export class IsoSortAtKey extends IsoTimeKey<{
294
295
  return { primary: result.sortAt }
295
296
  }
296
297
  }
298
+
299
+ type KeyResult = { key: string }
300
+ type RkeyLabeledResult = SingleKeyCursor
301
+
302
+ export class RkeyKey<RkeyResult = KeyResult> extends GenericSingleKey<
303
+ RkeyResult,
304
+ RkeyLabeledResult
305
+ > {
306
+ labelResult(result: RkeyResult): RkeyLabeledResult
307
+ labelResult<RkeyResult extends KeyResult>(result: RkeyResult) {
308
+ return { primary: result }
309
+ }
310
+ labeledResultToCursor(labeled: RkeyLabeledResult) {
311
+ return {
312
+ primary: labeled.primary,
313
+ }
314
+ }
315
+ cursorToLabeledResult(cursor: SingleKeyCursor) {
316
+ try {
317
+ ensureValidRecordKey(cursor.primary)
318
+ return {
319
+ primary: cursor.primary,
320
+ }
321
+ } catch {
322
+ throw new InvalidRequestError('Malformed cursor')
323
+ }
324
+ }
325
+ }
326
+
327
+ export class StashKeyKey extends RkeyKey<{
328
+ key: string
329
+ }> {
330
+ labelResult(result: { key: string }) {
331
+ return { primary: result.key }
332
+ }
333
+ }
@@ -0,0 +1,12 @@
1
+ export const tableName = 'activity_subscription'
2
+ export interface ActivitySubscription {
3
+ creator: string
4
+ subjectDid: string
5
+ // key from the bsync stash.
6
+ key: string
7
+ indexedAt: string
8
+ post: boolean
9
+ reply: boolean
10
+ }
11
+
12
+ export type PartialDB = { [tableName]: ActivitySubscription }
@@ -26,6 +26,7 @@ import * as Like from './plugins/like'
26
26
  import * as List from './plugins/list'
27
27
  import * as ListBlock from './plugins/list-block'
28
28
  import * as ListItem from './plugins/list-item'
29
+ import * as NotifDeclaration from './plugins/notif-declaration'
29
30
  import * as Post from './plugins/post'
30
31
  import * as Postgate from './plugins/post-gate'
31
32
  import * as Profile from './plugins/profile'
@@ -52,6 +53,7 @@ export class IndexingService {
52
53
  feedGenerator: FeedGenerator.PluginType
53
54
  starterPack: StarterPack.PluginType
54
55
  labeler: Labeler.PluginType
56
+ notifDeclaration: NotifDeclaration.PluginType
55
57
  chatDeclaration: ChatDeclaration.PluginType
56
58
  verification: Verification.PluginType
57
59
  status: Status.PluginType
@@ -77,6 +79,7 @@ export class IndexingService {
77
79
  feedGenerator: FeedGenerator.makePlugin(this.db, this.background),
78
80
  starterPack: StarterPack.makePlugin(this.db, this.background),
79
81
  labeler: Labeler.makePlugin(this.db, this.background),
82
+ notifDeclaration: NotifDeclaration.makePlugin(this.db, this.background),
80
83
  chatDeclaration: ChatDeclaration.makePlugin(this.db, this.background),
81
84
  verification: Verification.makePlugin(this.db, this.background),
82
85
  status: Status.makePlugin(this.db, this.background),
@@ -0,0 +1,59 @@
1
+ import { CID } from 'multiformats/cid'
2
+ import { AtUri } from '@atproto/syntax'
3
+ import * as lex from '../../../../lexicon/lexicons'
4
+ import { BackgroundQueue } from '../../background'
5
+ import { Database } from '../../db'
6
+ import { DatabaseSchema } from '../../db/database-schema'
7
+ import { RecordProcessor } from '../processor'
8
+
9
+ // @NOTE this indexer is a placeholder to ensure it gets indexed in the generic records table
10
+ const lexId = lex.ids.AppBskyNotificationDeclaration
11
+
12
+ const insertFn = async (
13
+ _db: DatabaseSchema,
14
+ uri: AtUri,
15
+ _cid: CID,
16
+ _obj: unknown,
17
+ _timestamp: string,
18
+ ): Promise<unknown | null> => {
19
+ if (uri.rkey !== 'self') return null
20
+ return true
21
+ }
22
+
23
+ const findDuplicate = async (): Promise<AtUri | null> => {
24
+ return null
25
+ }
26
+
27
+ const notifsForInsert = () => {
28
+ return []
29
+ }
30
+
31
+ const deleteFn = async (
32
+ _db: DatabaseSchema,
33
+ uri: AtUri,
34
+ ): Promise<unknown | null> => {
35
+ if (uri.rkey !== 'self') return null
36
+ return true
37
+ }
38
+
39
+ const notifsForDelete = () => {
40
+ return { notifs: [], toDelete: [] }
41
+ }
42
+
43
+ export type PluginType = RecordProcessor<unknown, unknown>
44
+
45
+ export const makePlugin = (
46
+ db: Database,
47
+ background: BackgroundQueue,
48
+ ): PluginType => {
49
+ return new RecordProcessor(db, background, {
50
+ lexId,
51
+ insertFn,
52
+ findDuplicate,
53
+ deleteFn,
54
+ notifsForInsert,
55
+ notifsForDelete,
56
+ })
57
+ }
58
+
59
+ export default makePlugin
@@ -0,0 +1,83 @@
1
+ import { PlainMessage } from '@bufbuild/protobuf'
2
+ import { ServiceImpl } from '@connectrpc/connect'
3
+ import { keyBy } from '@atproto/common'
4
+ import { Service } from '../../../proto/bsky_connect'
5
+ import {
6
+ ActivitySubscription,
7
+ GetActivitySubscriptionsByActorAndSubjectsResponse,
8
+ } from '../../../proto/bsky_pb'
9
+ import { Namespaces } from '../../../stash'
10
+ import { Database } from '../db'
11
+ import { StashKeyKey } from '../db/pagination'
12
+
13
+ export default (db: Database): Partial<ServiceImpl<typeof Service>> => ({
14
+ async getActivitySubscriptionsByActorAndSubjects(req) {
15
+ const { actorDid, subjectDids } = req
16
+ if (subjectDids.length === 0) {
17
+ return new GetActivitySubscriptionsByActorAndSubjectsResponse({
18
+ subscriptions: [],
19
+ })
20
+ }
21
+
22
+ const res = await db.db
23
+ .selectFrom('activity_subscription')
24
+ .selectAll()
25
+ .where('creator', '=', actorDid)
26
+ .where('subjectDid', 'in', subjectDids)
27
+ .execute()
28
+
29
+ const bySubject = keyBy(res, 'subjectDid')
30
+ const subscriptions = subjectDids.map(
31
+ (did): PlainMessage<ActivitySubscription> => {
32
+ const subject = bySubject.get(did)
33
+ if (!subject) {
34
+ return {
35
+ actorDid,
36
+ namespace:
37
+ Namespaces.AppBskyNotificationDefsSubjectActivitySubscription,
38
+ key: '',
39
+ post: undefined,
40
+ reply: undefined,
41
+ subjectDid: '',
42
+ }
43
+ }
44
+
45
+ return {
46
+ actorDid,
47
+ namespace:
48
+ Namespaces.AppBskyNotificationDefsSubjectActivitySubscription,
49
+ key: subject.key,
50
+ post: subject.post ? {} : undefined,
51
+ reply: subject.reply ? {} : undefined,
52
+ subjectDid: subject.subjectDid,
53
+ }
54
+ },
55
+ )
56
+
57
+ return {
58
+ subscriptions,
59
+ }
60
+ },
61
+
62
+ async getActivitySubscriptionDids(req) {
63
+ const { actorDid, cursor, limit } = req
64
+
65
+ let builder = db.db
66
+ .selectFrom('activity_subscription')
67
+ .selectAll()
68
+ .where('creator', '=', actorDid)
69
+
70
+ const { ref } = db.db.dynamic
71
+ const key = new StashKeyKey(ref('activity_subscription.key'))
72
+ builder = key.paginate(builder, {
73
+ cursor,
74
+ limit,
75
+ })
76
+ const res = await builder.execute()
77
+ const dids = res.map(({ subjectDid }) => subjectDid)
78
+ return {
79
+ dids,
80
+ cursor: key.packFromResult(res),
81
+ }
82
+ },
83
+ })