From aad30e2f640999605973a84fa6c5471d7115cb2a Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:52:20 +0200 Subject: [PATCH] Test public service contracts and consumers --- .changeset/public-read-service.md | 5 + README.md | 21 ++ .../com/example/event/getRecord.json | 69 +--- .../com/example/event/listRecords.json | 73 +---- .../generated/com/example/getCursor.json | 35 ++- .../generated/com/example/getOverview.json | 51 --- .../generated/com/example/getProfile.json | 69 +--- .../generated/com/example/notifyOfUpdate.json | 59 ---- .../com/example/profile/getRecord.json | 110 +++++++ .../com/example/profile/listRecords.json | 135 ++++++++ .../lexicons/generated/index.ts | 11 +- .../lexicons/generated/index.ts | 17 +- .../generated/statusphere/app/getCursor.json | 35 ++- .../statusphere/app/getOverview.json | 51 --- .../generated/statusphere/app/getProfile.json | 69 +--- .../statusphere/app/notifyOfUpdate.json | 59 ---- .../statusphere/app/profile/getRecord.json | 110 +++++++ .../statusphere/app/profile/listRecords.json | 135 ++++++++ .../statusphere/app/status/getRecord.json | 69 +--- .../statusphere/app/status/listRecords.json | 85 +---- .../src/lib/lexicons/index.ts | 4 +- .../types/statusphere/app/getCursor.ts | 26 +- .../types/statusphere/app/getOverview.ts | 46 --- .../types/statusphere/app/getProfile.ts | 100 +----- .../types/statusphere/app/notifyOfUpdate.ts | 63 ---- .../statusphere/app/profile/getRecord.ts | 74 +++++ .../statusphere/app/profile/listRecords.ts | 102 ++++++ .../types/statusphere/app/status/getRecord.ts | 91 +----- .../statusphere/app/status/listRecords.ts | 95 +----- docs/02-querying.md | 6 +- packages/contrail/README.md | 29 +- .../contrail/tests/backfill-status.test.ts | 9 +- packages/contrail/tests/connect.test.ts | 296 ++++++++++++++++++ .../tests/database-bootstrap-target.test.ts | 58 ++++ .../tests/jetstream-change-source.test.ts | 14 + .../contrail/tests/lexicon-generation.test.ts | 39 ++- packages/contrail/tests/persistent.test.ts | 21 +- .../contrail/tests/public-service-e2e.test.ts | 179 +++++++++++ .../tests/serving-source-position.test.ts | 75 +++++ .../contrail/tests/source-ordering.test.ts | 5 + packages/contrail/tests/worker.test.ts | 260 ++++++++++++++- 41 files changed, 1798 insertions(+), 1062 deletions(-) create mode 100644 .changeset/public-read-service.md delete mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json delete mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/notifyOfUpdate.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/profile/getRecord.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/profile/listRecords.json delete mode 100644 apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getOverview.json delete mode 100644 apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/notifyOfUpdate.json create mode 100644 apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/getRecord.json create mode 100644 apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/listRecords.json delete mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getOverview.ts delete 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/profile/getRecord.ts create mode 100644 apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/profile/listRecords.ts create mode 100644 packages/contrail/tests/connect.test.ts create mode 100644 packages/contrail/tests/public-service-e2e.test.ts create mode 100644 packages/contrail/tests/serving-source-position.test.ts diff --git a/.changeset/public-read-service.md b/.changeset/public-read-service.md new file mode 100644 index 0000000..a6bb312 --- /dev/null +++ b/.changeset/public-read-service.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +Add self-describing anonymous read-through services with verified contracts, durable ordered-source positions, cacheable Lexicon discovery, and a safe `contrail connect` workflow for typed independent clients. diff --git a/README.md b/README.md index 6afad71..294b515 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,27 @@ pnpm contrail lexicons check Use `contrail lexicons all` to generate Contrail methods, pull referenced source Lexicons, and generate TypeScript types in one pass. The `pull` and `types` actions are also available separately. Contrail updates `lex.config.js` only when the file carries its generated marker; user-owned Atcute configuration is preserved. Pass `--no-atcute-config` to skip creating or checking that generated file. Contrail owns its config-specific query generation while delegating generic pulling and TypeScript generation to [Atcute](https://github.com/mary-ext/atcute). +## Public read-through services + +A deployment can publish a verified contract and Lexicon bundle for independent typed clients: + +```ts +export default createWorker(config, { + lexicons, + publicService: { endpoint: "https://api.example.com" }, +}); +``` + +Contrail remains a read-through cache over public AT Protocol data: anonymous reads may resolve identities, fetch missing public records, and improve profile or feed projections. Custom query handlers are public when they have matching authored query Lexicons. Anonymous discovery uses the HTTPS origin directly and does not require a service DID. The optional `notifyOfUpdate` procedure is not advertised in the anonymous read contract. + +Consumers connect and generate Atcute types with one command: + +```bash +pnpm contrail connect https://api.example.com +``` + +`getCursor` returns the committed opaque `{ source, epoch, cursor }` position of the primary ordered source. Compare complete positions for equality only; a source or epoch change requires a full client refetch. To avoid racing ingestion, read a position before and after a query and accept the query snapshot only when both positions match. + ## Other databases ```ts diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json b/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json index 47c29e1..5c73d83 100644 --- a/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json +++ b/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a single community.lexicon.calendar.event record by AT URI", + "description": "Get a community.lexicon.calendar.event record by AT URI", "parameters": { "type": "params", "required": [ @@ -18,7 +18,7 @@ }, "profiles": { "type": "boolean", - "description": "Include profile + identity info keyed by DID" + "description": "Include indexed profile and identity information" } } }, @@ -95,7 +95,7 @@ }, "value": { "type": "ref", - "ref": "#appBskyActorProfile" + "ref": "app.bsky.actor.profile#main" }, "collection": { "type": "string", @@ -105,69 +105,6 @@ "type": "string" } } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } } } } diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json b/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json index 3191ac3..e0e3496 100644 --- a/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json +++ b/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Query community.lexicon.calendar.event records with filters", + "description": "Query community.lexicon.calendar.event records", "parameters": { "type": "params", "properties": { @@ -20,11 +20,11 @@ "actor": { "type": "string", "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" + "description": "Filter by DID or handle" }, "profiles": { "type": "boolean", - "description": "Include profile + identity info keyed by DID" + "description": "Include indexed profile and identity information" }, "search": { "type": "string", @@ -51,7 +51,7 @@ "asc", "desc" ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" + "description": "Sort direction" } } }, @@ -147,7 +147,7 @@ }, "value": { "type": "ref", - "ref": "#appBskyActorProfile" + "ref": "app.bsky.actor.profile#main" }, "collection": { "type": "string", @@ -157,69 +157,6 @@ "type": "string" } } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } } } } diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/getCursor.json b/apps/cloudflare-workers/lexicons/generated/com/example/getCursor.json index aa95e2b..a5238d7 100644 --- a/apps/cloudflare-workers/lexicons/generated/com/example/getCursor.json +++ b/apps/cloudflare-workers/lexicons/generated/com/example/getCursor.json @@ -4,24 +4,45 @@ "defs": { "main": { "type": "query", - "description": "Get the current cursor position", + "description": "Get the committed primary ordered-source position", "output": { "encoding": "application/json", "schema": { "type": "object", "properties": { - "time_us": { - "type": "integer" - }, - "date": { - "type": "string" + "position": { + "type": "ref", + "ref": "#sourcePosition" }, - "seconds_ago": { + "updatedAt": { "type": "integer" + }, + "updatedAtDate": { + "type": "string", + "format": "datetime" } } } } + }, + "sourcePosition": { + "type": "object", + "required": [ + "source", + "epoch", + "cursor" + ], + "properties": { + "source": { + "type": "string" + }, + "epoch": { + "type": "string" + }, + "cursor": { + "type": "string" + } + } } } } diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json b/apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json deleted file mode 100644 index 0617a37..0000000 --- a/apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "com.example.getOverview", - "defs": { - "main": { - "type": "query", - "description": "Get an overview of all indexed collections", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "total_records", - "collections" - ], - "properties": { - "total_records": { - "type": "integer" - }, - "collections": { - "type": "array", - "items": { - "type": "ref", - "ref": "#collectionStats" - } - } - } - } - } - }, - "collectionStats": { - "type": "object", - "required": [ - "collection", - "records", - "unique_users" - ], - "properties": { - "collection": { - "type": "string" - }, - "records": { - "type": "integer" - }, - "unique_users": { - "type": "integer" - } - } - } - } -} diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/getProfile.json b/apps/cloudflare-workers/lexicons/generated/com/example/getProfile.json index 97e7134..22c367e 100644 --- a/apps/cloudflare-workers/lexicons/generated/com/example/getProfile.json +++ b/apps/cloudflare-workers/lexicons/generated/com/example/getProfile.json @@ -4,7 +4,6 @@ "defs": { "main": { "type": "query", - "description": "Get a user's profiles by DID or handle", "parameters": { "type": "params", "required": [ @@ -13,8 +12,7 @@ "properties": { "actor": { "type": "string", - "format": "at-identifier", - "description": "DID or handle of the user" + "format": "at-identifier" } } }, @@ -60,7 +58,7 @@ }, "value": { "type": "ref", - "ref": "#appBskyActorProfile" + "ref": "app.bsky.actor.profile#main" }, "collection": { "type": "string", @@ -70,69 +68,6 @@ "type": "string" } } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } } } } diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/notifyOfUpdate.json b/apps/cloudflare-workers/lexicons/generated/com/example/notifyOfUpdate.json deleted file mode 100644 index 67be4b8..0000000 --- a/apps/cloudflare-workers/lexicons/generated/com/example/notifyOfUpdate.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "com.example.notifyOfUpdate", - "defs": { - "main": { - "type": "procedure", - "description": "Notify of a record change for immediate indexing. Fetches the record from the user's PDS and indexes (or deletes) it.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "Single AT URI to fetch and index" - }, - "uris": { - "type": "array", - "items": { - "type": "string", - "format": "at-uri" - }, - "maxLength": 25, - "description": "Batch of AT URIs to fetch and index (max 25)" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "indexed", - "deleted" - ], - "properties": { - "indexed": { - "type": "integer", - "description": "Number of records created or updated" - }, - "deleted": { - "type": "integer", - "description": "Number of records deleted (not found on PDS)" - }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Errors for individual URIs that could not be processed" - } - } - } - } - } - } -} diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/profile/getRecord.json b/apps/cloudflare-workers/lexicons/generated/com/example/profile/getRecord.json new file mode 100644 index 0000000..473d663 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/profile/getRecord.json @@ -0,0 +1,110 @@ +{ + "lexicon": 1, + "id": "com.example.profile.getRecord", + "defs": { + "main": { + "type": "query", + "description": "Get a app.bsky.actor.profile record by AT URI", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the record" + }, + "profiles": { + "type": "boolean", + "description": "Include indexed profile and identity information" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "uri", + "value", + "did", + "collection", + "rkey", + "time_us" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "profileEntry": { + "type": "object", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/profile/listRecords.json b/apps/cloudflare-workers/lexicons/generated/com/example/profile/listRecords.json new file mode 100644 index 0000000..4deea49 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/profile/listRecords.json @@ -0,0 +1,135 @@ +{ + "lexicon": 1, + "id": "com.example.profile.listRecords", + "defs": { + "main": { + "type": "query", + "description": "Query app.bsky.actor.profile records", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "cursor": { + "type": "string" + }, + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Filter by DID or handle" + }, + "profiles": { + "type": "boolean", + "description": "Include indexed profile and identity information" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "records" + ], + "properties": { + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "#record" + } + }, + "cursor": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "record": { + "type": "object", + "required": [ + "uri", + "cid", + "value", + "did", + "collection", + "rkey", + "time_us" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + } + } + }, + "profileEntry": { + "type": "object", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/apps/cloudflare-workers/lexicons/generated/index.ts b/apps/cloudflare-workers/lexicons/generated/index.ts index 28c9d90..4a326d7 100644 --- a/apps/cloudflare-workers/lexicons/generated/index.ts +++ b/apps/cloudflare-workers/lexicons/generated/index.ts @@ -1,6 +1,5 @@ -// Checked-in Lexicon bundle. -// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them -// at `/xrpc/.lexicons` for consumer apps to typegen against. +// Auto-generated by @atmo-dev/contrail. Do not edit. +// Regenerate with `contrail lexicons generate`. import _0 from "../pulled/app/bsky/actor/profile.json"; import _1 from "../pulled/community/lexicon/calendar/event.json"; @@ -11,8 +10,8 @@ import _5 from "../pulled/community/lexicon/location/hthree.json"; import _6 from "./com/example/event/getRecord.json"; import _7 from "./com/example/event/listRecords.json"; import _8 from "./com/example/getCursor.json"; -import _9 from "./com/example/getOverview.json"; -import _10 from "./com/example/getProfile.json"; -import _11 from "./com/example/notifyOfUpdate.json"; +import _9 from "./com/example/getProfile.json"; +import _10 from "./com/example/profile/getRecord.json"; +import _11 from "./com/example/profile/listRecords.json"; export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11]; diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts b/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts index 7ffad08..562f2bd 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts @@ -1,14 +1,13 @@ -// Checked-in Lexicon bundle. -// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them -// at `/xrpc/.lexicons` for consumer apps to typegen against. +// Auto-generated by @atmo-dev/contrail. Do not edit. +// Regenerate with `contrail lexicons generate`. import _0 from "../pulled/app/bsky/actor/profile.json"; import _1 from "../pulled/xyz/statusphere/status.json"; import _2 from "./statusphere/app/getCursor.json"; -import _3 from "./statusphere/app/getOverview.json"; -import _4 from "./statusphere/app/getProfile.json"; -import _5 from "./statusphere/app/notifyOfUpdate.json"; -import _7 from "./statusphere/app/status/getRecord.json"; -import _8 from "./statusphere/app/status/listRecords.json"; +import _3 from "./statusphere/app/getProfile.json"; +import _4 from "./statusphere/app/profile/getRecord.json"; +import _5 from "./statusphere/app/profile/listRecords.json"; +import _6 from "./statusphere/app/status/getRecord.json"; +import _7 from "./statusphere/app/status/listRecords.json"; -export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _7, _8]; +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7]; diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getCursor.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getCursor.json index f85bdbf..520e385 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getCursor.json +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getCursor.json @@ -4,24 +4,45 @@ "defs": { "main": { "type": "query", - "description": "Get the current cursor position", + "description": "Get the committed primary ordered-source position", "output": { "encoding": "application/json", "schema": { "type": "object", "properties": { - "time_us": { - "type": "integer" - }, - "date": { - "type": "string" + "position": { + "type": "ref", + "ref": "#sourcePosition" }, - "seconds_ago": { + "updatedAt": { "type": "integer" + }, + "updatedAtDate": { + "type": "string", + "format": "datetime" } } } } + }, + "sourcePosition": { + "type": "object", + "required": [ + "source", + "epoch", + "cursor" + ], + "properties": { + "source": { + "type": "string" + }, + "epoch": { + "type": "string" + }, + "cursor": { + "type": "string" + } + } } } } diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getOverview.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getOverview.json deleted file mode 100644 index 67dd2f5..0000000 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getOverview.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "statusphere.app.getOverview", - "defs": { - "main": { - "type": "query", - "description": "Get an overview of all indexed collections", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "total_records", - "collections" - ], - "properties": { - "total_records": { - "type": "integer" - }, - "collections": { - "type": "array", - "items": { - "type": "ref", - "ref": "#collectionStats" - } - } - } - } - } - }, - "collectionStats": { - "type": "object", - "required": [ - "collection", - "records", - "unique_users" - ], - "properties": { - "collection": { - "type": "string" - }, - "records": { - "type": "integer" - }, - "unique_users": { - "type": "integer" - } - } - } - } -} diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json index 82732a4..5dbd415 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json @@ -4,7 +4,6 @@ "defs": { "main": { "type": "query", - "description": "Get a user's profiles by DID or handle", "parameters": { "type": "params", "required": [ @@ -13,8 +12,7 @@ "properties": { "actor": { "type": "string", - "format": "at-identifier", - "description": "DID or handle of the user" + "format": "at-identifier" } } }, @@ -60,7 +58,7 @@ }, "value": { "type": "ref", - "ref": "#appBskyActorProfile" + "ref": "app.bsky.actor.profile#main" }, "collection": { "type": "string", @@ -70,69 +68,6 @@ "type": "string" } } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } } } } diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/notifyOfUpdate.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/notifyOfUpdate.json deleted file mode 100644 index f34ded3..0000000 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/notifyOfUpdate.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "statusphere.app.notifyOfUpdate", - "defs": { - "main": { - "type": "procedure", - "description": "Notify of a record change for immediate indexing. Fetches the record from the user's PDS and indexes (or deletes) it.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "Single AT URI to fetch and index" - }, - "uris": { - "type": "array", - "items": { - "type": "string", - "format": "at-uri" - }, - "maxLength": 25, - "description": "Batch of AT URIs to fetch and index (max 25)" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "indexed", - "deleted" - ], - "properties": { - "indexed": { - "type": "integer", - "description": "Number of records created or updated" - }, - "deleted": { - "type": "integer", - "description": "Number of records deleted (not found on PDS)" - }, - "errors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Errors for individual URIs that could not be processed" - } - } - } - } - } - } -} diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/getRecord.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/getRecord.json new file mode 100644 index 0000000..20d799c --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/getRecord.json @@ -0,0 +1,110 @@ +{ + "lexicon": 1, + "id": "statusphere.app.profile.getRecord", + "defs": { + "main": { + "type": "query", + "description": "Get a app.bsky.actor.profile record by AT URI", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the record" + }, + "profiles": { + "type": "boolean", + "description": "Include indexed profile and identity information" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "uri", + "value", + "did", + "collection", + "rkey", + "time_us" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "profileEntry": { + "type": "object", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/listRecords.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/listRecords.json new file mode 100644 index 0000000..bae9f79 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/profile/listRecords.json @@ -0,0 +1,135 @@ +{ + "lexicon": 1, + "id": "statusphere.app.profile.listRecords", + "defs": { + "main": { + "type": "query", + "description": "Query app.bsky.actor.profile records", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "cursor": { + "type": "string" + }, + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Filter by DID or handle" + }, + "profiles": { + "type": "boolean", + "description": "Include indexed profile and identity information" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "records" + ], + "properties": { + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "#record" + } + }, + "cursor": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "ref", + "ref": "#profileEntry" + } + } + } + } + } + }, + "record": { + "type": "object", + "required": [ + "uri", + "cid", + "value", + "did", + "collection", + "rkey", + "time_us" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "time_us": { + "type": "integer" + } + } + }, + "profileEntry": { + "type": "object", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "app.bsky.actor.profile#main" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + } + } + } + } +} diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/getRecord.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/getRecord.json index c866e5e..99d2454 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/getRecord.json +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/getRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a single xyz.statusphere.status record by AT URI", + "description": "Get a xyz.statusphere.status record by AT URI", "parameters": { "type": "params", "required": [ @@ -18,7 +18,7 @@ }, "profiles": { "type": "boolean", - "description": "Include profile + identity info keyed by DID" + "description": "Include indexed profile and identity information" } } }, @@ -95,7 +95,7 @@ }, "value": { "type": "ref", - "ref": "#appBskyActorProfile" + "ref": "app.bsky.actor.profile#main" }, "collection": { "type": "string", @@ -105,69 +105,6 @@ "type": "string" } } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } } } } diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/listRecords.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/listRecords.json index 762f83f..70eef40 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/listRecords.json +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/listRecords.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Query xyz.statusphere.status records with filters", + "description": "Query xyz.statusphere.status records", "parameters": { "type": "params", "properties": { @@ -20,15 +20,11 @@ "actor": { "type": "string", "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" + "description": "Filter by DID or handle" }, "profiles": { "type": "boolean", - "description": "Include profile + identity info keyed by DID" - }, - "status": { - "type": "string", - "description": "Filter by status" + "description": "Include indexed profile and identity information" }, "createdAtMin": { "type": "string", @@ -38,11 +34,15 @@ "type": "string", "description": "Maximum value for createdAt" }, + "status": { + "type": "string", + "description": "Filter by status" + }, "sort": { "type": "string", "knownValues": [ - "status", - "createdAt" + "createdAt", + "status" ], "description": "Field to sort by (default: time_us)" }, @@ -52,7 +52,7 @@ "asc", "desc" ], - "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" + "description": "Sort direction" } } }, @@ -148,7 +148,7 @@ }, "value": { "type": "ref", - "ref": "#appBskyActorProfile" + "ref": "app.bsky.actor.profile#main" }, "collection": { "type": "string", @@ -158,69 +158,6 @@ "type": "string" } } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } } } } diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts index 48c9760..9f81202 100644 --- a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/index.ts @@ -1,7 +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 StatusphereAppProfileGetRecord from "./types/statusphere/app/profile/getRecord.js"; +export * as StatusphereAppProfileListRecords from "./types/statusphere/app/profile/listRecords.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 index 05a42fc..c2255ab 100644 --- 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 @@ -8,18 +8,34 @@ const _mainSchema = /*#__PURE__*/ v.query( "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()), - }), + "schema": /*#__PURE__*/ v.object( + { + get "position"() { + return /*#__PURE__*/ v.optional(sourcePositionSchema) + }, + "updatedAt": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), + "updatedAtDate": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), + } + ), } } ); +const _sourcePositionSchema = /*#__PURE__*/ v.object({ + "$type": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("statusphere.app.getCursor#sourcePosition")), + "cursor": /*#__PURE__*/ v.string(), + "epoch": /*#__PURE__*/ v.string(), + "source": /*#__PURE__*/ v.string(), +}); type main$schematype = typeof _mainSchema; +type sourcePosition$schematype = typeof _sourcePositionSchema; export interface mainSchema extends main$schematype {} + +export interface sourcePositionSchema extends sourcePosition$schematype {} export const mainSchema = _mainSchema as mainSchema; +export const sourcePositionSchema = _sourcePositionSchema as sourcePositionSchema; + +export interface SourcePosition extends v.InferInput {} export interface $params {} 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 deleted file mode 100644 index e058174..0000000 --- a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/getOverview.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 {} - -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 index 7597288..4bcad8f 100644 --- 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 @@ -1,98 +1,14 @@ 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 AppBskyActorProfile from "@atcute/bluesky/types/app/actor/profile"; -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(), - } - ), + "params": /*#__PURE__*/ v.object({ + "actor": /*#__PURE__*/ v.actorIdentifierString(), + }), "output": { "type": "lex", "schema": /*#__PURE__*/ v.object( @@ -115,25 +31,19 @@ const _profileEntrySchema = /*#__PURE__*/ v.object( "rkey": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), "uri": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), get "value"() { - return /*#__PURE__*/ v.optional(appBskyActorProfileSchema) + return /*#__PURE__*/ v.optional(AppBskyActorProfile.mainSchema) }, } ); -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 {} - export interface ProfileEntry extends v.InferInput {} export interface $params extends v.InferInput {} 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 deleted file mode 100644 index e7c784c..0000000 --- a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/notifyOfUpdate.ts +++ /dev/null @@ -1,63 +0,0 @@ -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/profile/getRecord.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/profile/getRecord.ts new file mode 100644 index 0000000..3ba2a7f --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/profile/getRecord.ts @@ -0,0 +1,74 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; +import * as AppBskyActorProfile from "@atcute/bluesky/types/app/actor/profile"; + +const _mainSchema = /*#__PURE__*/ v.query( + "statusphere.app.profile.getRecord", + { + "params": /*#__PURE__*/ v.object( + { + /** + * Include indexed profile and identity information + */ + "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 AppBskyActorProfile.mainSchema + }, + } + ), + } + } +); +const _profileEntrySchema = /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("statusphere.app.profile.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(AppBskyActorProfile.mainSchema) + }, + } +); +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; + +export interface mainSchema extends main$schematype {} + +export interface profileEntrySchema extends profileEntry$schematype {} +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; + +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.profile.getRecord": mainSchema; + } +} diff --git a/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/profile/listRecords.ts b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/profile/listRecords.ts new file mode 100644 index 0000000..df0f714 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/src/lib/lexicons/types/statusphere/app/profile/listRecords.ts @@ -0,0 +1,102 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; +import * as AppBskyActorProfile from "@atcute/bluesky/types/app/actor/profile"; + +const _mainSchema = /*#__PURE__*/ v.query( + "statusphere.app.profile.listRecords", + { + "params": /*#__PURE__*/ v.object( + { + /** + * Filter by DID or handle + */ + "actor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), + "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 + ), + /** + * Include indexed profile and identity information + */ + "profiles": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + } + ), + "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.profile.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(AppBskyActorProfile.mainSchema) + }, + } +); +const _recordSchema = /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("statusphere.app.profile.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 AppBskyActorProfile.mainSchema + }, + } +); +type main$schematype = typeof _mainSchema; +type profileEntry$schematype = typeof _profileEntrySchema; +type record$schematype = typeof _recordSchema; + +export interface mainSchema extends main$schematype {} + +export interface profileEntrySchema extends profileEntry$schematype {} + +export interface recordSchema extends record$schematype {} +export const mainSchema = _mainSchema as mainSchema; +export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; +export const recordSchema = _recordSchema as recordSchema; + +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.profile.listRecords": 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 index 836d45f..f923374 100644 --- 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 @@ -1,95 +1,16 @@ 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 AppBskyActorProfile from "@atcute/bluesky/types/app/actor/profile"; 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 + * Include indexed profile and identity information */ "profiles": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), /** @@ -129,25 +50,19 @@ const _profileEntrySchema = /*#__PURE__*/ v.object( "rkey": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), "uri": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), get "value"() { - return /*#__PURE__*/ v.optional(appBskyActorProfileSchema) + return /*#__PURE__*/ v.optional(AppBskyActorProfile.mainSchema) }, } ); -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 {} - export interface ProfileEntry extends v.InferInput {} export interface $params extends v.InferInput {} 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 index 207312c..467fe66 100644 --- 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 @@ -1,95 +1,16 @@ 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 AppBskyActorProfile from "@atcute/bluesky/types/app/actor/profile"; 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) + * Filter by DID or handle */ "actor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), /** @@ -114,11 +35,11 @@ const _mainSchema = /*#__PURE__*/ v.query( 50 ), /** - * Sort direction (default: desc for dates/numbers/counts, asc for strings) + * Sort direction */ "order": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>()), /** - * Include profile + identity info keyed by DID + * Include indexed profile and identity information */ "profiles": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), /** @@ -157,7 +78,7 @@ const _profileEntrySchema = /*#__PURE__*/ v.object( "rkey": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), "uri": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), get "value"() { - return /*#__PURE__*/ v.optional(appBskyActorProfileSchema) + return /*#__PURE__*/ v.optional(AppBskyActorProfile.mainSchema) }, } ); @@ -175,25 +96,19 @@ const _recordSchema = /*#__PURE__*/ v.object( }, } ); -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 {} - export interface ProfileEntry extends v.InferInput {} export interface Record extends v.InferInput {} diff --git a/docs/02-querying.md b/docs/02-querying.md index 6997372..46dac29 100644 --- a/docs/02-querying.md +++ b/docs/02-querying.md @@ -7,7 +7,7 @@ Once [indexing](./01-indexing.md) is set up, every collection you declared gets | `{namespace}.{short}.listRecords` | Paginated list with filters, sorts, hydration | | `{namespace}.{short}.getRecord?uri=…` | Single record by AT-URI | -Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, and optionally `{namespace}.lexicons`. +Top-level methods include `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.notifyOfUpdate`, and optionally `{namespace}.getFeed` and `{namespace}.lexicons`. ## HTTP (what most callers use) @@ -30,7 +30,7 @@ Dotted field names become camelCase params — `queryable: { "subject.uri": {} } ## Operational status -`GET /status` and `GET /xrpc/{namespace}.getOverview` return the current JSON overview. It includes indexed record totals, the live-ingest cursor and lag, and durable backfill state: +`GET /status` returns the current JSON overview. It includes indexed record totals, live-ingest freshness, and durable backfill state: - discovery source progress; - mutually exclusive account totals for `complete`, `pending`, `retrying`, and `failed`; @@ -42,6 +42,8 @@ Dotted field names become camelCase params — `queryable: { "subject.uri": {} } "Known" is deliberate: while relay discovery is incomplete, Contrail cannot honestly claim how many accounts remain undiscovered. `/health` remains a lightweight liveness response and does not claim that historical backfill is complete. +`GET /xrpc/{namespace}.getCursor` returns the committed primary ordered-source position when `orderedSource` is configured. The `{ source, epoch, cursor }` tuple is opaque: compare complete tuples for equality only, and treat a source or epoch change as a full reset. A consumer that needs a stable query snapshot can read the position before and after its query and retry when the two positions differ. + ## Programmatic ```ts diff --git a/packages/contrail/README.md b/packages/contrail/README.md index b312637..2ff33a3 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -89,7 +89,34 @@ pnpm contrail lexicons generate pnpm contrail lexicons check ``` -`contrail lexicons all` also pulls referenced record Lexicons and runs Atcute TypeScript generation. Use `--public` when generating only the collection methods intended for a public read surface. Generated `lex.config.js` files carry an ownership marker; existing user-owned Atcute configuration is never replaced. Pass `--no-atcute-config` to manage that file yourself. The generator is also exported from `@atmo-dev/contrail/lexicons` for programmatic use. +`contrail lexicons all` also pulls referenced record Lexicons and runs Atcute TypeScript generation. Use `--public` when generating only methods advertised by the anonymous read surface. Generated `lex.config.js` files carry an ownership marker; existing user-owned Atcute configuration is never replaced. Pass `--no-atcute-config` to manage that file yourself. The generator is also exported from `@atmo-dev/contrail/lexicons` for programmatic use. + +## Public read-through service + +```ts +export default createWorker(config, { + lexicons, + publicService: { endpoint: "https://api.example.com" }, +}); +``` + +Discovery at `/.well-known/contrail` advertises a canonical contract digest and a content-addressed Lexicon bundle. Collection reads, profiles, feeds, and authored custom queries remain anonymous read-through operations: they may acquire public AT Protocol data and improve the cache behind the response. `notifyOfUpdate` remains separately controlled by `config.notify` and is not part of the anonymous contract. + +Configure the primary ordered source so `getCursor` can expose its committed position: + +```ts +const config = { + orderedSource: { + source: "jetstream", + epoch: "primary-2026", // change whenever cursor continuity changes + }, + // ... +}; +``` + +The returned cursor is opaque. Compare the complete `{ source, epoch, cursor }` value for equality; never order cursors from different epochs. Consumers can read the position before and after a query, retry if it changed, then poll it as a refetch/invalidation signal. + +Connect an independent consumer with `contrail connect `. A repeated connection requires `--update`; provider files and the lock are staged and swapped without deleting consumer-owned Lexicons. ## Runtime record validation diff --git a/packages/contrail/tests/backfill-status.test.ts b/packages/contrail/tests/backfill-status.test.ts index a431268..6549e76 100644 --- a/packages/contrail/tests/backfill-status.test.ts +++ b/packages/contrail/tests/backfill-status.test.ts @@ -881,7 +881,12 @@ describe("backfill status JSON", () => { const root = await app.fetch(new Request("http://localhost/")); expect(await root.json()).toEqual({ status: "ok" }); - const xrpc = await app.fetch(new Request("http://localhost/xrpc/com.example.getOverview")); - expect(((await xrpc.json()) as any).backfill).toEqual(overview.backfill); + const status = await app.fetch(new Request("http://localhost/status")); + expect(((await status.json()) as any).backfill).toEqual(overview.backfill); + + const removed = await app.fetch( + new Request("http://localhost/xrpc/com.example.getOverview"), + ); + expect(removed.status).toBe(404); }); }); diff --git a/packages/contrail/tests/connect.test.ts b/packages/contrail/tests/connect.test.ts new file mode 100644 index 0000000..bcc6b4b --- /dev/null +++ b/packages/contrail/tests/connect.test.ts @@ -0,0 +1,296 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { connectPublicService } from "../src/cli/commands/connect"; +import { + contractFromManifest, + digestLexiconDocuments, + digestPublicContract, + type PublicServiceManifest, +} from "../src/public-service"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +const endpoint = "https://api.atmo.rsvp"; +const method = "atmo.rsvp.event.listRecords"; +const methodLexicon = { + lexicon: 1, + id: method, + defs: { main: { type: "query" } }, +}; +const sourceLexicon = { + lexicon: 1, + id: "community.lexicon.calendar.event", + defs: { main: { type: "record" } }, +}; + +async function serviceFixture(values = [methodLexicon, sourceLexicon]) { + const { digest } = await digestLexiconDocuments(values); + const manifest: PublicServiceManifest = { + format: "contrail.service", + version: 1, + endpoint, + namespace: "atmo.rsvp", + contract: { digest: "" }, + lexicons: { url: `${endpoint}/lexicons/${digest}`, digest }, + status: { url: `${endpoint}/status` }, + collections: [ + { + alias: "event", + nsid: "community.lexicon.calendar.event", + methods: [method], + queryable: [], + searchable: [], + relations: [], + references: [], + }, + ], + methods: [method], + }; + manifest.contract.digest = await digestPublicContract( + contractFromManifest(manifest), + ); + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/.well-known/contrail")) { + return Response.json(manifest); + } + if (url.includes("/lexicons/")) return Response.json({ lexicons: values }); + return new Response("not found", { status: 404 }); + }); + return { fetcher, manifest, values }; +} + +async function temporaryRoot() { + const root = await mkdtemp(join(tmpdir(), "contrail-connect-")); + roots.push(root); + return root; +} + +describe("contrail connect", () => { + it("verifies and atomically locks a discovered service", async () => { + const root = await temporaryRoot(); + const { fetcher, manifest, values } = await serviceFixture(); + + const result = await connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher, + }); + + expect(result.written).toBe(2); + expect(result.lock).toMatchObject({ + endpoint, + namespace: "atmo.rsvp", + contractDigest: manifest.contract.digest, + lexiconRoot: "lexicons/pulled/api.atmo.rsvp", + }); + expect( + JSON.parse( + await readFile( + join( + root, + "lexicons/pulled/api.atmo.rsvp/atmo/rsvp/event/listRecords.json", + ), + "utf8", + ), + ), + ).toEqual(values[0]); + expect( + JSON.parse(await readFile(join(root, "contrail.lock.json"), "utf8")), + ).toEqual(result.lock); + + await writeFile(join(root, "lexicons/pulled/consumer-owned.json"), "keep"); + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher, + }), + ).rejects.toThrow("rerun with --update"); + await connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher, + update: true, + }); + expect( + await readFile(join(root, "lexicons/pulled/consumer-owned.json"), "utf8"), + ).toBe("keep"); + }); + + it("preserves the previous provider and lock when an update fails validation", async () => { + const root = await temporaryRoot(); + const fixture = await serviceFixture(); + await connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: fixture.fetcher, + }); + const lockPath = join(root, "contrail.lock.json"); + const documentPath = join( + root, + "lexicons/pulled/api.atmo.rsvp/atmo/rsvp/event/listRecords.json", + ); + const previousLock = await readFile(lockPath, "utf8"); + const previousDocument = await readFile(documentPath, "utf8"); + fixture.manifest.contract.digest = `sha256:${"f".repeat(64)}`; + + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: fixture.fetcher, + update: true, + }), + ).rejects.toThrow("Contract digest mismatch"); + expect(await readFile(lockPath, "utf8")).toBe(previousLock); + expect(await readFile(documentPath, "utf8")).toBe(previousDocument); + }); + + it("rejects Lexicon and contract digest mismatches", async () => { + const root = await temporaryRoot(); + const lexiconMismatch = await serviceFixture(); + lexiconMismatch.manifest.lexicons.digest = `sha256:${"0".repeat(64)}`; + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: lexiconMismatch.fetcher, + }), + ).rejects.toThrow("Lexicon digest mismatch"); + + const contractMismatch = await serviceFixture(); + contractMismatch.manifest.contract.digest = `sha256:${"1".repeat(64)}`; + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: contractMismatch.fetcher, + }), + ).rejects.toThrow("Contract digest mismatch"); + }); + + it("rejects advertised methods without matching query Lexicons", async () => { + const root = await temporaryRoot(); + const fixture = await serviceFixture([ + { ...methodLexicon, defs: { main: { type: "procedure" } } }, + sourceLexicon, + ]); + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: fixture.fetcher, + }), + ).rejects.toThrow("matching query Lexicon"); + }); + + it("rejects inconsistent method namespaces and collection capabilities", async () => { + const root = await temporaryRoot(); + const outside = await serviceFixture(); + outside.manifest.methods.push("other.example.read"); + outside.manifest.contract.digest = await digestPublicContract( + contractFromManifest(outside.manifest), + ); + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: outside.fetcher, + }), + ).rejects.toThrow("outside its namespace"); + + const unknown = await serviceFixture(); + unknown.manifest.collections[0]!.methods.push("atmo.rsvp.event.getRecord"); + unknown.manifest.contract.digest = await digestPublicContract( + contractFromManifest(unknown.manifest), + ); + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: unknown.fetcher, + }), + ).rejects.toThrow("advertises an unknown method"); + }); + + it("rejects cross-origin redirects and bounded request timeouts", async () => { + const root = await temporaryRoot(); + const redirected = vi.fn(async () => { + const response = Response.json({}); + Object.defineProperty(response, "url", { + value: "https://evil.example/", + }); + return response; + }); + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: redirected, + }), + ).rejects.toThrow("redirected to a different origin"); + + const hanging = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher: hanging, + timeoutMs: 5, + }), + ).rejects.toThrow(); + }); + + it("never cleans an output path outside the consumer project", async () => { + const root = await temporaryRoot(); + const { fetcher } = await serviceFixture(); + await expect( + connectPublicService({ + endpoint, + root, + out: ".", + lock: "contrail.lock.json", + fetcher, + }), + ).rejects.toThrow("path must stay inside"); + }); +}); diff --git a/packages/contrail/tests/database-bootstrap-target.test.ts b/packages/contrail/tests/database-bootstrap-target.test.ts index 6caac93..bc7da5d 100644 --- a/packages/contrail/tests/database-bootstrap-target.test.ts +++ b/packages/contrail/tests/database-bootstrap-target.test.ts @@ -6,6 +6,7 @@ import { bootstrapFreshProjection, getBootstrapFailure, getBootstrapVerification, + getServingSourcePosition, initSchema, queryRecords, resolveConfig, @@ -236,6 +237,9 @@ describe("database bootstrap target", () => { source_cursor: "2", }); expect(await getBootstrapVerification(db)).toMatchObject({ ok: true }); + expect(await getServingSourcePosition(db)).toMatchObject({ + position: sourcePosition(3), + }); }); it("blocks completion and persists aggregate verification failures", async () => { @@ -317,6 +321,60 @@ describe("database bootstrap target", () => { expect((await target.load())?.phase).toBe("complete"); }); + it("rolls projection and checkpoint back when source position cannot commit", async () => { + const resolved = config(); + const db = createSqliteDatabase(":memory:"); + await initSchema(db, resolved); + const target = new DatabaseBootstrapTarget(db, resolved); + const prepared = snapshot(); + await target.beginCapture(sourcePosition(1)); + await target.setSnapshot(prepared, sourcePosition(1)); + await target.applySnapshotBatch(prepared, { + records: [], + sourceTimeUs: 1, + progress: { partition: "pds", cursor: null, complete: true }, + done: true, + }); + await target.beginCatchup(sourcePosition(2)); + await db + .prepare( + `CREATE TRIGGER fail_source_position + BEFORE INSERT ON source_position + BEGIN SELECT RAISE(ABORT, 'injected source position failure'); END`, + ) + .run(); + const batch = { + mutations: [ + { + operation: "put" as const, + ...record("a", "atomic"), + sourceTimeUs: 2, + position: sourcePosition(2), + }, + ], + checkpoint: sourcePosition(2), + caughtUp: true, + }; + + await expect(target.applyMutationBatch(batch)).rejects.toThrow( + "injected source position failure", + ); + expect( + (await queryRecords(db, resolved, { collection: "event" })).records, + ).toHaveLength(0); + expect((await target.load())?.changeCheckpoint).toEqual(sourcePosition(1)); + expect(await getServingSourcePosition(db)).toBeNull(); + + await db.prepare("DROP TRIGGER fail_source_position").run(); + await target.applyMutationBatch(batch); + expect( + (await queryRecords(db, resolved, { collection: "event" })).records, + ).toHaveLength(1); + expect(await getServingSourcePosition(db)).toMatchObject({ + position: sourcePosition(2), + }); + }); + it("rejects a mutation position from another epoch before advancing progress", async () => { const resolved = config(); const db = createSqliteDatabase(":memory:"); diff --git a/packages/contrail/tests/jetstream-change-source.test.ts b/packages/contrail/tests/jetstream-change-source.test.ts index 09e32dc..ab45cfb 100644 --- a/packages/contrail/tests/jetstream-change-source.test.ts +++ b/packages/contrail/tests/jetstream-change-source.test.ts @@ -62,6 +62,20 @@ function config() { } describe("Jetstream change source", () => { + it("requires the bootstrap epoch to match the configured live source", () => { + const configured = resolveConfig({ + ...config(), + orderedSource: { source: "jetstream", epoch: "live-epoch" }, + }); + expect( + () => + new JetstreamChangeSource(configured, { + epoch: "different-epoch", + retentionUs: 60_000_000, + }), + ).toThrow("does not match configured ordered source"); + }); + it("uses real stream events as marks and replays through the exact watermark", async () => { const nowUs = Date.now() * 1000; const start = nowUs - 20_000; diff --git a/packages/contrail/tests/lexicon-generation.test.ts b/packages/contrail/tests/lexicon-generation.test.ts index cbd998f..7f02e9f 100644 --- a/packages/contrail/tests/lexicon-generation.test.ts +++ b/packages/contrail/tests/lexicon-generation.test.ts @@ -98,7 +98,7 @@ function fixture() { }, }, }; - return { root, config, pulled }; + return { root, config, pulled, write }; } function parameters(document: any): Record { @@ -118,10 +118,15 @@ describe("Contrail Lexicon generation", () => { expect(result.methods).toEqual([ "example.public.event.getRecord", "example.public.event.listRecords", + "example.public.getCursor", "example.public.rsvp.getRecord", "example.public.rsvp.listRecords", ]); - expect(result.generated["example.public.getCursor"]).toBeUndefined(); + expect(result.generated["example.public.getCursor"]).toBeDefined(); + expect( + (result.generated["example.public.getCursor"] as any).defs.sourcePosition + .required, + ).toEqual(["source", "epoch", "cursor"]); const event = result.generated["example.public.event.listRecords"] as any; const params = parameters(event); @@ -149,6 +154,34 @@ describe("Contrail Lexicon generation", () => { ); }); + it("references profile record schemas without duplicating their definitions", () => { + const { root, config, write } = fixture(); + write("community.example.profile", { + lexicon: 1, + id: "community.example.profile", + defs: { + main: { + type: "record", + key: "literal:self", + record: { + type: "object", + properties: { displayName: { type: "string" } }, + }, + }, + }, + }); + config.profiles = [ + { collection: "community.example.profile", shortName: "profile" }, + ]; + const result = generateLexicons({ config, rootDir: root, quiet: true }); + const profile = result.generated["example.public.getProfile"] as any; + expect(profile.defs.profileEntry.properties.value).toEqual({ + type: "ref", + ref: "community.example.profile#main", + }); + expect(profile.defs.communityExampleProfile).toBeUndefined(); + }); + it("respects disabled standard methods", () => { const { root, config } = fixture(); config.collections.event!.methods = ["listRecords"]; @@ -170,7 +203,7 @@ describe("Contrail Lexicon generation", () => { }; const result = generateLexicons({ config, rootDir: root, quiet: true }); expect(result.methods).toContain("example.public.getCursor"); - expect(result.methods).toContain("example.public.getOverview"); + expect(result.methods).not.toContain("example.public.getOverview"); expect(result.methods).toContain("example.public.notifyOfUpdate"); expect(result.methods).toContain("example.public.getFeed"); const feed = result.generated["example.public.getFeed"] as any; diff --git a/packages/contrail/tests/persistent.test.ts b/packages/contrail/tests/persistent.test.ts index 9c36b5a..47767fd 100644 --- a/packages/contrail/tests/persistent.test.ts +++ b/packages/contrail/tests/persistent.test.ts @@ -3,7 +3,11 @@ import type { ContrailConfig, Database } from "../src/index"; import { resolveConfig } from "../src/index"; import { createTestDb, createTestDbWithSchema, TEST_CONFIG } from "./helpers"; import { runPersistent } from "../src/index"; -import { getLastCursor, queryRecords } from "../src/index"; +import { + getLastCursor, + getServingSourcePosition, + queryRecords, +} from "../src/index"; import { initSchema } from "../src/index"; const applyIdentityEventMock = vi.fn().mockResolvedValue(undefined); @@ -48,7 +52,11 @@ function mockSubscription(events: Array<{ kind: string; did: string; time_us: nu } describe("runPersistent", () => { - it("flushes when batch size is reached", async () => { + it("flushes records and the ordered source position atomically", async () => { + const config = resolveConfig({ + ...TEST_CONFIG, + orderedSource: { source: "jetstream", epoch: "persistent-test" }, + }); const events = Array.from({ length: 50 }, (_, i) => ({ kind: "commit" as const, did: `did:plc:user${i}`, @@ -65,7 +73,7 @@ describe("runPersistent", () => { const controller = new AbortController(); // After yielding 50 events, the mock hangs. Give it time to flush, then abort. - const promise = runPersistent(db, TEST_CONFIG, { + const promise = runPersistent(db, config, { batchSize: 50, flushIntervalMs: 60_000, // high so only batch size triggers flush signal: controller.signal, @@ -85,6 +93,13 @@ describe("runPersistent", () => { const cursor = await getLastCursor(db); expect(cursor).toBe(1049); // last event's time_us + expect(await getServingSourcePosition(db)).toMatchObject({ + position: { + source: "jetstream", + epoch: "persistent-test", + cursor: "1049", + }, + }); }); it("keeps dependent events discovered in the same flush batch", async () => { diff --git a/packages/contrail/tests/public-service-e2e.test.ts b/packages/contrail/tests/public-service-e2e.test.ts new file mode 100644 index 0000000..84200f2 --- /dev/null +++ b/packages/contrail/tests/public-service-e2e.test.ts @@ -0,0 +1,179 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { connectPublicService } from "../src/cli/commands/connect"; +import { generateLexiconTypesWithAtcute } from "../src/cli/atcute"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { createApp } from "../src/core/router"; +import { + createIngestEvent, + ingestRecords, + initSchema, + resolveConfig, + type ContrailConfig, +} from "../src/index"; +import { generateLexicons } from "../src/lexicons/generate"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function temporaryRoot(label: string): string { + const root = mkdtempSync(join(process.cwd(), `.${label}-`)); + roots.push(root); + return root; +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +describe("public service consumer integration", () => { + it("discovers, connects, generates types, compiles, and queries", async () => { + const serviceRoot = temporaryRoot("public-service"); + const consumerRoot = temporaryRoot("public-consumer"); + const sourceLexicon = { + lexicon: 1, + id: "community.example.event", + defs: { + main: { + type: "record", + key: "tid", + record: { + type: "object", + required: ["name"], + properties: { name: { type: "string" } }, + }, + }, + }, + }; + writeJson( + join(serviceRoot, "lexicons/pulled/community/example/event.json"), + sourceLexicon, + ); + const config: ContrailConfig = { + namespace: "com.example", + profiles: [], + orderedSource: { source: "jetstream", epoch: "e2e" }, + collections: { + event: { + collection: "community.example.event", + queryable: { name: {} }, + }, + }, + }; + const generated = generateLexicons({ + config, + rootDir: serviceRoot, + surface: "public", + quiet: true, + }); + const lexicons = [sourceLexicon, ...Object.values(generated.generated)]; + const resolved = resolveConfig(config); + const db = createSqliteDatabase(":memory:"); + await initSchema(db, resolved); + await ingestRecords( + db, + [ + createIngestEvent({ + uri: "at://did:plc:test/community.example.event/1", + did: "did:plc:test", + collection: "community.example.event", + rkey: "1", + operation: "create", + cid: "bafyreievent", + value: { name: "Typed event" }, + timeUs: 1, + indexedAt: 1, + }), + ], + resolved, + ); + const app = createApp(db, resolved, { + lexicons, + publicService: { endpoint: "https://api.example.com" }, + }); + const fetcher: typeof fetch = (input, init) => + app.fetch(new Request(input, init)); + + writeFileSync( + join(consumerRoot, "lex.config.js"), + `import { defineLexiconConfig } from "@atcute/lex-cli"; +export default defineLexiconConfig({ + generate: { + files: ["lexicons/pulled/**/*.json"], + outdir: "src/lexicons/", + }, +}); +`, + ); + await connectPublicService({ + endpoint: "https://api.example.com", + root: consumerRoot, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher, + }); + generateLexiconTypesWithAtcute(consumerRoot); + + mkdirSync(join(consumerRoot, "src"), { recursive: true }); + writeFileSync( + join(consumerRoot, "src", "consumer.ts"), + `import { Client, simpleFetchHandler } from "@atcute/client"; +import "./lexicons/index.js"; +const client = new Client({ + handler: simpleFetchHandler({ service: "https://api.example.com" }), +}); +const response = await client.get("com.example.event.listRecords", { + params: { name: "Typed event", limit: 1 }, +}); +if (response.ok) { + const name: string = response.data.records[0]!.value.name; + console.log(name); +} +`, + ); + writeJson(join(consumerRoot, "tsconfig.json"), { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + skipLibCheck: true, + }, + include: ["src/**/*.ts"], + }); + const npmExecPath = process.env.npm_execpath; + const command = npmExecPath ? process.execPath : "pnpm"; + const args = npmExecPath + ? [ + npmExecPath, + "exec", + "tsc", + "--project", + join(consumerRoot, "tsconfig.json"), + ] + : ["exec", "tsc", "--project", join(consumerRoot, "tsconfig.json")]; + const checked = spawnSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + }); + expect(checked.status, `${checked.stdout}\n${checked.stderr}`).toBe(0); + + const queried = await app.fetch( + new Request( + "https://api.example.com/xrpc/com.example.event.listRecords?name=Typed%20event&limit=1", + ), + ); + expect(queried.status).toBe(200); + expect(await queried.json()).toMatchObject({ + records: [{ value: { name: "Typed event" } }], + }); + }); +}); diff --git a/packages/contrail/tests/serving-source-position.test.ts b/packages/contrail/tests/serving-source-position.test.ts new file mode 100644 index 0000000..9266732 --- /dev/null +++ b/packages/contrail/tests/serving-source-position.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + Contrail, + assertServingSourceCompatibility, + getLastCursor, + getServingSourcePosition, + initSchema, + saveCursor, + type ContrailConfig, +} from "../src/index"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; + +const config: ContrailConfig = { + namespace: "com.example", + profiles: [], + orderedSource: { source: "jetstream", epoch: "primary-2026" }, + collections: { + event: { collection: "com.example.event" }, + }, +}; + +describe("serving source positions", () => { + it("commits the legacy replay cursor and opaque source position together", async () => { + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + + await saveCursor(db, 123_456, config.orderedSource); + + expect(await getLastCursor(db)).toBe(123_456); + expect(await getServingSourcePosition(db)).toMatchObject({ + position: { + source: "jetstream", + epoch: "primary-2026", + cursor: "123456", + }, + }); + + await saveCursor(db, 100, config.orderedSource); + expect(await getLastCursor(db)).toBe(123_456); + expect(await getServingSourcePosition(db)).toMatchObject({ + position: { cursor: "123456" }, + }); + }); + + it("rejects a configured continuity epoch that differs from durable state", async () => { + const db = createSqliteDatabase(":memory:"); + await initSchema(db, config); + await saveCursor(db, 10, config.orderedSource); + + await expect( + assertServingSourceCompatibility(db, { + source: "jetstream", + epoch: "replacement-epoch", + }), + ).rejects.toThrow("does not match durable source position"); + + const contrail = new Contrail({ + ...config, + orderedSource: { source: "jetstream", epoch: "replacement-epoch" }, + }); + await expect(contrail.init(db)).rejects.toThrow( + "does not match durable source position", + ); + }); + + it("validates ordered source configuration", () => { + expect( + () => + new Contrail({ + ...config, + orderedSource: { source: "jetstream", epoch: "" }, + }), + ).toThrow("orderedSource requires non-empty source and epoch values"); + }); +}); diff --git a/packages/contrail/tests/source-ordering.test.ts b/packages/contrail/tests/source-ordering.test.ts index 08b363b..dd643ed 100644 --- a/packages/contrail/tests/source-ordering.test.ts +++ b/packages/contrail/tests/source-ordering.test.ts @@ -3,9 +3,11 @@ import { createIngestEvent, ingestRecords, initSchema, + getServingSourcePosition, queryRecords, resolveConfig, saveCursorStatement, + saveOrderedSourcePositionStatement, type ContrailConfig, type Database, type IngestEvent, @@ -427,6 +429,7 @@ describe("durable source ordering", () => { await db .prepare("INSERT INTO cursor_failure (value) VALUES ('duplicate')") .run(); + const orderedSource = { source: "jetstream", epoch: "atomic-test" }; const event = mutation({ operation: "create", sourceTime: 500, @@ -438,6 +441,7 @@ describe("durable source ordering", () => { ingestRecords(db, [event], resolved, { trailingStatements: [ saveCursorStatement(db, 500), + saveOrderedSourcePositionStatement(db, orderedSource, 500), db.prepare("INSERT INTO cursor_failure (value) VALUES ('duplicate')"), ], }), @@ -453,6 +457,7 @@ describe("durable source ordering", () => { expect( await db.prepare("SELECT time_us FROM cursor WHERE id = 1").first(), ).toBeNull(); + expect(await getServingSourcePosition(db)).toBeNull(); }); it("stores record time separately from source and local times", async () => { diff --git a/packages/contrail/tests/worker.test.ts b/packages/contrail/tests/worker.test.ts index b595a82..b2561e2 100644 --- a/packages/contrail/tests/worker.test.ts +++ b/packages/contrail/tests/worker.test.ts @@ -2,10 +2,17 @@ import { describe, it, expect, vi } from "vitest"; import { createWorker } from "../src/worker"; import { Contrail } from "../src/contrail"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import type { ContrailConfig } from "../src/index"; +import { + contractFromManifest, + digestPublicContract, + saveCursor, + type ContrailConfig, +} from "../src/index"; const MINIMAL_CONFIG: ContrailConfig = { namespace: "com.example", + profiles: [], + orderedSource: { source: "jetstream", epoch: "worker-test" }, collections: { event: { collection: "community.lexicon.calendar.event", @@ -14,6 +21,20 @@ const MINIMAL_CONFIG: ContrailConfig = { }, }; +function queryLexicons(...ids: string[]) { + return ids.map((id) => ({ + lexicon: 1, + id, + defs: { main: { type: "query" } }, + })); +} + +const MINIMAL_PUBLIC_LEXICONS = queryLexicons( + "com.example.getCursor", + "com.example.event.getRecord", + "com.example.event.listRecords", +); + describe("createWorker", () => { it("returns an object with fetch + scheduled handlers", () => { const worker = createWorker(MINIMAL_CONFIG); @@ -28,7 +49,9 @@ describe("createWorker", () => { // Before first fetch: schema not present yet. const tables = await db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursor'") + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='cursor'", + ) .first<{ name: string }>(); expect(tables).toBeNull(); @@ -36,7 +59,9 @@ describe("createWorker", () => { // After first fetch: schema present. const after = await db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursor'") + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='cursor'", + ) .first<{ name: string }>(); expect(after?.name).toBe("cursor"); @@ -67,7 +92,7 @@ describe("createWorker", () => { const res = await worker.fetch( new Request("http://localhost/xrpc/com.example.lexicons"), - env + env, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ lexicons }); @@ -80,11 +105,236 @@ describe("createWorker", () => { const res = await worker.fetch( new Request("http://localhost/xrpc/com.example.lexicons"), - env + env, ); expect(res.status).toBe(404); }); + it("serves deterministic public discovery and stable Lexicons", async () => { + const db = createSqliteDatabase(":memory:"); + const lexicons = MINIMAL_PUBLIC_LEXICONS; + const worker = createWorker(MINIMAL_CONFIG, { + lexicons, + publicService: { endpoint: "https://api.example.com" }, + }); + const env = { DB: db }; + + const manifestResponse = await worker.fetch( + new Request("https://api.example.com/.well-known/contrail"), + env, + ); + expect(manifestResponse.status).toBe(200); + expect(manifestResponse.headers.get("access-control-allow-origin")).toBe( + "*", + ); + const manifest = await manifestResponse.json(); + expect(manifest).toMatchObject({ + format: "contrail.service", + version: 1, + endpoint: "https://api.example.com", + namespace: "com.example", + contract: { digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) }, + lexicons: { + url: expect.stringMatching( + /^https:\/\/api\.example\.com\/lexicons\/sha256:[0-9a-f]{64}$/, + ), + digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }, + methods: [ + "com.example.event.getRecord", + "com.example.event.listRecords", + "com.example.getCursor", + ], + collections: expect.arrayContaining([ + { + alias: "event", + nsid: "community.lexicon.calendar.event", + methods: [ + "com.example.event.getRecord", + "com.example.event.listRecords", + ], + queryable: ["startsAt"], + searchable: [], + relations: [], + references: [], + }, + ]), + }); + expect(manifest.contract.digest).not.toBe(manifest.lexicons.digest); + expect(await digestPublicContract(contractFromManifest(manifest))).toBe( + manifest.contract.digest, + ); + expect(manifest.lexicons.url).toBe( + `https://api.example.com/lexicons/${manifest.lexicons.digest}`, + ); + + const lexiconResponse = await worker.fetch( + new Request("https://api.example.com/lexicons"), + env, + ); + expect(lexiconResponse.status).toBe(200); + expect(await lexiconResponse.json()).toEqual({ + lexicons: [...lexicons].sort((left, right) => + left.id.localeCompare(right.id), + ), + }); + expect(lexiconResponse.headers.get("etag")).toBe( + `"${manifest.lexicons.digest}"`, + ); + const immutableLexicons = await worker.fetch( + new Request(manifest.lexicons.url), + env, + ); + expect(immutableLexicons.status).toBe(200); + expect(immutableLexicons.headers.get("cache-control")).toContain( + "immutable", + ); + + const statusResponse = await worker.fetch( + new Request("https://api.example.com/status"), + env, + ); + const status = await statusResponse.json(); + expect(status).toMatchObject({ + serving: "ready", + freshness: { last_event_at: null, seconds_ago: null }, + }); + expect(status.ingestion).toBeUndefined(); + expect(statusResponse.headers.get("cache-control")).toContain("max-age=15"); + expect(JSON.stringify(status)).not.toContain("cursor"); + + const emptyCursor = await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.getCursor"), + env, + ); + expect(await emptyCursor.json()).toEqual({}); + + await saveCursor(db, 1234, MINIMAL_CONFIG.orderedSource); + const cursorResponse = await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.getCursor"), + env, + ); + expect(cursorResponse.status).toBe(200); + expect(await cursorResponse.json()).toMatchObject({ + position: { + source: "jetstream", + epoch: "worker-test", + cursor: "1234", + }, + }); + expect( + ( + await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.getOverview"), + env, + ) + ).status, + ).toBe(404); + }); + + it("keeps profiles, feeds, custom queries, and configured notify routes", async () => { + const config: ContrailConfig = { + ...MINIMAL_CONFIG, + profiles: [{ collection: "com.example.profile", shortName: "profile" }], + feeds: { network: { targets: ["event"] } }, + notify: "secret", + collections: { + event: { + ...MINIMAL_CONFIG.collections.event, + queries: { + featured: async () => Response.json({ records: [] }), + }, + }, + }, + }; + const lexicons = queryLexicons( + "com.example.getCursor", + "com.example.getProfile", + "com.example.getFeed", + "com.example.event.getRecord", + "com.example.event.listRecords", + "com.example.event.featured", + "com.example.profile.getRecord", + "com.example.profile.listRecords", + "com.example.follow.getRecord", + "com.example.follow.listRecords", + ); + const worker = createWorker(config, { + lexicons, + publicService: { endpoint: "https://api.example.com" }, + }); + const env = { DB: createSqliteDatabase(":memory:") }; + + const profile = await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.getProfile"), + env, + ); + expect(profile.status).toBe(400); + const feed = await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.getFeed"), + env, + ); + expect(feed.status).toBe(400); + const custom = await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.event.featured"), + env, + ); + expect(custom.status).toBe(200); + expect(await custom.json()).toEqual({ records: [] }); + const manifest = await ( + await worker.fetch( + new Request("https://api.example.com/.well-known/contrail"), + env, + ) + ).json(); + expect(manifest.methods).toEqual( + expect.arrayContaining([ + "com.example.getCursor", + "com.example.getProfile", + "com.example.getFeed", + "com.example.event.featured", + ]), + ); + expect(manifest.methods).not.toContain("com.example.notifyOfUpdate"); + + const notify = await worker.fetch( + new Request("https://api.example.com/xrpc/com.example.notifyOfUpdate", { + method: "POST", + body: JSON.stringify({ uri: "at://did:plc:test/com.example.event/1" }), + }), + env, + ); + expect(notify.status).toBe(401); + }); + + it("refuses public mode without an HTTPS origin and Lexicons", () => { + expect(() => + createWorker(MINIMAL_CONFIG, { + publicService: { endpoint: "https://api.example.com" }, + }), + ).toThrow("non-empty Lexicon bundle"); + + expect(() => + createWorker(MINIMAL_CONFIG, { + lexicons: [{ lexicon: 1, id: "com.example.foo" }], + publicService: { endpoint: "http://api.example.com" }, + }), + ).toThrow("must use HTTPS"); + + expect(() => + createWorker(MINIMAL_CONFIG, { + lexicons: [ + { + lexicon: 1, + id: "com.example.event.listRecords", + defs: { main: { type: "query" } }, + }, + ], + publicService: { endpoint: "https://api.example.com" }, + }), + ).toThrow("public method requires a matching query Lexicon"); + }); + it("scheduled handler runs live ingest then a bounded backfill retry slice", async () => { const order: string[] = []; const ingest = vi -- 2.51.2