a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cache): initial commit

Mary b9d81ecd 7c8ef9f0

+1269
+45
packages/clients/cache/README.md
··· 1 + # @atcute/cache 2 + 3 + > [!WARNING] 4 + > very experimental package 5 + 6 + normalized cache store for AT Protocol 7 + 8 + ```ts 9 + import { NormalizedCache } from '@atcute/cache'; 10 + import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedGetTimeline } from '@atcute/bluesky'; 11 + 12 + const cache = new NormalizedCache(); 13 + 14 + // register entity types with key extractors 15 + cache.define({ 16 + schema: AppBskyFeedDefs.postViewSchema, 17 + key: (post) => post.uri, 18 + }); 19 + 20 + cache.define({ 21 + schema: AppBskyActorDefs.profileViewBasicSchema, 22 + key: (profile) => profile.did, 23 + }); 24 + 25 + // normalize API responses 26 + const response = await rpc.get('app.bsky.feed.getTimeline', { params: {} }); 27 + 28 + const timeline = cache.normalize(AppBskyFeedGetTimeline.mainSchema.output.schema, response.data); 29 + 30 + // read entities from cache 31 + const post = cache.get(AppBskyFeedDefs.postViewSchema, 'at://did:plc:.../app.bsky.feed.post/...'); 32 + const profile = cache.get(AppBskyActorDefs.profileViewBasicSchema, 'did:plc:...'); 33 + 34 + // optimistic updates 35 + cache.update(AppBskyFeedDefs.postViewSchema, postUri, (post) => ({ 36 + ...post, 37 + viewer: { ...post.viewer, like: tempLikeUri }, 38 + likeCount: (post.likeCount ?? 0) + 1, 39 + })); 40 + 41 + // subscribe to entity changes 42 + const unsubscribe = cache.subscribe(AppBskyFeedDefs.postViewSchema, postUri, (post) => { 43 + console.log('post changed:', post); 44 + }); 45 + ```
+3
packages/clients/cache/lib/index.ts
··· 1 + export { NormalizedCache } from './store.js'; 2 + export type { NormalizedCacheOptions } from './store.js'; 3 + export type { EntityDefinition, EntitySubscriber, EntityTypeId, TypeSubscriber } from './types.js';
+39
packages/clients/cache/lib/predicates.ts
··· 1 + import type { 2 + ArraySchema, 3 + BaseSchema, 4 + LiteralSchema, 5 + NullableSchema, 6 + ObjectSchema, 7 + OptionalSchema, 8 + VariantSchema, 9 + } from '@atcute/lexicons/validations'; 10 + 11 + /** check if schema is an object schema */ 12 + export const isObjectSchema = (schema: BaseSchema): schema is ObjectSchema => { 13 + return schema.type === 'object'; 14 + }; 15 + 16 + /** check if schema is an array schema */ 17 + export const isArraySchema = (schema: BaseSchema): schema is ArraySchema => { 18 + return schema.type === 'array'; 19 + }; 20 + 21 + /** check if schema is a variant schema */ 22 + export const isVariantSchema = (schema: BaseSchema): schema is VariantSchema => { 23 + return schema.type === 'variant'; 24 + }; 25 + 26 + /** check if schema is an optional schema */ 27 + export const isOptionalSchema = (schema: BaseSchema): schema is OptionalSchema => { 28 + return schema.type === 'optional'; 29 + }; 30 + 31 + /** check if schema is a nullable schema */ 32 + export const isNullableSchema = (schema: BaseSchema): schema is NullableSchema => { 33 + return schema.type === 'nullable'; 34 + }; 35 + 36 + /** check if schema is a literal schema */ 37 + export const isLiteralSchema = (schema: BaseSchema): schema is LiteralSchema => { 38 + return schema.type === 'literal'; 39 + };
+595
packages/clients/cache/lib/store.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + 3 + import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedGetFeed } from '@atcute/bluesky'; 4 + import * as v from '@atcute/lexicons/validations'; 5 + 6 + import { NormalizedCache } from './store.js'; 7 + 8 + // sample data from https://api.bsky.app/xrpc/app.bsky.feed.getFeed 9 + const sampleFeedResponse: AppBskyFeedGetFeed.$output = { 10 + cursor: 'eyJvIjoiMjAyNS0xMi0wNVQyMDozNDoyNy40NDA2OTEwNjlaIn0=', 11 + feed: [ 12 + { 13 + post: { 14 + uri: 'at://did:plc:22oxxd7xnozzqadsljvq57vy/app.bsky.feed.post/3m7bwu54vqs2j', 15 + cid: 'bafyreiadxl3lsfumlcli6tzysmcyc7abpshygyodhgq7gwyplg75ozjrim', 16 + author: { 17 + did: 'did:plc:22oxxd7xnozzqadsljvq57vy', 18 + handle: 'thatmc.bsky.social', 19 + displayName: 'That Mack', 20 + avatar: 21 + 'https://cdn.bsky.app/img/avatar/plain/did:plc:22oxxd7xnozzqadsljvq57vy/bafkreihelz336fougwoydtj4rtqgxo22w4uwkhw7cb5i7j24dfmxmfsysa@jpeg', 22 + labels: [], 23 + createdAt: '2023-11-14T18:03:54.458Z', 24 + }, 25 + record: { 26 + $type: 'app.bsky.feed.post', 27 + createdAt: '2025-12-06T02:20:21.173Z', 28 + text: 'Heron and Cranes', 29 + }, 30 + likeCount: 1780, 31 + repostCount: 89, 32 + replyCount: 43, 33 + quoteCount: 8, 34 + indexedAt: '2025-12-06T02:20:26.131Z', 35 + labels: [], 36 + }, 37 + feedContext: 't-photography-blip2', 38 + }, 39 + { 40 + post: { 41 + uri: 'at://did:plc:257qwyterkrxkrwxdn2qdotm/app.bsky.feed.post/3m7bomphtzk22', 42 + cid: 'bafyreiadzqccriq5wqzhjhtyjqk64bt22adc34lxxjivvmsgxqmuaf3j3i', 43 + author: { 44 + did: 'did:plc:257qwyterkrxkrwxdn2qdotm', 45 + handle: 'wpacheco-smamx.bsky.social', 46 + displayName: 'Wpacheco', 47 + avatar: 48 + 'https://cdn.bsky.app/img/avatar/plain/did:plc:257qwyterkrxkrwxdn2qdotm/bafkreidij6bpsvg3atvec7o73l4n5642jf6k224jpq7h3dzjxekpvu3gly@jpeg', 49 + labels: [], 50 + createdAt: '2024-11-15T06:21:41.341Z', 51 + }, 52 + record: { 53 + $type: 'app.bsky.feed.post', 54 + createdAt: '2025-12-05T23:53:02.039Z', 55 + text: 'FIFA is a joke.', 56 + }, 57 + likeCount: 3279, 58 + repostCount: 436, 59 + replyCount: 156, 60 + quoteCount: 20, 61 + indexedAt: '2025-12-05T23:53:03.733Z', 62 + labels: [], 63 + }, 64 + feedContext: 't-sports-blip2', 65 + }, 66 + ], 67 + }; 68 + 69 + describe('NormalizedCache', () => { 70 + describe('define', () => { 71 + it('registers entity type', () => { 72 + const cache = new NormalizedCache(); 73 + cache.define({ 74 + schema: AppBskyFeedDefs.postViewSchema, 75 + key: (post) => post.uri, 76 + }); 77 + 78 + // should not throw when getting from a defined schema 79 + expect(cache.get(AppBskyFeedDefs.postViewSchema, 'at://test/post/1')).toBeUndefined(); 80 + }); 81 + 82 + it('throws on duplicate definition', () => { 83 + const cache = new NormalizedCache(); 84 + cache.define({ 85 + schema: AppBskyFeedDefs.postViewSchema, 86 + key: (post) => post.uri, 87 + }); 88 + 89 + expect(() => { 90 + cache.define({ 91 + schema: AppBskyFeedDefs.postViewSchema, 92 + key: (post) => post.uri, 93 + }); 94 + }).toThrow('entity type "app.bsky.feed.defs#postView" is already defined'); 95 + }); 96 + 97 + it('throws on schema without $type', () => { 98 + const cache = new NormalizedCache(); 99 + const invalidSchema = v.object({ foo: v.string() }); 100 + 101 + expect(() => { 102 + cache.define({ 103 + schema: invalidSchema, 104 + key: (x: any) => x.foo, 105 + }); 106 + }).toThrow('schema must have a $type literal field'); 107 + }); 108 + }); 109 + 110 + describe('get/set/has', () => { 111 + it('stores and retrieves entities', () => { 112 + const cache = new NormalizedCache(); 113 + cache.define({ 114 + schema: AppBskyFeedDefs.postViewSchema, 115 + key: (post) => post.uri, 116 + }); 117 + 118 + const post = sampleFeedResponse.feed[0].post; 119 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 120 + 121 + expect(cache.has(AppBskyFeedDefs.postViewSchema, post.uri)).toBe(true); 122 + expect(cache.get(AppBskyFeedDefs.postViewSchema, post.uri)).toBe(post); 123 + }); 124 + 125 + it('returns undefined for non-existent entities', () => { 126 + const cache = new NormalizedCache(); 127 + cache.define({ 128 + schema: AppBskyFeedDefs.postViewSchema, 129 + key: (post) => post.uri, 130 + }); 131 + 132 + expect(cache.get(AppBskyFeedDefs.postViewSchema, 'at://nonexistent')).toBeUndefined(); 133 + expect(cache.has(AppBskyFeedDefs.postViewSchema, 'at://nonexistent')).toBe(false); 134 + }); 135 + 136 + it('throws when setting to unregistered schema', () => { 137 + const cache = new NormalizedCache(); 138 + const post = sampleFeedResponse.feed[0].post; 139 + 140 + expect(() => { 141 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 142 + }).toThrow('schema is not registered'); 143 + }); 144 + }); 145 + 146 + describe('update', () => { 147 + it('updates existing entity', () => { 148 + const cache = new NormalizedCache(); 149 + cache.define({ 150 + schema: AppBskyFeedDefs.postViewSchema, 151 + key: (post) => post.uri, 152 + }); 153 + 154 + const post = { ...sampleFeedResponse.feed[0].post }; 155 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 156 + 157 + const result = cache.update(AppBskyFeedDefs.postViewSchema, post.uri, (existing) => ({ 158 + ...existing, 159 + likeCount: 9999, 160 + })); 161 + 162 + expect(result).toBe(true); 163 + expect(cache.get(AppBskyFeedDefs.postViewSchema, post.uri)?.likeCount).toBe(9999); 164 + }); 165 + 166 + it('returns false when entity does not exist', () => { 167 + const cache = new NormalizedCache(); 168 + cache.define({ 169 + schema: AppBskyFeedDefs.postViewSchema, 170 + key: (post) => post.uri, 171 + }); 172 + 173 + const result = cache.update(AppBskyFeedDefs.postViewSchema, 'at://nonexistent', (post) => post); 174 + expect(result).toBe(false); 175 + }); 176 + }); 177 + 178 + describe('delete', () => { 179 + it('removes entity from cache', () => { 180 + const cache = new NormalizedCache(); 181 + cache.define({ 182 + schema: AppBskyFeedDefs.postViewSchema, 183 + key: (post) => post.uri, 184 + }); 185 + 186 + const post = sampleFeedResponse.feed[0].post; 187 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 188 + 189 + expect(cache.delete(AppBskyFeedDefs.postViewSchema, post.uri)).toBe(true); 190 + expect(cache.has(AppBskyFeedDefs.postViewSchema, post.uri)).toBe(false); 191 + }); 192 + 193 + it('returns false when entity does not exist', () => { 194 + const cache = new NormalizedCache(); 195 + cache.define({ 196 + schema: AppBskyFeedDefs.postViewSchema, 197 + key: (post) => post.uri, 198 + }); 199 + 200 + expect(cache.delete(AppBskyFeedDefs.postViewSchema, 'at://nonexistent')).toBe(false); 201 + }); 202 + }); 203 + 204 + describe('getAll', () => { 205 + it('returns all entities of a type', () => { 206 + const cache = new NormalizedCache(); 207 + cache.define({ 208 + schema: AppBskyFeedDefs.postViewSchema, 209 + key: (post) => post.uri, 210 + }); 211 + 212 + const post1 = sampleFeedResponse.feed[0].post; 213 + const post2 = sampleFeedResponse.feed[1].post; 214 + 215 + cache.set(AppBskyFeedDefs.postViewSchema, post1.uri, post1); 216 + cache.set(AppBskyFeedDefs.postViewSchema, post2.uri, post2); 217 + 218 + const all = cache.getAll(AppBskyFeedDefs.postViewSchema); 219 + expect(all.size).toBe(2); 220 + expect(all.get(post1.uri)).toBe(post1); 221 + expect(all.get(post2.uri)).toBe(post2); 222 + }); 223 + }); 224 + 225 + describe('clear', () => { 226 + it('removes all entities', () => { 227 + const cache = new NormalizedCache(); 228 + cache.define({ 229 + schema: AppBskyFeedDefs.postViewSchema, 230 + key: (post) => post.uri, 231 + }); 232 + cache.define({ 233 + schema: AppBskyActorDefs.profileViewBasicSchema, 234 + key: (profile) => profile.did, 235 + }); 236 + 237 + const post = sampleFeedResponse.feed[0].post; 238 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 239 + cache.set(AppBskyActorDefs.profileViewBasicSchema, post.author.did, post.author); 240 + 241 + cache.clear(); 242 + 243 + expect(cache.getAll(AppBskyFeedDefs.postViewSchema).size).toBe(0); 244 + expect(cache.getAll(AppBskyActorDefs.profileViewBasicSchema).size).toBe(0); 245 + }); 246 + }); 247 + 248 + describe('extract', () => { 249 + it('extracts and normalizes entities from response', () => { 250 + const cache = new NormalizedCache(); 251 + cache.define({ 252 + schema: AppBskyFeedDefs.postViewSchema, 253 + key: (post) => post.uri, 254 + }); 255 + 256 + const outputSchema = AppBskyFeedGetFeed.mainSchema.output.schema; 257 + const response = structuredClone(sampleFeedResponse); 258 + const result = cache.normalize(outputSchema, response); 259 + 260 + // entities should be in the cache 261 + const post1 = cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[0].post.uri); 262 + const post2 = cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[1].post.uri); 263 + 264 + expect(post1).toBeDefined(); 265 + expect(post2).toBeDefined(); 266 + expect(post1?.likeCount).toBe(1780); 267 + expect(post2?.likeCount).toBe(3279); 268 + 269 + // result should have cached refs swapped in 270 + expect(result.feed[0].post).toBe(post1); 271 + expect(result.feed[1].post).toBe(post2); 272 + }); 273 + 274 + it('merges entities with existing cache entries', () => { 275 + const cache = new NormalizedCache(); 276 + cache.define({ 277 + schema: AppBskyFeedDefs.postViewSchema, 278 + key: (post) => post.uri, 279 + merge: (existing, incoming) => ({ 280 + ...incoming, 281 + likeCount: incoming.likeCount ?? existing.likeCount, 282 + }), 283 + }); 284 + 285 + // first extract 286 + const outputSchema = AppBskyFeedGetFeed.mainSchema.output.schema; 287 + const response1 = structuredClone(sampleFeedResponse); 288 + cache.normalize(outputSchema, response1); 289 + 290 + const post = cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[0].post.uri); 291 + expect(post?.likeCount).toBe(1780); 292 + 293 + // second extract with updated data 294 + const response2 = structuredClone(sampleFeedResponse); 295 + response2.feed[0].post.likeCount = 2000; 296 + 297 + cache.normalize(outputSchema, response2); 298 + 299 + // should be same object reference 300 + expect(cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[0].post.uri)).toBe(post); 301 + // but with updated value 302 + expect(post?.likeCount).toBe(2000); 303 + }); 304 + 305 + it('normalizes nested entities', () => { 306 + const cache = new NormalizedCache(); 307 + cache.define({ 308 + schema: AppBskyFeedDefs.postViewSchema, 309 + key: (post) => post.uri, 310 + }); 311 + cache.define({ 312 + schema: AppBskyActorDefs.profileViewBasicSchema, 313 + key: (profile) => profile.did, 314 + }); 315 + 316 + const outputSchema = AppBskyFeedGetFeed.mainSchema.output.schema; 317 + const response = structuredClone(sampleFeedResponse); 318 + const result = cache.normalize(outputSchema, response); 319 + 320 + // author should be normalized 321 + const author = cache.get(AppBskyActorDefs.profileViewBasicSchema, sampleFeedResponse.feed[0].post.author.did); 322 + expect(author).toBeDefined(); 323 + expect(author?.handle).toBe('thatmc.bsky.social'); 324 + 325 + // and should be the same reference in the post 326 + const post = result.feed[0].post as any; 327 + expect(post.author).toBe(author); 328 + }); 329 + 330 + it('shares same entity across multiple references', () => { 331 + const cache = new NormalizedCache(); 332 + cache.define({ 333 + schema: AppBskyActorDefs.profileViewBasicSchema, 334 + key: (profile) => profile.did, 335 + }); 336 + 337 + // create response where same author appears in multiple posts 338 + const author: AppBskyActorDefs.ProfileViewBasic = { 339 + did: 'did:plc:shared-author', 340 + handle: 'shared.bsky.social', 341 + displayName: 'Shared Author', 342 + labels: [], 343 + }; 344 + 345 + const schema = v.object({ 346 + feed: v.array( 347 + v.object({ 348 + post: v.object({ 349 + uri: v.string(), 350 + get author() { 351 + return AppBskyActorDefs.profileViewBasicSchema; 352 + }, 353 + }), 354 + }), 355 + ), 356 + }); 357 + 358 + const response: v.InferOutput<typeof schema> = { 359 + feed: [ 360 + { post: { uri: 'at://test/post/1', author } }, 361 + { post: { uri: 'at://test/post/2', author: { ...author } } }, // clone 362 + ], 363 + }; 364 + 365 + const result = cache.normalize(schema, response); 366 + 367 + // both posts should reference the same cached author 368 + expect((result.feed[0].post as any).author).toBe((result.feed[1].post as any).author); 369 + }); 370 + 371 + it('normalizer() returns a reusable function', () => { 372 + const cache = new NormalizedCache(); 373 + cache.define({ 374 + schema: AppBskyFeedDefs.postViewSchema, 375 + key: (post) => post.uri, 376 + }); 377 + 378 + const outputSchema = AppBskyFeedGetFeed.mainSchema.output.schema; 379 + const normalizeResponse = cache.normalizer(outputSchema); 380 + 381 + const response1 = structuredClone(sampleFeedResponse); 382 + const result1 = normalizeResponse(response1); 383 + 384 + expect(cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[0].post.uri)).toBeDefined(); 385 + expect(result1.feed[0].post).toBe(cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[0].post.uri)); 386 + 387 + // use the same normalizer again 388 + const response2 = structuredClone(sampleFeedResponse); 389 + response2.feed[0].post.likeCount = 5000; 390 + const result2 = normalizeResponse(response2); 391 + 392 + // should update the same cached entity 393 + expect(result2.feed[0].post).toBe(result1.feed[0].post); 394 + expect((result2.feed[0].post as any).likeCount).toBe(5000); 395 + }); 396 + }); 397 + 398 + describe('subscriptions', () => { 399 + it('notifies subscribers on set', () => { 400 + const cache = new NormalizedCache(); 401 + cache.define({ 402 + schema: AppBskyFeedDefs.postViewSchema, 403 + key: (post) => post.uri, 404 + }); 405 + 406 + const post = sampleFeedResponse.feed[0].post; 407 + const callback = vi.fn(); 408 + 409 + cache.subscribe(AppBskyFeedDefs.postViewSchema, post.uri, callback); 410 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 411 + 412 + expect(callback).toHaveBeenCalledWith(post); 413 + }); 414 + 415 + it('notifies subscribers on update', () => { 416 + const cache = new NormalizedCache(); 417 + cache.define({ 418 + schema: AppBskyFeedDefs.postViewSchema, 419 + key: (post) => post.uri, 420 + }); 421 + 422 + const post = { ...sampleFeedResponse.feed[0].post }; 423 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 424 + 425 + const callback = vi.fn(); 426 + cache.subscribe(AppBskyFeedDefs.postViewSchema, post.uri, callback); 427 + 428 + cache.update(AppBskyFeedDefs.postViewSchema, post.uri, (p) => ({ ...p, likeCount: 9999 })); 429 + 430 + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ likeCount: 9999 })); 431 + }); 432 + 433 + it('notifies subscribers on delete with undefined', () => { 434 + const cache = new NormalizedCache(); 435 + cache.define({ 436 + schema: AppBskyFeedDefs.postViewSchema, 437 + key: (post) => post.uri, 438 + }); 439 + 440 + const post = sampleFeedResponse.feed[0].post; 441 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 442 + 443 + const callback = vi.fn(); 444 + cache.subscribe(AppBskyFeedDefs.postViewSchema, post.uri, callback); 445 + 446 + cache.delete(AppBskyFeedDefs.postViewSchema, post.uri); 447 + 448 + expect(callback).toHaveBeenCalledWith(undefined); 449 + }); 450 + 451 + it('unsubscribes correctly', () => { 452 + const cache = new NormalizedCache(); 453 + cache.define({ 454 + schema: AppBskyFeedDefs.postViewSchema, 455 + key: (post) => post.uri, 456 + }); 457 + 458 + const post = sampleFeedResponse.feed[0].post; 459 + const callback = vi.fn(); 460 + 461 + const unsubscribe = cache.subscribe(AppBskyFeedDefs.postViewSchema, post.uri, callback); 462 + unsubscribe(); 463 + 464 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 465 + 466 + expect(callback).not.toHaveBeenCalled(); 467 + }); 468 + 469 + it('notifies type subscribers on any entity change', () => { 470 + const cache = new NormalizedCache(); 471 + cache.define({ 472 + schema: AppBskyFeedDefs.postViewSchema, 473 + key: (post) => post.uri, 474 + }); 475 + 476 + const callback = vi.fn(); 477 + cache.subscribeType(AppBskyFeedDefs.postViewSchema, callback); 478 + 479 + const post1 = sampleFeedResponse.feed[0].post; 480 + const post2 = sampleFeedResponse.feed[1].post; 481 + 482 + cache.set(AppBskyFeedDefs.postViewSchema, post1.uri, post1); 483 + cache.set(AppBskyFeedDefs.postViewSchema, post2.uri, post2); 484 + 485 + expect(callback).toHaveBeenCalledTimes(2); 486 + expect(callback).toHaveBeenCalledWith(post1.uri, post1); 487 + expect(callback).toHaveBeenCalledWith(post2.uri, post2); 488 + }); 489 + }); 490 + 491 + describe('deleteType', () => { 492 + it('removes all entities of a type', () => { 493 + const cache = new NormalizedCache(); 494 + cache.define({ 495 + schema: AppBskyFeedDefs.postViewSchema, 496 + key: (post) => post.uri, 497 + }); 498 + 499 + const post1 = sampleFeedResponse.feed[0].post; 500 + const post2 = sampleFeedResponse.feed[1].post; 501 + 502 + cache.set(AppBskyFeedDefs.postViewSchema, post1.uri, post1); 503 + cache.set(AppBskyFeedDefs.postViewSchema, post2.uri, post2); 504 + 505 + cache.deleteType(AppBskyFeedDefs.postViewSchema); 506 + 507 + expect(cache.getAll(AppBskyFeedDefs.postViewSchema).size).toBe(0); 508 + }); 509 + 510 + it('notifies subscribers when deleting type', () => { 511 + const cache = new NormalizedCache(); 512 + cache.define({ 513 + schema: AppBskyFeedDefs.postViewSchema, 514 + key: (post) => post.uri, 515 + }); 516 + 517 + const post = sampleFeedResponse.feed[0].post; 518 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 519 + 520 + const callback = vi.fn(); 521 + cache.subscribe(AppBskyFeedDefs.postViewSchema, post.uri, callback); 522 + 523 + cache.deleteType(AppBskyFeedDefs.postViewSchema); 524 + 525 + expect(callback).toHaveBeenCalledWith(undefined); 526 + }); 527 + }); 528 + 529 + describe('wrapEntity option', () => { 530 + it('wraps new entities from extract', () => { 531 + const wrapEntity = vi.fn((entity: any) => { 532 + entity.__wrapped = true; 533 + return entity; 534 + }); 535 + const cache = new NormalizedCache({ wrapEntity }); 536 + 537 + cache.define({ 538 + schema: AppBskyFeedDefs.postViewSchema, 539 + key: (post) => post.uri, 540 + }); 541 + 542 + const outputSchema = AppBskyFeedGetFeed.mainSchema.output.schema; 543 + const response = structuredClone(sampleFeedResponse); 544 + cache.normalize(outputSchema, response); 545 + 546 + expect(wrapEntity).toHaveBeenCalled(); 547 + 548 + const post = cache.get(AppBskyFeedDefs.postViewSchema, sampleFeedResponse.feed[0].post.uri); 549 + expect((post as any).__wrapped).toBe(true); 550 + }); 551 + 552 + it('wraps new entities from set', () => { 553 + const wrapEntity = vi.fn((entity: any) => { 554 + entity.__wrapped = true; 555 + return entity; 556 + }); 557 + const cache = new NormalizedCache({ wrapEntity }); 558 + 559 + cache.define({ 560 + schema: AppBskyFeedDefs.postViewSchema, 561 + key: (post) => post.uri, 562 + }); 563 + 564 + const post = sampleFeedResponse.feed[0].post; 565 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 566 + 567 + expect(wrapEntity).toHaveBeenCalledWith(post); 568 + 569 + const cached = cache.get(AppBskyFeedDefs.postViewSchema, post.uri); 570 + expect((cached as any).__wrapped).toBe(true); 571 + }); 572 + 573 + it('does not re-wrap existing entities', () => { 574 + const wrapEntity = vi.fn((entity: any) => { 575 + entity.__wrapped = true; 576 + return entity; 577 + }); 578 + const cache = new NormalizedCache({ wrapEntity }); 579 + 580 + cache.define({ 581 + schema: AppBskyFeedDefs.postViewSchema, 582 + key: (post) => post.uri, 583 + }); 584 + 585 + const post = sampleFeedResponse.feed[0].post; 586 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, post); 587 + 588 + // update existing entity 589 + cache.set(AppBskyFeedDefs.postViewSchema, post.uri, { ...post, likeCount: 9999 }); 590 + 591 + // wrapEntity should only be called once (for the initial set) 592 + expect(wrapEntity).toHaveBeenCalledTimes(1); 593 + }); 594 + }); 595 + });
+459
packages/clients/cache/lib/store.ts
··· 1 + import type { BaseSchema, InferOutput, ObjectSchema, VariantSchema } from '@atcute/lexicons/validations'; 2 + 3 + import { 4 + isArraySchema, 5 + isNullableSchema, 6 + isObjectSchema, 7 + isOptionalSchema, 8 + isVariantSchema, 9 + } from './predicates.js'; 10 + import type { EntityDefinition, EntitySubscriber, EntityTypeId, TypeSubscriber } from './types.js'; 11 + import { getTypeIdFromSchema } from './types.js'; 12 + 13 + type AnyEntityDefinition = EntityDefinition<ObjectSchema>; 14 + 15 + interface EntityStoreEntry { 16 + definition: AnyEntityDefinition; 17 + entities: Map<string, WeakRef<object>>; 18 + subscribers: Map<string, Set<EntitySubscriber<unknown>>>; 19 + typeSubscribers: Set<TypeSubscriber<unknown>>; 20 + } 21 + 22 + export interface NormalizedCacheOptions { 23 + wrapEntity?: (entity: unknown) => unknown; 24 + } 25 + 26 + /** 27 + * normalized cache store for AT Protocol responses 28 + */ 29 + export class NormalizedCache { 30 + #stores = new Map<EntityTypeId, EntityStoreEntry>(); 31 + #schemaToTypeId = new Map<ObjectSchema, EntityTypeId>(); 32 + #wrapEntity: ((entity: unknown) => unknown) | undefined; 33 + #registry = new FinalizationRegistry<{ typeId: EntityTypeId; key: string }>((held) => { 34 + const store = this.#stores.get(held.typeId); 35 + if (store) { 36 + const ref = store.entities.get(held.key); 37 + // only delete if the ref is actually dead (not replaced with a new one) 38 + if (ref !== undefined && ref.deref() === undefined) { 39 + store.entities.delete(held.key); 40 + } 41 + } 42 + }); 43 + 44 + constructor(options?: NormalizedCacheOptions) { 45 + this.#wrapEntity = options?.wrapEntity; 46 + } 47 + 48 + #getTypeId(schema: ObjectSchema): EntityTypeId | undefined { 49 + let typeId = this.#schemaToTypeId.get(schema); 50 + if (typeId === undefined) { 51 + typeId = getTypeIdFromSchema(schema); 52 + if (typeId !== undefined) { 53 + this.#schemaToTypeId.set(schema, typeId); 54 + } 55 + } 56 + return typeId; 57 + } 58 + 59 + #getStore(schema: ObjectSchema): EntityStoreEntry | undefined { 60 + const typeId = this.#getTypeId(schema); 61 + return typeId ? this.#stores.get(typeId) : undefined; 62 + } 63 + 64 + #notifySubscribers(store: EntityStoreEntry, key: string, entity: object | undefined): void { 65 + // notify entity-specific subscribers 66 + const entitySubs = store.subscribers.get(key); 67 + if (entitySubs) { 68 + for (const cb of entitySubs) { 69 + cb(entity); 70 + } 71 + } 72 + 73 + // notify type subscribers 74 + for (const cb of store.typeSubscribers) { 75 + cb(key, entity); 76 + } 77 + } 78 + 79 + #upsertEntity( 80 + typeId: EntityTypeId, 81 + key: string, 82 + incoming: object, 83 + merge: ((existing: any, incoming: any) => any) | undefined, 84 + ): object { 85 + const store = this.#stores.get(typeId)!; 86 + const existingRef = store.entities.get(key); 87 + const existing = existingRef?.deref(); 88 + 89 + if (existing !== undefined) { 90 + // merge incoming into existing 91 + const merged = merge ? merge(existing, incoming) : incoming; 92 + Object.assign(existing, merged); 93 + this.#notifySubscribers(store, key, existing); 94 + return existing; 95 + } 96 + 97 + // new entity - wrap and store it 98 + const entity: any = this.#wrapEntity ? this.#wrapEntity(incoming) : incoming; 99 + store.entities.set(key, new WeakRef(entity)); 100 + this.#registry.register(entity, { typeId, key }); 101 + this.#notifySubscribers(store, key, entity); 102 + return entity; 103 + } 104 + 105 + #resolveVariantMember(schema: VariantSchema, data: Record<string, unknown>): ObjectSchema | undefined { 106 + const type = data.$type as string | undefined; 107 + if (type === undefined) { 108 + return undefined; 109 + } 110 + 111 + for (const member of schema.members) { 112 + const memberTypeId = getTypeIdFromSchema(member as ObjectSchema); 113 + if (memberTypeId === type) { 114 + return member as ObjectSchema; 115 + } 116 + } 117 + 118 + return undefined; 119 + } 120 + 121 + #walkAndExtract(schema: BaseSchema, data: unknown): unknown { 122 + if (data === null || data === undefined) { 123 + return data; 124 + } 125 + 126 + if (isObjectSchema(schema)) { 127 + // check if this is a registered entity type 128 + const typeId = this.#getTypeId(schema); 129 + const isEntity = typeId !== undefined && this.#stores.has(typeId); 130 + 131 + let entity = data as Record<string, unknown>; 132 + if (isEntity) { 133 + const store = this.#stores.get(typeId)!; 134 + const key = store.definition.key(entity); 135 + entity = this.#upsertEntity(typeId, key, entity, store.definition.merge) as Record<string, unknown>; 136 + } 137 + 138 + // walk nested properties 139 + const shape = schema.shape; 140 + let cloned = false; 141 + 142 + for (const propName in shape) { 143 + const propSchema = shape[propName]; 144 + const propValue = entity[propName]; 145 + 146 + if (propValue !== undefined) { 147 + const extracted = this.#walkAndExtract(propSchema, propValue); 148 + if (extracted !== propValue) { 149 + // only mutate entities in-place; clone non-entities on first modification 150 + if (!isEntity && !cloned) { 151 + entity = { ...entity }; 152 + cloned = true; 153 + } 154 + 155 + entity[propName] = extracted; 156 + } 157 + } 158 + } 159 + 160 + return entity; 161 + } 162 + 163 + if (isArraySchema(schema)) { 164 + const prev = data as unknown[]; 165 + 166 + let modified = false; 167 + const next: unknown[] = []; 168 + 169 + for (let i = 0; i < prev.length; i++) { 170 + const item = prev[i]; 171 + const extracted = this.#walkAndExtract(schema.item, item); 172 + 173 + next.push(extracted); 174 + 175 + if (extracted !== item) { 176 + modified = true; 177 + } 178 + } 179 + 180 + return modified ? next : prev; 181 + } 182 + 183 + if (isVariantSchema(schema)) { 184 + const objectData = data as Record<string, unknown>; 185 + 186 + const member = this.#resolveVariantMember(schema, objectData); 187 + if (member) { 188 + return this.#walkAndExtract(member, data); 189 + } 190 + 191 + return data; 192 + } 193 + 194 + if (isOptionalSchema(schema) || isNullableSchema(schema)) { 195 + return this.#walkAndExtract(schema.wrapped, data); 196 + } 197 + 198 + // primitive types - return as-is 199 + return data; 200 + } 201 + 202 + /** 203 + * register an entity type for normalization 204 + * @param definition entity definition with schema, key extractor, and optional merge function 205 + */ 206 + define<T extends ObjectSchema>(definition: EntityDefinition<T>): void { 207 + const typeId = getTypeIdFromSchema(definition.schema); 208 + if (typeId === undefined) { 209 + throw new Error('schema must have a $type literal field'); 210 + } 211 + 212 + if (this.#stores.has(typeId)) { 213 + throw new Error(`entity type "${typeId}" is already defined`); 214 + } 215 + 216 + this.#stores.set(typeId, { 217 + definition: definition as unknown as AnyEntityDefinition, 218 + entities: new Map(), 219 + subscribers: new Map(), 220 + typeSubscribers: new Set(), 221 + }); 222 + 223 + this.#schemaToTypeId.set(definition.schema, typeId); 224 + } 225 + 226 + /** 227 + * walk response using schema, normalize and cache entities 228 + * @param schema the response schema 229 + * @param data the response data 230 + * @returns response with cached entity refs swapped in 231 + */ 232 + normalize<T extends BaseSchema>(schema: T, data: InferOutput<T>): InferOutput<T> { 233 + return this.#walkAndExtract(schema, data) as InferOutput<T>; 234 + } 235 + 236 + /** 237 + * create a reusable normalizer function for a schema 238 + * @param schema the response schema 239 + * @returns function that normalizes data according to schema 240 + */ 241 + normalizer<T extends BaseSchema>(schema: T): (data: InferOutput<T>) => InferOutput<T> { 242 + return (data) => this.#walkAndExtract(schema, data) as InferOutput<T>; 243 + } 244 + 245 + /** 246 + * get entity from cache by schema and key 247 + * @param schema the entity schema 248 + * @param key the entity key 249 + * @returns the cached entity or undefined if not found/collected 250 + */ 251 + get<T extends ObjectSchema>(schema: T, key: string): InferOutput<T> | undefined { 252 + const store = this.#getStore(schema); 253 + if (!store) { 254 + return undefined; 255 + } 256 + 257 + const ref = store.entities.get(key); 258 + return ref?.deref() as InferOutput<T> | undefined; 259 + } 260 + 261 + /** 262 + * check if entity exists in cache 263 + * @param schema the entity schema 264 + * @param key the entity key 265 + */ 266 + has(schema: ObjectSchema, key: string): boolean { 267 + const store = this.#getStore(schema); 268 + if (!store) { 269 + return false; 270 + } 271 + 272 + const ref = store.entities.get(key); 273 + return ref?.deref() !== undefined; 274 + } 275 + 276 + /** 277 + * get all cached entities of a type 278 + * @param schema the entity schema 279 + * @returns map of key to entity (only includes live refs) 280 + */ 281 + getAll<T extends ObjectSchema>(schema: T): Map<string, InferOutput<T>> { 282 + const store = this.#getStore(schema); 283 + const result = new Map<string, InferOutput<T>>(); 284 + 285 + if (!store) { 286 + return result; 287 + } 288 + 289 + for (const [key, ref] of store.entities) { 290 + const entity = ref.deref(); 291 + if (entity !== undefined) { 292 + result.set(key, entity as InferOutput<T>); 293 + } 294 + } 295 + 296 + return result; 297 + } 298 + 299 + /** 300 + * set entity directly in cache 301 + * @param schema the entity schema 302 + * @param key the entity key 303 + * @param entity the entity to cache 304 + */ 305 + set<T extends ObjectSchema>(schema: T, key: string, entity: InferOutput<T>): void { 306 + const typeId = this.#getTypeId(schema); 307 + if (typeId === undefined || !this.#stores.has(typeId)) { 308 + throw new Error('schema is not registered'); 309 + } 310 + 311 + const store = this.#stores.get(typeId)!; 312 + const existingRef = store.entities.get(key); 313 + const existing = existingRef?.deref(); 314 + 315 + if (existing !== undefined) { 316 + Object.assign(existing, entity); 317 + this.#notifySubscribers(store, key, existing); 318 + } else { 319 + const wrapped: any = this.#wrapEntity ? this.#wrapEntity(entity) : entity; 320 + store.entities.set(key, new WeakRef(wrapped)); 321 + this.#registry.register(wrapped, { typeId, key }); 322 + this.#notifySubscribers(store, key, wrapped); 323 + } 324 + } 325 + 326 + /** 327 + * update entity with updater function 328 + * @param schema the entity schema 329 + * @param key the entity key 330 + * @param updater function that returns updated entity 331 + * @returns true if entity was found and updated 332 + */ 333 + update<T extends ObjectSchema>( 334 + schema: T, 335 + key: string, 336 + updater: (entity: InferOutput<T>) => InferOutput<T>, 337 + ): boolean { 338 + const store = this.#getStore(schema); 339 + if (!store) { 340 + return false; 341 + } 342 + 343 + const ref = store.entities.get(key); 344 + const existing = ref?.deref() as InferOutput<T> | undefined; 345 + 346 + if (existing === undefined) { 347 + return false; 348 + } 349 + 350 + const updated = updater(existing); 351 + Object.assign(existing, updated); 352 + this.#notifySubscribers(store, key, existing); 353 + return true; 354 + } 355 + 356 + /** 357 + * delete entity from cache 358 + * @param schema the entity schema 359 + * @param key the entity key 360 + * @returns true if entity was found and deleted 361 + */ 362 + delete(schema: ObjectSchema, key: string): boolean { 363 + const store = this.#getStore(schema); 364 + if (!store) { 365 + return false; 366 + } 367 + 368 + const existed = store.entities.has(key); 369 + store.entities.delete(key); 370 + 371 + if (existed) { 372 + this.#notifySubscribers(store, key, undefined); 373 + } 374 + 375 + return existed; 376 + } 377 + 378 + /** 379 + * delete all entities of a type 380 + * @param schema the entity schema 381 + */ 382 + deleteType(schema: ObjectSchema): void { 383 + const store = this.#getStore(schema); 384 + if (!store) { 385 + return; 386 + } 387 + 388 + const keys = [...store.entities.keys()]; 389 + store.entities.clear(); 390 + 391 + for (const key of keys) { 392 + this.#notifySubscribers(store, key, undefined); 393 + } 394 + } 395 + 396 + /** clear entire cache */ 397 + clear(): void { 398 + for (const [_typeId, store] of this.#stores) { 399 + const keys = [...store.entities.keys()]; 400 + store.entities.clear(); 401 + 402 + for (const key of keys) { 403 + this.#notifySubscribers(store, key, undefined); 404 + } 405 + } 406 + } 407 + 408 + /** 409 + * subscribe to changes for a specific entity 410 + * @param schema the entity schema 411 + * @param key the entity key 412 + * @param callback called when entity changes 413 + * @returns unsubscribe function 414 + */ 415 + subscribe<T extends ObjectSchema>( 416 + schema: T, 417 + key: string, 418 + callback: EntitySubscriber<InferOutput<T>>, 419 + ): () => void { 420 + const store = this.#getStore(schema); 421 + if (!store) { 422 + throw new Error('schema is not registered'); 423 + } 424 + 425 + let subs = store.subscribers.get(key); 426 + if (!subs) { 427 + subs = new Set(); 428 + store.subscribers.set(key, subs); 429 + } 430 + 431 + subs.add(callback as EntitySubscriber<unknown>); 432 + 433 + return () => { 434 + subs!.delete(callback as EntitySubscriber<unknown>); 435 + if (subs!.size === 0) { 436 + store.subscribers.delete(key); 437 + } 438 + }; 439 + } 440 + 441 + /** 442 + * subscribe to all changes for an entity type 443 + * @param schema the entity schema 444 + * @param callback called when any entity of this type changes 445 + * @returns unsubscribe function 446 + */ 447 + subscribeType<T extends ObjectSchema>(schema: T, callback: TypeSubscriber<InferOutput<T>>): () => void { 448 + const store = this.#getStore(schema); 449 + if (!store) { 450 + throw new Error('schema is not registered'); 451 + } 452 + 453 + store.typeSubscribers.add(callback as TypeSubscriber<unknown>); 454 + 455 + return () => { 456 + store.typeSubscribers.delete(callback as TypeSubscriber<unknown>); 457 + }; 458 + } 459 + }
+55
packages/clients/cache/lib/types.ts
··· 1 + import type { BaseSchema, InferOutput, ObjectSchema } from '@atcute/lexicons/validations'; 2 + 3 + import { isLiteralSchema, isOptionalSchema } from './predicates.js'; 4 + 5 + /** entity type identifier, extracted from schema's $type literal */ 6 + export type EntityTypeId = string; 7 + 8 + /** 9 + * definition for an entity type that can be normalized 10 + * @template T the object schema type 11 + */ 12 + export interface EntityDefinition<T extends ObjectSchema = ObjectSchema> { 13 + /** the schema for this entity type */ 14 + schema: T; 15 + /** extract cache key from entity instance */ 16 + key: (entity: InferOutput<T>) => string; 17 + /** 18 + * merge strategy when entity already exists in cache 19 + * @param existing the currently cached entity 20 + * @param incoming the new entity data 21 + * @returns partial entity with fields to update 22 + */ 23 + merge?: (existing: InferOutput<T>, incoming: InferOutput<T>) => Partial<InferOutput<T>>; 24 + } 25 + 26 + /** subscriber callback type */ 27 + export type EntitySubscriber<T> = (entity: T | undefined) => void; 28 + 29 + /** type-level subscriber callback */ 30 + export type TypeSubscriber<T> = (key: string, entity: T | undefined) => void; 31 + 32 + /** 33 + * extract the $type literal value from an object schema 34 + * @param schema object schema with $type field 35 + * @returns the $type string value or undefined 36 + */ 37 + export const getTypeIdFromSchema = (schema: ObjectSchema): EntityTypeId | undefined => { 38 + const shape = schema.shape; 39 + let typeField: BaseSchema | undefined = shape.$type; 40 + 41 + if (typeField === undefined) { 42 + return undefined; 43 + } 44 + 45 + // unwrap optional 46 + if (isOptionalSchema(typeField)) { 47 + typeField = typeField.wrapped; 48 + } 49 + 50 + if (isLiteralSchema(typeField) && typeof typeField.expected === 'string') { 51 + return typeField.expected; 52 + } 53 + 54 + return undefined; 55 + };
+32
packages/clients/cache/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "@atcute/cache", 4 + "version": "0.1.0", 5 + "description": "normalized cache store for AT Protocol clients", 6 + "license": "0BSD", 7 + "repository": { 8 + "url": "https://github.com/mary-ext/atcute", 9 + "directory": "packages/clients/cache" 10 + }, 11 + "files": [ 12 + "dist/", 13 + "lib/", 14 + "!lib/**/*.bench.ts", 15 + "!lib/**/*.test.ts" 16 + ], 17 + "exports": { 18 + ".": "./dist/index.js" 19 + }, 20 + "scripts": { 21 + "build": "tsgo --project tsconfig.build.json", 22 + "test": "vitest run", 23 + "prepublish": "rm -rf dist; pnpm run build" 24 + }, 25 + "dependencies": { 26 + "@atcute/lexicons": "workspace:^" 27 + }, 28 + "devDependencies": { 29 + "@atcute/bluesky": "workspace:^", 30 + "vitest": "^4.0.14" 31 + } 32 + }
+4
packages/clients/cache/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["**/*.test.ts"] 4 + }
+24
packages/clients/cache/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "outDir": "dist/", 4 + "esModuleInterop": true, 5 + "skipLibCheck": true, 6 + "target": "ESNext", 7 + "allowJs": true, 8 + "resolveJsonModule": true, 9 + "moduleDetection": "force", 10 + "isolatedModules": true, 11 + "verbatimModuleSyntax": true, 12 + "strict": true, 13 + "noImplicitOverride": true, 14 + "noUnusedLocals": true, 15 + "noUnusedParameters": true, 16 + "useDefineForClassFields": false, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true 22 + }, 23 + "include": ["lib"] 24 + }
+13
pnpm-lock.yaml
··· 119 119 specifier: workspace:^ 120 120 version: link:../../utilities/tid 121 121 122 + packages/clients/cache: 123 + dependencies: 124 + '@atcute/lexicons': 125 + specifier: workspace:^ 126 + version: link:../../lexicons/lexicons 127 + devDependencies: 128 + '@atcute/bluesky': 129 + specifier: workspace:^ 130 + version: link:../../definitions/bluesky 131 + vitest: 132 + specifier: ^4.0.14 133 + version: 4.0.14(@types/node@24.10.1)(@vitest/browser-playwright@4.0.14)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 134 + 122 135 packages/clients/client: 123 136 dependencies: 124 137 '@atcute/identity':