finish phase 4: drop @atproto/api dependency, lex-only errors, delete toLex and legacy validators
oxlint-suppressions.json gains a baseline entry for webpack.config.js: the file was never previously staged/linted (lint script covers src+modules only), and editing it exposed its pre-existing violations to lint-staged. All suppressed counts are pre-existing; this change only removes a line.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
import {RichText} from '@bsky.app/sdk/richtext'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
|
||||
|
||||
@@ -1813,5 +1813,16 @@
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"webpack.config.js": {
|
||||
"import/no-nodejs-modules": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-call": {
|
||||
"count": 11
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,6 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.34",
|
||||
"@atproto/common-web": "0.5.6",
|
||||
"@atproto/lex": "0.2.2",
|
||||
"@atproto/lex-client": "0.2.4",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* label simply drops the `https://` scheme for readability.
|
||||
*
|
||||
* Kept as a dependency-free leaf module (no #/lib/strings/url-helpers import)
|
||||
* so its unit tests stay fast and isolated from the heavy @atproto/api graph.
|
||||
* so its unit tests stay fast and isolated from the heavy SDK graph.
|
||||
*/
|
||||
|
||||
function stripLeadingAt(handle: string): string {
|
||||
|
||||
@@ -7,22 +7,20 @@
|
||||
jest.unmock('multiformats/cid')
|
||||
jest.unmock('multiformats/hashes/hasher')
|
||||
|
||||
import {BlobRef} from '@atproto/api'
|
||||
import {CID} from 'multiformats/cid'
|
||||
|
||||
import {computeCid} from '#/lib/api/computeCid'
|
||||
import {type app, type com} from '#/lexicons'
|
||||
|
||||
/*
|
||||
* Golden-CID regression test for the composer post pipeline (design section F).
|
||||
* Golden-CID regression test for the composer post pipeline.
|
||||
*
|
||||
* `computeCid` hashes a post record in the client so a thread's later posts can
|
||||
* reference earlier posts by CID before the server assigns them. The hash is
|
||||
* byte-sensitive: any drift in how records (especially blobs) are serialized to
|
||||
* DAG-CBOR silently produces the wrong CID and breaks reply chains with NO type
|
||||
* error. These golden values were captured from the PRE-migration `computeCid`
|
||||
* (the `instanceof BlobRef` path) and MUST remain byte-identical after the guard
|
||||
* is changed to the structural `isBlobRef` shape check.
|
||||
* error. These golden values MUST remain byte-identical - they gate the
|
||||
* structural `isBlobRef` shape check.
|
||||
*
|
||||
* The blob CID below is a fixed, deterministic CIDv1/raw/sha256 used purely as a
|
||||
* stable fixture - it is not derived from any real upload.
|
||||
@@ -30,10 +28,7 @@ import {type app, type com} from '#/lexicons'
|
||||
const BLOB_CID = 'bafkreieq5jui4j25lacwomsqgjeswwl3y5zcdrresptwgmfylxo2depppq'
|
||||
|
||||
/**
|
||||
* Build a post record with an image embed whose blob is the given value. Used to
|
||||
* prove that a `@atproto/api` `BlobRef` class instance (the shape the not-yet
|
||||
* -migrated video path still yields) and a plain-JSON lex blob (the shape lex
|
||||
* `uploadBlob` returns) hash to the SAME CID.
|
||||
* Build a post record with an image embed whose blob is the given value.
|
||||
*/
|
||||
function postWithImageBlob(blob: unknown): app.bsky.feed.post.Main {
|
||||
return {
|
||||
@@ -65,22 +60,14 @@ describe('computeCid', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('case 2: record whose embed carries a BlobRef class instance', async () => {
|
||||
const blob = new BlobRef(CID.parse(BLOB_CID), 'image/jpeg', 12345)
|
||||
expect(await computeCid(postWithImageBlob(blob))).toBe(
|
||||
'bafyreiem7g6vja66nebr7he4fshfnlyndyldbvle2n265oixscmepjcbii',
|
||||
)
|
||||
})
|
||||
|
||||
it('case 2b: a plain-JSON blob object hashes identically to the class instance', async () => {
|
||||
it('case 2: record whose embed carries a plain-JSON lex blob', async () => {
|
||||
/*
|
||||
* This is the post-migration shape: lex `uploadBlob` returns a plain object
|
||||
* `{$type: 'blob', ref, mimeType, size}` (with `ref` a parsed CID), not a
|
||||
* `BlobRef` class instance. The structural `isBlobRef` guard must treat it
|
||||
* exactly like the class instance so the CID is unchanged. Under the
|
||||
* pre-change `instanceof` code this case already matches because the plain
|
||||
* object walks through `prepareForHashing` unchanged and DAG-CBOR encodes
|
||||
* its CID `ref` the same way `.ipld()` does.
|
||||
* The blob shape lex `uploadBlob` returns: a plain object
|
||||
* `{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID. The
|
||||
* structural `isBlobRef` guard passes it through `prepareForHashing`
|
||||
* untouched and DAG-CBOR encodes its CID `ref` as a CID link. The golden
|
||||
* CID below is the byte-identical value the pre-migration `BlobRef` class
|
||||
* instance produced via `.ipld()`.
|
||||
*/
|
||||
const blob = {
|
||||
$type: 'blob' as const,
|
||||
|
||||
@@ -46,10 +46,9 @@ export async function computeCid(
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a plain-JSON lexicon blob, the shape lex `uploadBlob` now returns
|
||||
* (`{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID). Replaces
|
||||
* the old `instanceof BlobRef` check, since lex blobs are plain objects, not
|
||||
* class instances (design section F).
|
||||
* True for a plain-JSON lexicon blob, the shape lex `uploadBlob` returns
|
||||
* (`{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID). Lex blobs
|
||||
* are plain objects, not class instances.
|
||||
*/
|
||||
function isBlobRef(v: unknown): boolean {
|
||||
if (v == null || typeof v !== 'object') return false
|
||||
@@ -57,24 +56,6 @@ function isBlobRef(v: unknown): boolean {
|
||||
return o.$type === 'blob' && 'ref' in o && 'mimeType' in o
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a legacy `@atproto/api` `BlobRef` class instance. During the
|
||||
* migration the video embed path still yields these (its blob comes from the
|
||||
* not-yet-migrated `app.bsky.video.getJobStatus` bridge call), so we must keep
|
||||
* handling them here even though the composer's own uploads are now plain lex
|
||||
* blobs. A class instance is duck-typed by its `ipld()` method plus the
|
||||
* `ref`/`mimeType` fields; it has NO `$type` and a non-plain prototype, so it
|
||||
* would otherwise slip past both `isBlobRef` and `isPlainObject` and be encoded
|
||||
* wrong - silently breaking video reply-chain CIDs.
|
||||
*/
|
||||
function isBlobRefInstance(
|
||||
v: unknown,
|
||||
): v is {ipld: () => unknown; ref: unknown; mimeType: unknown} {
|
||||
if (v == null || typeof v !== 'object') return false
|
||||
const o = v as Record<string, unknown>
|
||||
return typeof o.ipld === 'function' && 'ref' in o && 'mimeType' in o
|
||||
}
|
||||
|
||||
// Returns a transformed version of the object for use in DAG-CBOR.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function prepareForHashing(v: any): any {
|
||||
@@ -87,16 +68,6 @@ function prepareForHashing(v: any): any {
|
||||
return v
|
||||
}
|
||||
|
||||
/*
|
||||
* A legacy `BlobRef` class instance must be converted via `ipld()` to the
|
||||
* plain `{$type, ref, mimeType, size}` object; encoding the instance directly
|
||||
* would emit its internal `original` field and omit `$type`, producing the
|
||||
* wrong CID. `ipld()` returns exactly what `isBlobRef` accepts above.
|
||||
*/
|
||||
if (isBlobRefInstance(v)) {
|
||||
return v.ipld()
|
||||
}
|
||||
|
||||
// Walk through arrays
|
||||
if (Array.isArray(v)) {
|
||||
let pure = true
|
||||
|
||||
@@ -402,10 +402,9 @@ async function resolveMedia(
|
||||
return {
|
||||
$type: 'app.bsky.embed.video',
|
||||
/*
|
||||
* The video blob is a legacy `@atproto/api` BlobRef from the not-yet
|
||||
* -migrated video pipeline (getJobStatus, in composer state/video). Its
|
||||
* structural shape matches the lexicon blob field; the CID hasher handles
|
||||
* both class instances and plain lex blobs (see computeCid).
|
||||
* The video blob is a plain lex blob from the video pipeline
|
||||
* (getJobStatus, in composer state/video). Its structural shape matches
|
||||
* the lexicon blob field and the CID hasher (see computeCid).
|
||||
*/
|
||||
video: videoDraft.pendingPublish.blobRef,
|
||||
alt: videoDraft.altText || undefined,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {XRPCError} from '@atproto/api'
|
||||
import {
|
||||
type Procedure,
|
||||
type Query,
|
||||
@@ -15,21 +14,9 @@ import {
|
||||
import {isErrorMaybeAppPasswordPermissions, isNetworkError} from '../errors'
|
||||
|
||||
/**
|
||||
* Old-world fixture: `@atproto/api` XRPCError. `.headers` is a plain record,
|
||||
* `.error` is the lexicon code string, `.status` a numeric ResponseType enum.
|
||||
*/
|
||||
function oldError(
|
||||
status: number,
|
||||
error: string,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
return new XRPCError(status, error, undefined, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* New-world fixture: lex `XrpcResponseError`. Built from a WHATWG `Response`
|
||||
* (so `.headers` is a `Headers` object and `.status` is the numeric HTTP
|
||||
* status) plus a JSON error payload carrying `.error`.
|
||||
* Lex `XrpcResponseError` fixture. Built from a WHATWG `Response` (so
|
||||
* `.headers` is a `Headers` object and `.status` is the numeric HTTP status)
|
||||
* plus a JSON error payload carrying `.error`.
|
||||
*/
|
||||
function lexError(
|
||||
status: number,
|
||||
@@ -45,8 +32,7 @@ function lexError(
|
||||
}
|
||||
|
||||
describe('isXrpcError', () => {
|
||||
it('matches both old XRPCError and lex XrpcResponseError', () => {
|
||||
expect(isXrpcError(oldError(400, 'TokenInvalid'))).toBe(true)
|
||||
it('matches a lex XrpcResponseError', () => {
|
||||
expect(isXrpcError(lexError(400, 'TokenInvalid'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -58,8 +44,7 @@ describe('isXrpcError', () => {
|
||||
})
|
||||
|
||||
describe('getErrorStatus', () => {
|
||||
it('reads status from both worlds', () => {
|
||||
expect(typeof getErrorStatus(oldError(400, 'TokenInvalid'))).toBe('number')
|
||||
it('reads status from a lex XrpcResponseError', () => {
|
||||
expect(getErrorStatus(lexError(429, 'RateLimitExceeded'))).toBe(429)
|
||||
})
|
||||
|
||||
@@ -69,8 +54,7 @@ describe('getErrorStatus', () => {
|
||||
})
|
||||
|
||||
describe('getErrorName', () => {
|
||||
it('reads the lexicon error code from both worlds', () => {
|
||||
expect(getErrorName(oldError(400, 'TokenInvalid'))).toBe('TokenInvalid')
|
||||
it('reads the lexicon error code', () => {
|
||||
expect(getErrorName(lexError(400, 'TokenInvalid'))).toBe('TokenInvalid')
|
||||
})
|
||||
|
||||
@@ -80,12 +64,6 @@ describe('getErrorName', () => {
|
||||
})
|
||||
|
||||
describe('getErrorHeader', () => {
|
||||
it('reads a header from the old record shape (case-insensitive)', () => {
|
||||
const e = oldError(429, 'RateLimitExceeded', {'ratelimit-reset': '123'})
|
||||
expect(getErrorHeader(e, 'ratelimit-reset')).toBe('123')
|
||||
expect(getErrorHeader(e, 'RateLimit-Reset')).toBe('123')
|
||||
})
|
||||
|
||||
it('reads a header from the lex Headers object', () => {
|
||||
const e = lexError(429, 'RateLimitExceeded', {'ratelimit-reset': '123'})
|
||||
expect(getErrorHeader(e, 'ratelimit-reset')).toBe('123')
|
||||
@@ -99,10 +77,7 @@ describe('getErrorHeader', () => {
|
||||
})
|
||||
|
||||
describe('isErrorMaybeAppPasswordPermissions', () => {
|
||||
it('matches a TokenInvalid error from both worlds', () => {
|
||||
expect(
|
||||
isErrorMaybeAppPasswordPermissions(oldError(400, 'TokenInvalid')),
|
||||
).toBe(true)
|
||||
it('matches a TokenInvalid error', () => {
|
||||
expect(
|
||||
isErrorMaybeAppPasswordPermissions(lexError(400, 'TokenInvalid')),
|
||||
).toBe(true)
|
||||
@@ -118,9 +93,6 @@ describe('isErrorMaybeAppPasswordPermissions', () => {
|
||||
})
|
||||
|
||||
it('does not match unrelated XRPC errors', () => {
|
||||
expect(
|
||||
isErrorMaybeAppPasswordPermissions(oldError(400, 'InvalidRequest')),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isErrorMaybeAppPasswordPermissions(lexError(400, 'InvalidRequest')),
|
||||
).toBe(false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {t} from '@lingui/core/macro'
|
||||
|
||||
import {getErrorName, getErrorStatus, isXrpcError} from '#/lib/xrpc-error'
|
||||
import {getErrorName, isXrpcError} from '#/lib/xrpc-error'
|
||||
|
||||
export function cleanError(e: unknown): string {
|
||||
if (!e) {
|
||||
@@ -87,13 +87,11 @@ export function isCancelledError(e: unknown) {
|
||||
return str.includes('cancel')
|
||||
}
|
||||
|
||||
// TODO Replace this with error.shouldRetry() when available. -dsb
|
||||
const RETRYABLE_ERRORS = [408, 425, 429, 500, 502, 503, 504, 522, 524]
|
||||
export function isRetryableHttpStatus(status: number) {
|
||||
return RETRYABLE_ERRORS.includes(status)
|
||||
}
|
||||
|
||||
export function shouldRetryError(e: unknown) {
|
||||
const status = getErrorStatus(e)
|
||||
return status !== undefined && isRetryableHttpStatus(status)
|
||||
return isXrpcError(e) && e.shouldRetry()
|
||||
}
|
||||
|
||||
+14
-24
@@ -1,44 +1,34 @@
|
||||
import {XRPCError} from '@atproto/api'
|
||||
import {XrpcError, XrpcResponseError} from '@atproto/lex-client'
|
||||
|
||||
/**
|
||||
* True for an XRPC error from either the old bridge agent (`@atproto/api`
|
||||
* `XRPCError`) or a lex `Client` (`@atproto/lex-client` `XrpcError`, the
|
||||
* abstract base of `XrpcResponseError`/`XrpcInvalidResponseError`/
|
||||
* `XrpcInternalError`). During the migration both worlds can throw, so matchers
|
||||
* must accept both.
|
||||
* True for an XRPC error from a lex `Client` (`@atproto/lex-client` `XrpcError`,
|
||||
* the abstract base of `XrpcResponseError`/`XrpcInvalidResponseError`/
|
||||
* `XrpcInternalError`).
|
||||
*/
|
||||
export function isXrpcError(e: unknown): e is XRPCError | XrpcError {
|
||||
return e instanceof XRPCError || e instanceof XrpcError
|
||||
export function isXrpcError(e: unknown): e is XrpcError {
|
||||
return e instanceof XrpcError
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status, or undefined if not an XRPC error / no response. Only lex
|
||||
* HTTP status, or undefined if not an XRPC error / no response. Only
|
||||
* `XrpcResponseError` (a server response) carries a status; the internal/fetch
|
||||
* lex errors do not.
|
||||
*/
|
||||
export function getErrorStatus(e: unknown): number | undefined {
|
||||
if (e instanceof XRPCError) return e.status
|
||||
if (e instanceof XrpcResponseError) return e.status
|
||||
return undefined
|
||||
return e instanceof XrpcResponseError ? e.status : undefined
|
||||
}
|
||||
|
||||
/** The lexicon error code (`err.error`), from either world. */
|
||||
/** The lexicon error code (`err.error`). */
|
||||
export function getErrorName(e: unknown): string | undefined {
|
||||
if (isXrpcError(e)) return (e as {error?: string}).error
|
||||
return undefined
|
||||
return isXrpcError(e) ? (e as {error?: string}).error : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a response header off an XRPC error, normalizing the shape change:
|
||||
* old XRPCError.headers is a plain record; lex XrpcResponseError.headers is a
|
||||
* WHATWG Headers object.
|
||||
* Read a response header off an XRPC error. Only `XrpcResponseError` carries a
|
||||
* response; its `.headers` is a WHATWG `Headers` object.
|
||||
*/
|
||||
export function getErrorHeader(e: unknown, name: string): string | undefined {
|
||||
if (e instanceof XrpcResponseError) return e.headers.get(name) ?? undefined
|
||||
if (e instanceof XRPCError) {
|
||||
const h = (e as {headers?: Record<string, string>}).headers
|
||||
return h?.[name.toLowerCase()]
|
||||
}
|
||||
return undefined
|
||||
return e instanceof XrpcResponseError
|
||||
? (e.headers.get(name) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {ScreenTransition} from '#/components/ScreenTransition'
|
||||
import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {type app} from '#/lexicons'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
function keyExtractor(item: bsky.profile.AnyProfileView) {
|
||||
|
||||
@@ -66,8 +66,8 @@ function toSystemMessageView(
|
||||
* The guard disjunction is kept inline in the `if` (rather than a separate
|
||||
* boolean) so TS narrows `ev` to the union of system-message log types, all
|
||||
* of which carry a `message: SystemMessageView`. The generated lexicon types
|
||||
* are stricter than the old `@atproto/api` ones (no `[k: string]: unknown`
|
||||
* index signature), so a separate boolean would not narrow the access.
|
||||
* are strict (no `[k: string]: unknown` index signature), so a separate
|
||||
* boolean would not narrow the access.
|
||||
*/
|
||||
if (
|
||||
bsky.isType(chat.bsky.convo.defs.logAddMember, ev) ||
|
||||
|
||||
@@ -205,7 +205,7 @@ export function useFeedPreviews(
|
||||
if (item.isFallbackMarker) continue
|
||||
|
||||
const moderations = item.items.map(item =>
|
||||
// @atproto/api and yields lex-typed slice items.
|
||||
// the tuner yields lex-typed slice items.
|
||||
moderatePost(item.post, moderationOpts!),
|
||||
)
|
||||
|
||||
@@ -232,7 +232,7 @@ export function useFeedPreviews(
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
|
||||
uri: subItem.post.uri,
|
||||
// off @atproto/api and yields lex-typed slice items.
|
||||
// the tuner yields lex-typed slice items.
|
||||
post: subItem.post,
|
||||
record: subItem.record,
|
||||
moderation: moderations[i],
|
||||
|
||||
@@ -288,7 +288,7 @@ export function usePostFeedQuery(
|
||||
.map(slice => {
|
||||
const moderations = slice.items.map(item =>
|
||||
moderatePost(
|
||||
// off @atproto/api and yields lex-typed slice items.
|
||||
// the tuner yields lex-typed slice items.
|
||||
item.post,
|
||||
moderationOpts!,
|
||||
),
|
||||
@@ -341,7 +341,7 @@ export function usePostFeedQuery(
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
// off @atproto/api and yields lex-typed slice items.
|
||||
// the tuner yields lex-typed slice items.
|
||||
post: item.post,
|
||||
record: item.record,
|
||||
moderation: moderations[i],
|
||||
|
||||
@@ -289,8 +289,8 @@ export function buildSearchPostsV2Filters(
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent with timestamp parsing in @atproto/api. Only the date is used; the
|
||||
* time is appended here since the lexicon expects a datetime value.
|
||||
* Consistent with atproto timestamp parsing. Only the date is used; the time
|
||||
* is appended here since the lexicon expects a datetime value.
|
||||
*/
|
||||
const parseTimestamp = (value: string | undefined): string | undefined => {
|
||||
if (!value) return undefined
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {AppBskyFeedPost} from '@atproto/api'
|
||||
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -44,8 +42,8 @@ describe('types/bsky new-world helpers (#/lexicons)', () => {
|
||||
})
|
||||
|
||||
it('accepts an invalid body that has the right $type (dangerous semantics)', () => {
|
||||
// Mirrors the old `dangerousIsType`: only the `$type` is checked, so a
|
||||
// structurally invalid record still passes.
|
||||
// Only the `$type` is checked, so a structurally invalid record still
|
||||
// passes.
|
||||
expect(bsky.isType(app.bsky.feed.post, invalidPost)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -55,8 +53,7 @@ describe('types/bsky new-world helpers (#/lexicons)', () => {
|
||||
})
|
||||
|
||||
it('returns false (does not throw) for null and undefined', () => {
|
||||
// Mirrors the old `dangerousIsType`/`is$typed` behavior - call sites
|
||||
// pass e.g. `post.record` which may be undefined.
|
||||
// Call sites pass e.g. `post.record` which may be undefined.
|
||||
expect(bsky.isType(app.bsky.feed.post, null)).toBe(false)
|
||||
expect(bsky.isType(app.bsky.feed.post, undefined)).toBe(false)
|
||||
expect(bsky.isType(app.bsky.feed.post, 'string')).toBe(false)
|
||||
@@ -122,42 +119,3 @@ describe('types/bsky new-world helpers (#/lexicons)', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('types/bsky legacy helpers (@atproto/api) still work', () => {
|
||||
it('dangerousIsType accepts the right $type without validating the body', () => {
|
||||
expect(
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
validPost,
|
||||
AppBskyFeedPost.isRecord,
|
||||
),
|
||||
).toBe(true)
|
||||
// Right $type, invalid body - still passes the fast guard.
|
||||
expect(
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
invalidPost,
|
||||
AppBskyFeedPost.isRecord,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
wrongType,
|
||||
AppBskyFeedPost.isRecord,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('validate fully validates the body', () => {
|
||||
expect(
|
||||
bsky.validate<AppBskyFeedPost.Record>(
|
||||
validPost,
|
||||
AppBskyFeedPost.validateRecord,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
bsky.validate<AppBskyFeedPost.Record>(
|
||||
invalidPost,
|
||||
AppBskyFeedPost.validateRecord,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+10
-78
@@ -10,86 +10,21 @@ import {
|
||||
type ValidateOptions,
|
||||
type ValidationResult,
|
||||
} from '@atproto/lex'
|
||||
import {type ValidationResult as LegacyValidationResult} from '@atproto/lexicon'
|
||||
|
||||
export * as post from '#/types/bsky/post'
|
||||
export * as profile from '#/types/bsky/profile'
|
||||
export * as starterPack from '#/types/bsky/starterPack'
|
||||
|
||||
/**
|
||||
* Unsafe cast from an old `@atproto/api` view/record type to its `#/lexicons`
|
||||
* equivalent. Only for mixed-world boundaries during the migration where a
|
||||
* producer has not yet flipped. Structurally the shapes are identical modulo
|
||||
* branded string types; this asserts the brand the compiler cannot prove.
|
||||
* Every use is a migration debt marker - grep `toLex` at Phase 4 cleanup.
|
||||
*/
|
||||
export function toLex<T>(v: unknown): T {
|
||||
return v as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast type checking without full schema validation, for use with data we
|
||||
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
|
||||
* utils do not assert the type of the entire object, only the `$type` string.
|
||||
*
|
||||
* For full validation of the object schema, use the `validate` export from
|
||||
* this file.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import * as bsky from '#/types/bsky'
|
||||
*
|
||||
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
|
||||
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function dangerousIsType<R extends {$type?: string}>(
|
||||
record: unknown,
|
||||
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
|
||||
): record is R {
|
||||
return identity(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully validates the object schema, which has a performance cost.
|
||||
*
|
||||
* For faster checks with data we trust, like that from our app view, use the
|
||||
* `dangerousIsType` export from this same file.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import * as bsky from '#/types/bsky'
|
||||
*
|
||||
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
|
||||
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validate<R extends {$type?: string}>(
|
||||
record: unknown,
|
||||
validator: (v: unknown) => LegacyValidationResult<R>,
|
||||
): record is R {
|
||||
return validator(record).success
|
||||
}
|
||||
|
||||
/*
|
||||
* New-world helpers (below) operate on the generated lexicon schema objects
|
||||
* from '#/lexicons', replacing the `@atproto/api`-based helpers above.
|
||||
*
|
||||
* The old helpers take a record plus a standalone `is*`/`validate*` function
|
||||
* (e.g. `AppBskyFeedPost.isRecord`). The new codegen instead attaches the
|
||||
* validation surface directly to each schema, so these helpers take the schema
|
||||
* object itself:
|
||||
* These helpers operate on the generated lexicon schema objects from
|
||||
* '#/lexicons'. The codegen attaches the validation surface directly to each
|
||||
* schema, so these helpers take the schema object itself:
|
||||
*
|
||||
* ```ts
|
||||
* import {app} from '#/lexicons'
|
||||
* import * as bsky from '#/types/bsky'
|
||||
*
|
||||
* // old: bsky.dangerousIsType(v, AppBskyFeedPost.isRecord)
|
||||
* bsky.isType(app.bsky.feed.post, v)
|
||||
*
|
||||
* // old: bsky.validate(v, AppBskyFeedPost.validateRecord)
|
||||
* bsky.matches(app.bsky.feed.post, v)
|
||||
* ```
|
||||
*
|
||||
@@ -113,9 +48,8 @@ type TypedSchema = RecordSchema | TypedObjectSchema
|
||||
* string; it does NOT assert the rest of the object matches the schema. An
|
||||
* invalid record with the right `$type` will pass.
|
||||
*
|
||||
* This is the '#/lexicons' equivalent of {@link dangerousIsType}. For full
|
||||
* validation of the object schema, use {@link matches}, {@link parse}, or
|
||||
* {@link safeParse} from this same file.
|
||||
* For full validation of the object schema, use {@link matches}, {@link parse},
|
||||
* or {@link safeParse} from this same file.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
@@ -135,10 +69,9 @@ export function isType<S extends TypedSchema>(
|
||||
* Deliberately NOT delegating to the schema's `isTypeOf`: the generated
|
||||
* `TypedObjectSchema.isTypeOf` treats a MISSING `$type` as a match
|
||||
* (maybe-typed semantics), which would let any plain object satisfy any
|
||||
* def-schema check and break `$type`-discriminated unions. The old
|
||||
* `dangerousIsType` required a present, matching `$type`, and so do we.
|
||||
* The nullish/object guard also mirrors the old `is$typed` behavior of
|
||||
* returning false (not throwing) for null/undefined input.
|
||||
* def-schema check and break `$type`-discriminated unions. We require a
|
||||
* present, matching `$type`. The nullish/object guard also returns false
|
||||
* (not throws) for null/undefined input.
|
||||
*/
|
||||
return (
|
||||
value != null &&
|
||||
@@ -151,9 +84,8 @@ export function isType<S extends TypedSchema>(
|
||||
* Fully validates the object against the schema (strict, no coercion), which
|
||||
* has a performance cost, and narrows the value on success.
|
||||
*
|
||||
* This is the '#/lexicons' equivalent of {@link validate}. For faster checks
|
||||
* with data we trust, like that from our app view, use {@link isType} from this
|
||||
* same file.
|
||||
* For faster checks with data we trust, like that from our app view, use
|
||||
* {@link isType} from this same file.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {app} from '#/lexicons'
|
||||
|
||||
/*
|
||||
* The generated `$type`-only guards. The old `@atproto/api`
|
||||
* `AppBskyGraphDefs.isStarterPackView*` helpers matched on a present,
|
||||
* matching `$type`; we reproduce that here against the `#/lexicons` schema's
|
||||
* `$type` string rather than delegating to the schema's `isTypeOf` (which
|
||||
* treats a missing `$type` as a match).
|
||||
* The generated `$type`-only guards. These match on a present, matching
|
||||
* `$type` against the `#/lexicons` schema's `$type` string rather than
|
||||
* delegating to the schema's `isTypeOf` (which treats a missing `$type` as a
|
||||
* match).
|
||||
*/
|
||||
export function isBasicView(
|
||||
v: unknown,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type app} from '#/lexicons'
|
||||
/**
|
||||
* Types for draft display and local media tracking.
|
||||
* Server draft types come from @atproto/api.
|
||||
* Server draft types come from the atproto lexicons.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ function patchSourceMapFilter(rules, pathPattern) {
|
||||
|
||||
module.exports = async function (env, argv) {
|
||||
env.babel = {
|
||||
dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo', '@atproto/api'],
|
||||
dangerouslyAddModulePathsToTranspile: ['@bsky.app/expo'],
|
||||
}
|
||||
let config = await createExpoWebpackConfigAsync(env, argv)
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user