[SDK] Widen #/types/bsky views to dual-world unions (#11353)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:14 +03:00
committed by GitHub
parent c52e730112
commit b804f267cc
5 changed files with 529 additions and 43 deletions
+277
View File
@@ -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, To> = From extends To ? true : false
type Expect<T extends true> = T
type _PostArmAcceptsNewWorld = Expect<
Assignable<
{type: 'post'; view: $Typed<app.bsky.embed.record.ViewRecord>},
EmbedType<'post'>
>
>
type _PostArmAcceptsOldWorld = Expect<
Assignable<
{type: 'post'; view: $TypedApi<AppBskyEmbedRecord.ViewRecord>},
EmbedType<'post'>
>
>
type _ParseEmbedAcceptsNewWorld = Expect<
Assignable<
app.bsky.feed.defs.PostView['embed'],
Parameters<typeof parseEmbed>[0]
>
>
type _ParseEmbedAcceptsOldWorld = Expect<
Assignable<
AppBskyFeedDefs.PostView['embed'],
Parameters<typeof parseEmbed>[0]
>
>
type _ParseEmbedReturnsEmbed = Expect<
Assignable<ReturnType<typeof parseEmbed>, 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')
})
})
@@ -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, To> = From extends To ? true : false
type Expect<T extends true> = T
type _AcceptsNewBasicView = Expect<
Assignable<app.bsky.graph.defs.StarterPackViewBasic, AnyStarterPackView>
>
type _AcceptsNewFullView = Expect<
Assignable<app.bsky.graph.defs.StarterPackView, AnyStarterPackView>
>
type _AcceptsOldBasicView = Expect<
Assignable<AppBskyGraphDefs.StarterPackViewBasic, AnyStarterPackView>
>
type _AcceptsOldFullView = Expect<
Assignable<AppBskyGraphDefs.StarterPackView, AnyStarterPackView>
>
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')
}
}
})
})
+94 -38
View File
@@ -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<AppBskyEmbedRecord.ViewRecord>
view:
| $Typed<app.bsky.embed.record.ViewRecord>
| $TypedApi<AppBskyEmbedRecord.ViewRecord>
}
| {
type: 'post_not_found'
view: $Typed<AppBskyEmbedRecord.ViewNotFound>
view:
| $Typed<app.bsky.embed.record.ViewNotFound>
| $TypedApi<AppBskyEmbedRecord.ViewNotFound>
}
| {
type: 'post_blocked'
view: $Typed<AppBskyEmbedRecord.ViewBlocked>
view:
| $Typed<app.bsky.embed.record.ViewBlocked>
| $TypedApi<AppBskyEmbedRecord.ViewBlocked>
}
| {
type: 'post_detached'
view: $Typed<AppBskyEmbedRecord.ViewDetached>
view:
| $Typed<app.bsky.embed.record.ViewDetached>
| $TypedApi<AppBskyEmbedRecord.ViewDetached>
}
| {
type: 'feed'
view: $Typed<AppBskyFeedDefs.GeneratorView>
view:
| $Typed<app.bsky.feed.defs.GeneratorView>
| $TypedApi<AppBskyFeedDefs.GeneratorView>
}
| {
type: 'list'
view: $Typed<AppBskyGraphDefs.ListView>
view:
| $Typed<app.bsky.graph.defs.ListView>
| $TypedApi<AppBskyGraphDefs.ListView>
}
| {
type: 'labeler'
view: $Typed<AppBskyLabelerDefs.LabelerView>
view:
| $Typed<app.bsky.labeler.defs.LabelerView>
| $TypedApi<AppBskyLabelerDefs.LabelerView>
}
| {
type: 'starter_pack'
view: $Typed<AppBskyGraphDefs.StarterPackViewBasic>
view:
| $Typed<app.bsky.graph.defs.StarterPackViewBasic>
| $TypedApi<AppBskyGraphDefs.StarterPackViewBasic>
}
| {
type: 'images'
view: $Typed<AppBskyEmbedImages.View>
/*
* 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<AppBskyEmbedImages.View>
}
| {
type: 'gallery'
view: $Typed<AppBskyEmbedGallery.View>
/*
* 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<AppBskyEmbedGallery.View>
}
| {
type: 'link'
view: $Typed<AppBskyEmbedExternal.View>
view:
| $Typed<app.bsky.embed.external.View>
| $TypedApi<AppBskyEmbedExternal.View>
}
| {
type: 'video'
view: $Typed<AppBskyEmbedVideo.View>
view:
| $Typed<app.bsky.embed.video.View>
| $TypedApi<AppBskyEmbedVideo.View>
}
| {
type: 'post_with_media'
@@ -72,43 +118,45 @@ export type Embed =
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
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),
+14 -1
View File
@@ -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
+45 -4
View File
@@ -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