diff --git a/.changeset/atproto-record-shape.md b/.changeset/atproto-record-shape.md new file mode 100644 index 0000000..e7495cc --- /dev/null +++ b/.changeset/atproto-record-shape.md @@ -0,0 +1,28 @@ +--- +"@atmo-dev/contrail": minor +"@atmo-dev/contrail-sync": minor +"@atmo-dev/contrail-lexicons": minor +--- + +align the `listRecords` / `getRecord` response envelope with atproto's `com.atproto.repo.*`. the field that carries the record value is now `value`, not `record`. + +**before** (contrail-specific): + +```jsonc +{ "records": [{ "uri", "did", "collection", "rkey", "cid", "record": {...}, "time_us" }] } +``` + +**after** (atproto-compatible plus extras): + +```jsonc +{ "records": [{ "uri", "cid", "value": {...}, "did", "collection", "rkey", "time_us" }] } +``` + +changes: +- `#record` def now requires `["uri", "cid", "value"]` (matches atproto's standard `com.atproto.repo.listRecords#record`). `did`/`collection`/`rkey`/`time_us` remain in the response but are optional. +- `getRecord` top-level output requires `["uri", "value"]` (matches atproto's `com.atproto.repo.getRecord`). +- profile entries in `?profiles=true` responses use `value` instead of `record` for the profile record body. +- realtime watch events (`record.created`, `snapshot.record`, `hydration.added`) — the inner record payload's body field is now `value`. +- `@atmo-dev/contrail-sync`: `WatchRecord.value` (was `record`); `addOptimistic({ value })` (was `record`). + +**breaking.** anywhere you read `r.record` from a contrail response, rename to `r.value`. anywhere you call `addOptimistic({ record: ... })`, switch to `addOptimistic({ value: ... })`. regenerate committed `lexicons/generated/*` in each deployment — the new shape will be advertised on next `contrail-lex generate` run. diff --git a/.changeset/drop-queryable-autodetect.md b/.changeset/drop-queryable-autodetect.md new file mode 100644 index 0000000..8e665f2 --- /dev/null +++ b/.changeset/drop-queryable-autodetect.md @@ -0,0 +1,11 @@ +--- +"@atmo-dev/contrail-lexicons": patch +--- + +the lexicon generator previously auto-detected queryable fields by walking the pulled record schema (string → equality, datetime → range, etc.) and merged them with the user's explicit `queryable` before emitting `listRecords.json`. + +problem: the runtime does **not** auto-detect — it only honors fields explicitly declared in `colConfig.queryable`. So the generated lexicon advertised filter params (e.g. `?mode=online`, `?status=going`) that the server silently ignored. Clients would pass them and get unfiltered results back. + +fix: the generator now only emits what the user declared. the lexicon matches the runtime. one source of truth. + +if you were relying on the phantom params, add the fields explicitly to your config's `queryable` map. if you weren't, nothing changes except smaller, more honest `listRecords.json` files on the next `contrail-lex generate` run. diff --git a/README.md b/README.md index 40634f9..1a09429 100644 --- a/README.md +++ b/README.md @@ -83,18 +83,20 @@ the worker keeps itself fresh from now on via the cron. hit: GET https://.workers.dev/xrpc/com.example.event.listRecords?startsAtMin=2026-01-01&limit=10 ``` -returns every `community.lexicon.calendar.event` record published anywhere on atproto that matches, as JSON. that's it — no PDS setup, no lexicon publishing, no relay configuration. everything scales from there: add filters, add full-text search, add more collections, turn on [spaces](./docs/03-spaces.md) for private records, mount the handler in sveltekit instead, swap the adapter for postgres. +returns every `community.lexicon.calendar.event` record published anywhere on atproto that matches, as JSON. that's it — no PDS setup, no lexicon publishing, no relay configuration. everything scales from there: add filters, add full-text search, add more collections, turn on [spaces](./docs/04-spaces.md) for private records, mount the handler in sveltekit instead, swap the adapter for postgres. **not using workers?** same library, different `db`. see [adapters](./docs/01-indexing.md#adapters) for node:sqlite and postgres. ## Docs -- [Indexing](./docs/01-indexing.md) — the core: collections, queries, ingestion, adapters -- [Lexicons](./docs/02-lexicons.md) — `contrail-lex` CLI, codegen, publishing -- [Spaces](./docs/03-spaces.md) — permissioned records stored by the appview -- [Communities](./docs/04-communities.md) — group-controlled atproto DIDs -- [Sync](./docs/05-sync.md) — reactive client-side store over `watchRecords` -- [Examples](./docs/06-examples.md) — reference deployments in the repo +- [Indexing](./docs/01-indexing.md) — the core: collections, ingestion, adapters +- [Querying](./docs/02-querying.md) — filters, sorts, hydration, search, pagination +- [Lexicons](./docs/03-lexicons.md) — `contrail-lex` CLI, codegen, publishing +- [Spaces](./docs/04-spaces.md) — permissioned records stored by the appview +- [Communities](./docs/05-communities.md) — group-controlled atproto DIDs +- [Sync](./docs/06-sync.md) — reactive client-side store over `watchRecords` +- [Examples](./docs/07-examples.md) — reference deployments in the repo +- Frameworks: [SvelteKit + Cloudflare](./docs/frameworks/sveltekit-cloudflare.md) ## Packages diff --git a/apps/cloudflare-workers/README.md b/apps/cloudflare-workers/README.md index ccedbef..94ed881 100644 --- a/apps/cloudflare-workers/README.md +++ b/apps/cloudflare-workers/README.md @@ -39,5 +39,5 @@ pnpm contrail backfill # backfill against the local D1 created by wrangler - **add a collection:** append to `collections` in `src/contrail.config.ts`; redeploy; `pnpm contrail backfill --remote` to backfill the new one. - **add full-text search:** `searchable: ["field1", "field2"]`, redeploy, no backfill needed (fts indexes repopulate on ingest). - **add relations / references:** see [indexing docs](../../docs/01-indexing.md). -- **private records:** see [spaces docs](../../docs/03-spaces.md). -- **group-controlled DIDs:** see [communities docs](../../docs/04-communities.md). +- **private records:** see [spaces docs](../../docs/04-spaces.md). +- **group-controlled DIDs:** see [communities docs](../../docs/05-communities.md). diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json b/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json new file mode 100644 index 0000000..3d73338 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json @@ -0,0 +1,169 @@ +{ + "lexicon": 1, + "id": "com.example.event.getRecord", + "defs": { + "main": { + "type": "query", + "description": "Get a single community.lexicon.calendar.event 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 profile + identity info keyed by DID" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "uri", + "value" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "community.lexicon.calendar.event#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": "#appBskyActorProfile" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "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 new file mode 100644 index 0000000..cbfe275 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json @@ -0,0 +1,221 @@ +{ + "lexicon": 1, + "id": "com.example.event.listRecords", + "defs": { + "main": { + "type": "query", + "description": "Query community.lexicon.calendar.event records with filters", + "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 (triggers on-demand backfill)" + }, + "profiles": { + "type": "boolean", + "description": "Include profile + identity info keyed by DID" + }, + "search": { + "type": "string", + "description": "Full-text search across: name, description" + }, + "startsAtMin": { + "type": "string", + "description": "Minimum value for startsAt" + }, + "startsAtMax": { + "type": "string", + "description": "Maximum value for startsAt" + }, + "sort": { + "type": "string", + "knownValues": [ + "startsAt" + ], + "description": "Field to sort by (default: time_us)" + }, + "order": { + "type": "string", + "knownValues": [ + "asc", + "desc" + ], + "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" + } + } + }, + "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" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "community.lexicon.calendar.event#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": "#appBskyActorProfile" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "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 new file mode 100644 index 0000000..aa95e2b --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/getCursor.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.example.getCursor", + "defs": { + "main": { + "type": "query", + "description": "Get the current cursor position", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "time_us": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "seconds_ago": { + "type": "integer" + } + } + } + } + } + } +} diff --git a/apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json b/apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json new file mode 100644 index 0000000..0617a37 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json @@ -0,0 +1,51 @@ +{ + "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 new file mode 100644 index 0000000..97e7134 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/getProfile.json @@ -0,0 +1,138 @@ +{ + "lexicon": 1, + "id": "com.example.getProfile", + "defs": { + "main": { + "type": "query", + "description": "Get a user's profiles by DID or handle", + "parameters": { + "type": "params", + "required": [ + "actor" + ], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "DID or handle of the user" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "profiles" + ], + "properties": { + "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": "#appBskyActorProfile" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "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 new file mode 100644 index 0000000..67be4b8 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/notifyOfUpdate.json @@ -0,0 +1,59 @@ +{ + "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/permissionSet.json b/apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json new file mode 100644 index 0000000..12ccb96 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.example.permissionSet", + "defs": { + "main": { + "type": "permission-set", + "title": "com.example", + "description": "All XRPC methods exposed by the com.example service.", + "permissions": [ + { + "type": "permission", + "resource": "rpc", + "aud": "*", + "lxm": [ + "com.example.event.getRecord", + "com.example.event.listRecords", + "com.example.getCursor", + "com.example.getOverview", + "com.example.getProfile", + "com.example.notifyOfUpdate" + ] + } + ] + } + } +} diff --git a/apps/cloudflare-workers/lexicons/pulled/README.md b/apps/cloudflare-workers/lexicons/pulled/README.md new file mode 100644 index 0000000..14e581a --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/README.md @@ -0,0 +1,5 @@ +# lexicon sources + +this directory contains lexicon documents pulled from the following sources: + +- atproto (nsids: app.bsky.actor.profile, community.lexicon.calendar.event, community.lexicon.location.address, community.lexicon.location.fsq, community.lexicon.location.geo, community.lexicon.location.hthree) diff --git a/apps/cloudflare-workers/lexicons/pulled/app/bsky/actor/profile.json b/apps/cloudflare-workers/lexicons/pulled/app/bsky/actor/profile.json new file mode 100644 index 0000000..1d22cc4 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/app/bsky/actor/profile.json @@ -0,0 +1,67 @@ +{ + "id": "app.bsky.actor.profile", + "defs": { + "main": { + "key": "literal:self", + "type": "record", + "record": { + "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" + } + } + }, + "description": "A declaration of a Bluesky account profile." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/cloudflare-workers/lexicons/pulled/community/lexicon/calendar/event.json b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/calendar/event.json new file mode 100644 index 0000000..96e54cb --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/calendar/event.json @@ -0,0 +1,142 @@ +{ + "id": "community.lexicon.calendar.event", + "defs": { + "uri": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string", + "description": "The display name of the URI." + } + }, + "description": "A URI associated with the event." + }, + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": ["createdAt", "name"], + "properties": { + "mode": { + "ref": "community.lexicon.calendar.event#mode", + "type": "ref", + "description": "The attendance mode of the event." + }, + "name": { + "type": "string", + "description": "The name of the event." + }, + "uris": { + "type": "array", + "items": { + "ref": "community.lexicon.calendar.event#uri", + "type": "ref" + }, + "description": "URIs associated with the event." + }, + "endsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event ends." + }, + "status": { + "ref": "community.lexicon.calendar.event#status", + "type": "ref", + "description": "The status of the event." + }, + "startsAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event starts." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when the event was created." + }, + "locations": { + "type": "array", + "items": { + "refs": [ + "community.lexicon.calendar.event#uri", + "community.lexicon.location.address", + "community.lexicon.location.fsq", + "community.lexicon.location.geo", + "community.lexicon.location.hthree" + ], + "type": "union" + }, + "description": "The locations where the event takes place." + }, + "description": { + "type": "string", + "description": "The description of the event." + } + } + }, + "description": "A calendar event." + }, + "mode": { + "type": "string", + "default": "community.lexicon.calendar.event#inperson", + "description": "The mode of the event.", + "knownValues": [ + "community.lexicon.calendar.event#hybrid", + "community.lexicon.calendar.event#inperson", + "community.lexicon.calendar.event#virtual" + ] + }, + "hybrid": { + "type": "token", + "description": "A hybrid event that takes place both online and offline." + }, + "status": { + "type": "string", + "default": "community.lexicon.calendar.event#scheduled", + "description": "The status of the event.", + "knownValues": [ + "community.lexicon.calendar.event#cancelled", + "community.lexicon.calendar.event#planned", + "community.lexicon.calendar.event#postponed", + "community.lexicon.calendar.event#rescheduled", + "community.lexicon.calendar.event#scheduled" + ] + }, + "planned": { + "type": "token", + "description": "The event has been created, but not finalized." + }, + "virtual": { + "type": "token", + "description": "A virtual event that takes place online." + }, + "inperson": { + "type": "token", + "description": "An in-person event that takes place offline." + }, + "cancelled": { + "type": "token", + "description": "The event has been cancelled." + }, + "postponed": { + "type": "token", + "description": "The event has been postponed and a new start date has not been set." + }, + "scheduled": { + "type": "token", + "description": "The event has been created and scheduled." + }, + "rescheduled": { + "type": "token", + "description": "The event has been rescheduled." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/address.json b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/address.json new file mode 100644 index 0000000..acfa0fc --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/address.json @@ -0,0 +1,40 @@ +{ + "id": "community.lexicon.location.address", + "defs": { + "main": { + "type": "object", + "required": ["country"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "region": { + "type": "string", + "description": "The administrative region of the country. For example, a state in the USA." + }, + "street": { + "type": "string", + "description": "The street address." + }, + "country": { + "type": "string", + "maxLength": 10, + "minLength": 2, + "description": "The ISO 3166 country code. Preferably the 2-letter code." + }, + "locality": { + "type": "string", + "description": "The locality of the region. For example, a city in the USA." + }, + "postalCode": { + "type": "string", + "description": "The postal code of the location." + } + }, + "description": "A physical location in the form of a street address." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/fsq.json b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/fsq.json new file mode 100644 index 0000000..91faa7a --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/fsq.json @@ -0,0 +1,28 @@ +{ + "id": "community.lexicon.location.fsq", + "defs": { + "main": { + "type": "object", + "required": ["fsq_place_id"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + }, + "fsq_place_id": { + "type": "string", + "description": "The unique identifier of a Foursquare POI." + } + }, + "description": "A physical location contained in the Foursquare Open Source Places dataset." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/geo.json b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/geo.json new file mode 100644 index 0000000..8676479 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/geo.json @@ -0,0 +1,27 @@ +{ + "id": "community.lexicon.location.geo", + "defs": { + "main": { + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "altitude": { + "type": "string" + }, + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + } + }, + "description": "A physical location in the form of a WGS84 coordinate." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/hthree.json b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/hthree.json new file mode 100644 index 0000000..fa6cb99 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/hthree.json @@ -0,0 +1,22 @@ +{ + "id": "community.lexicon.location.hthree", + "defs": { + "main": { + "type": "object", + "required": ["value"], + "properties": { + "name": { + "type": "string", + "description": "The name of the location." + }, + "value": { + "type": "string", + "description": "The h3 encoded location." + } + }, + "description": "A physical location in the form of a H3 encoded location." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json index 63ce636..3d6c40f 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json @@ -37,16 +37,21 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "tools.atmo.chat.channel#main" + }, "did": { "type": "string", "format": "did" @@ -58,13 +63,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "tools.atmo.chat.channel#main" - }, "time_us": { "type": "integer" }, @@ -101,19 +99,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json index 3a9d098..903635a 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json @@ -44,34 +44,15 @@ "type": "string", "description": "Filter by communityDid" }, - "name": { - "type": "string", - "description": "Filter by name" - }, - "topic": { - "type": "string", - "description": "Filter by topic" - }, "visibility": { "type": "string", "description": "Filter by visibility" }, - "createdAtMin": { - "type": "string", - "description": "Minimum value for createdAt" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, "sort": { "type": "string", "knownValues": [ "communityDid", - "name", - "topic", - "visibility", - "createdAt" + "visibility" ], "description": "Field to sort by (default: time_us)" }, @@ -118,16 +99,22 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "cid", + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "tools.atmo.chat.channel#main" + }, "did": { "type": "string", "format": "did" @@ -139,13 +126,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "tools.atmo.chat.channel#main" - }, "time_us": { "type": "integer" }, @@ -173,19 +153,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json index c128f52..9ee81e7 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json @@ -44,34 +44,15 @@ "type": "string", "description": "Filter by communityDid" }, - "name": { - "type": "string", - "description": "Filter by name" - }, - "topic": { - "type": "string", - "description": "Filter by topic" - }, "visibility": { "type": "string", "description": "Filter by visibility" }, - "createdAtMin": { - "type": "string", - "description": "Minimum value for createdAt" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, "sort": { "type": "string", "knownValues": [ "communityDid", - "name", - "topic", - "visibility", - "createdAt" + "visibility" ], "description": "Field to sort by (default: time_us)" }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json index ee61ec6..8f8ed24 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json @@ -54,19 +54,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json index aefe0d3..2206480 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json @@ -37,16 +37,21 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "tools.atmo.chat.message#main" + }, "did": { "type": "string", "format": "did" @@ -58,13 +63,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "tools.atmo.chat.message#main" - }, "time_us": { "type": "integer" }, @@ -101,19 +99,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json index 2aa24f3..c956d72 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json @@ -40,10 +40,6 @@ "type": "string", "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." }, - "text": { - "type": "string", - "description": "Filter by text" - }, "createdAtMin": { "type": "string", "description": "Minimum value for createdAt" @@ -52,16 +48,10 @@ "type": "string", "description": "Maximum value for createdAt" }, - "replyTo": { - "type": "string", - "description": "Filter by replyTo" - }, "sort": { "type": "string", "knownValues": [ - "text", - "createdAt", - "replyTo" + "createdAt" ], "description": "Field to sort by (default: time_us)" }, @@ -108,16 +98,22 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "cid", + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "tools.atmo.chat.message#main" + }, "did": { "type": "string", "format": "did" @@ -129,13 +125,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "tools.atmo.chat.message#main" - }, "time_us": { "type": "integer" }, @@ -163,19 +152,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json index b5e607a..ebda09b 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json @@ -40,10 +40,6 @@ "type": "string", "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." }, - "text": { - "type": "string", - "description": "Filter by text" - }, "createdAtMin": { "type": "string", "description": "Minimum value for createdAt" @@ -52,16 +48,10 @@ "type": "string", "description": "Maximum value for createdAt" }, - "replyTo": { - "type": "string", - "description": "Filter by replyTo" - }, "sort": { "type": "string", "knownValues": [ - "text", - "createdAt", - "replyTo" + "createdAt" ], "description": "Field to sort by (default: time_us)" }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json index 81f345d..1a15c83 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json @@ -37,16 +37,21 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "tools.atmo.chat.server#main" + }, "did": { "type": "string", "format": "did" @@ -58,13 +63,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "tools.atmo.chat.server#main" - }, "time_us": { "type": "integer" }, @@ -101,19 +99,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json index f7e057f..aa145f6 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json @@ -44,29 +44,10 @@ "type": "string", "description": "Filter by communityDid" }, - "name": { - "type": "string", - "description": "Filter by name" - }, - "description": { - "type": "string", - "description": "Filter by description" - }, - "createdAtMin": { - "type": "string", - "description": "Minimum value for createdAt" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, "sort": { "type": "string", "knownValues": [ - "communityDid", - "name", - "description", - "createdAt" + "communityDid" ], "description": "Field to sort by (default: time_us)" }, @@ -113,16 +94,22 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "cid", + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "tools.atmo.chat.server#main" + }, "did": { "type": "string", "format": "did" @@ -134,13 +121,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "tools.atmo.chat.server#main" - }, "time_us": { "type": "integer" }, @@ -168,19 +148,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json index 9a8bbe7..39d9ff0 100644 --- a/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json +++ b/apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json @@ -44,29 +44,10 @@ "type": "string", "description": "Filter by communityDid" }, - "name": { - "type": "string", - "description": "Filter by name" - }, - "description": { - "type": "string", - "description": "Filter by description" - }, - "createdAtMin": { - "type": "string", - "description": "Minimum value for createdAt" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, "sort": { "type": "string", "knownValues": [ - "communityDid", - "name", - "description", - "createdAt" + "communityDid" ], "description": "Field to sort by (default: time_us)" }, diff --git a/apps/group-chat/src/lib/contrail/client.ts b/apps/group-chat/src/lib/contrail/client.ts index f9d7dc9..2b86128 100644 --- a/apps/group-chat/src/lib/contrail/client.ts +++ b/apps/group-chat/src/lib/contrail/client.ts @@ -14,13 +14,13 @@ export interface Profile { export function extractProfile(entry: { did: string; handle?: string; - record?: unknown; + value?: unknown; }): Profile { - const record = entry.record as { displayName?: string; avatar?: string } | undefined; + const value = entry.value as { displayName?: string; avatar?: string } | undefined; return { handle: entry.handle ?? entry.did, - displayName: record?.displayName, - avatar: record?.avatar + displayName: value?.displayName, + avatar: value?.avatar }; } diff --git a/apps/group-chat/src/lib/rooms/profiles.svelte.ts b/apps/group-chat/src/lib/rooms/profiles.svelte.ts index 7091cd7..a0d0a39 100644 --- a/apps/group-chat/src/lib/rooms/profiles.svelte.ts +++ b/apps/group-chat/src/lib/rooms/profiles.svelte.ts @@ -42,8 +42,8 @@ export function ensureProfile(did: string): void { profiles[did] = { did, handle: entry?.handle ?? undefined, - displayName: entry?.record?.displayName, - avatar: entry?.record?.avatar + displayName: entry?.value?.displayName, + avatar: entry?.value?.avatar }; } catch { profiles[did] = { did, failed: true }; diff --git a/apps/group-chat/src/lib/rooms/realtime.svelte.ts b/apps/group-chat/src/lib/rooms/realtime.svelte.ts index db74c8b..e6f41c7 100644 --- a/apps/group-chat/src/lib/rooms/realtime.svelte.ts +++ b/apps/group-chat/src/lib/rooms/realtime.svelte.ts @@ -110,7 +110,7 @@ export function connectCommunityRealtime(communityDid: string): () => void { } catch { return; } - // Payload shape mirrors listRecords output (uri/did/collection/rkey/cid/record/time_us/space?). + // Payload shape mirrors listRecords output (uri/cid/value/did/collection/rkey/time_us/space?). // `space` is only set for space records — which is what we filter this community stream to. if (kind === 'record.created') { const p = ev.payload as { @@ -118,13 +118,13 @@ export function connectCommunityRealtime(communityDid: string): () => void { did: string; collection: string; rkey: string; - record: Record; + value: Record; time_us: number; space?: string; }; if (!p.space) return; if (p.collection === 'tools.atmo.chat.message') { - const rec = p.record as { text?: string; createdAt?: string; replyTo?: string }; + const rec = p.value as { text?: string; createdAt?: string; replyTo?: string }; if (rec.text && rec.createdAt) { channelMessages.append(p.space, { rkey: p.rkey, diff --git a/apps/group-chat/src/lib/rooms/watch.svelte.ts b/apps/group-chat/src/lib/rooms/watch.svelte.ts index 9271c28..2d57f6a 100644 --- a/apps/group-chat/src/lib/rooms/watch.svelte.ts +++ b/apps/group-chat/src/lib/rooms/watch.svelte.ts @@ -7,7 +7,7 @@ * params: { spaceUri, limit: 50 } * }) * ); - * // template: {#each messagesQuery.records as r (r.rkey)} {r.record.text} + * // template: {#each messagesQuery.records as r (r.rkey)} {r.value.text} * * Records and params are typed via the app's generated `XRPCQueries` / * `Records` ambient declarations (from `src/lexicon-types/`). Reading @@ -243,14 +243,14 @@ export class WatchQuery { addOptimistic(input: { rkey: string; did: string; - record: RecordShapeOf; + value: RecordShapeOf; time_us?: number; }): void { this.#store.addOptimistic({ rkey: input.rkey, did: input.did, collection: this.#collection, - record: input.record as Record, + value: input.value as Record, time_us: input.time_us }); } diff --git a/apps/group-chat/src/routes/+layout.server.ts b/apps/group-chat/src/routes/+layout.server.ts index c113b0f..02bc114 100644 --- a/apps/group-chat/src/routes/+layout.server.ts +++ b/apps/group-chat/src/routes/+layout.server.ts @@ -29,8 +29,8 @@ export const load: LayoutServerLoad = async ({ locals, platform }) => { const entry = data.profiles?.[0]; const profile: Profile = { handle: entry?.handle ?? undefined, - displayName: entry?.record?.displayName, - avatar: entry?.record?.avatar + displayName: entry?.value?.displayName, + avatar: entry?.value?.avatar }; return { did: locals.did, profile }; } catch { diff --git a/apps/group-chat/src/routes/+page.server.ts b/apps/group-chat/src/routes/+page.server.ts index 494c86d..3b2f8e5 100644 --- a/apps/group-chat/src/routes/+page.server.ts +++ b/apps/group-chat/src/routes/+page.server.ts @@ -47,7 +47,7 @@ export const load: PageServerLoad = async ({ locals, platform }) => { if (r.did !== parsed.communityDid) continue; if (parsed.key !== 'members') continue; if (r.rkey !== 'self') continue; - const rec = r.record; + const rec = r.value; if (!rec?.communityDid || !rec.name || !rec.createdAt) continue; const iconCid = rec.icon?.ref?.$link; servers.push({ diff --git a/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts b/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts index e97550f..2b7ee00 100644 --- a/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts +++ b/apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts @@ -41,14 +41,14 @@ export const load: LayoutServerLoad = async ({ locals, params, platform }) => { r.space && r.did === communityDid && r.rkey === 'self' && - r.record?.communityDid === communityDid + r.value?.communityDid === communityDid ); - if (row?.record?.name) { - const iconCid = row.record.icon?.ref?.$link; + if (row?.value?.name) { + const iconCid = row.value.icon?.ref?.$link; server = { communityDid, - name: row.record.name, - description: row.record.description, + name: row.value.name, + description: row.value.description, iconUrl: iconCid ? `/api/blob?spaceUri=${encodeURIComponent(row.space!)}&cid=${encodeURIComponent(iconCid)}` : undefined, diff --git a/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte b/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte index 7bdbf64..15310ba 100644 --- a/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte +++ b/apps/group-chat/src/routes/c/[communityDid]/+layout.svelte @@ -37,7 +37,7 @@ function projectChannel( r: (typeof channelsQuery.records)[number] ): ChannelMeta | null { - const rec = r.record; + const rec = r.value; if (!r._space || rec.communityDid !== data.communityDid || !rec.name) return null; if (!rec.createdAt) return null; let visibility: 'public' | 'private'; diff --git a/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte b/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte index 13da1b9..1d8aedb 100644 --- a/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte +++ b/apps/group-chat/src/routes/c/[communityDid]/[channelKey]/+page.svelte @@ -46,9 +46,9 @@ messagesQuery.records.map((r) => ({ rkey: r.rkey, authorDid: r.did, - text: r.record.text ?? '', - createdAt: r.record.createdAt ?? '', - replyTo: r.record.replyTo, + text: r.value.text ?? '', + createdAt: r.value.createdAt ?? '', + replyTo: r.value.replyTo, pending: r.optimistic === 'pending', failed: r.optimistic === 'failed', error: r.optimisticError @@ -79,7 +79,7 @@ messagesQuery.addOptimistic({ rkey, did: data.myDid, - record: { + value: { $type: 'tools.atmo.chat.message', text: t, createdAt: new Date().toISOString() diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json index ab6b24d..101859b 100644 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json @@ -43,16 +43,21 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "community.lexicon.calendar.event#main" + }, "did": { "type": "string", "format": "did" @@ -64,13 +69,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.event#main" - }, "time_us": { "type": "integer" }, @@ -202,19 +200,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json index 1c68aee..52cbe9e 100644 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json @@ -52,14 +52,6 @@ "type": "string", "description": "Filter by name" }, - "endsAtMin": { - "type": "string", - "description": "Minimum value for endsAt" - }, - "endsAtMax": { - "type": "string", - "description": "Maximum value for endsAt" - }, "status": { "type": "string", "description": "Filter by status" @@ -72,6 +64,14 @@ "type": "string", "description": "Maximum value for startsAt" }, + "endsAtMin": { + "type": "string", + "description": "Minimum value for endsAt" + }, + "endsAtMax": { + "type": "string", + "description": "Maximum value for endsAt" + }, "createdAtMin": { "type": "string", "description": "Minimum value for createdAt" @@ -80,10 +80,6 @@ "type": "string", "description": "Maximum value for createdAt" }, - "description": { - "type": "string", - "description": "Filter by description" - }, "rsvpsCountMin": { "type": "integer", "description": "Minimum total rsvps count" @@ -111,11 +107,10 @@ "knownValues": [ "mode", "name", - "endsAt", "status", "startsAt", + "endsAt", "createdAt", - "description", "rsvpsCount", "rsvpsInterestedCount", "rsvpsGoingCount", @@ -166,16 +161,22 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "cid", + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "community.lexicon.calendar.event#main" + }, "did": { "type": "string", "format": "did" @@ -187,13 +188,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.event#main" - }, "time_us": { "type": "integer" }, @@ -316,19 +310,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json index d6c1fe5..1b366ee 100644 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json @@ -54,19 +54,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json index 6bb1bcf..6dbb232 100644 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json @@ -41,16 +41,21 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "community.lexicon.calendar.rsvp#main" + }, "did": { "type": "string", "format": "did" @@ -62,13 +67,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.rsvp#main" - }, "time_us": { "type": "integer" }, @@ -151,19 +149,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json index 3491b78..35917fe 100644 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json @@ -103,16 +103,22 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "cid", + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "community.lexicon.calendar.rsvp#main" + }, "did": { "type": "string", "format": "did" @@ -124,13 +130,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.rsvp#main" - }, "time_us": { "type": "integer" }, @@ -204,19 +203,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, 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 4706150..82732a4 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json @@ -54,19 +54,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json index 232cfee..c40af0f 100644 --- a/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json @@ -10,7 +10,7 @@ { "type": "permission", "resource": "rpc", - "inheritAud": true, + "aud": "*", "lxm": [ "statusphere.app.getCursor", "statusphere.app.getOverview", 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 da31528..8cb1446 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 @@ -28,16 +28,21 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "xyz.statusphere.status#main" + }, "did": { "type": "string", "format": "did" @@ -49,13 +54,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "xyz.statusphere.status#main" - }, "time_us": { "type": "integer" }, @@ -87,19 +85,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, 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 8fb9309..9fea6dd 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 @@ -89,16 +89,22 @@ "type": "object", "required": [ "uri", - "did", - "collection", - "rkey", - "time_us" + "cid", + "value" ], "properties": { "uri": { "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "xyz.statusphere.status#main" + }, "did": { "type": "string", "format": "did" @@ -110,13 +116,6 @@ "rkey": { "type": "string" }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "xyz.statusphere.status#main" - }, "time_us": { "type": "integer" } @@ -139,19 +138,20 @@ "type": "string", "format": "at-uri" }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "ref", + "ref": "#appBskyActorProfile" + }, "collection": { "type": "string", "format": "nsid" }, "rkey": { "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "#appBskyActorProfile" } } }, diff --git a/apps/sveltekit-cloudflare-workers/src/lib/contrail/client.ts b/apps/sveltekit-cloudflare-workers/src/lib/contrail/client.ts index f9d7dc9..2b86128 100644 --- a/apps/sveltekit-cloudflare-workers/src/lib/contrail/client.ts +++ b/apps/sveltekit-cloudflare-workers/src/lib/contrail/client.ts @@ -14,13 +14,13 @@ export interface Profile { export function extractProfile(entry: { did: string; handle?: string; - record?: unknown; + value?: unknown; }): Profile { - const record = entry.record as { displayName?: string; avatar?: string } | undefined; + const value = entry.value as { displayName?: string; avatar?: string } | undefined; return { handle: entry.handle ?? entry.did, - displayName: record?.displayName, - avatar: record?.avatar + displayName: value?.displayName, + avatar: value?.avatar }; } diff --git a/apps/sveltekit-cloudflare-workers/src/routes/+page.server.ts b/apps/sveltekit-cloudflare-workers/src/routes/+page.server.ts index a9bb158..70c2c94 100644 --- a/apps/sveltekit-cloudflare-workers/src/routes/+page.server.ts +++ b/apps/sveltekit-cloudflare-workers/src/routes/+page.server.ts @@ -15,12 +15,12 @@ export const load: PageServerLoad = async ({ platform }) => { const records = res.data.records; const statuses = records .map((r) => { - const record = r.record as { status: string; createdAt: string }; + const value = r.value as { status: string; createdAt: string }; return { did: r.did, rkey: r.rkey, - status: record.status, - createdAt: record.createdAt + status: value.status, + createdAt: value.createdAt }; }) .filter((s) => !isNaN(new Date(s.createdAt).getTime())); diff --git a/docs/01-indexing.md b/docs/01-indexing.md index 0a811e8..20910fe 100644 --- a/docs/01-indexing.md +++ b/docs/01-indexing.md @@ -174,46 +174,7 @@ Safe to run repeatedly — each pass converges toward zero missing / stale. Prog Refresh is **not** a replacement for `ingest`/`runPersistent` — it walks every user's full history, which is expensive. Use it after outages or during dev idle, not as a continuous freshness mechanism. -## Querying - -### HTTP (what most callers use) - -Every config field gets a predictable URL param: - -``` -/xrpc/com.example.event.listRecords?mode=online&startsAtMin=2026-01-01&rsvpsGoingCountMin=10&sort=startsAt&order=asc&hydrateRsvps=5 -/xrpc/com.example.event.getRecord?uri=at://did:plc:.../...&hydrateRsvps=5 -``` - -| Config produces | URL param | -|---|---| -| `queryable: { field: {} }` | `?field=value` (equality) | -| `queryable: { field: { type: "range" } }` | `?fieldMin=…`, `?fieldMax=…` | -| `relations: { rel: {...} }` | `?relCountMin=N`, `?sort=relCount`, `?hydrateRel=N` | -| `relations: { rel: { groups: { going } } }` | `?relGoingCountMin=N`, `?sort=relGoingCount` | -| `references: { ref: {...} }` | `?hydrateRef=true` | - -Dotted field names become camelCase params — `queryable: { "subject.uri": {} }` → `?subjectUri=…`. - -### Programmatic - -```ts -const { records, cursor } = await contrail.query("event", { - filters: { mode: "online" }, - rangeFilters: { startsAt: { min: "2026-01-01" } }, - countFilters: { rsvp: 10 }, // keyed by child collection short name - sort: { recordField: "startsAt", direction: "asc" }, - limit: 20, -}); -``` - -The programmatic shape doesn't use the URL param names — keys are the underlying field/collection identifiers: - -- `filters` / `rangeFilters` — keyed by the field name from your config (`startsAt`, `subject.uri`), not the camelCased URL param. -- `countFilters` — keyed by the target collection's short name for totals, or by the full `nsid#group` token for group counts. E.g., `{ rsvp: 10 }` for "at least 10 RSVPs total," or `{ "community.lexicon.calendar.rsvp#going": 10 }` for "at least 10 going." -- `sort` — `{ recordField, direction }` for field sorts, `{ countType, direction }` for count sorts (where `countType` is the same collection-short-name or `nsid#group` as above). - -For count filters / sorts, the HTTP side is nicer than the programmatic side — consider going through `createHandler` + `fetch` even for in-process calls if you want the friendly names. +Reading the indexed data — filters, sorts, hydration, search, pagination — has its own doc: [Querying](./02-querying.md). ## Adapters @@ -237,6 +198,6 @@ const db = createPostgresDatabase(pool); | `jetstreams` | Bluesky | Jetstream URLs | | `relays` | Bluesky | Relay URLs for discovery | | `notify` | off | `true` opens `notifyOfUpdate`; a string requires `Bearer` | -| `spaces` | — | See [Spaces](./03-spaces.md) | -| `community` | — | See [Communities](./04-communities.md) | -| `realtime` | — | See [Sync](./05-sync.md) | +| `spaces` | — | See [Spaces](./04-spaces.md) | +| `community` | — | See [Communities](./05-communities.md) | +| `realtime` | — | See [Sync](./06-sync.md) | diff --git a/docs/02-querying.md b/docs/02-querying.md new file mode 100644 index 0000000..f2cec15 --- /dev/null +++ b/docs/02-querying.md @@ -0,0 +1,167 @@ +# Querying + +Once [indexing](./01-indexing.md) is set up, every collection you declared gets a pair of XRPC endpoints under `/xrpc/{namespace}.{short}.*`: + +| Endpoint | Returns | +|---|---| +| `{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`, `{namespace}.permissionSet`. + +## HTTP (what most callers use) + +Every config field becomes a predictable URL param: + +``` +/xrpc/com.example.event.listRecords?mode=online&startsAtMin=2026-01-01&rsvpsGoingCountMin=10&sort=startsAt&order=asc&hydrateRsvps=5 +/xrpc/com.example.event.getRecord?uri=at://did:plc:.../...&hydrateRsvps=5 +``` + +| Config produces | URL param | +|---|---| +| `queryable: { field: {} }` | `?field=value` (equality) | +| `queryable: { field: { type: "range" } }` | `?fieldMin=…`, `?fieldMax=…` | +| `relations: { rel: {...} }` | `?relCountMin=N`, `?sort=relCount`, `?hydrateRel=N` | +| `relations: { rel: { groups: { going } } }` | `?relGoingCountMin=N`, `?sort=relGoingCount` | +| `references: { ref: {...} }` | `?hydrateRef=true` | + +Dotted field names become camelCase params — `queryable: { "subject.uri": {} }` → `?subjectUri=…`. + +## Programmatic + +```ts +const { records, cursor } = await contrail.query("event", { + filters: { mode: "online" }, + rangeFilters: { startsAt: { min: "2026-01-01" } }, + countFilters: { rsvp: 10 }, // keyed by child collection short name + sort: { recordField: "startsAt", direction: "asc" }, + limit: 20, +}); +``` + +The programmatic shape doesn't use the URL param names — keys are the underlying field/collection identifiers: + +- `filters` / `rangeFilters` — keyed by the field name from your config (`startsAt`, `subject.uri`), not the camelCased URL param. +- `countFilters` — keyed by the target collection's short name for totals, or by the full `nsid#group` token for group counts. E.g., `{ rsvp: 10 }` for "at least 10 RSVPs total," or `{ "community.lexicon.calendar.rsvp#going": 10 }` for "at least 10 going." +- `sort` — `{ recordField, direction }` for field sorts, `{ countType, direction }` for count sorts (where `countType` is the same collection-short-name or `nsid#group` as above). + +For count filters / sorts, the HTTP side is nicer than the programmatic side — consider going through `createHandler` + `fetch` even for in-process calls if you want the friendly names. Or use `createServerClient` from `@atmo-dev/contrail/server` for a typed XRPC client that runs in-process (no fetch roundtrip). + +## Pagination + +``` +?limit=25&cursor= +``` + +`cursor` is opaque — pass back whatever `listRecords` returned in its `cursor` field. `limit` is 1–200 (default 50). Cursors embed the sort kind, so a cursor from a `sort=startsAt` query is ignored by a `sort=rsvpsCount` query instead of silently returning wrong results. + +```ts +let cursor: string | undefined; +do { + const page = await contrail.query("event", { limit: 100, cursor }); + // process page.records + cursor = page.cursor; +} while (cursor); +``` + +## Hydration + +Each record response is a flat shape: + +```jsonc +{ + "uri": "at://did:plc:.../community.lexicon.calendar.event/...", + "cid": "...", + "record": { "name": "Rust meetup", "startsAt": "2026-03-16T...", ... }, + "rsvpsCount": 42, // from relations + "rsvpsGoingCount": 30, + // relations + references appear here only when hydrated +} +``` + +### `?hydrateRel=N` (relations) + +Embeds the latest N child records per group, inline under the parent: + +``` +/xrpc/com.example.event.listRecords?hydrateRsvps=5 +``` + +Returns: + +```jsonc +{ + "records": [{ + "uri": "at://.../event/...", + "record": { "name": "..." }, + "rsvpsCount": 42, + "rsvps": { + "going": [ {uri, record}, ... 5 items ], + "interested":[ {uri, record}, ... 5 items ] + } + }] +} +``` + +Max 50 per group. For grouped relations you get one array per group value; for ungrouped relations just a flat array. + +### `?hydrateRef=true` (references) + +Embeds the single referenced parent record — useful for RSVP lists that need to show event details: + +``` +/xrpc/com.example.rsvp.listRecords?subjectUri=at://.../event/...&hydrateEvent=true +``` + +Each RSVP record in the response gains an `event: {uri, cid, record}` field. + +### `?profiles=true` + +Opt in to profile + handle hydration for every DID referenced in the result: + +``` +/xrpc/com.example.event.listRecords?profiles=true +``` + +Response grows a top-level `profiles` map keyed by DID: + +```jsonc +{ + "records": [...], + "profiles": { + "did:plc:alice...": { "handle": "alice.bsky.social", "record": {...} } + } +} +``` + +Which profile NSID to hydrate from is configured at the top level of Contrail's config (`profiles`, defaults to `["app.bsky.actor.profile"]`). + +## Full-text search + +``` +?search=meetup +?search=meetup* +?search="rust meetup" +?search=rust OR typescript +``` + +Combinable with every other filter and sort. Backed by SQLite FTS5 (D1) or Postgres tsvector (Postgres adapter). Not available on `node:sqlite` — that adapter doesn't ship FTS5. + +When searching, results are ranked by relevance by default. Override with an explicit `sort` param. + +## Examples + +``` +# Upcoming events with 10+ going RSVPs, with RSVP records + profiles +?startsAtMin=2026-03-16&rsvpsGoingCountMin=10&hydrateRsvps=5&profiles=true + +# Events for a specific user (by handle — triggers on-demand backfill) +?actor=alice.bsky.social&profiles=true + +# RSVPs for one event, with the event record embedded +?subjectUri=at://did:plc:.../event/...&hydrateEvent=true&profiles=true + +# Search + filter + sort +?search=meetup&mode=online&sort=startsAt&order=asc +``` diff --git a/docs/02-lexicons.md b/docs/03-lexicons.md similarity index 100% rename from docs/02-lexicons.md rename to docs/03-lexicons.md diff --git a/docs/03-spaces.md b/docs/04-spaces.md similarity index 100% rename from docs/03-spaces.md rename to docs/04-spaces.md diff --git a/docs/04-communities.md b/docs/05-communities.md similarity index 97% rename from docs/04-communities.md rename to docs/05-communities.md index fbfcfa5..2fb520f 100644 --- a/docs/04-communities.md +++ b/docs/05-communities.md @@ -1,6 +1,6 @@ # Communities -Group-controlled atproto DIDs. A community is a DID whose signing/rotation keys are held by the appview on behalf of multiple members, with tiered access levels. Built on top of [spaces](./03-spaces.md). +Group-controlled atproto DIDs. A community is a DID whose signing/rotation keys are held by the appview on behalf of multiple members, with tiered access levels. Built on top of [spaces](./04-spaces.md). ## When to use this diff --git a/docs/05-sync.md b/docs/06-sync.md similarity index 100% rename from docs/05-sync.md rename to docs/06-sync.md diff --git a/docs/06-examples.md b/docs/07-examples.md similarity index 100% rename from docs/06-examples.md rename to docs/07-examples.md diff --git a/docs/frameworks/sveltekit-cloudflare.md b/docs/frameworks/sveltekit-cloudflare.md new file mode 100644 index 0000000..e5508b2 --- /dev/null +++ b/docs/frameworks/sveltekit-cloudflare.md @@ -0,0 +1,240 @@ +# SvelteKit + Cloudflare Workers + +How to add contrail to an existing SvelteKit project deployed on Cloudflare Workers (via `@sveltejs/adapter-cloudflare`). Gives you XRPC endpoints alongside your pages, Jetstream ingestion on cron, and a typed in-process client for server loaders. + +Assumes you already have a SvelteKit app with `@sveltejs/adapter-cloudflare` and a D1 binding. If you don't, [`apps/sveltekit-cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/sveltekit-cloudflare-workers) is a complete starting point. + +## Install + +```bash +pnpm add @atmo-dev/contrail +pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli +``` + +## Project layout + +``` +src/ + lib/ + contrail.config.ts # your config — auto-detected by the CLI + contrail/ + index.ts # Contrail instance + ensureInit + server client + routes/ + xrpc/[...path]/+server.ts # mounts all contrail XRPC endpoints + api/cron/+server.ts # hit by the cron trigger (see below) +wrangler.jsonc +scripts/ + append-scheduled.ts # workaround — see "Cron" below +``` + +## 1. Declare the config + +```ts +// src/lib/contrail.config.ts +import type { ContrailConfig } from "@atmo-dev/contrail"; + +export const config: ContrailConfig = { + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { startsAt: { type: "range" } }, + searchable: ["name", "description"], + }, + }, +}; +``` + +## 2. The Contrail instance + +```ts +// src/lib/contrail/index.ts +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler, createServerClient } from "@atmo-dev/contrail/server"; +import type { Client } from "@atcute/client"; +import { config } from "../contrail.config"; + +export const contrail = new Contrail(config); + +let initialized = false; +export async function ensureInit(db: D1Database) { + if (!initialized) { await contrail.init(db); initialized = true; } +} + +const handle = createHandler(contrail); + +/** Typed in-process XRPC client for loaders / actions. Pass `did` to act as + * that user (no JWT / PDS roundtrip); omit for anonymous public reads. */ +export function getServerClient(db: D1Database, did?: string): Client { + return createServerClient(async (req) => { + await ensureInit(db); + return handle(req, db) as Promise; + }, did); +} +``` + +Why the lazy `ensureInit`: Workers cold-start many times; doing schema init on the first request keeps the boot path fast and means `contrail.init()` doesn't need top-level `await` (which the adapter doesn't love). + +## 3. Mount the XRPC routes + +One catch-all that forwards to contrail's handler: + +```ts +// src/routes/xrpc/[...path]/+server.ts +import type { RequestHandler } from "./$types"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { contrail, ensureInit } from "$lib/contrail"; + +const handle = createHandler(contrail); + +async function h(req: Request, platform: App.Platform | undefined) { + const db = platform!.env.DB; + await ensureInit(db); + return handle(req, db) as Promise; +} + +export const GET: RequestHandler = ({ request, platform }) => h(request, platform); +export const POST: RequestHandler = ({ request, platform }) => h(request, platform); +``` + +Now every `com.example.*.listRecords` / `com.example.*.getRecord` / `com.example.notifyOfUpdate` / etc. is served under `/xrpc/...`. + +## 4. Using the typed client in loaders + +```ts +// src/routes/+page.server.ts +import { getServerClient } from "$lib/contrail"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ platform, locals }) => { + const rpc = getServerClient(platform!.env.DB, locals.did ?? undefined); + const res = await rpc.get("com.example.event.listRecords", { + params: { startsAtMin: "2026-01-01", limit: 20 }, + }); + return { events: res.ok ? res.data.records : [] }; +}; +``` + +`createServerClient` bypasses fetch — the loader runs contrail's XRPC handler in-process, no extra network hop. `did` sets the caller identity without requiring a signed JWT (it's a server-to-server trust boundary; anything crossing an untrusted boundary still needs real service-auth). + +## 5. Cron ingest — the workaround + +SvelteKit's `@sveltejs/adapter-cloudflare` doesn't expose a `scheduled()` export on the generated worker ([issue #4841](https://github.com/sveltejs/kit/issues/4841)). The easiest fix: an HTTP endpoint that does the ingest, plus a post-build script that appends a `scheduled` handler calling that endpoint. + +**Endpoint:** + +```ts +// src/routes/api/cron/+server.ts +import type { RequestHandler } from "./$types"; +import { contrail, ensureInit } from "$lib/contrail"; + +export const POST: RequestHandler = async ({ request, platform }) => { + if (request.headers.get("X-Cron-Secret") !== platform!.env.CRON_SECRET) { + return new Response("Unauthorized", { status: 401 }); + } + const db = platform!.env.DB; + await ensureInit(db); + await contrail.ingest({}, db); + return new Response("OK"); +}; +``` + +**Post-build patch:** + +```ts +// scripts/append-scheduled.ts +import { readFileSync, writeFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const workerPath = join(root, ".svelte-kit", "cloudflare", "_worker.js"); + +writeFileSync( + workerPath, + readFileSync(workerPath, "utf-8") + + ` +worker_default.scheduled = async function (event, env, ctx) { + const req = new Request("http://localhost/api/cron", { + method: "POST", + headers: { "X-Cron-Secret": env.CRON_SECRET ?? "" }, + }); + ctx.waitUntil(this.fetch(req, env, ctx)); +}; +` +); +``` + +**Wire it into `build`:** + +```jsonc +// package.json +"scripts": { + "build": "vite build && tsx scripts/append-scheduled.ts" +} +``` + +`CRON_SECRET` is any random string — generate one, set it as a secret with `wrangler secret put CRON_SECRET`. The cron handler self-auths with it so nobody external can trigger your ingest. + +## 6. Wrangler config + +```jsonc +// wrangler.jsonc +{ + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2025-12-25", + "compatibility_flags": ["nodejs_compat_v2"], + "assets": { "binding": "ASSETS", "directory": ".svelte-kit/cloudflare" }, + "d1_databases": [ + { "binding": "DB", "database_name": "yourapp", "database_id": "..." } + ], + "triggers": { "crons": ["*/1 * * * *"] } +} +``` + +Type the D1 binding in `src/app.d.ts`: + +```ts +declare global { + namespace App { + interface Platform { + env: { + DB: D1Database; + CRON_SECRET: string; + // ...other bindings + }; + } + } +} +``` + +## 7. Deploy + backfill + +```bash +npx wrangler d1 create yourapp # copy the id into wrangler.jsonc +pnpm build && pnpm wrangler deploy +npx wrangler secret put CRON_SECRET # paste any random string +pnpm contrail backfill --remote # one-time historical backfill +``` + +From now on: + +- Pages and XRPC endpoints are served under your domain. +- The cron fires every minute, hitting `/api/cron`, which runs `contrail.ingest()`. +- Loaders that need live data use `getServerClient()` for zero-overhead typed calls. +- Need to reconcile after an outage? `pnpm contrail refresh --remote`. + +## Where to go next + +- [Indexing](../01-indexing.md) — config options, adapter choices +- [Querying](../02-querying.md) — filters, sorts, hydration, search +- [Lexicons](../03-lexicons.md) — generate TS types for your XRPC surface +- [Spaces](../04-spaces.md) / [Communities](../05-communities.md) — private records + group-controlled DIDs, which both slot into the same handler you just mounted +- [Sync](../06-sync.md) — reactive client-side subscriptions (`createWatchStore`) wrapped in Svelte `$state` + +## Common gotchas + +- **Top-level await in `$lib/contrail/index.ts`** will fail to bundle — use the lazy `ensureInit` pattern above. +- **`ensureInit` is per-isolate, not global.** Cloudflare cold-starts spin new isolates; each one pays one init call on its first request. `contrail.init()` is idempotent so this is safe, just not instant. +- **SvelteKit's `adapter-cloudflare` regenerates `_worker.js` on every build**, so the `append-scheduled.ts` patch has to run *after* `vite build`. Don't try to put it in `prebuild`. +- **`D1Database` type in platform env** needs `@cloudflare/workers-types` in `devDependencies` and `types` in your tsconfig. diff --git a/packages/contrail/src/core/router/collection.ts b/packages/contrail/src/core/router/collection.ts index d54f6d8..a537963 100644 --- a/packages/contrail/src/core/router/collection.ts +++ b/packages/contrail/src/core/router/collection.ts @@ -140,7 +140,7 @@ async function runQueryStream(opts: { rkey: event.payload.rkey, collection: event.payload.collection, cid: event.payload.cid, - record: event.payload.record, + value: event.payload.record, _space: event.payload.space } }); @@ -185,7 +185,7 @@ async function runQueryStream(opts: { rkey: event.payload.rkey, collection: event.payload.collection, cid: event.payload.cid, - record: event.payload.record, + value: event.payload.record, time_us: nowUs, indexed_at: event.ts, _space: event.payload.space diff --git a/packages/contrail/src/core/router/helpers.ts b/packages/contrail/src/core/router/helpers.ts index 3aeb8d5..3637b08 100644 --- a/packages/contrail/src/core/router/helpers.ts +++ b/packages/contrail/src/core/router/helpers.ts @@ -2,31 +2,31 @@ import type { Database, RecordRow } from "../types"; export interface FormattedRecord { uri: string; + cid: string | null; + value: unknown; did: string; collection: string; rkey: string; - cid: string | null; - record: any; time_us: number; - [key: string]: any; + [key: string]: unknown; } export function formatRecord(row: RecordRow): FormattedRecord { - let record = null; + let value: unknown = null; if (row.record) { try { - record = JSON.parse(row.record); + value = JSON.parse(row.record); } catch { - record = row.record; + value = row.record; } } return { uri: row.uri, + cid: row.cid, + value, did: row.did, collection: row.collection, rkey: row.rkey, - cid: row.cid, - record, time_us: row.time_us, ...(row._space ? { space: row._space } : {}), }; diff --git a/packages/contrail/src/core/router/profiles.ts b/packages/contrail/src/core/router/profiles.ts index fb0cc39..3e9ec8d 100644 --- a/packages/contrail/src/core/router/profiles.ts +++ b/packages/contrail/src/core/router/profiles.ts @@ -9,10 +9,10 @@ export interface ProfileEntry { did: string; handle: string | null; uri?: string; + cid?: string | null; + value?: unknown; collection?: string; rkey?: string; - cid?: string | null; - record?: any; } export function collectDids( @@ -60,12 +60,12 @@ export async function resolveProfiles( ); for (const row of rows) { - let record = null; + let value: unknown = null; if (row.record) { try { - record = JSON.parse(row.record); + value = JSON.parse(row.record); } catch { - record = row.record; + value = row.record; } } if (!result[row.did]) result[row.did] = []; @@ -76,7 +76,7 @@ export async function resolveProfiles( collection, rkey: row.rkey, cid: row.cid, - record, + value, }); } } @@ -165,7 +165,7 @@ async function fetchMissingProfiles( collection, rkey, cid, - record, + value: record, }); } catch { // Skip failures silently diff --git a/packages/contrail/tests/helpers.test.ts b/packages/contrail/tests/helpers.test.ts index 5f5f3fd..be66975 100644 --- a/packages/contrail/tests/helpers.test.ts +++ b/packages/contrail/tests/helpers.test.ts @@ -15,7 +15,7 @@ describe("formatRecord", () => { indexed_at: 2000, }; const formatted = formatRecord(row); - expect(formatted.record).toEqual({ name: "hello" }); + expect(formatted.value).toEqual({ name: "hello" }); expect(formatted.uri).toBe("at://did:plc:test/test.col/abc"); expect(formatted.did).toBe("did:plc:test"); }); @@ -31,7 +31,7 @@ describe("formatRecord", () => { time_us: 0, indexed_at: 0, }; - expect(formatRecord(row).record).toBeNull(); + expect(formatRecord(row).value).toBeNull(); }); it("returns raw string for invalid JSON", () => { @@ -45,7 +45,7 @@ describe("formatRecord", () => { time_us: 0, indexed_at: 0, }; - expect(formatRecord(row).record).toBe("not-json"); + expect(formatRecord(row).value).toBe("not-json"); }); }); diff --git a/packages/lexicons/src/generate.ts b/packages/lexicons/src/generate.ts index d4e6f48..9e2c745 100644 --- a/packages/lexicons/src/generate.ts +++ b/packages/lexicons/src/generate.ts @@ -141,19 +141,6 @@ export function generateLexicons(options: GenerateOptions): Record { - const filePath = findCollectionLexicon(collection); - if (!filePath) return {}; - try { - const doc = JSON.parse(readFileSync(filePath, "utf-8")); - const mainRecord = doc.defs?.main?.record; - if (!mainRecord?.properties) return {}; - return analyzeProperties(doc.defs, mainRecord.properties, ""); - } catch { - return {}; - } - } - function getKnownValues(collection: string, fieldName: string): string[] { const filePath = findCollectionLexicon(collection); if (!filePath) return []; @@ -217,13 +204,17 @@ export function generateLexicons(options: GenerateOptions): Record = { uri: { type: "string", format: "at-uri" }, + cid: { type: "string", format: "cid" }, + value: collectionRef ? { type: "ref", ref: collectionRef } : { type: "unknown" }, did: { type: "string", format: "did" }, collection: { type: "string", format: "nsid" }, rkey: { type: "string" }, - cid: { type: "string" }, - record: collectionRef ? { type: "ref", ref: collectionRef } : { type: "unknown" }, time_us: { type: "integer" }, ...(config.spaces ? { @@ -256,7 +247,7 @@ export function generateLexicons(options: GenerateOptions): Record { @@ -345,10 +336,10 @@ export function generateLexicons(options: GenerateOptions): Record 1) recordField = { type: "union", refs: objectRefs }; - else recordField = { type: "unknown" }; + let valueField: any; + if (objectRefs.length === 1) valueField = { type: "ref", ref: objectRefs[0] }; + else if (objectRefs.length > 1) valueField = { type: "union", refs: objectRefs }; + else valueField = { type: "unknown" }; return { profileEntry: { type: "object", @@ -357,10 +348,10 @@ export function generateLexicons(options: GenerateOptions): Record 0 && !feedParams["search"]) { @@ -581,9 +570,7 @@ export function generateLexicons(options: GenerateOptions): Record, - properties: Record, - prefix: string -): Record { - const result: Record = {}; - for (const [field, def] of Object.entries(properties)) { - const path = prefix ? `${prefix}.${field}` : field; - if (def.type === "string") { - if (def.format === "datetime") result[path] = { type: "range" }; - else if (def.format !== "uri" && def.format !== "at-uri") result[path] = {}; - } else if (def.type === "integer" || def.type === "number") { - result[path] = { type: "range" }; - } else if (def.type === "ref" && def.ref === "com.atproto.repo.strongRef") { - result[`${path}.uri`] = {}; - } else if (def.type === "union" && Array.isArray(def.refs) && def.refs.includes("com.atproto.repo.strongRef")) { - result[`${path}.uri`] = {}; - } else if (def.type === "ref" && def.ref) { - const refId = def.ref.includes("#") ? def.ref.split("#")[1] : null; - if (refId && defs[refId]?.type === "string") result[path] = {}; - } - } - return result; -} - function scanLexiconsDir(dirs: string[]): string[] { const files: string[] = []; for (const dir of dirs) { diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 4161e3d..95555ef 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -14,13 +14,13 @@ export interface WatchRecord { uri: string; + cid?: string | null; + value: Record; did: string; rkey: string; collection: string; - record: Record; time_us?: number; indexed_at?: number; - cid?: string | null; /** Set when the record originates from a per-space table. */ _space?: string; /** Present on optimistic entries added via `addOptimistic` — not set by @@ -111,7 +111,7 @@ export interface WatchStore { rkey: string; did: string; collection?: string; - record: Record; + value: Record; time_us?: number; uri?: string; }): void; @@ -563,7 +563,7 @@ export function createWatchStore(options: WatchStoreOptions): WatchStore { did: input.did, rkey: input.rkey, collection: input.collection ?? "", - record: input.record, + value: input.value, time_us: input.time_us ?? now * 1000, indexed_at: now, cid: null,