From b804f267ccc50591b4f53fb42efc76becccb7575 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 13 Aug 2026 22:26:14 +0300 Subject: [PATCH] [SDK] Widen `#/types/bsky` views to dual-world unions (#11353) Co-authored-by: Claude Fable 5 --- src/types/bsky/__tests__/post.test.ts | 277 +++++++++++++++++++ src/types/bsky/__tests__/starterPack.test.ts | 99 +++++++ src/types/bsky/post.ts | 132 ++++++--- src/types/bsky/profile.ts | 15 +- src/types/bsky/starterPack.ts | 49 +++- 5 files changed, 529 insertions(+), 43 deletions(-) create mode 100644 src/types/bsky/__tests__/post.test.ts create mode 100644 src/types/bsky/__tests__/starterPack.test.ts diff --git a/src/types/bsky/__tests__/post.test.ts b/src/types/bsky/__tests__/post.test.ts new file mode 100644 index 0000000000..6db1d23878 --- /dev/null +++ b/src/types/bsky/__tests__/post.test.ts @@ -0,0 +1,277 @@ +import { + type $Typed as $TypedApi, + type AppBskyEmbedRecord, + type AppBskyFeedDefs, +} from '@atproto/api' +import {type $Typed} from '@atproto/lex' + +import {type app} from '#/lexicons' +import { + type Embed, + type EmbedType, + parseEmbed, + parseEmbedRecordView, +} from '#/types/bsky/post' + +const now = () => new Date().toISOString() + +const imagesView = { + $type: 'app.bsky.embed.images#view', + images: [ + { + $type: 'app.bsky.embed.images#viewImage', + thumb: 'https://example.com/thumb.jpg', + fullsize: 'https://example.com/full.jpg', + alt: 'alt text', + }, + ], +} + +const galleryView = { + $type: 'app.bsky.embed.gallery#view', + items: [ + { + $type: 'app.bsky.embed.gallery#viewImage', + thumbnail: 'https://example.com/thumb.jpg', + fullsize: 'https://example.com/full.jpg', + alt: 'alt text', + aspectRatio: {width: 1, height: 1}, + }, + ], +} + +const externalView = { + $type: 'app.bsky.embed.external#view', + external: { + $type: 'app.bsky.embed.external#viewExternal', + uri: 'https://example.com', + title: 'title', + description: 'description', + }, +} + +const videoView = { + $type: 'app.bsky.embed.video#view', + cid: 'bafyvideo', + playlist: 'https://example.com/playlist.m3u8', +} + +const viewRecord = { + $type: 'app.bsky.embed.record#viewRecord', + uri: 'at://did:plc:abc/app.bsky.feed.post/123', + cid: 'bafypost', + author: { + $type: 'app.bsky.actor.defs#profileViewBasic', + did: 'did:plc:abc', + handle: 'alice.test', + }, + value: {$type: 'app.bsky.feed.post', text: 'hello', createdAt: now()}, + indexedAt: now(), +} + +const recordView = { + $type: 'app.bsky.embed.record#view', + record: viewRecord, +} + +const generatorView = { + $type: 'app.bsky.feed.defs#generatorView', + uri: 'at://did:plc:abc/app.bsky.feed.generator/feed', + cid: 'bafyfeed', + did: 'did:web:example.com', + creator: { + $type: 'app.bsky.actor.defs#profileView', + did: 'did:plc:abc', + handle: 'alice.test', + }, + displayName: 'Cool feed', + indexedAt: now(), +} + +const starterPackViewBasic = { + $type: 'app.bsky.graph.defs#starterPackViewBasic', + uri: 'at://did:plc:abc/app.bsky.graph.starterpack/123', + cid: 'bafypack', + record: {}, + creator: { + $type: 'app.bsky.actor.defs#profileViewBasic', + did: 'did:plc:abc', + handle: 'alice.test', + }, + indexedAt: now(), +} + +/** + * Casts a fixture into the `parseEmbed` input position. The fixtures are plain + * objects standing in for app view responses; the guards under test only read + * `$type`, so the structural detail beyond that is not load-bearing. + */ +const asEmbed = (v: unknown) => v as app.bsky.feed.defs.PostView['embed'] + +/* + * Type-level assertions for the dual-world widening. These are compile-time + * only: each widened arm must accept both the `#/lexicons` view and the + * `@atproto/api` view, and `parseEmbed` must accept a `PostView.embed` from + * either world, because both worlds have live producers. + */ +type Assignable = From extends To ? true : false +type Expect = T + +type _PostArmAcceptsNewWorld = Expect< + Assignable< + {type: 'post'; view: $Typed}, + EmbedType<'post'> + > +> +type _PostArmAcceptsOldWorld = Expect< + Assignable< + {type: 'post'; view: $TypedApi}, + EmbedType<'post'> + > +> +type _ParseEmbedAcceptsNewWorld = Expect< + Assignable< + app.bsky.feed.defs.PostView['embed'], + Parameters[0] + > +> +type _ParseEmbedAcceptsOldWorld = Expect< + Assignable< + AppBskyFeedDefs.PostView['embed'], + Parameters[0] + > +> +type _ParseEmbedReturnsEmbed = Expect< + Assignable, Embed> +> + +describe('types/bsky/post parseEmbed', () => { + it('parses an images view', () => { + const embed = parseEmbed(asEmbed(imagesView)) + expect(embed.type).toBe('images') + if (embed.type === 'images') { + expect(embed.view.images).toHaveLength(1) + } + }) + + it('parses a gallery view', () => { + const embed = parseEmbed(asEmbed(galleryView)) + expect(embed.type).toBe('gallery') + if (embed.type === 'gallery') { + expect(embed.view.items).toHaveLength(1) + } + }) + + it('parses an external view', () => { + const embed = parseEmbed(asEmbed(externalView)) + expect(embed.type).toBe('link') + if (embed.type === 'link') { + expect(embed.view.external.uri).toBe('https://example.com') + } + }) + + it('parses a video view', () => { + const embed = parseEmbed(asEmbed(videoView)) + expect(embed.type).toBe('video') + if (embed.type === 'video') { + expect(embed.view.playlist).toBe('https://example.com/playlist.m3u8') + } + }) + + it('parses a record view through to its inner record', () => { + const embed = parseEmbed(asEmbed(recordView)) + expect(embed.type).toBe('post') + if (embed.type === 'post') { + expect(embed.view.uri).toBe('at://did:plc:abc/app.bsky.feed.post/123') + } + }) + + it('parses a recordWithMedia view into both halves', () => { + const embed = parseEmbed( + asEmbed({ + $type: 'app.bsky.embed.recordWithMedia#view', + record: recordView, + media: imagesView, + }), + ) + expect(embed.type).toBe('post_with_media') + if (embed.type === 'post_with_media') { + expect(embed.view.type).toBe('post') + expect(embed.media.type).toBe('images') + } + }) + + it('returns the unknown arm for an unrecognised $type', () => { + const embed = parseEmbed(asEmbed({$type: 'com.example.someEmbed#view'})) + expect(embed).toEqual({type: 'unknown', view: null}) + }) + + it('returns the unknown arm for undefined', () => { + expect(parseEmbed(undefined)).toEqual({type: 'unknown', view: null}) + }) + + it('does not match an embed with no $type', () => { + expect(parseEmbed(asEmbed({images: []}))).toEqual({ + type: 'unknown', + view: null, + }) + }) +}) + +describe('types/bsky/post parseEmbedRecordView', () => { + const asRecordView = (record: unknown) => + ({record}) as app.bsky.embed.record.View + + it('parses each known record variant', () => { + expect(parseEmbedRecordView(asRecordView(viewRecord)).type).toBe('post') + expect( + parseEmbedRecordView( + asRecordView({$type: 'app.bsky.embed.record#viewNotFound'}), + ).type, + ).toBe('post_not_found') + expect( + parseEmbedRecordView( + asRecordView({$type: 'app.bsky.embed.record#viewBlocked'}), + ).type, + ).toBe('post_blocked') + expect( + parseEmbedRecordView( + asRecordView({$type: 'app.bsky.embed.record#viewDetached'}), + ).type, + ).toBe('post_detached') + expect(parseEmbedRecordView(asRecordView(generatorView)).type).toBe('feed') + expect( + parseEmbedRecordView( + asRecordView({$type: 'app.bsky.graph.defs#listView'}), + ).type, + ).toBe('list') + expect( + parseEmbedRecordView( + asRecordView({$type: 'app.bsky.labeler.defs#labelerView'}), + ).type, + ).toBe('labeler') + expect(parseEmbedRecordView(asRecordView(starterPackViewBasic)).type).toBe( + 'starter_pack', + ) + }) + + it('returns the unknown arm for an unrecognised record', () => { + expect( + parseEmbedRecordView(asRecordView({$type: 'com.example.thing'})), + ).toEqual({type: 'unknown', view: null}) + }) +}) + +describe('types/bsky/post Embed dual-world types', () => { + it('has the compile-time dual-world assertions above satisfied', () => { + /* + * The assertions are the `_*` types declared at module scope; a failure + * surfaces as a typecheck error, not a test failure. This case exists so the + * intent is visible when reading the suite. + */ + const parsedNewWorld: Embed = parseEmbed( + asEmbed({$type: 'app.bsky.embed.images#view', images: []}), + ) + expect(parsedNewWorld.type).toBe('images') + }) +}) diff --git a/src/types/bsky/__tests__/starterPack.test.ts b/src/types/bsky/__tests__/starterPack.test.ts new file mode 100644 index 0000000000..b973766f2e --- /dev/null +++ b/src/types/bsky/__tests__/starterPack.test.ts @@ -0,0 +1,99 @@ +import {type AppBskyGraphDefs} from '@atproto/api' + +import {type app} from '#/lexicons' +import { + type AnyStarterPackView, + isBasicView, + isView, +} from '#/types/bsky/starterPack' + +const now = () => new Date().toISOString() + +const creator = { + $type: 'app.bsky.actor.defs#profileViewBasic', + did: 'did:plc:abc', + handle: 'alice.test', +} + +const basicView = { + $type: 'app.bsky.graph.defs#starterPackViewBasic', + uri: 'at://did:plc:abc/app.bsky.graph.starterpack/123', + cid: 'bafypack', + record: {}, + creator, + indexedAt: now(), +} + +const fullView = { + ...basicView, + $type: 'app.bsky.graph.defs#starterPackView', +} + +/* + * Type-level assertions for the dual-world widening: the alias must accept a + * starter pack view from either world, because both have live producers. These + * are compile-time only - a failure surfaces as a typecheck error. + */ +type Assignable = From extends To ? true : false +type Expect = T + +type _AcceptsNewBasicView = Expect< + Assignable +> +type _AcceptsNewFullView = Expect< + Assignable +> +type _AcceptsOldBasicView = Expect< + Assignable +> +type _AcceptsOldFullView = Expect< + Assignable +> + +describe('types/bsky/starterPack guards', () => { + describe('isBasicView', () => { + it('accepts a basic view', () => { + expect(isBasicView(basicView)).toBe(true) + }) + + it('rejects the full view', () => { + expect(isBasicView(fullView)).toBe(false) + }) + + it('rejects a missing or absent $type', () => { + expect(isBasicView({uri: 'at://x', cid: 'y'})).toBe(false) + expect(isBasicView(null)).toBe(false) + expect(isBasicView(undefined)).toBe(false) + expect(isBasicView('string')).toBe(false) + }) + }) + + describe('isView', () => { + it('accepts the full view', () => { + expect(isView(fullView)).toBe(true) + }) + + it('rejects the basic view', () => { + expect(isView(basicView)).toBe(false) + }) + + it('rejects a missing or absent $type', () => { + expect(isView({uri: 'at://x', cid: 'y'})).toBe(false) + expect(isView(null)).toBe(false) + expect(isView(undefined)).toBe(false) + }) + }) + + it('narrows a view from either world to a readable shape', () => { + /* + * The `$type` string is world-independent, so one guard narrows values from + * both producers; the narrowed union stays structurally readable. + */ + for (const view of [basicView, fullView]) { + if (isBasicView(view) || isView(view)) { + expect(typeof view.uri).toBe('string') + expect(typeof view.creator.did).toBe('string') + } + } + }) +}) diff --git a/src/types/bsky/post.ts b/src/types/bsky/post.ts index 43621ef63b..2c8e3f4d87 100644 --- a/src/types/bsky/post.ts +++ b/src/types/bsky/post.ts @@ -1,64 +1,110 @@ import { - type $Typed, - AppBskyEmbedExternal, - AppBskyEmbedGallery, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyEmbedVideo, - AppBskyFeedDefs, - AppBskyGraphDefs, - AppBskyLabelerDefs, + type $Typed as $TypedApi, + type AppBskyEmbedExternal, + type AppBskyEmbedGallery, + type AppBskyEmbedImages, + type AppBskyEmbedRecord, + type AppBskyEmbedVideo, + type AppBskyFeedDefs, + type AppBskyGraphDefs, + type AppBskyLabelerDefs, } from '@atproto/api' +import {type $Typed} from '@atproto/lex' +import {app} from '#/lexicons' +import {isType} from '#/types/bsky' + +/* + * Each `view` slot below accepts both the generated `#/lexicons` view and the + * `@atproto/api` view of the same def, because both worlds have live producers: + * `parseEmbed` narrows with the `#/lexicons` schemas and so returns new-world + * views, while call sites that build an `Embed` by hand still pass views + * produced through the `@atproto/api` agent. + * + * The two worlds share the same `$type` strings, so the guards in this file + * narrow a value from either producer; only the static type differs. + * + * TODO: remove the @atproto/api arms once all producers emit #/lexicons views + */ export type Embed = | { type: 'post' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_not_found' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_blocked' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_detached' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'feed' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'list' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'labeler' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'starter_pack' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'images' - view: $Typed + /* + * Only the `@atproto/api` view, unlike the other arms: the ImageEmbed + * consumer reads `view.images` directly, and the `#/lexicons` view is + * assignable to this slot, so `parseEmbed`'s new-world value flows in + * while the consumer keeps a single structural shape to read from. + */ + view: $TypedApi } | { type: 'gallery' - view: $Typed + /* + * Old-world only for the same reason as the `images` arm above: the + * consumer narrows `view.items` with `AppBskyEmbedGallery.isViewImage`, + * which cannot narrow the new view's `Unknown$TypedObject` arm. + */ + view: $TypedApi } | { type: 'link' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'video' - view: $Typed + view: + | $Typed + | $TypedApi } | { type: 'post_with_media' @@ -72,43 +118,45 @@ export type Embed = export type EmbedType = Extract -export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed { - if (AppBskyEmbedRecord.isViewRecord(record)) { +export function parseEmbedRecordView({ + record, +}: app.bsky.embed.record.View): Embed { + if (isType(app.bsky.embed.record.viewRecord, record)) { return { type: 'post', view: record, } - } else if (AppBskyEmbedRecord.isViewNotFound(record)) { + } else if (isType(app.bsky.embed.record.viewNotFound, record)) { return { type: 'post_not_found', view: record, } - } else if (AppBskyEmbedRecord.isViewBlocked(record)) { + } else if (isType(app.bsky.embed.record.viewBlocked, record)) { return { type: 'post_blocked', view: record, } - } else if (AppBskyEmbedRecord.isViewDetached(record)) { + } else if (isType(app.bsky.embed.record.viewDetached, record)) { return { type: 'post_detached', view: record, } - } else if (AppBskyFeedDefs.isGeneratorView(record)) { + } else if (isType(app.bsky.feed.defs.generatorView, record)) { return { type: 'feed', view: record, } - } else if (AppBskyGraphDefs.isListView(record)) { + } else if (isType(app.bsky.graph.defs.listView, record)) { return { type: 'list', view: record, } - } else if (AppBskyLabelerDefs.isLabelerView(record)) { + } else if (isType(app.bsky.labeler.defs.labelerView, record)) { return { type: 'labeler', view: record, } - } else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) { + } else if (isType(app.bsky.graph.defs.starterPackViewBasic, record)) { return { type: 'starter_pack', view: record, @@ -121,30 +169,38 @@ export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed { } } -export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed { - if (AppBskyEmbedImages.isView(embed)) { +export function parseEmbed( + /* + * Accepts a `PostView.embed` from either world; the `#/lexicons` guards below + * narrow on `$type`, which is world-independent. + */ + embed: + | app.bsky.feed.defs.PostView['embed'] + | AppBskyFeedDefs.PostView['embed'], +): Embed { + if (isType(app.bsky.embed.images.view, embed)) { return { type: 'images', view: embed, } - } else if (AppBskyEmbedGallery.isView(embed)) { + } else if (isType(app.bsky.embed.gallery.view, embed)) { return { type: 'gallery', view: embed, } - } else if (AppBskyEmbedExternal.isView(embed)) { + } else if (isType(app.bsky.embed.external.view, embed)) { return { type: 'link', view: embed, } - } else if (AppBskyEmbedVideo.isView(embed)) { + } else if (isType(app.bsky.embed.video.view, embed)) { return { type: 'video', view: embed, } - } else if (AppBskyEmbedRecord.isView(embed)) { + } else if (isType(app.bsky.embed.record.view, embed)) { return parseEmbedRecordView(embed) - } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + } else if (isType(app.bsky.embed.recordWithMedia.view, embed)) { return { type: 'post_with_media', view: parseEmbedRecordView(embed.record), diff --git a/src/types/bsky/profile.ts b/src/types/bsky/profile.ts index 12c8146ae1..4231a6d0fe 100644 --- a/src/types/bsky/profile.ts +++ b/src/types/bsky/profile.ts @@ -1,9 +1,22 @@ import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api' +import {type app, type chat} from '#/lexicons' + /** - * Matches any profile view exported by our SDK + * Matches any profile view exported by our SDK, in either world. + * + * Both the generated `#/lexicons` views and the `@atproto/api` views are + * accepted because both are live producers: queries migrated to the lexicon + * client emit the former, unmigrated ones emit the latter, and consumers of + * this alias take profiles from both. + * + * TODO: remove the @atproto/api arms once all producers emit #/lexicons views */ export type AnyProfileView = + | app.bsky.actor.defs.ProfileViewBasic + | app.bsky.actor.defs.ProfileView + | app.bsky.actor.defs.ProfileViewDetailed + | chat.bsky.actor.defs.ProfileViewBasic | AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewDetailed diff --git a/src/types/bsky/starterPack.ts b/src/types/bsky/starterPack.ts index 0064e16bc6..23df0e4631 100644 --- a/src/types/bsky/starterPack.ts +++ b/src/types/bsky/starterPack.ts @@ -1,11 +1,52 @@ -import {AppBskyGraphDefs} from '@atproto/api' +import {type AppBskyGraphDefs} from '@atproto/api' -export const isBasicView = AppBskyGraphDefs.isStarterPackViewBasic -export const isView = AppBskyGraphDefs.isStarterPackView +import {app} from '#/lexicons' + +/* + * `$type`-only guards for starter pack views. They compare against the + * `#/lexicons` schema's `$type` string rather than delegating to the schema's + * `isTypeOf` (which treats a missing `$type` as a match), matching the + * present-and-equal semantics of the old `@atproto/api` + * `AppBskyGraphDefs.isStarterPackView*` helpers. + * + * The `$type` string is identical in both worlds, so a single check narrows a + * value from either producer; the narrowed type is the union of both worlds' + * views for the same reason {@link AnyStarterPackView} is. + */ +export function isBasicView( + v: unknown, +): v is + | app.bsky.graph.defs.StarterPackViewBasic + | AppBskyGraphDefs.StarterPackViewBasic { + return ( + v != null && + typeof v === 'object' && + (v as {$type?: unknown}).$type === + app.bsky.graph.defs.starterPackViewBasic.$type + ) +} + +export function isView( + v: unknown, +): v is app.bsky.graph.defs.StarterPackView | AppBskyGraphDefs.StarterPackView { + return ( + v != null && + typeof v === 'object' && + (v as {$type?: unknown}).$type === app.bsky.graph.defs.starterPackView.$type + ) +} /** - * Matches any starter pack view exported by our SDK + * Matches any starter pack view exported by our SDK, in either world. + * + * Both the generated `#/lexicons` views and the `@atproto/api` views are + * accepted because both are live producers: queries migrated to the lexicon + * client emit the former, unmigrated ones emit the latter. + * + * TODO: remove the @atproto/api arms once all producers emit #/lexicons views */ export type AnyStarterPackView = + | app.bsky.graph.defs.StarterPackViewBasic + | app.bsky.graph.defs.StarterPackView | AppBskyGraphDefs.StarterPackViewBasic | AppBskyGraphDefs.StarterPackView