From 847aa1ebc80eb008b5da4a7b21e14397f61c8f22 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:39:56 +0200 Subject: [PATCH] Address ingestion review findings --- .changeset/lean-contrail.md | 2 +- apps/sveltekit-cloudflare-workers/README.md | 6 +- .../lex.config.ts | 12 + .../sveltekit-cloudflare-workers/package.json | 6 +- .../sveltekit-cloudflare-workers/src/app.d.ts | 1 + .../src/lib/lexicons/index.ts | 7 + .../types/statusphere/app/getCursor.ts | 30 +++ .../types/statusphere/app/getOverview.ts | 47 ++++ .../types/statusphere/app/getProfile.ts | 138 ++++++++++++ .../types/statusphere/app/notifyOfUpdate.ts | 64 ++++++ .../types/statusphere/app/status/getRecord.ts | 156 +++++++++++++ .../statusphere/app/status/listRecords.ts | 208 ++++++++++++++++++ .../lexicons/types/xyz/statusphere/status.ts | 34 +++ packages/contrail/src/core/ingest.ts | 6 +- packages/contrail/src/core/refresh.ts | 2 +- packages/contrail/src/core/router/notify.ts | 2 +- packages/contrail/tests/ingest.test.ts | 20 ++ packages/contrail/tests/notify.test.ts | 7 +- packages/contrail/tests/refresh.test.ts | 53 +++++ 19 files changed, 788 insertions(+), 13 deletions(-) create mode 100644 apps/sveltekit-cloudflare-workers/lex.config.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getCursor.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getOverview.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getProfile.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/notifyOfUpdate.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/getRecord.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/listRecords.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/xyz/statusphere/status.ts diff --git a/.changeset/lean-contrail.md b/.changeset/lean-contrail.md index 4dfc23a..1dcedb3 100644 --- a/.changeset/lean-contrail.md +++ b/.changeset/lean-contrail.md @@ -2,4 +2,4 @@ "@atmo-dev/contrail": minor --- -Collapse Contrail into one public package and one AppView implementation. Remove the spaces, authority, record-host, community, realtime, sync, and custom Lexicon-tooling products. Route Jetstream, persistent, backfill, refresh, and immediate synchronization records through the shared `ingestRecords` admission and projection path. Make materialized relation counts converge when children arrive before parents, and prevent transient PDS failures from being interpreted as authoritative deletions. +Collapse Contrail into one public package and one AppView implementation. Remove the spaces, authority, record-host, community, realtime, sync, and custom Lexicon-tooling products. Route Jetstream, persistent, backfill, refresh, and immediate synchronization records through the shared `ingestRecords` admission and projection path. Make materialized relation counts converge when children arrive before parents or move during refresh, and prevent transient PDS failures from being interpreted as authoritative deletions. Keep dependent-subject filtering scoped to dependent collections and restore typed example XRPC clients with Atcute's generator. diff --git a/apps/sveltekit-cloudflare-workers/README.md b/apps/sveltekit-cloudflare-workers/README.md index 50917ff..7b4a716 100644 --- a/apps/sveltekit-cloudflare-workers/README.md +++ b/apps/sveltekit-cloudflare-workers/README.md @@ -32,9 +32,9 @@ export const config: ContrailConfig = { }; ``` -After changing the config, run `pnpm generate:pull` to regenerate lexicons and types. +The checked-in files under `lexicons/generated/` describe this example's Contrail XRPC methods. After changing those Lexicons, run `pnpm generate:types` to regenerate the Atcute schemas and client augmentation. -Run `pnpm sync` to backfill existing records from the network. +Run `pnpm backfill` to load existing records from the network. Wrangler bindings (`wrangler.jsonc`): @@ -66,7 +66,7 @@ const res = await client.get('statusphere.app.status.listRecords', { res.data.records // typed response ``` -Types are generated from contrail's config via `pnpm generate:pull`, which produces lexicon JSON and TypeScript types that register with `@atcute/client`. +`pnpm generate:types` uses `@atcute/lex-cli` to turn the checked-in method and record Lexicons into TypeScript schemas that register with `@atcute/client`. The generated TypeScript is committed so editors and clean builds retain typed queries. **Scheduled ingestion** works around SvelteKit's lack of `scheduled` export support ([sveltejs/kit#4841](https://github.com/sveltejs/kit/issues/4841)) by appending a handler post-build that self-calls `/api/cron`. diff --git a/apps/sveltekit-cloudflare-workers/lex.config.ts b/apps/sveltekit-cloudflare-workers/lex.config.ts new file mode 100644 index 0000000..5ff0055 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/lex.config.ts @@ -0,0 +1,12 @@ +import { defineLexiconConfig } from '@atcute/lex-cli'; + +export default defineLexiconConfig({ + generate: { + files: [ + 'lexicons/generated/**/*.json', + 'lexicons/pulled/xyz/**/*.json' + ], + outdir: 'src/lib/lexicons', + imports: ['@atcute/atproto', '@atcute/bluesky'] + } +}); diff --git a/apps/sveltekit-cloudflare-workers/package.json b/apps/sveltekit-cloudflare-workers/package.json index 388f55c..a01b724 100644 --- a/apps/sveltekit-cloudflare-workers/package.json +++ b/apps/sveltekit-cloudflare-workers/package.json @@ -5,14 +5,16 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build && contrail append-scheduled", + "build": "pnpm generate:types && vite build && contrail append-scheduled", + "generate:types": "lex-cli generate", "backfill": "contrail backfill", "backfill:remote": "contrail backfill --remote", "refresh": "contrail refresh", "refresh:remote": "contrail refresh --remote", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "pnpm generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "typecheck": "pnpm check", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint .", diff --git a/apps/sveltekit-cloudflare-workers/src/app.d.ts b/apps/sveltekit-cloudflare-workers/src/app.d.ts index 831d778..e0a0866 100644 --- a/apps/sveltekit-cloudflare-workers/src/app.d.ts +++ b/apps/sveltekit-cloudflare-workers/src/app.d.ts @@ -30,5 +30,6 @@ declare global { } import type {} from '@atcute/atproto'; import type {} from '@atcute/bluesky'; +import type {} from './lib/lexicons/index.js'; export {}; diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts new file mode 100644 index 0000000..48c9760 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts @@ -0,0 +1,7 @@ +export * as StatusphereAppGetCursor from "./types/statusphere/app/getCursor.js"; +export * as StatusphereAppGetOverview from "./types/statusphere/app/getOverview.js"; +export * as StatusphereAppGetProfile from "./types/statusphere/app/getProfile.js"; +export * as StatusphereAppNotifyOfUpdate from "./types/statusphere/app/notifyOfUpdate.js"; +export * as StatusphereAppStatusGetRecord from "./types/statusphere/app/status/getRecord.js"; +export * as StatusphereAppStatusListRecords from "./types/statusphere/app/status/listRecords.js"; +export * as XyzStatusphereStatus from "./types/xyz/statusphere/status.js"; diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getCursor.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getCursor.ts new file mode 100644 index 0000000..106d63b --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getCursor.ts @@ -0,0 +1,30 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.query("statusphere.app.getCursor", { + params: null, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + date: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + seconds_ago: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + time_us: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "statusphere.app.getCursor": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getOverview.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getOverview.ts new file mode 100644 index 0000000..f6707d2 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getOverview.ts @@ -0,0 +1,47 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _collectionStatsSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("statusphere.app.getOverview#collectionStats"), + ), + collection: /*#__PURE__*/ v.string(), + records: /*#__PURE__*/ v.integer(), + unique_users: /*#__PURE__*/ v.integer(), +}); +const _mainSchema = /*#__PURE__*/ v.query("statusphere.app.getOverview", { + params: null, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get collections() { + return /*#__PURE__*/ v.array(collectionStatsSchema); + }, + total_records: /*#__PURE__*/ v.integer(), + }), + }, +}); + +type collectionStats$schematype = typeof _collectionStatsSchema; +type main$schematype = typeof _mainSchema; + +export interface collectionStatsSchema extends collectionStats$schematype {} +export interface mainSchema extends main$schematype {} + +export const collectionStatsSchema = + _collectionStatsSchema as collectionStatsSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface CollectionStats extends v.InferInput< + typeof collectionStatsSchema +> {} + +export interface $params {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "statusphere.app.getOverview": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getProfile.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getProfile.ts new file mode 100644 index 0000000..71ea609 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getProfile.ts @@ -0,0 +1,138 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; +import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; + +const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("statusphere.app.getProfile#appBskyActorProfile"), + ), + /** + * Small image to be displayed next to posts from account. AKA, 'profile picture' + * @accept image/png, image/jpeg + * @maxSize 1000000 + */ + avatar: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/png", "image/jpeg"]), + ]), + ), + /** + * Larger horizontal image to display behind profile view. + * @accept image/png, image/jpeg + * @maxSize 1000000 + */ + banner: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/png", "image/jpeg"]), + ]), + ), + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * Free-form profile description text. + * @maxLength 2560 + * @maxGraphemes 256 + */ + description: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 2560), + /*#__PURE__*/ v.stringGraphemes(0, 256), + ]), + ), + /** + * @maxLength 640 + * @maxGraphemes 64 + */ + displayName: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 640), + /*#__PURE__*/ v.stringGraphemes(0, 64), + ]), + ), + get joinedViaStarterPack() { + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); + }, + /** + * Self-label values, specific to the Bluesky application, on the overall account. + */ + get labels() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), + ); + }, + get pinnedPost() { + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); + }, + /** + * Free-form pronouns text. + * @maxLength 200 + * @maxGraphemes 20 + */ + pronouns: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 200), + /*#__PURE__*/ v.stringGraphemes(0, 20), + ]), + ), + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), +}); +const _mainSchema = /*#__PURE__*/ v.query("statusphere.app.getProfile", { + params: /*#__PURE__*/ v.object({ + /** + * DID or handle of the user + */ + actor: /*#__PURE__*/ v.actorIdentifierString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get profiles() { + return /*#__PURE__*/ v.array(profileEntrySchema); + }, + }), + }, +}); +const _profileEntrySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("statusphere.app.getProfile#profileEntry"), + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), + did: /*#__PURE__*/ v.didString(), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + get value() { + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); + }, +}); + +type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; + +export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} +export interface mainSchema extends main$schematype {} +export interface profileEntrySchema extends profileEntry$schematype {} + +export const appBskyActorProfileSchema = + _appBskyActorProfileSchema as appBskyActorProfileSchema; +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; + +export interface AppBskyActorProfile extends v.InferInput< + typeof appBskyActorProfileSchema +> {} +export interface ProfileEntry extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "statusphere.app.getProfile": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/notifyOfUpdate.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/notifyOfUpdate.ts new file mode 100644 index 0000000..316dc16 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/notifyOfUpdate.ts @@ -0,0 +1,64 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure( + "statusphere.app.notifyOfUpdate", + { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Single AT URI to fetch and index + */ + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + /** + * Batch of AT URIs to fetch and index (max 25) + * @maxLength 25 + */ + uris: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.resourceUriString()), + [/*#__PURE__*/ v.arrayLength(0, 25)], + ), + ), + }), + }, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Number of records deleted (not found on PDS) + */ + deleted: /*#__PURE__*/ v.integer(), + /** + * Errors for individual URIs that could not be processed + */ + errors: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), + ), + /** + * Number of records created or updated + */ + indexed: /*#__PURE__*/ v.integer(), + }), + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "statusphere.app.notifyOfUpdate": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/getRecord.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/getRecord.ts new file mode 100644 index 0000000..d8fe01d --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/getRecord.ts @@ -0,0 +1,156 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; +import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; +import * as XyzStatusphereStatus from "../../../xyz/statusphere/status.js"; + +const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "statusphere.app.status.getRecord#appBskyActorProfile", + ), + ), + /** + * Small image to be displayed next to posts from account. AKA, 'profile picture' + * @accept image/png, image/jpeg + * @maxSize 1000000 + */ + avatar: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/png", "image/jpeg"]), + ]), + ), + /** + * Larger horizontal image to display behind profile view. + * @accept image/png, image/jpeg + * @maxSize 1000000 + */ + banner: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/png", "image/jpeg"]), + ]), + ), + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * Free-form profile description text. + * @maxLength 2560 + * @maxGraphemes 256 + */ + description: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 2560), + /*#__PURE__*/ v.stringGraphemes(0, 256), + ]), + ), + /** + * @maxLength 640 + * @maxGraphemes 64 + */ + displayName: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 640), + /*#__PURE__*/ v.stringGraphemes(0, 64), + ]), + ), + get joinedViaStarterPack() { + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); + }, + /** + * Self-label values, specific to the Bluesky application, on the overall account. + */ + get labels() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), + ); + }, + get pinnedPost() { + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); + }, + /** + * Free-form pronouns text. + * @maxLength 200 + * @maxGraphemes 20 + */ + pronouns: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 200), + /*#__PURE__*/ v.stringGraphemes(0, 20), + ]), + ), + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), +}); +const _mainSchema = /*#__PURE__*/ v.query("statusphere.app.status.getRecord", { + params: /*#__PURE__*/ v.object({ + /** + * Include profile + identity info keyed by DID + */ + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + /** + * AT URI of the record + */ + uri: /*#__PURE__*/ v.resourceUriString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.nsidString(), + did: /*#__PURE__*/ v.didString(), + get profiles() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(profileEntrySchema), + ); + }, + rkey: /*#__PURE__*/ v.string(), + time_us: /*#__PURE__*/ v.integer(), + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return XyzStatusphereStatus.mainSchema; + }, + }), + }, +}); +const _profileEntrySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("statusphere.app.status.getRecord#profileEntry"), + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), + did: /*#__PURE__*/ v.didString(), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + get value() { + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); + }, +}); + +type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; + +export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} +export interface mainSchema extends main$schematype {} +export interface profileEntrySchema extends profileEntry$schematype {} + +export const appBskyActorProfileSchema = + _appBskyActorProfileSchema as appBskyActorProfileSchema; +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; + +export interface AppBskyActorProfile extends v.InferInput< + typeof appBskyActorProfileSchema +> {} +export interface ProfileEntry extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "statusphere.app.status.getRecord": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/listRecords.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/listRecords.ts new file mode 100644 index 0000000..2012c1d --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/status/listRecords.ts @@ -0,0 +1,208 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; +import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; +import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; +import * as XyzStatusphereStatus from "../../../xyz/statusphere/status.js"; + +const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "statusphere.app.status.listRecords#appBskyActorProfile", + ), + ), + /** + * Small image to be displayed next to posts from account. AKA, 'profile picture' + * @accept image/png, image/jpeg + * @maxSize 1000000 + */ + avatar: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/png", "image/jpeg"]), + ]), + ), + /** + * Larger horizontal image to display behind profile view. + * @accept image/png, image/jpeg + * @maxSize 1000000 + */ + banner: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/png", "image/jpeg"]), + ]), + ), + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + /** + * Free-form profile description text. + * @maxLength 2560 + * @maxGraphemes 256 + */ + description: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 2560), + /*#__PURE__*/ v.stringGraphemes(0, 256), + ]), + ), + /** + * @maxLength 640 + * @maxGraphemes 64 + */ + displayName: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 640), + /*#__PURE__*/ v.stringGraphemes(0, 64), + ]), + ), + get joinedViaStarterPack() { + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); + }, + /** + * Self-label values, specific to the Bluesky application, on the overall account. + */ + get labels() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), + ); + }, + get pinnedPost() { + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); + }, + /** + * Free-form pronouns text. + * @maxLength 200 + * @maxGraphemes 20 + */ + pronouns: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 200), + /*#__PURE__*/ v.stringGraphemes(0, 20), + ]), + ), + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), +}); +const _mainSchema = /*#__PURE__*/ v.query( + "statusphere.app.status.listRecords", + { + params: /*#__PURE__*/ v.object({ + /** + * Filter by DID or handle (triggers on-demand backfill) + */ + actor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), + /** + * Maximum value for createdAt + */ + createdAtMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Minimum value for createdAt + */ + createdAtMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * @minimum 1 + * @maximum 200 + * @default 50 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 200), + ]), + 50, + ), + /** + * Sort direction (default: desc for dates/numbers/counts, asc for strings) + */ + order: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), + ), + /** + * Include profile + identity info keyed by DID + */ + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + /** + * Field to sort by (default: time_us) + */ + sort: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"createdAt" | "status" | (string & {})>(), + ), + /** + * Filter by status + */ + status: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get profiles() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(profileEntrySchema), + ); + }, + get records() { + return /*#__PURE__*/ v.array(recordSchema); + }, + }), + }, + }, +); +const _profileEntrySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("statusphere.app.status.listRecords#profileEntry"), + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), + did: /*#__PURE__*/ v.didString(), + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + get value() { + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); + }, +}); +const _recordSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("statusphere.app.status.listRecords#record"), + ), + cid: /*#__PURE__*/ v.cidString(), + collection: /*#__PURE__*/ v.nsidString(), + did: /*#__PURE__*/ v.didString(), + rkey: /*#__PURE__*/ v.string(), + time_us: /*#__PURE__*/ v.integer(), + uri: /*#__PURE__*/ v.resourceUriString(), + get value() { + return XyzStatusphereStatus.mainSchema; + }, +}); + +type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; +type record$schematype = typeof _recordSchema; + +export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} +export interface mainSchema extends main$schematype {} +export interface profileEntrySchema extends profileEntry$schematype {} +export interface recordSchema extends record$schematype {} + +export const appBskyActorProfileSchema = + _appBskyActorProfileSchema as appBskyActorProfileSchema; +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; +export const recordSchema = _recordSchema as recordSchema; + +export interface AppBskyActorProfile extends v.InferInput< + typeof appBskyActorProfileSchema +> {} +export interface ProfileEntry extends v.InferInput {} +export interface Record extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "statusphere.app.status.listRecords": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/xyz/statusphere/status.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/xyz/statusphere/status.ts new file mode 100644 index 0000000..d43760e --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/xyz/statusphere/status.ts @@ -0,0 +1,34 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("xyz.statusphere.status"), + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * @minLength 1 + * @maxLength 32 + * @maxGraphemes 1 + */ + status: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(1, 32), + /*#__PURE__*/ v.stringGraphemes(0, 1), + ]), + }), +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface Main extends v.InferInput {} + +declare module "@atcute/lexicons/ambient" { + interface Records { + "xyz.statusphere.status": mainSchema; + } +} diff --git a/packages/contrail/src/core/ingest.ts b/packages/contrail/src/core/ingest.ts index fb5a6d7..416faff 100644 --- a/packages/contrail/src/core/ingest.ts +++ b/packages/contrail/src/core/ingest.ts @@ -148,9 +148,9 @@ async function filterUnknownSubjects( for (const event of events) { if (event.operation === "delete") continue; const shortName = resolveCollectionKey(config, event.collection); - const subjectField = shortName - ? config.collections[shortName]?.subjectField - : undefined; + const collection = shortName ? config.collections[shortName] : undefined; + const subjectField = + collection?.discover === false ? collection.subjectField : undefined; if (!subjectField) continue; const record = parseRecord(event.record); diff --git a/packages/contrail/src/core/refresh.ts b/packages/contrail/src/core/refresh.ts index c14ec37..5b247b8 100644 --- a/packages/contrail/src/core/refresh.ts +++ b/packages/contrail/src/core/refresh.ts @@ -208,7 +208,7 @@ export async function refresh( const existing = await lookupExistingRecords( db, events.map((e) => ({ uri: e.uri, collection: e.collection })), - false, + true, // old body is required to recount a relation's previous target config ); diff --git a/packages/contrail/src/core/router/notify.ts b/packages/contrail/src/core/router/notify.ts index f67042a..e14e33a 100644 --- a/packages/contrail/src/core/router/notify.ts +++ b/packages/contrail/src/core/router/notify.ts @@ -61,7 +61,7 @@ async function fetchRecordFromPDS( .json() .catch(() => null) as { error?: unknown; message?: unknown } | null; const errorCode = typeof body?.error === "string" ? body.error : undefined; - if (response.status === 404 || errorCode === "RecordNotFound") { + if (errorCode === "RecordNotFound") { return { kind: "not-found" }; } const detail = diff --git a/packages/contrail/tests/ingest.test.ts b/packages/contrail/tests/ingest.test.ts index 9fa063d..ddeca27 100644 --- a/packages/contrail/tests/ingest.test.ts +++ b/packages/contrail/tests/ingest.test.ts @@ -24,6 +24,10 @@ const config = resolveConfig({ discover: false, subjectField: "subject", }, + directedEvent: { + collection: "com.example.directedEvent", + subjectField: "subject", + }, }, }); @@ -101,6 +105,22 @@ describe("ingestRecords", () => { expect(result.dropped.unknownCollection).toBe(1); }); + it("does not apply dependent subject filtering to discoverable collections", async () => { + const event = createIngestEvent({ + did: "did:plc:alice", + collection: "com.example.directedEvent", + rkey: "public", + operation: "create", + cid: "cid-public", + value: { subject: "did:plc:unknown" }, + timeUs: 1, + }); + + const result = await ingestRecords(db, [event], config); + expect(result.accepted).toEqual([event]); + expect(result.dropped.unknownSubject).toBe(0); + }); + it("applies dependent subject filtering in the same admission path", async () => { await db .prepare( diff --git a/packages/contrail/tests/notify.test.ts b/packages/contrail/tests/notify.test.ts index 1a7de14..85c6680 100644 --- a/packages/contrail/tests/notify.test.ts +++ b/packages/contrail/tests/notify.test.ts @@ -75,7 +75,10 @@ describe("POST notifyOfUpdate", () => { headers: { "Content-Type": "application/json" }, }); } - return new Response("not found", { status: 404 }); + return new Response(JSON.stringify({ error: "RecordNotFound" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); }) ); } @@ -225,7 +228,7 @@ describe("POST notifyOfUpdate", () => { expect(result.records).toHaveLength(0); }); - it.each([429, 500])( + it.each([404, 429, 500])( "preserves local state when the PDS returns %s", async (status) => { const did = "did:plc:test"; diff --git a/packages/contrail/tests/refresh.test.ts b/packages/contrail/tests/refresh.test.ts index b84f016..965edc0 100644 --- a/packages/contrail/tests/refresh.test.ts +++ b/packages/contrail/tests/refresh.test.ts @@ -30,6 +30,7 @@ vi.mock("../src/core/client", async (importOriginal) => { const ALICE = "did:plc:alice"; const BOB = "did:plc:bob"; const EVENT_NSID = "community.lexicon.calendar.event"; +const RSVP_NSID = "community.lexicon.calendar.rsvp"; function aliceEventUri(rkey: string): string { return `at://${ALICE}/${EVENT_NSID}/${rkey}`; @@ -193,6 +194,58 @@ describe("refresh", () => { expect(result.total.missing).toBeGreaterThanOrEqual(1); }); + it("recounts both relation targets when a refreshed child moves", async () => { + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + const eventA = aliceEventUri("event-a"); + const eventB = aliceEventUri("event-b"); + const rsvpUri = `at://${ALICE}/${RSVP_NSID}/rsvp`; + + await ingestRecords( + db, + [ + makeEvent({ uri: eventA, did: ALICE, rkey: "event-a" }), + makeEvent({ uri: eventB, did: ALICE, rkey: "event-b" }), + makeEvent({ + uri: rsvpUri, + did: ALICE, + collection: RSVP_NSID, + rkey: "rsvp", + cid: "old-cid", + record: { + subject: { uri: eventA }, + status: "community.lexicon.calendar.rsvp#going", + }, + }), + ], + TEST_CONFIG, + ); + + pages.set(`${ALICE}|${RSVP_NSID}`, [ + { + uri: rsvpUri, + cid: "new-cid", + value: { + subject: { uri: eventB }, + status: "community.lexicon.calendar.rsvp#going", + }, + }, + ]); + + await refresh(db, TEST_CONFIG, { ignoreWindowMs: 0 }); + const rows = await db + .prepare( + "SELECT uri, count_rsvp FROM records_event WHERE uri IN (?, ?) ORDER BY uri", + ) + .bind(eventA, eventB) + .all<{ uri: string; count_rsvp: number }>(); + + expect(rows.results).toEqual([ + { uri: eventA, count_rsvp: 0 }, + { uri: eventB, count_rsvp: 1 }, + ]); + }); + it("returns elapsed time and the configured ignore window", async () => { const db = await createTestDbWithSchema(); const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 30_000 }); -- 2.51.2