diff --git a/.changeset/backfill-all-rename.md b/.changeset/backfill-all-rename.md new file mode 100644 index 0000000..9718563 --- /dev/null +++ b/.changeset/backfill-all-rename.md @@ -0,0 +1,31 @@ +--- +"@atmo-dev/contrail": minor +--- + +rename `contrail.sync()` → `contrail.backfillAll()` and emit progress via `config.logger` by default. + +the method previously returned `{ discovered, backfilled }` but emitted no output, so callers had to wire up their own `onProgress`. it now logs discovery + throttled backfill progress + final summary through `config.logger` (defaults to `console`). supplying `onProgress` still takes over, and passing a no-op logger silences it. + +also renames the internal `backfillAll` function (in `src/core/backfill.ts`) to `backfillPending` to reduce confusion with the new public method. not publicly exported, so no user-facing impact. + +adds a `contrail` CLI bin with a `backfill` subcommand so workers deploys don't need a local script file at all: + +```json +"scripts": { + "backfill": "contrail backfill --config src/config.ts", + "backfill:remote": "contrail backfill --config src/config.ts --remote" +} +``` + +auto-detects `contrail.config.ts`, `app/config.ts`, or `src/lib/contrail/config.ts`; loads TS configs via `jiti` (no tsx hook required). flags: `--config`, `--remote`, `--binding `, `--concurrency `. + +the underlying helper is also exported at `@atmo-dev/contrail/workers` for embedded use: + +```ts +import { backfillAll } from "@atmo-dev/contrail/workers"; +await backfillAll({ config, remote: true }); +``` + +`wrangler` is an optional peer dep — only imported at runtime when the cli/helper is called. + +breaking: `contrail.sync()` is gone; rename callsites to `contrail.backfillAll()`. signature and return shape unchanged. diff --git a/.changeset/jiti-config-loader.md b/.changeset/jiti-config-loader.md new file mode 100644 index 0000000..494bd26 --- /dev/null +++ b/.changeset/jiti-config-loader.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail-lexicons": patch +--- + +fix `contrail-lex --config ` with plain TS files. previously broke with `ERR_UNKNOWN_FILE_EXTENSION` under plain node — bare `contrail-lex` invocation couldn't load TS configs. now uses `jiti` to handle TS + ESM + CJS transparently, so invocations like `contrail-lex all --config src/config.ts` work without needing tsx/ts-node preregistered. diff --git a/.changeset/lexicons-publish-cmd.md b/.changeset/lexicons-publish-cmd.md new file mode 100644 index 0000000..5efbcbf --- /dev/null +++ b/.changeset/lexicons-publish-cmd.md @@ -0,0 +1,13 @@ +--- +"@atmo-dev/contrail-lexicons": minor +--- + +add `contrail-lex publish` subcommand. wraps `publishLexicons` so you can push lexicon JSON to a PDS without writing a script: + +```bash +contrail-lex publish +# or via env: +LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social LEXICON_ACCOUNT_PASSWORD=xxxx contrail-lex publish +``` + +supports `--generated-dir` (default `lexicons-generated`), `--skip-confirm` (for CI), and `--dry-run` (print what would be published + the DNS records needed, no writes, credentials not required). diff --git a/.changeset/refresh-command.md b/.changeset/refresh-command.md new file mode 100644 index 0000000..a429382 --- /dev/null +++ b/.changeset/refresh-command.md @@ -0,0 +1,25 @@ +--- +"@atmo-dev/contrail": minor +--- + +add `refresh` — a "catch-up" CLI + method that reconciles every known DID's PDS against the DB and reports what was missed. + +unlike `backfillAll`, it ignores the `backfills` state table and sweeps fresh. useful after jetstream outages or after leaving a dev deployment idle for days. + +each record in each configured collection is classified as: + +- **missing** — PDS has it, DB doesn't +- **stale update** — DB has it with a different CID, *and* the DB row was written before the ignore window (default 60s, configurable) +- **in sync** — same CID, or DB row is within the ignore window + +```bash +pnpm contrail refresh # totals +pnpm contrail refresh --by-collection # + per-nsid breakdown +pnpm contrail refresh --ignore-window 30 +``` + +programmatic: `contrail.refresh({ ignoreWindowMs, concurrency })` returns per-collection stats + totals. also exported from `@atmo-dev/contrail/workers` as `refresh()` for wrangler-backed deployments. + +safe to run repeatedly — each pass converges toward zero. not a replacement for `ingest` / `runPersistent` (walks every user's history, which is expensive); use for after-outage reconciliation or dev-idle catch-up. + +also extends `ExistingRecordInfo` with an `indexed_at: number | null` field so callers using `lookupExistingRecords` can inspect per-row freshness without a second query. diff --git a/README.md b/README.md index 28adadf..40634f9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # Contrail -> **Pre-alpha.** Expect breaking changes. +> **pre-alpha.** Expect breaking changes. -A library for building AT Protocol appviews. Declare your collections, get automatic Jetstream ingestion, typed XRPC endpoints, optional permissioned spaces for private records, group-controlled communities, and a client-side reactive sync layer. Runs on Cloudflare Workers + D1, Node.js + PostgreSQL, or SvelteKit. +a library for easily creating (serverless) atproto backends/appviews. + +- declare collections +- get automatic jetstream backfill and ingestion, typed XRPC endpoints +- optional: permissioned spaces and group-controlled communities + +mostly tested on cloudflare workers with d1 but should run in any node env too (+ has adapters for node:sqlite and postgres for the db). ## Install @@ -12,39 +18,82 @@ pnpm add @atmo-dev/contrail ## Minimal example +a complete cloudflare worker that indexes public calendar events from the atproto network and serves them over a typed XRPC endpoint. two files + config. a runnable version lives in [`apps/cloudflare-workers`](./apps/cloudflare-workers) — clone, deploy, `pnpm contrail backfill --remote`, done. + +**`src/contrail.config.ts`** — picked up automatically by the `contrail` CLI: + ```ts -import { Contrail } from "@atmo-dev/contrail"; +import type { ContrailConfig } from "@atmo-dev/contrail"; -const contrail = new Contrail({ +export const config: ContrailConfig = { namespace: "com.example", - db, // D1, node:sqlite, or @atmo-dev/contrail/postgres collections: { event: { - collection: "community.lexicon.calendar.event", - queryable: { startsAt: { type: "range" } }, - searchable: ["name", "description"], + collection: "community.lexicon.calendar.event", // NSID to index + queryable: { startsAt: { type: "range" } }, // ?startsAtMin=... + searchable: ["name", "description"], // ?search=... }, }, -}); - -await contrail.init(); -await contrail.ingest(); // pulls from Jetstream +}; ``` -Mount the XRPC handler in any fetch-style framework: +**`src/worker.ts`** — serves XRPC requests, pulls new records every minute: ```ts +import { Contrail } from "@atmo-dev/contrail"; import { createHandler } from "@atmo-dev/contrail/server"; -export default { fetch: createHandler(contrail) }; +import { config } from "./contrail.config"; + +const contrail = new Contrail(config); +const handle = createHandler(contrail); +let ready = false; + +export default { + async fetch(req: Request, env: { DB: D1Database }) { + if (!ready) { await contrail.init(env.DB); ready = true; } // create tables once + return handle(req, env.DB); // xrpc routes + }, + async scheduled(_ev: ScheduledEvent, env: { DB: D1Database }, ctx: ExecutionContext) { + ctx.waitUntil(contrail.ingest({}, env.DB)); // pull new records + }, +}; +``` + +and a d1 binding + cron in `wrangler.jsonc`: + +```jsonc +{ + "main": "src/worker.ts", + "d1_databases": [{ "binding": "DB", "database_name": "contrail", "database_id": "..." }], + "triggers": { "crons": ["*/1 * * * *"] } +} +``` + +then: + +```bash +npx wrangler d1 create contrail # copy the id into wrangler.jsonc +pnpm wrangler deploy # deploy the worker +pnpm contrail backfill --remote # one-shot historical backfill ``` +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. + +**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 -- [Spaces](./docs/02-spaces.md) — permissioned records stored by the appview -- [Communities](./docs/03-communities.md) — group-controlled atproto DIDs -- [Sync](./docs/04-sync.md) — reactive client-side store over `watchRecords` -- [Lexicons](./docs/05-lexicons.md) — `contrail-lex` CLI and codegen +- [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 ## Packages diff --git a/apps/cloudflare-workers/README.md b/apps/cloudflare-workers/README.md index 38bb787..ccedbef 100644 --- a/apps/cloudflare-workers/README.md +++ b/apps/cloudflare-workers/README.md @@ -1,65 +1,43 @@ -# Contrail — Cloudflare Workers Example +# cloudflare-workers -A complete example of using Contrail to index AT Protocol calendar events and RSVPs on Cloudflare Workers + D1. +minimal, runnable contrail deployment — cloudflare workers + d1, one collection (`community.lexicon.calendar.event`), no spaces / communities / realtime. mirrors the setup shown in the [root README](../../README.md) exactly. -## Setup +## layout -```bash -# Copy this folder to a new project -cp -r examples/cloudflare-workers my-contrail-app -cd my-contrail-app - -# Install dependencies -npm install - -# Create a D1 database -npx wrangler d1 create contrail +``` +src/contrail.config.ts — shared config (collections, queryables, searchables) +src/worker.ts — fetch handler + scheduled ingest +wrangler.jsonc — d1 binding + cron ``` -Copy the `database_id` from the output into `wrangler.jsonc`. - -> **Note:** The `contrail` dependency installs from GitHub. This may take a minute on first install since it also pulls in the AT Protocol client libraries. - -## Configure - -Edit `config.ts` to define your collections, queryable fields, relations, and references. See the [Contrail README](https://github.com/flo-bit/contrail) for all options. +backfills run via the `contrail` cli from the library (see `package.json` scripts) — no script file needed. -## Develop +## setup ```bash -# Discover users from relays and backfill their records -npm run sync - -# Start the dev server (ingests from Jetstream every minute) -npm run dev +pnpm install +npx wrangler d1 create contrail # copy database_id into wrangler.jsonc +pnpm deploy # deploy the worker +pnpm contrail backfill --remote # discover + backfill historical events ``` -Your XRPC API is now available at `http://localhost:8787`: +then hit: ``` -# List events sorted by RSVP count -/xrpc/rsvp.atmo.event.listRecords?sort=rsvpsCount - -# Upcoming events with 10+ going RSVPs -/xrpc/rsvp.atmo.event.listRecords?startsAtMin=2026-03-16&rsvpsGoingCountMin=10 - -# Single event with hydrated RSVPs and profiles -/xrpc/rsvp.atmo.event.getRecord?uri=at://...&hydrateRsvps=10&profiles=true - -# Search events -/xrpc/rsvp.atmo.event.listRecords?search=meetup - -# RSVPs for a specific event -/xrpc/rsvp.atmo.rsvp.listRecords?subjectUri=at://... +GET https://.workers.dev/xrpc/com.example.event.listRecords?startsAtMin=2026-01-01&limit=10 ``` -## Deploy +## local dev ```bash -npm run deploy - -# Sync against production D1 -npm run sync:remote +pnpm dev # wrangler dev, cron fires every minute +pnpm contrail backfill # backfill against the local D1 created by wrangler ``` -Ingestion runs automatically via cron (`*/1 * * * *`). +## extending + +- **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). diff --git a/apps/cloudflare-workers/generate.ts b/apps/cloudflare-workers/generate.ts deleted file mode 100644 index 61361dd..0000000 --- a/apps/cloudflare-workers/generate.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import { config } from "./config"; -import { generateLexicons } from "@atmo-dev/contrail-lexicons"; - -const ROOT_DIR = dirname(fileURLToPath(import.meta.url)); - -generateLexicons({ - config, - rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, "lexicons-generated"), - writeRuntimeFiles: true, -}); diff --git a/apps/cloudflare-workers/package.json b/apps/cloudflare-workers/package.json index cffa716..48b55a0 100644 --- a/apps/cloudflare-workers/package.json +++ b/apps/cloudflare-workers/package.json @@ -5,21 +5,15 @@ "type": "module", "scripts": { "dev": "wrangler dev --test-scheduled", - "deploy": "wrangler deploy", - "generate": "tsx generate.ts", - "generate:pull": "tsx generate.ts && lex-cli pull && tsx generate.ts && lex-cli pull && lex-cli generate", - "sync": "tsx sync.ts", - "sync:remote": "tsx sync.ts --remote" + "deploy": "wrangler deploy" }, "dependencies": { "@atmo-dev/contrail": "workspace:*" }, "devDependencies": { - "@atcute/lex-cli": "^2.5.3", - "@atcute/lexicon-doc": "^2.1.2", + "@atcute/lex-cli": "^2.8.1", "@atmo-dev/contrail-lexicons": "workspace:*", "@cloudflare/workers-types": "^4.20250124.0", - "tsx": "^4.21.0", "typescript": "^5.7.3", "wrangler": "^4.63.0" } diff --git a/apps/cloudflare-workers/src/contrail.config.ts b/apps/cloudflare-workers/src/contrail.config.ts new file mode 100644 index 0000000..b6802e1 --- /dev/null +++ b/apps/cloudflare-workers/src/contrail.config.ts @@ -0,0 +1,12 @@ +import type { ContrailConfig } from "@atmo-dev/contrail"; + +export const config: ContrailConfig = { + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", // NSID to index + queryable: { startsAt: { type: "range" } }, // ?startsAtMin=... + searchable: ["name", "description"], // ?search=... + }, + }, +}; diff --git a/apps/cloudflare-workers/src/worker.ts b/apps/cloudflare-workers/src/worker.ts new file mode 100644 index 0000000..8d9a216 --- /dev/null +++ b/apps/cloudflare-workers/src/worker.ts @@ -0,0 +1,17 @@ +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { config } from "./contrail.config"; + +const contrail = new Contrail(config); +const handle = createHandler(contrail); +let ready = false; + +export default { + async fetch(req: Request, env: { DB: D1Database }) { + if (!ready) { await contrail.init(env.DB); ready = true; } // create tables once + return handle(req, env.DB); // xrpc routes + }, + async scheduled(_ev: ScheduledEvent, env: { DB: D1Database }, ctx: ExecutionContext) { + ctx.waitUntil(contrail.ingest({}, env.DB)); // pull new records + }, +}; diff --git a/apps/cloudflare-workers/sync.ts b/apps/cloudflare-workers/sync.ts deleted file mode 100644 index 09bacd0..0000000 --- a/apps/cloudflare-workers/sync.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Discover users from relays and backfill their records from PDS. - * - * Usage: - * npx tsx sync.ts # local D1 - * npx tsx sync.ts --remote # prod D1 - */ -import { Contrail } from "@atmo-dev/contrail"; -import { config } from "./config"; -import { getPlatformProxy } from "wrangler"; - -function elapsed(start: number): string { - const ms = Date.now() - start; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const mins = Math.floor(ms / 60_000); - const secs = ((ms % 60_000) / 1000).toFixed(0); - return `${mins}m ${secs}s`; -} - -async function main() { - const remote = process.argv.includes("--remote"); - const syncStart = Date.now(); - - console.log(`=== Sync (${remote ? "remote/prod" : "local"} D1) ===\n`); - - const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>({ - environment: remote ? "production" : undefined, - }); - - const contrail = new Contrail({ ...config, db: env.DB }); - - try { - await contrail.init(); - - console.log("--- Discovery ---"); - const discoveryStart = Date.now(); - const discovered = await contrail.discover(); - console.log(` Done: ${discovered.length} users in ${elapsed(discoveryStart)}\n`); - - console.log("--- Backfill ---"); - const backfillStart = Date.now(); - const total = await contrail.backfill({ - concurrency: 100, - onProgress: ({ records, usersComplete, usersTotal, usersFailed }) => { - const secs = (Date.now() - backfillStart) / 1000; - const rate = secs > 0 ? Math.round(records / secs) : 0; - const failStr = usersFailed > 0 ? ` | ${usersFailed} failed` : ""; - process.stdout.write( - `\r ${records} records | ${usersComplete}/${usersTotal} users | ${rate}/s | ${elapsed(backfillStart)}${failStr} ` - ); - }, - }); - process.stdout.write("\n"); - console.log(` Done: ${total} records in ${elapsed(backfillStart)}\n`); - - console.log(`=== Finished in ${elapsed(syncStart)} ===`); - console.log(` Discovered: ${discovered.length} users`); - console.log(` Backfilled: ${total} records`); - } finally { - await dispose(); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/cloudflare-workers/worker.ts b/apps/cloudflare-workers/worker.ts deleted file mode 100644 index b046d44..0000000 --- a/apps/cloudflare-workers/worker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { config } from "./config"; - -const contrail = new Contrail(config); -const handle = createHandler(contrail); - -let initialized = false; - -export default { - async fetch(request: Request, env: { DB: D1Database }) { - if (!initialized) { - await contrail.init(env.DB); - initialized = true; - } - return handle(request, env.DB); - }, - - async scheduled( - _event: ScheduledEvent, - env: { DB: D1Database }, - ctx: ExecutionContext - ) { - if (!initialized) { - await contrail.init(env.DB); - initialized = true; - } - ctx.waitUntil(contrail.ingest({}, env.DB)); - }, -}; diff --git a/apps/cloudflare-workers/wrangler.jsonc b/apps/cloudflare-workers/wrangler.jsonc index 0b403a5..230f9fe 100644 --- a/apps/cloudflare-workers/wrangler.jsonc +++ b/apps/cloudflare-workers/wrangler.jsonc @@ -1,6 +1,6 @@ { "name": "contrail", - "main": "worker.ts", + "main": "src/worker.ts", "compatibility_date": "2025-12-25", "observability": { "enabled": true diff --git a/apps/group-chat/README.md b/apps/group-chat/README.md index 07f807c..e1b1169 100644 --- a/apps/group-chat/README.md +++ b/apps/group-chat/README.md @@ -21,7 +21,7 @@ pnpm dev ``` Re-run `pnpm generate` whenever you add/remove collections or toggle contrail -modules in `src/lib/contrail/config.ts`: the OAuth scope list is derived from +modules in `src/lib/contrail.config.ts`: the OAuth scope list is derived from it, so the consent screen only asks for permissions you actually use. Dev mode uses a loopback OAuth client — no Cloudflare setup needed. The realtime Durable Object runs locally via miniflare. diff --git a/apps/group-chat/lexicons/tools/atmo/chat/channel.json b/apps/group-chat/lexicons/custom/tools/atmo/chat/channel.json similarity index 100% rename from apps/group-chat/lexicons/tools/atmo/chat/channel.json rename to apps/group-chat/lexicons/custom/tools/atmo/chat/channel.json diff --git a/apps/group-chat/lexicons/tools/atmo/chat/message.json b/apps/group-chat/lexicons/custom/tools/atmo/chat/message.json similarity index 100% rename from apps/group-chat/lexicons/tools/atmo/chat/message.json rename to apps/group-chat/lexicons/custom/tools/atmo/chat/message.json diff --git a/apps/group-chat/lexicons/tools/atmo/chat/server.json b/apps/group-chat/lexicons/custom/tools/atmo/chat/server.json similarity index 100% rename from apps/group-chat/lexicons/tools/atmo/chat/server.json rename to apps/group-chat/lexicons/custom/tools/atmo/chat/server.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/channel/getRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/channel/listRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/channel/watchRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/adopt.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/adopt.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/adopt.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/adopt.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/defs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/defs.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/delete.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/delete.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/delete.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/delete.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/deleteRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/deleteRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/deleteRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/deleteRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/getHealth.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/getHealth.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/getHealth.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/getHealth.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/list.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/list.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/list.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/list.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/mint.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/mint.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/putRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/putRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/putRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/putRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/reauth.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/reauth.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/reauth.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/reauth.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/create.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/create.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/delete.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/delete.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/deleteRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/deleteRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/grant.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/grant.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/listMembers.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/listMembers.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/putRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/putRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/resync.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/resync.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/revoke.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/revoke.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/setAccessLevel.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/community/space/setAccessLevel.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/getCursor.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getCursor.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/getCursor.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/getCursor.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/getOverview.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getOverview.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/getOverview.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/getOverview.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/getProfile.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/getProfile.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/getProfile.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/invite/create.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/invite/create.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/invite/defs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/invite/defs.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/invite/list.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/invite/list.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/invite/redeem.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/invite/redeem.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/invite/revoke.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/invite/revoke.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/message/getRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/message/listRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/message/watchRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/notifyOfUpdate.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/notifyOfUpdate.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/notifyOfUpdate.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/notifyOfUpdate.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/permissionSet.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/permissionSet.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/permissionSet.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/permissionSet.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/realtime/subscribe.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/subscribe.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/realtime/subscribe.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/subscribe.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/realtime/ticket.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/realtime/ticket.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/server/getRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/server/listRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json 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 similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/server/watchRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/addMember.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/addMember.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/createSpace.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/createSpace.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/createSpace.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/createSpace.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/defs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/defs.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/deleteRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/deleteRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/getBlob.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/getBlob.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/getRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/getRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/getSpace.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/getSpace.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/leaveSpace.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/leaveSpace.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/listBlobs.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/listBlobs.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/listMembers.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/listMembers.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/listRecords.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/listRecords.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/listSpaces.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/listSpaces.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/putRecord.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/putRecord.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/removeMember.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/removeMember.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/space/uploadBlob.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/space/uploadBlob.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json diff --git a/apps/group-chat/lexicons-generated/tools/atmo/chat/spaceExt/whoami.json b/apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json similarity index 100% rename from apps/group-chat/lexicons-generated/tools/atmo/chat/spaceExt/whoami.json rename to apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json diff --git a/apps/group-chat/lexicons-pulled/README.md b/apps/group-chat/lexicons/pulled/README.md similarity index 100% rename from apps/group-chat/lexicons-pulled/README.md rename to apps/group-chat/lexicons/pulled/README.md diff --git a/apps/group-chat/lexicons-pulled/app/bsky/actor/profile.json b/apps/group-chat/lexicons/pulled/app/bsky/actor/profile.json similarity index 100% rename from apps/group-chat/lexicons-pulled/app/bsky/actor/profile.json rename to apps/group-chat/lexicons/pulled/app/bsky/actor/profile.json diff --git a/apps/group-chat/scripts/generate.ts b/apps/group-chat/scripts/generate.ts index 205c0f9..c29853a 100644 --- a/apps/group-chat/scripts/generate.ts +++ b/apps/group-chat/scripts/generate.ts @@ -1,7 +1,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { writeFileSync } from 'fs'; -import { baseConfig } from '../src/lib/contrail/config'; +import { baseConfig } from '../src/lib/contrail.config'; import { generateLexicons, extractXrpcMethods } from '@atmo-dev/contrail-lexicons'; const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -19,7 +19,7 @@ const configForGen = { const generated = generateLexicons({ config: configForGen, rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, 'lexicons-generated'), + outputDir: join(ROOT_DIR, 'lexicons', 'generated'), writeRuntimeFiles: true }); diff --git a/apps/group-chat/scripts/sync.ts b/apps/group-chat/scripts/sync.ts index c946f86..c1f672f 100644 --- a/apps/group-chat/scripts/sync.ts +++ b/apps/group-chat/scripts/sync.ts @@ -6,7 +6,7 @@ * pnpm sync:remote # prod D1 */ import { Contrail } from '@atmo-dev/contrail'; -import { config } from '../src/lib/contrail/config'; +import { config } from '../src/lib/contrail.config'; import { getPlatformProxy } from 'wrangler'; function elapsed(start: number): string { diff --git a/apps/group-chat/src/lib/contrail/config.ts b/apps/group-chat/src/lib/contrail.config.ts similarity index 100% rename from apps/group-chat/src/lib/contrail/config.ts rename to apps/group-chat/src/lib/contrail.config.ts diff --git a/apps/group-chat/src/lib/contrail/index.ts b/apps/group-chat/src/lib/contrail/index.ts index 555d348..a75157a 100644 --- a/apps/group-chat/src/lib/contrail/index.ts +++ b/apps/group-chat/src/lib/contrail/index.ts @@ -13,7 +13,7 @@ import { import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; import type { Client } from '@atcute/client'; import { dev } from '$app/environment'; -import { baseConfig } from './config'; +import { baseConfig } from '../contrail.config'; type Env = App.Platform['env']; diff --git a/apps/postgres/config.ts b/apps/postgres/config.ts deleted file mode 100644 index a7169da..0000000 --- a/apps/postgres/config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ContrailConfig } from "@atmo-dev/contrail"; - -export const config: ContrailConfig = { - namespace: "rsvp.atmo", - collections: { - event: { - collection: "community.lexicon.calendar.event", - queryable: { - mode: {}, - name: {}, - status: {}, - startsAt: { type: "range" }, - endsAt: { type: "range" }, - createdAt: { type: "range" }, - }, - searchable: ["name", "description"], - relations: { - rsvps: { - collection: "rsvp", - groupBy: "status", - count: true, - groups: { - interested: "community.lexicon.calendar.rsvp#interested", - going: "community.lexicon.calendar.rsvp#going", - notgoing: "community.lexicon.calendar.rsvp#notgoing", - }, - }, - }, - }, - rsvp: { - collection: "community.lexicon.calendar.rsvp", - queryable: { - status: {}, - "subject.uri": {}, - }, - references: { - event: { - collection: "event", - field: "subject.uri", - }, - }, - }, - }, -}; diff --git a/apps/cloudflare-workers/config.ts b/apps/postgres/contrail.config.ts similarity index 100% rename from apps/cloudflare-workers/config.ts rename to apps/postgres/contrail.config.ts diff --git a/apps/postgres/ingest.ts b/apps/postgres/ingest.ts index 1acc13e..0f0cca4 100644 --- a/apps/postgres/ingest.ts +++ b/apps/postgres/ingest.ts @@ -10,7 +10,7 @@ import pg from "pg"; import { Contrail } from "@atmo-dev/contrail"; import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config } from "./config"; +import { config } from "./contrail.config"; const DATABASE_URL = process.env.DATABASE_URL ?? diff --git a/apps/postgres/serve.ts b/apps/postgres/serve.ts index ce48513..27bfcac 100644 --- a/apps/postgres/serve.ts +++ b/apps/postgres/serve.ts @@ -9,7 +9,7 @@ import { createServer } from "node:http"; import { Contrail } from "@atmo-dev/contrail"; import { createHandler } from "@atmo-dev/contrail/server"; import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config } from "./config"; +import { config } from "./contrail.config"; const DATABASE_URL = process.env.DATABASE_URL ?? diff --git a/apps/postgres/sync.ts b/apps/postgres/sync.ts index eecbdb8..4efe47d 100644 --- a/apps/postgres/sync.ts +++ b/apps/postgres/sync.ts @@ -10,7 +10,7 @@ import pg from "pg"; import { Contrail } from "@atmo-dev/contrail"; import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; -import { config } from "./config"; +import { config } from "./contrail.config"; const DATABASE_URL = process.env.DATABASE_URL ?? diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/create.json b/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/create.json deleted file mode 100644 index 9b4d211..0000000 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/create.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a community-owned space. Caller must have manager+ on the target space and cannot grant a higher level than their own. Returns the raw token once; only its SHA-256 is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "accessLevel" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#accessLevel" - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms. Omit for no expiry." - }, - "maxUses": { - "type": "integer", - "minimum": 1, - "description": "Omit for unlimited redemptions." - }, - "note": { - "type": "string", - "maxLength": 500 - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token", - "tokenHash", - "spaceUri", - "accessLevel", - "createdAt" - ], - "properties": { - "token": { - "type": "string", - "description": "Raw token. Shown once — cannot be retrieved later." - }, - "tokenHash": { - "type": "string" - }, - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#accessLevel" - }, - "expiresAt": { - "type": "integer" - }, - "maxUses": { - "type": "integer" - }, - "createdAt": { - "type": "integer" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "Forbidden" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/list.json b/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/list.json deleted file mode 100644 index 7bd0010..0000000 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/list.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.invite.list", - "defs": { - "main": { - "type": "query", - "description": "List invites for a community-owned space. Caller must have manager+ on the space. Raw tokens are never returned — tokenHash is the stable id.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "includeRevoked": { - "type": "boolean", - "default": false - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "invites" - ], - "properties": { - "invites": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#inviteView" - } - } - } - } - }, - "errors": [ - { - "name": "Forbidden" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/revoke.json b/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/revoke.json deleted file mode 100644 index c7aa335..0000000 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/revoke.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.invite.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Revoke an invite. Caller must be the invite's creator or have manager+ on the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "tokenHash" - ], - "properties": { - "tokenHash": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/whoami.json b/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/whoami.json deleted file mode 100644 index 524345d..0000000 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/whoami.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.whoami", - "defs": { - "main": { - "type": "query", - "description": "Report the JWT issuer's effective access level in a space (resolving delegated memberships).", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#accessLevel", - "description": "Null when caller has no resolvable access." - } - } - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/redeem.json b/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/redeem.json deleted file mode 100644 index b0fa07e..0000000 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/redeem.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.spaceExt.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem an invite token. The JWT issuer becomes a member of the space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - } - }, - "errors": [ - { - "name": "InvalidInvite" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/README.md b/apps/rsvp-atmo/lexicons/README.md deleted file mode 100644 index 8534000..0000000 --- a/apps/rsvp-atmo/lexicons/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Custom lexicons - -Place your own hand-authored lexicon JSON files here. - -## Folder conventions - -- **`lexicons/`** (this folder) — Your own custom lexicons. Hand-authored. -- **`lexicons-pulled/`** — Downloaded via `lex-cli pull` from the atproto registry. - Generated from the `pull.sources` list in `lex.config.js`. -- **`lexicons-generated/`** — Auto-generated by `pnpm generate` from `app/config.ts`. - Don't edit by hand. -- **`lexicon-templates/{spaces,community,realtime}/`** — Shipped library templates; - the generator instantiates them under `.{space,community,realtime}.*` into - `lexicons-generated/`. `spaces/ext/` also emits `.spaceExt.*` (contrail - extras — invites, whoami — outside the permissioned-data spec). - -All three (`lexicons/`, `lexicons-pulled/`, `lexicons-generated/`) are picked up by -`lex-cli generate` (see `lex.config.js` `files` glob). diff --git a/apps/rsvp-atmo/lexicons/custom/README.md b/apps/rsvp-atmo/lexicons/custom/README.md new file mode 100644 index 0000000..dc7779b --- /dev/null +++ b/apps/rsvp-atmo/lexicons/custom/README.md @@ -0,0 +1,13 @@ +# Custom lexicons + +Place your own hand-authored lexicon JSON files here. + +## Folder conventions + +- **`lexicons/custom/`** (this folder) — Your own hand-authored lexicons. +- **`lexicons/pulled/`** — Downloaded via `lex-cli pull` from the atproto registry. + Generated from the `pull.sources` list in `lex.config.js`. +- **`lexicons/generated/`** — Auto-generated by `pnpm generate` from your Contrail config. + Don't edit by hand. + +All three are picked up by `lex-cli generate` (see `lex.config.js` `files` glob). diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/adopt.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/adopt.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/adopt.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/adopt.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/defs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/defs.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/defs.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/defs.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/delete.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/delete.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/delete.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/delete.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/deleteRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/deleteRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/deleteRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/deleteRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/getHealth.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/getHealth.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/getHealth.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/getHealth.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/list.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/list.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/list.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/list.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/mint.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/mint.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/mint.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/mint.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/putRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/putRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/putRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/putRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/reauth.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/reauth.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/reauth.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/reauth.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/create.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/create.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/create.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/create.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/delete.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/delete.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/delete.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/delete.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/deleteRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/deleteRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/deleteRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/deleteRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/grant.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/grant.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/grant.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/grant.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/listMembers.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/listMembers.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/listMembers.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/listMembers.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/putRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/putRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/putRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/putRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/resync.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/resync.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/resync.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/resync.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/revoke.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/revoke.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/revoke.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/revoke.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/setAccessLevel.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/setAccessLevel.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/space/setAccessLevel.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/setAccessLevel.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/event/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/event/getRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/event/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/event/listRecords.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/getCursor.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getCursor.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/getCursor.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getCursor.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/getOverview.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getOverview.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/getOverview.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getOverview.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/getProfile.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/getProfile.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/create.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json similarity index 59% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/create.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json index b604074..9858929 100644 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/create.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "rsvp.atmo.spaceExt.invite.create", + "id": "rsvp.atmo.invite.create", "defs": { "main": { "type": "procedure", - "description": "Create an invite for a space. Caller must be the space owner. Returns the raw token once; only the hash is stored.", + "description": "Create an invite for a space. The service dispatches on space ownership: user-owned spaces take `kind` (default `join`); community-owned spaces take `accessLevel`. Exactly one of `kind` / `accessLevel` must be set. Returns the raw token once; only the hash is stored.", "input": { "encoding": "application/json", "schema": { @@ -24,8 +24,17 @@ "read", "read-join" ], - "default": "join", - "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." + "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." + }, + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." }, "expiresAt": { "type": "integer", @@ -33,8 +42,7 @@ }, "maxUses": { "type": "integer", - "minimum": 1, - "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." + "minimum": 1 }, "note": { "type": "string", @@ -58,7 +66,7 @@ }, "invite": { "type": "ref", - "ref": "rsvp.atmo.space.defs#inviteView" + "ref": "rsvp.atmo.invite.defs#inviteView" } } } @@ -69,6 +77,9 @@ }, { "name": "Forbidden" + }, + { + "name": "InvalidRequest" } ] } diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json new file mode 100644 index 0000000..050b3a2 --- /dev/null +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json @@ -0,0 +1,73 @@ +{ + "lexicon": 1, + "id": "rsvp.atmo.invite.defs", + "defs": { + "inviteView": { + "type": "object", + "description": "An invite row as exposed to clients. Either `kind` (user-owned space) or `accessLevel` (community-owned space) is set, never both.", + "required": [ + "tokenHash", + "spaceUri", + "createdBy", + "createdAt", + "usedCount" + ], + "properties": { + "tokenHash": { + "type": "string", + "description": "Stable identifier for list/revoke operations." + }, + "spaceUri": { + "type": "string", + "format": "at-uri" + }, + "kind": { + "type": "string", + "knownValues": [ + "join", + "read", + "read-join" + ], + "description": "Set for user-owned spaces. Absent for community-owned." + }, + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "Set for community-owned spaces. Absent for user-owned." + }, + "createdBy": { + "type": "string", + "format": "did" + }, + "createdAt": { + "type": "integer", + "description": "Unix ms." + }, + "expiresAt": { + "type": "integer", + "description": "Unix ms. Omitted for no expiry." + }, + "maxUses": { + "type": "integer", + "minimum": 1 + }, + "usedCount": { + "type": "integer" + }, + "revokedAt": { + "type": "integer", + "description": "Unix ms. Omitted if not revoked." + }, + "note": { + "type": "string", + "maxLength": 500 + } + } + } + } +} diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/list.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json similarity index 81% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/list.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json index 304be52..2e4f6f7 100644 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/list.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "rsvp.atmo.spaceExt.invite.list", + "id": "rsvp.atmo.invite.list", "defs": { "main": { "type": "query", - "description": "List invites for a space. Owner only.", + "description": "List invites for a space. User-owned spaces: owner-only. Community-owned spaces: manager+.", "parameters": { "type": "params", "required": [ @@ -33,7 +33,7 @@ "type": "array", "items": { "type": "ref", - "ref": "rsvp.atmo.space.defs#inviteView" + "ref": "rsvp.atmo.invite.defs#inviteView" } } } diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/redeem.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json similarity index 58% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/redeem.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json index 0995e9b..6968ec1 100644 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/community/invite/redeem.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "rsvp.atmo.community.invite.redeem", + "id": "rsvp.atmo.invite.redeem", "defs": { "main": { "type": "procedure", - "description": "Redeem a community invite. The caller's DID (from service-auth) is granted the access level encoded in the invite. Atomic — a token is consumed only if the invite is currently usable.", + "description": "Redeem an invite token. User-owned spaces: caller becomes a member. Community-owned spaces: caller is granted the invite's access level.", "input": { "encoding": "application/json", "schema": { @@ -24,32 +24,32 @@ "schema": { "type": "object", "required": [ - "spaceUri", - "accessLevel", - "communityDid" + "spaceUri" ], "properties": { "spaceUri": { "type": "string", "format": "at-uri" }, + "kind": { + "type": "string", + "description": "Set for user-owned spaces — echoes the invite kind consumed." + }, "accessLevel": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#accessLevel" + "type": "string", + "description": "Set for community-owned spaces — the level granted." }, "communityDid": { "type": "string", - "format": "did" + "format": "did", + "description": "Set for community-owned spaces." } } } }, "errors": [ { - "name": "InvalidRequest" - }, - { - "name": "InvalidToken" + "name": "InvalidInvite" }, { "name": "NotFound" diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/revoke.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json similarity index 70% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/revoke.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json index 40c4293..b5c97d5 100644 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/invite/revoke.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json @@ -1,22 +1,22 @@ { "lexicon": 1, - "id": "rsvp.atmo.spaceExt.invite.revoke", + "id": "rsvp.atmo.invite.revoke", "defs": { "main": { "type": "procedure", - "description": "Revoke an invite. Owner only. Invites are identified by their tokenHash (visible in list output).", + "description": "Revoke an invite by tokenHash. User-owned spaces: owner-only. Community-owned spaces: invite creator OR manager+ on the target space.", "input": { "encoding": "application/json", "schema": { "type": "object", "required": [ - "spaceUri", "tokenHash" ], "properties": { "spaceUri": { "type": "string", - "format": "at-uri" + "format": "at-uri", + "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." }, "tokenHash": { "type": "string" diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/notifyOfUpdate.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/notifyOfUpdate.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/notifyOfUpdate.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/notifyOfUpdate.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/permissionSet.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json similarity index 83% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/permissionSet.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json index 5fae345..30572a6 100644 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/permissionSet.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json @@ -16,10 +16,6 @@ "rsvp.atmo.community.delete", "rsvp.atmo.community.deleteRecord", "rsvp.atmo.community.getHealth", - "rsvp.atmo.community.invite.create", - "rsvp.atmo.community.invite.list", - "rsvp.atmo.community.invite.redeem", - "rsvp.atmo.community.invite.revoke", "rsvp.atmo.community.list", "rsvp.atmo.community.mint", "rsvp.atmo.community.putRecord", @@ -33,12 +29,15 @@ "rsvp.atmo.community.space.resync", "rsvp.atmo.community.space.revoke", "rsvp.atmo.community.space.setAccessLevel", - "rsvp.atmo.community.space.whoami", "rsvp.atmo.event.getRecord", "rsvp.atmo.event.listRecords", "rsvp.atmo.getCursor", "rsvp.atmo.getOverview", "rsvp.atmo.getProfile", + "rsvp.atmo.invite.create", + "rsvp.atmo.invite.list", + "rsvp.atmo.invite.redeem", + "rsvp.atmo.invite.revoke", "rsvp.atmo.notifyOfUpdate", "rsvp.atmo.rsvp.getRecord", "rsvp.atmo.rsvp.listRecords", @@ -56,10 +55,6 @@ "rsvp.atmo.space.putRecord", "rsvp.atmo.space.removeMember", "rsvp.atmo.space.uploadBlob", - "rsvp.atmo.spaceExt.invite.create", - "rsvp.atmo.spaceExt.invite.list", - "rsvp.atmo.spaceExt.invite.redeem", - "rsvp.atmo.spaceExt.invite.revoke", "rsvp.atmo.spaceExt.whoami" ] } diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/rsvp/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/rsvp/getRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/rsvp/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/rsvp/listRecords.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/addMember.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/addMember.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/addMember.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/addMember.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/createSpace.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/createSpace.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/createSpace.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/createSpace.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/defs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/defs.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/defs.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/defs.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/deleteRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/deleteRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/deleteRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/deleteRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/getBlob.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getBlob.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/getBlob.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getBlob.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/getRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/getSpace.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getSpace.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/getSpace.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getSpace.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/leaveSpace.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/leaveSpace.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/leaveSpace.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/leaveSpace.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listBlobs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listBlobs.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listBlobs.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listBlobs.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listMembers.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listMembers.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listMembers.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listMembers.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listRecords.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listRecords.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listRecords.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listSpaces.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listSpaces.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/listSpaces.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listSpaces.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/putRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/putRecord.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/putRecord.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/putRecord.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/removeMember.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/removeMember.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/removeMember.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/removeMember.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/uploadBlob.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/uploadBlob.json similarity index 100% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/space/uploadBlob.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/uploadBlob.json diff --git a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/whoami.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json similarity index 57% rename from apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/whoami.json rename to apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json index 0f5d078..236d190 100644 --- a/apps/rsvp-atmo/lexicons-generated/rsvp/atmo/spaceExt/whoami.json +++ b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Report the caller's relationship to a space: whether they are the owner and/or a member. Useful for clients to avoid a listMembers roundtrip.", + "description": "Caller's relationship to a space. For user-owned spaces membership is binary (`isMember`). For community-owned spaces `accessLevel` is also returned, resolved through the community's access-level ladder (including delegated grants).", "parameters": { "type": "params", "required": [ @@ -31,6 +31,16 @@ }, "isMember": { "type": "boolean" + }, + "accessLevel": { + "type": "string", + "knownValues": [ + "member", + "manager", + "admin", + "owner" + ], + "description": "Only set for community-owned spaces. Null when the caller has no resolvable access." } } } diff --git a/apps/rsvp-atmo/lexicons-pulled/README.md b/apps/rsvp-atmo/lexicons/pulled/README.md similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/README.md rename to apps/rsvp-atmo/lexicons/pulled/README.md diff --git a/apps/rsvp-atmo/lexicons-pulled/app/bsky/actor/profile.json b/apps/rsvp-atmo/lexicons/pulled/app/bsky/actor/profile.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/app/bsky/actor/profile.json rename to apps/rsvp-atmo/lexicons/pulled/app/bsky/actor/profile.json diff --git a/apps/rsvp-atmo/lexicons-pulled/community/lexicon/calendar/event.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/event.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/community/lexicon/calendar/event.json rename to apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/event.json diff --git a/apps/rsvp-atmo/lexicons-pulled/community/lexicon/calendar/rsvp.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/rsvp.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/community/lexicon/calendar/rsvp.json rename to apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/rsvp.json diff --git a/apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/address.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/address.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/address.json rename to apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/address.json diff --git a/apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/fsq.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/fsq.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/fsq.json rename to apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/fsq.json diff --git a/apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/geo.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/geo.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/geo.json rename to apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/geo.json diff --git a/apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/hthree.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/hthree.json similarity index 100% rename from apps/rsvp-atmo/lexicons-pulled/community/lexicon/location/hthree.json rename to apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/hthree.json diff --git a/apps/rsvp-atmo/package.json b/apps/rsvp-atmo/package.json index 8503756..7c57671 100644 --- a/apps/rsvp-atmo/package.json +++ b/apps/rsvp-atmo/package.json @@ -10,7 +10,7 @@ "deploy": "wrangler deploy", "clean": "tsx scripts/clean.ts", "generate": "tsx src/generate.ts", - "generate:pull": "contrail-lex all --config src/config.ts", + "generate:pull": "contrail-lex all", "publish-lexicons": "tsx scripts/publish-lexicons.ts", "typecheck": "tsc --noEmit", "ingest": "curl -s http://localhost:8787/__scheduled?cron=*/1+*+*+*+*", diff --git a/apps/rsvp-atmo/scripts/clean.ts b/apps/rsvp-atmo/scripts/clean.ts index 144384d..7345b4c 100644 --- a/apps/rsvp-atmo/scripts/clean.ts +++ b/apps/rsvp-atmo/scripts/clean.ts @@ -13,8 +13,8 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const targets = [ ".wrangler", "lex.config.js", - "lexicons-pulled", - "lexicons-generated", + "lexicons/pulled", + "lexicons/generated", "src/lexicon-types", ]; diff --git a/apps/rsvp-atmo/scripts/publish-lexicons.ts b/apps/rsvp-atmo/scripts/publish-lexicons.ts index f5909f9..88a7a5d 100644 --- a/apps/rsvp-atmo/scripts/publish-lexicons.ts +++ b/apps/rsvp-atmo/scripts/publish-lexicons.ts @@ -1,6 +1,6 @@ /** * CLI wrapper for `publishLexicons` — publishes contrail's own generated - * lexicons (under `lexicons-generated/`) to the logged-in account's PDS. + * lexicons (under `lexicons/generated/`) to the logged-in account's PDS. * * Usage: * LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social \ @@ -30,7 +30,7 @@ async function main(): Promise { } await publishLexicons({ - generatedDir: join(ROOT, "lexicons-generated"), + generatedDir: join(ROOT, "lexicons", "generated"), identifier, password, }); diff --git a/apps/rsvp-atmo/src/config.ts b/apps/rsvp-atmo/src/contrail.config.ts similarity index 100% rename from apps/rsvp-atmo/src/config.ts rename to apps/rsvp-atmo/src/contrail.config.ts diff --git a/apps/rsvp-atmo/src/generate.ts b/apps/rsvp-atmo/src/generate.ts index 2239922..f06f1c7 100644 --- a/apps/rsvp-atmo/src/generate.ts +++ b/apps/rsvp-atmo/src/generate.ts @@ -5,7 +5,7 @@ */ import { join, dirname } from "path"; import { fileURLToPath } from "url"; -import { config } from "./config"; +import { config } from "./contrail.config"; import { generateLexicons } from "@atmo-dev/contrail-lexicons"; const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -13,6 +13,6 @@ const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); generateLexicons({ config, rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, "lexicons-generated"), + outputDir: join(ROOT_DIR, "lexicons", "generated"), writeRuntimeFiles: true, }); diff --git a/apps/rsvp-atmo/src/sync.ts b/apps/rsvp-atmo/src/sync.ts index bf25bc9..6fda0d9 100644 --- a/apps/rsvp-atmo/src/sync.ts +++ b/apps/rsvp-atmo/src/sync.ts @@ -6,7 +6,7 @@ * pnpm sync --remote # prod D1 */ import { Contrail } from "@atmo-dev/contrail"; -import { config } from "./config"; +import { config } from "./contrail.config"; import { getPlatformProxy } from "wrangler"; function elapsed(start: number): string { diff --git a/apps/rsvp-atmo/src/worker.ts b/apps/rsvp-atmo/src/worker.ts index b046d44..c0322c6 100644 --- a/apps/rsvp-atmo/src/worker.ts +++ b/apps/rsvp-atmo/src/worker.ts @@ -1,6 +1,6 @@ import { Contrail } from "@atmo-dev/contrail"; import { createHandler } from "@atmo-dev/contrail/server"; -import { config } from "./config"; +import { config } from "./contrail.config"; const contrail = new Contrail(config); const handle = createHandler(contrail); diff --git a/apps/sveltekit-cloudflare-workers/README.md b/apps/sveltekit-cloudflare-workers/README.md index 4917318..50917ff 100644 --- a/apps/sveltekit-cloudflare-workers/README.md +++ b/apps/sveltekit-cloudflare-workers/README.md @@ -13,7 +13,7 @@ Dev mode uses a loopback OAuth client — no keys or Cloudflare setup needed. ## Config -Define which AT Protocol collections to index in `src/lib/contrail/config.ts`: +Define which AT Protocol collections to index in `src/lib/contrail.config.ts`: ```ts import type { ContrailConfig } from '@atmo-dev/contrail'; diff --git a/apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/getCursor.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getCursor.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/getCursor.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getCursor.json diff --git a/apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/getOverview.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getOverview.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/getOverview.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getOverview.json diff --git a/apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/getProfile.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/getProfile.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/getProfile.json diff --git a/apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/notifyOfUpdate.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/notifyOfUpdate.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/notifyOfUpdate.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/notifyOfUpdate.json diff --git a/apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/permissionSet.json b/apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/permissionSet.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/permissionSet.json 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 similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/status/getRecord.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/getRecord.json 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 similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-generated/statusphere/app/status/listRecords.json rename to apps/sveltekit-cloudflare-workers/lexicons/generated/statusphere/app/status/listRecords.json diff --git a/apps/sveltekit-cloudflare-workers/lexicons-pulled/README.md b/apps/sveltekit-cloudflare-workers/lexicons/pulled/README.md similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-pulled/README.md rename to apps/sveltekit-cloudflare-workers/lexicons/pulled/README.md diff --git a/apps/sveltekit-cloudflare-workers/lexicons-pulled/app/bsky/actor/profile.json b/apps/sveltekit-cloudflare-workers/lexicons/pulled/app/bsky/actor/profile.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-pulled/app/bsky/actor/profile.json rename to apps/sveltekit-cloudflare-workers/lexicons/pulled/app/bsky/actor/profile.json diff --git a/apps/sveltekit-cloudflare-workers/lexicons-pulled/xyz/statusphere/status.json b/apps/sveltekit-cloudflare-workers/lexicons/pulled/xyz/statusphere/status.json similarity index 100% rename from apps/sveltekit-cloudflare-workers/lexicons-pulled/xyz/statusphere/status.json rename to apps/sveltekit-cloudflare-workers/lexicons/pulled/xyz/statusphere/status.json diff --git a/apps/sveltekit-cloudflare-workers/scripts/generate.ts b/apps/sveltekit-cloudflare-workers/scripts/generate.ts index 6dd6fde..b951669 100644 --- a/apps/sveltekit-cloudflare-workers/scripts/generate.ts +++ b/apps/sveltekit-cloudflare-workers/scripts/generate.ts @@ -1,6 +1,6 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { config } from '../src/lib/contrail/config'; +import { config } from '../src/lib/contrail.config'; import { generateLexicons } from '@atmo-dev/contrail-lexicons'; const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -8,6 +8,6 @@ const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); generateLexicons({ config, rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, 'lexicons-generated'), + outputDir: join(ROOT_DIR, 'lexicons', 'generated'), writeRuntimeFiles: true }); diff --git a/apps/sveltekit-cloudflare-workers/scripts/sync.ts b/apps/sveltekit-cloudflare-workers/scripts/sync.ts index c946f86..c1f672f 100644 --- a/apps/sveltekit-cloudflare-workers/scripts/sync.ts +++ b/apps/sveltekit-cloudflare-workers/scripts/sync.ts @@ -6,7 +6,7 @@ * pnpm sync:remote # prod D1 */ import { Contrail } from '@atmo-dev/contrail'; -import { config } from '../src/lib/contrail/config'; +import { config } from '../src/lib/contrail.config'; import { getPlatformProxy } from 'wrangler'; function elapsed(start: number): string { diff --git a/apps/sveltekit-cloudflare-workers/src/lib/contrail/config.ts b/apps/sveltekit-cloudflare-workers/src/lib/contrail.config.ts similarity index 100% rename from apps/sveltekit-cloudflare-workers/src/lib/contrail/config.ts rename to apps/sveltekit-cloudflare-workers/src/lib/contrail.config.ts diff --git a/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts b/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts index 7aaf747..29f4474 100644 --- a/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts +++ b/apps/sveltekit-cloudflare-workers/src/lib/contrail/index.ts @@ -1,7 +1,7 @@ import { Contrail } from '@atmo-dev/contrail'; import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; import type { Client } from '@atcute/client'; -import { config } from './config'; +import { config } from '../contrail.config'; export const contrail = new Contrail(config); diff --git a/docs/01-indexing.md b/docs/01-indexing.md index 9f22c70..0a811e8 100644 --- a/docs/01-indexing.md +++ b/docs/01-indexing.md @@ -4,24 +4,33 @@ Contrail's core job: mirror atproto records into your DB and expose them via XRP ## Collection shape +A realistic two-collection example: events and RSVPs. RSVPs point at events via `subject.uri`; events expose per-status RSVP counts. + ```ts collections: { event: { collection: "community.lexicon.calendar.event", // full NSID queryable: { - mode: {}, // ?mode=online - startsAt: { type: "range" }, // ?startsAtMin=...&startsAtMax=... + mode: {}, // ?mode=online + startsAt: { type: "range" }, // ?startsAtMin=...&startsAtMax=... }, searchable: ["name", "description"], // FTS5 / tsvector relations: { rsvps: { - collection: "rsvp", - groupBy: "status", - groups: { going: "community.lexicon.calendar.rsvp#going" }, + collection: "rsvp", // short name of the child collection + groupBy: "status", // field on the child record + groups: { + going: "community.lexicon.calendar.rsvp#going", + interested: "community.lexicon.calendar.rsvp#interested", + }, }, }, + }, + rsvp: { + collection: "community.lexicon.calendar.rsvp", + queryable: { status: {} }, references: { - event: { collection: "event", field: "subject.uri" }, + event: { collection: "event", field: "subject.uri" }, // RSVP's field → event's URI }, }, } @@ -29,45 +38,182 @@ collections: { - **queryable** — string equality or range, exposed as query params. - **searchable** — FTS5 on D1/Postgres. Not available on `node:sqlite`. -- **relations** — materialized many-to-one counts (`rsvpsGoingCount`). -- **references** — forward lookups. Hydrate inline with `?hydrateEvent=true`. +- **relations** — many-to-one with materialized counts. The `event` collection gains `rsvpsCount`, `rsvpsGoingCount`, `rsvpsInterestedCount` columns — filter (`?rsvpsGoingCountMin=10`) and sort (`?sort=rsvpsGoingCount`) on them. Hydrate inline with `?hydrateRsvps=5`. +- **references** — forward lookups from child → parent. `?hydrateEvent=true` on an RSVP query embeds the referenced event record. + +## Backfill (historical data) + +Run once at setup to pull every record that exists today. + +```ts +await contrail.backfillAll({ concurrency: 100 }); // discover + backfill, logs progress +``` + +Under the hood this is two steps you can call separately if you want finer control: + +```ts +await contrail.discover(); // walk relays, register DIDs +await contrail.backfill({ concurrency: 100 }); // fetch history for registered DIDs +``` + +`backfill()` picks up where it left off across runs — safe to re-run. + +### Workers CLI + +For Cloudflare Workers deploys, `@atmo-dev/contrail` ships a `contrail` bin that handles the `wrangler.getPlatformProxy` dance — no script file, no package.json alias needed: + +```bash +pnpm contrail backfill # local D1 (wrangler dev's bindings) +pnpm contrail backfill --remote # production D1 +``` + +Auto-detects configs at `contrail.config.ts`, `src/contrail.config.ts`, `src/lib/contrail.config.ts`, or `app/contrail.config.ts` (first match wins). Override with `--config `. Other flags: `--binding ` (default `DB`), `--concurrency ` (default 100). + +If you'd rather embed backfill inside your own script, `@atmo-dev/contrail/workers` exports the same logic as a function: + +```ts +import { backfillAll } from "@atmo-dev/contrail/workers"; +import { config } from "../src/contrail.config"; + +await backfillAll({ config, remote: process.argv.includes("--remote") }); +``` + +For node/postgres deploys, skip both — you already have a `db` in hand; just `await contrail.backfillAll({}, db)` directly. + +## Ingestion (ongoing new records) + +After the initial `backfillAll()`, keep the index fresh with new records as they're published. Pick the mode that matches your runtime. + +### Cron-driven (cloudflare workers) + +Workers can't hold long-lived connections, so run one catch-up cycle per cron fire: + +```ts +// wrangler.jsonc: "triggers": { "crons": ["*/1 * * * *"] } +async scheduled(_ev, env, ctx) { + ctx.waitUntil(contrail.ingest({}, env.DB)); +} +``` -## Ingestion +`ingest()` connects to Jetstream, streams events since the saved cursor, stops when caught up. Running every minute is fine — the next fire resumes where this one left off. Each cycle is bounded, so it can't blow past the Worker time limit. -Three ways records land in the DB: +### Persistent (node / any long-lived server) + +If your runtime can keep a socket open, skip the cron entirely: ```ts -await contrail.ingest(); // one Jetstream cycle, then stops -await contrail.runPersistent(); // long-lived connection, auto-reconnect -await contrail.notify(uri); // immediate PDS fetch for one record +const ac = new AbortController(); +await contrail.runPersistent({ + batchSize: 50, // flush every N events (default: 50) + flushIntervalMs: 5000, // or every N ms, whichever first + signal: ac.signal, +}); +// ac.abort() flushes the current batch and saves the cursor before returning ``` -Call `notify()` after your app writes to a PDS and needs the change reflected now. Jetstream catches the same event later; the duplicate is detected by CID. +One process, one socket, auto-reconnect on drops. Lower latency than cron mode (events land within seconds instead of up-to-a-minute), but needs a runtime that can run indefinitely. -## Discovery + backfill +### Immediate (`notify()`) + +Use this when your own app writes to a user's PDS and needs the change indexed *now* — waiting for the next cron / Jetstream flush is too slow: ```ts -await contrail.discover(); // find users from relays -await contrail.backfill({ concurrency: 100 }); // pull their history -await contrail.sync(); // both +await contrail.notify(uri); // one record +await contrail.notify([u1, u2, u3]); // batch, up to 25 +``` + +Fetches directly from the user's PDS and indexes synchronously. When Jetstream later delivers the same event, the duplicate is detected by CID and skipped. + +### Which one do I use? + +| | backfillAll | ingest | runPersistent | notify | +|---|---|---|---|---| +| when | once, at setup | every cron fire | start once, runs forever | per-write, on demand | +| runtime | local script | cloudflare workers | node / long-lived server | anywhere | +| scope | all historical records | events since last cursor | events since last cursor, live | specific URIs | +| latency | — | ~minute | ~seconds | immediate | + +Typical combos: +- **workers app:** `backfillAll()` once + `ingest()` on cron + optional `notify()` for self-writes +- **node server:** `backfillAll()` once + `runPersistent()` forever + optional `notify()` for self-writes + +## Refresh (catch-up after outages / dev idle) + +When Jetstream drops events — you went offline for a few days in dev, there was an outage, or you just want reassurance nothing was lost — `refresh` walks every known DID's PDS and reconciles against your DB: + +```bash +pnpm contrail refresh # totals only +pnpm contrail refresh --by-collection # totals + per-collection breakdown +pnpm contrail refresh --ignore-window 30 # grace seconds (default: 60) ``` +Each record is classified as: + +- **missing** — PDS has it, DB doesn't. Inserted. +- **stale update** — DB has it with a different CID, *and* the DB row is older than the ignore window. Upserted. +- **in sync** — same CID, or DB row is within the ignore window (jetstream probably just hadn't caught up yet). + +The ignore window is there so a refresh run seconds after a normal jetstream cycle doesn't double-count records that are about to sync anyway. Records inside the window are still written if they differ; they just don't show up in the stats. + +Report shape (`--by-collection`): + +``` +by collection: + community.lexicon.calendar.event + 3 missing, 1 stale updates, 842 in sync + community.lexicon.calendar.rsvp + 12 missing, 0 stale updates, 4108 in sync + +total: + 15 missing, 1 stale updates, 4950 in sync + 234 users scanned, 1 failed in 85.3s + (ignore window: 60s) +``` + +Safe to run repeatedly — each pass converges toward zero missing / stale. Programmatic equivalent: `contrail.refresh({ ignoreWindowMs, concurrency })` returns the same structure. + +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" }, - sort: { field: "startsAt", direction: "asc" }, + rangeFilters: { startsAt: { min: "2026-01-01" } }, + countFilters: { rsvp: 10 }, // keyed by child collection short name + sort: { recordField: "startsAt", direction: "asc" }, limit: 20, }); ``` -Or via HTTP once the handler is mounted: +The programmatic shape doesn't use the URL param names — keys are the underlying field/collection identifiers: -``` -/xrpc/com.example.event.listRecords?mode=online&sort=startsAt&limit=20 -/xrpc/com.example.event.getRecord?uri=at://did:plc:.../... -``` +- `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. ## Adapters @@ -91,6 +237,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](./02-spaces.md) | -| `community` | — | See [Communities](./03-communities.md) | -| `realtime` | — | See [Sync](./04-sync.md) | +| `spaces` | — | See [Spaces](./03-spaces.md) | +| `community` | — | See [Communities](./04-communities.md) | +| `realtime` | — | See [Sync](./05-sync.md) | diff --git a/docs/02-lexicons.md b/docs/02-lexicons.md new file mode 100644 index 0000000..09cd550 --- /dev/null +++ b/docs/02-lexicons.md @@ -0,0 +1,80 @@ +# Lexicons + +Contrail emits lexicon JSON for every XRPC method it exposes. Your app needs those files for two things: **generating typed TypeScript clients** and **publishing to a PDS** so other apps can discover your schemas. The `contrail-lex` CLI handles both. + +```bash +pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli +``` + +`@atcute/lex-cli` is a peer dep — you pin the version. + +## CLI + +```bash +contrail-lex generate # emit lexicon JSON from your Contrail config +contrail-lex pull # wraps `lex-cli pull` (fetch external lexicons) +contrail-lex types # wraps `lex-cli generate` (JSON → TS types) +contrail-lex all # generate → pull → generate → pull → types +contrail-lex all --no-types # same, skip the type step +contrail-lex publish # publish lexicons to your PDS (add --dry-run to preview) +``` + +Config is auto-detected at `contrail.config.ts`, `src/contrail.config.ts`, `src/lib/contrail.config.ts`, or `app/contrail.config.ts`. Override with `--config `. + +## What lands where + +Running `contrail-lex all` organises everything under one `lexicons/` directory: + +``` +lexicons/ + custom/ # hand-authored lexicons you write (optional) + generated/ # JSON emitted from your Contrail config + pulled/ # external NSIDs fetched by lex-cli pull +lex.config.js # regenerated each run (gitignore) +src/lexicon-types/ # TS types from lex-cli generate +``` + +`lexicons/custom/`, `lexicons/generated/`, and `lexicons/pulled/` should be **committed** — that way CI doesn't need network access. `lex.config.js` and `src/lexicon-types/` are regenerated on demand and safe to gitignore. + +## Publishing + +Once your lexicons are generated and committed, publish them as `com.atproto.lexicon.schema` records on your PDS so other apps can resolve them: + +```bash +contrail-lex publish + +# or via env vars (nicer for CI): +LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social \ +LEXICON_ACCOUNT_PASSWORD=xxxx-xxxx-xxxx-xxxx \ + contrail-lex publish +``` + +Writes each lexicon to `at:///com.atproto.lexicon.schema/`. You'll also need DNS TXT records for your NSID authorities — the command prints the exact records you need and won't proceed until you confirm (override with `--skip-confirm` in CI). + +Preview first with `--dry-run`: + +```bash +contrail-lex publish --dry-run +``` + +Walks your `lexicons/generated/` dir, prints the NSIDs it would publish and the exact TXT records you'd need. Doesn't log in or write anything. Credentials aren't required for dry-run, so it's safe to run without secrets configured — useful for sanity-checking what a release would push. + +**One-off flow:** generate once, commit, `contrail-lex publish --dry-run` to preview, `contrail-lex publish` once per version bump. You don't need to re-publish unless your lexicon JSON changes. + +## Programmatic API + +If you need to call the generator from code — e.g. to derive the XRPC method list for an OAuth permission set: + +```ts +import { generateLexicons, extractXrpcMethods } from "@atmo-dev/contrail-lexicons"; + +const generated = generateLexicons({ + config, + rootDir: process.cwd(), + outputDir: "lexicons/generated", +}); + +const methods = extractXrpcMethods(generated); // every query + procedure NSID +``` + +`publishLexicons` is also exported for custom flows, but the CLI covers the common case. diff --git a/docs/02-spaces.md b/docs/03-spaces.md similarity index 100% rename from docs/02-spaces.md rename to docs/03-spaces.md diff --git a/docs/03-communities.md b/docs/04-communities.md similarity index 97% rename from docs/03-communities.md rename to docs/04-communities.md index c07d517..fbfcfa5 100644 --- a/docs/03-communities.md +++ b/docs/04-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](./02-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](./03-spaces.md). ## When to use this diff --git a/docs/05-lexicons.md b/docs/05-lexicons.md deleted file mode 100644 index 32fba96..0000000 --- a/docs/05-lexicons.md +++ /dev/null @@ -1,91 +0,0 @@ -# Lexicons - -Contrail emits lexicon JSON for every XRPC method it exposes. Your app needs those JSON files for two things: publishing to your PDS (so other apps can discover them) and generating TypeScript types for typed clients. The `contrail-lex` CLI handles both. - -```bash -pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli -``` - -`@atcute/lex-cli` is a peer dep — you pin the version. - -## CLI - -```bash -contrail-lex generate # emit lexicon JSON from your Contrail config -contrail-lex pull # wraps `lex-cli pull` (fetches external lexicons) -contrail-lex types # wraps `lex-cli generate` (JSON → TS types) -contrail-lex all # generate → pull → generate → pull → types -contrail-lex all --no-types # skip the type step -``` - -The CLI auto-detects your config at `contrail.config.ts`, `app/config.ts`, or `src/lib/contrail/config.ts`. Override with `--config `. - -## What lands where - -Running `contrail-lex all` against a config with `namespace: "com.example"` writes: - -``` -lexicons-generated/ # JSON lexicons emitted from your config -lexicons-pulled/ # external NSIDs fetched by lex-cli pull -lex.config.js # regenerated each run (add to .gitignore) -src/lexicon-types/ # TS types emitted by lex-cli generate -``` - -`lexicons-generated/` and `lexicons-pulled/` should be **committed** — that way CI doesn't need network access and consumers of your repo can pull the JSON as a lexicon source. `lex.config.js` and `src/lexicon-types/` are generated on demand and safe to gitignore. - -## Publishing to a PDS - -Once committed, publish the lexicons to an atproto account so other apps can resolve them: - -```ts -import { publishLexicons } from "@atmo-dev/contrail-lexicons"; - -await publishLexicons({ - generatedDir: "lexicons-generated", - identifier: process.env.LEXICON_ACCOUNT_IDENTIFIER, - password: process.env.LEXICON_ACCOUNT_PASSWORD, -}); -``` - -Writes each lexicon as a `com.atproto.lexicon.schema` record under `at:///com.atproto.lexicon.schema/`. You'll also need DNS TXT records — publishing prints them for you. - -## Programmatic API - -```ts -import { generateLexicons, extractXrpcMethods } from "@atmo-dev/contrail-lexicons"; - -const generated = generateLexicons({ - config, - rootDir: process.cwd(), - outputDir: "lexicons-generated", -}); - -const methods = extractXrpcMethods(generated); // NSIDs of every query + procedure -``` - -Handy for emitting the method list for your OAuth permission set. - -## Consuming a third-party contrail instance - -Someone else runs a contrail deployment; you want typed client access to their XRPCs. Point `lex-cli pull` at their git repo: - -```js -// lex.config.js -import { defineLexiconConfig } from "@atcute/lex-cli"; - -export default defineLexiconConfig({ - outdir: "src/lexicon-types/", - imports: ["@atcute/atproto"], - files: ["lexicons/**/*.json"], - pull: { - outdir: "lexicons/", - sources: [{ - type: "git", - remote: "https://github.com/them/their-contrail.git", - pattern: ["lexicons-generated/**/*.json"], - }], - }, -}); -``` - -Then `npx lex-cli pull && npx lex-cli generate`, and their endpoints are typed in your client. diff --git a/docs/04-sync.md b/docs/05-sync.md similarity index 100% rename from docs/04-sync.md rename to docs/05-sync.md diff --git a/packages/contrail/README.md b/packages/contrail/README.md index 4e95bd6..6754a8d 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -104,8 +104,8 @@ await contrail.discover(); // Backfill their records from PDS await contrail.backfill({ concurrency: 100 }); -// Or both in one call -await contrail.sync({ concurrency: 100 }); +// Or both in one call — logs progress via config.logger +await contrail.backfillAll({ concurrency: 100 }); ``` ### Notify of immediate updates diff --git a/packages/contrail/package.json b/packages/contrail/package.json index de960fa..7253821 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -26,8 +26,19 @@ "./postgres": { "types": "./dist/adapters/postgres.d.ts", "import": "./dist/adapters/postgres.js" + }, + "./workers": { + "types": "./dist/workers/backfill.d.ts", + "import": "./dist/workers/backfill.js" + }, + "./cli-config": { + "types": "./dist/cli-config.d.ts", + "import": "./dist/cli-config.js" } }, + "bin": { + "contrail": "./dist/cli.js" + }, "repository": { "type": "git", "url": "https://github.com/flo-bit/contrail.git", @@ -57,7 +68,8 @@ "@atcute/jetstream": "^1.0.2", "@atcute/lexicons": "^1.2.9", "@atcute/xrpc-server": "^0.1.12", - "hono": "^4.12.8" + "hono": "^4.12.8", + "jiti": "^2.4.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250124.0", @@ -66,14 +78,19 @@ "pg": "^8.20.0", "tsup": "^8.5.0", "typescript": "^5.7.3", - "vitest": "^4.1.0" + "vitest": "^4.1.0", + "wrangler": "^4.63.0" }, "peerDependencies": { - "pg": "^8.0.0" + "pg": "^8.0.0", + "wrangler": "^4.0.0" }, "peerDependenciesMeta": { "pg": { "optional": true + }, + "wrangler": { + "optional": true } }, "license": "MIT" diff --git a/packages/contrail/src/cli-config.ts b/packages/contrail/src/cli-config.ts new file mode 100644 index 0000000..6482319 --- /dev/null +++ b/packages/contrail/src/cli-config.ts @@ -0,0 +1,58 @@ +/** + * Shared config-file discovery + loading for the `contrail` and + * `contrail-lex` CLIs. Exported so downstream CLIs can use the same + * auto-detect convention and fall back to the same list of locations. + * + * Not for runtime use by regular contrail apps — they pass `config` + * directly to `new Contrail(...)`. This module exists only to support + * CLI tooling that has to discover + load a TS/JS config file off disk. + */ +import { existsSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { createJiti } from "jiti"; +import type { ContrailConfig } from "./core/types.js"; + +/** Auto-detect locations (first match wins). Keep this list small and stable — + * every CLI in the ecosystem walks it, so additions are cross-cutting. */ +export const CONFIG_CANDIDATES: readonly string[] = [ + "contrail.config.ts", + "contrail.config.js", + "src/contrail.config.ts", + "src/contrail.config.js", + "src/lib/contrail.config.ts", + "src/lib/contrail.config.js", + "app/contrail.config.ts", + "app/contrail.config.js", +]; + +/** Human-readable summary for CLI error messages. */ +export const CONFIG_CANDIDATES_MESSAGE = + "contrail.config.ts | src/contrail.config.ts | src/lib/contrail.config.ts | app/contrail.config.ts"; + +/** Find a config file by walking the auto-detect list relative to `root`. + * When `explicit` is passed, resolves that (relative to `root`) and checks + * existence — no auto-detection. Returns `null` if nothing found. */ +export function findConfigFile(root: string, explicit?: string): string | null { + if (explicit) { + const p = resolve(root, explicit); + return existsSync(p) ? p : null; + } + for (const c of CONFIG_CANDIDATES) { + const p = join(root, c); + if (existsSync(p)) return p; + } + return null; +} + +/** Load a config file via jiti — handles TS + ESM + CJS transparently, + * no tsx/ts-node hook required. Accepts either a named export `config` or + * a default export. Throws if neither is present. */ +export async function loadConfig(path: string): Promise { + const jiti = createJiti(import.meta.url, { interopDefault: true }); + const mod = (await jiti.import(path)) as { config?: unknown; default?: unknown }; + const config = mod.config ?? mod.default; + if (!config || typeof config !== "object") { + throw new Error(`Config at ${path} did not export a \`config\` object`); + } + return config as T; +} diff --git a/packages/contrail/src/cli.ts b/packages/contrail/src/cli.ts new file mode 100644 index 0000000..4f98dfb --- /dev/null +++ b/packages/contrail/src/cli.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * contrail — CLI for one-off operations against a contrail deployment. + * + * Usage: + * contrail backfill [--remote] [--binding DB] [--config path] + * contrail refresh [--remote] [--ignore-window 60s] [--by-collection] + * + * Config auto-detects at contrail.config.ts, src/contrail.config.ts, + * src/lib/contrail.config.ts, or app/contrail.config.ts (first match wins). + */ +import { findConfigFile, loadConfig, CONFIG_CANDIDATES_MESSAGE } from "./cli-config.js"; +import { backfillAll, refresh } from "./workers/backfill.js"; +import type { CollectionStats, RefreshResult } from "./core/refresh.js"; + +type Subcommand = "backfill" | "refresh" | "help"; + +const USAGE = `contrail [options] + +Subcommands: + backfill One-time bulk load from each known DID's PDS (resumable) + refresh Fresh sweep: reconcile PDS vs DB, report missing + stale + help Print this message + +Options (backfill): + --config Path to Contrail config file (TS or JS). + --root Project root for auto-detection. Default: CWD. + --remote Use production D1 bindings. + --binding D1 binding name in wrangler.jsonc. Default: "DB". + --concurrency Passed to contrail.backfillAll(). Default: 100. + +Options (refresh): + --config Path to Contrail config file (TS or JS). + --root Project root for auto-detection. Default: CWD. + --remote Use production D1 bindings. + --binding D1 binding name in wrangler.jsonc. Default: "DB". + --concurrency Passed to contrail.refresh(). Default: 50. + --ignore-window Seconds of grace for stale-update detection. Default: 60. + --by-collection Print per-collection stats, not just totals. +`; + +interface Args { + cmd: Subcommand; + config?: string; + root: string; + remote: boolean; + binding: string; + concurrency?: number; + ignoreWindowMs?: number; + byCollection: boolean; +} + +function parseArgs(argv: string[]): Args { + const args = argv.slice(2); + const cmd = (args.shift() ?? "help") as Subcommand; + let config: string | undefined; + let root = process.cwd(); + let remote = false; + let binding = "DB"; + let concurrency: number | undefined; + let ignoreWindowMs: number | undefined; + let byCollection = false; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--config") config = args[++i]; + else if (a === "--root") root = args[++i]; + else if (a === "--remote") remote = true; + else if (a === "--binding") binding = args[++i]; + else if (a === "--concurrency") concurrency = parseInt(args[++i], 10); + else if (a === "--ignore-window") ignoreWindowMs = parseInt(args[++i], 10) * 1000; + else if (a === "--by-collection") byCollection = true; + else if (a === "-h" || a === "--help") + return { + cmd: "help", + config, + root, + remote, + binding, + concurrency, + ignoreWindowMs, + byCollection, + }; + } + return { cmd, config, root, remote, binding, concurrency, ignoreWindowMs, byCollection }; +} + +function resolveConfigPath(opts: Args): string | null { + const p = findConfigFile(opts.root, opts.config); + if (!p) { + console.error( + "Could not find a Contrail config. Pass --config or place one at\n" + + ` ${CONFIG_CANDIDATES_MESSAGE}` + ); + } + return p; +} + +function formatStats(s: CollectionStats): string { + return `${s.missing} missing, ${s.staleUpdates} stale updates, ${s.inSync} in sync`; +} + +function printRefreshReport(result: RefreshResult, byCollection: boolean): void { + console.log(""); + if (byCollection) { + console.log("by collection:"); + const entries = Object.entries(result.byCollection).sort(([a], [b]) => + a.localeCompare(b) + ); + for (const [nsid, stats] of entries) { + if (stats.missing === 0 && stats.staleUpdates === 0 && stats.inSync === 0) continue; + console.log(` ${nsid}`); + console.log(` ${formatStats(stats)}`); + } + console.log(""); + } + console.log("total:"); + console.log(` ${formatStats(result.total)}`); + console.log(` ${result.usersScanned} users scanned` + + (result.usersFailed ? `, ${result.usersFailed} failed` : "") + + ` in ${(result.elapsedMs / 1000).toFixed(1)}s`); + console.log(` (ignore window: ${(result.ignoreWindowMs / 1000).toFixed(0)}s)`); +} + +async function main(): Promise { + const opts = parseArgs(process.argv); + + if (opts.cmd === "help") { + process.stdout.write(USAGE); + return 0; + } + + if (opts.cmd === "backfill") { + const configPath = resolveConfigPath(opts); + if (!configPath) return 1; + const config = await loadConfig(configPath); + await backfillAll({ + config, + remote: opts.remote, + binding: opts.binding, + concurrency: opts.concurrency ?? 100, + }); + return 0; + } + + if (opts.cmd === "refresh") { + const configPath = resolveConfigPath(opts); + if (!configPath) return 1; + const config = await loadConfig(configPath); + const result = await refresh({ + config, + remote: opts.remote, + binding: opts.binding, + concurrency: opts.concurrency ?? 50, + ignoreWindowMs: opts.ignoreWindowMs, + }); + printRefreshReport(result, opts.byCollection); + return 0; + } + + console.error(USAGE); + return 1; +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error(err); + process.exit(1); + } +); diff --git a/packages/contrail/src/contrail.ts b/packages/contrail/src/contrail.ts index 38f04c1..5f0fb72 100644 --- a/packages/contrail/src/contrail.ts +++ b/packages/contrail/src/contrail.ts @@ -5,8 +5,10 @@ import { queryRecords } from "./core/db/records"; import type { QueryOptions, SortOption } from "./core/db/records"; import { runIngestCycle, createIngestState } from "./core/jetstream"; import type { IngestState } from "./core/jetstream"; -import { discoverDIDs, backfillAll } from "./core/backfill"; +import { discoverDIDs, backfillPending } from "./core/backfill"; import type { BackfillAllOptions, BackfillProgress } from "./core/backfill"; +import { refresh as runRefresh } from "./core/refresh"; +import type { RefreshOptions, RefreshResult } from "./core/refresh"; import { processNotifyUris } from "./core/router/notify"; import type { NotifyResult } from "./core/router/notify"; import { runPersistent as runPersistentIngestion } from "./core/persistent"; @@ -114,20 +116,94 @@ export class Contrail { options?: BackfillAllOptions, db?: Database ): Promise { - return backfillAll(this.getDb(db), this.config, options); + return backfillPending(this.getDb(db), this.config, options); } - /** Discover + backfill in one call. */ - async sync( + /** Discover every DID with records in the configured collections, then + * backfill their history. Logs progress via `config.logger` — supply + * `onProgress` to take over output, or pass a no-op logger in the config + * to silence the defaults. */ + async backfillAll( options?: BackfillAllOptions, db?: Database ): Promise<{ discovered: number; backfilled: number }> { const d = this.getDb(db); + const logger = this.config.logger; + const startedAt = Date.now(); + + logger?.log?.("discovering users…"); const discovered = await this.discover(d); - const backfilled = await this.backfill(options, d); + logger?.log?.(` discovered ${discovered.length} users`); + + // Wrap the call with a throttled default progress logger when the + // caller hasn't supplied their own. Throttle at 2s so we don't spam in + // fast/local runs; final summary always prints. + let effective = options; + if (!options?.onProgress) { + let lastLogAt = 0; + effective = { + ...options, + onProgress: ({ records, usersComplete, usersTotal, usersFailed }) => { + const now = Date.now(); + if (now - lastLogAt < 2_000) return; + lastLogAt = now; + const failStr = usersFailed > 0 ? `, ${usersFailed} failed` : ""; + logger?.log?.( + ` ${records} records | ${usersComplete}/${usersTotal} users${failStr}` + ); + }, + }; + } + + logger?.log?.("backfilling…"); + const backfilled = await this.backfill(effective, d); + const elapsedS = ((Date.now() - startedAt) / 1000).toFixed(1); + logger?.log?.( + ` done: ${backfilled} records across ${discovered.length} users in ${elapsedS}s` + ); return { discovered: discovered.length, backfilled }; } + /** Fresh sweep: re-walk every known DID's PDS, compare each record against + * our DB, count anything missing or stale (outside the ignore window). + * Apply the deltas. Returns stats per-collection + totals. + * + * Progress logs via `config.logger` unless `onProgress` is supplied. */ + async refresh( + options?: RefreshOptions, + db?: Database + ): Promise { + const d = this.getDb(db); + const logger = this.config.logger; + + let effective = options; + if (!options?.onProgress) { + let lastLogAt = 0; + effective = { + ...options, + onProgress: ({ usersComplete, usersTotal, usersFailed, recordsScanned }) => { + const now = Date.now(); + if (now - lastLogAt < 2_000) return; + lastLogAt = now; + const failStr = usersFailed > 0 ? `, ${usersFailed} failed` : ""; + logger?.log?.( + ` ${recordsScanned} records scanned | ${usersComplete}/${usersTotal} users${failStr}` + ); + }, + }; + } + + logger?.log?.("refreshing…"); + const result = await runRefresh(d, this.config, effective); + const elapsedS = (result.elapsedMs / 1000).toFixed(1); + logger?.log?.( + ` done: ${result.total.missing} missing, ${result.total.staleUpdates} stale updates ` + + `across ${result.usersScanned} users in ${elapsedS}s` + + (result.usersFailed > 0 ? ` (${result.usersFailed} failed)` : "") + ); + return result; + } + /** Immediately fetch and index specific records from their PDS. */ async notify( uris: string | string[], diff --git a/packages/contrail/src/core/backfill.ts b/packages/contrail/src/core/backfill.ts index d938f68..3d23dc1 100644 --- a/packages/contrail/src/core/backfill.ts +++ b/packages/contrail/src/core/backfill.ts @@ -219,7 +219,7 @@ export interface BackfillAllOptions { onProgress?: (progress: BackfillProgress) => void; } -export async function backfillAll( +export async function backfillPending( db: Database, config: ContrailConfig, options?: BackfillAllOptions diff --git a/packages/contrail/src/core/db/records.ts b/packages/contrail/src/core/db/records.ts index b7ea175..7ac7d8a 100644 --- a/packages/contrail/src/core/db/records.ts +++ b/packages/contrail/src/core/db/records.ts @@ -332,6 +332,9 @@ export async function saveCursor( export interface ExistingRecordInfo { cid: string | null; record: string | null; + /** When the row was last written to our DB (microseconds). Populated + * whenever `lookupExistingRecords` runs, regardless of `includeRecord`. */ + indexed_at: number | null; } /** @@ -358,7 +361,7 @@ export async function lookupExistingRecords( byShort.set(short, uris); } - const selectCols = includeRecord ? "uri, cid, record" : "uri, cid"; + const selectCols = includeRecord ? "uri, cid, record, indexed_at" : "uri, cid, indexed_at"; for (const [short, uris] of byShort) { const table = recordsTableName(short); for (let i = 0; i < uris.length; i += 50) { @@ -367,11 +370,17 @@ export async function lookupExistingRecords( const rows = await db .prepare(`SELECT ${selectCols} FROM ${table} WHERE uri IN (${placeholders})`) .bind(...chunk) - .all<{ uri: string; cid: string | null; record?: string | null }>(); + .all<{ + uri: string; + cid: string | null; + record?: string | null; + indexed_at: number | null; + }>(); for (const row of rows.results ?? []) { result.set(row.uri, { cid: row.cid, record: includeRecord ? (row.record ?? null) : null, + indexed_at: row.indexed_at ?? null, }); } } diff --git a/packages/contrail/src/core/refresh.ts b/packages/contrail/src/core/refresh.ts new file mode 100644 index 0000000..aacedaa --- /dev/null +++ b/packages/contrail/src/core/refresh.ts @@ -0,0 +1,266 @@ +/** + * Fresh refresh: re-walk every known DID's PDS for every configured collection + * and reconcile against what's in our DB. Unlike `backfillPending`, this + * ignores the `backfills` state machine — it's a "check what we might have + * missed" pass, not a resumable bulk load. + * + * Two categories of delta are counted: + * - missing — the PDS has a record we don't + * - staleUpdates — we have the same URI but a different CID, *and* our + * copy's `indexed_at` is older than `ignoreWindowMs` + * + * The ignore window exists because Jetstream can run ~seconds behind the + * PDS; without the window, "in-sync but racy" writes would show up as + * misses every run. Records inside the window are still applied (they + * might be legit updates), just not counted toward stats. + * + * Typical uses: + * - dev: "I ran backfillAll on Monday, haven't touched it for a week, + * how much did jetstream miss?" + * - prod: "we had jetstream outage yesterday, what did we drop?" + */ +import { type Did, type Nsid } from "@atcute/lexicons"; +import { isDid, isNsid } from "@atcute/lexicons/syntax"; + +import type { Client } from "@atcute/client"; +import type { ContrailConfig, Database, IngestEvent } from "./types.js"; +import { applyEvents, lookupExistingRecords } from "./db/records.js"; +import { getClient } from "./client.js"; + +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 10_000; + +async function withTimeout(fn: () => Promise, ms: number): Promise { + return Promise.race([ + fn(), + new Promise((_, rej) => + setTimeout(() => rej(new Error(`timeout after ${ms}ms`)), ms) + ), + ]); +} + +export interface CollectionStats { + /** Record exists on PDS but was absent from our DB. */ + missing: number; + /** Record exists in our DB with a different CID than the PDS, and our + * copy was written before the ignore window. */ + staleUpdates: number; + /** Record is present and matches (same CID, or within ignore window). */ + inSync: number; +} + +export interface RefreshProgress { + usersComplete: number; + usersTotal: number; + usersFailed: number; + recordsScanned: number; +} + +export interface RefreshResult { + /** Per-NSID stats. */ + byCollection: Record; + /** Sum across every NSID. */ + total: CollectionStats; + usersScanned: number; + usersFailed: number; + /** Effective ignore window used for classification, in ms. */ + ignoreWindowMs: number; + /** Wall-clock runtime, in ms. */ + elapsedMs: number; +} + +export interface RefreshOptions { + /** How many DIDs to fan out against in parallel. Default: 50. */ + concurrency?: number; + /** Records whose local `indexed_at` is within this window of `now` are + * still upserted but excluded from `staleUpdates` counts — guards + * against jetstream being briefly behind the PDS. Default: 60_000 ms. */ + ignoreWindowMs?: number; + /** Override which NSIDs to walk. Default: every `config.collections[*].collection`. */ + nsids?: string[]; + /** Optional progress callback (fires per completed DID). */ + onProgress?: (p: RefreshProgress) => void; + /** Max attempts per listRecords request. Default: 3. */ + maxRetries?: number; + /** Per-request timeout in ms. Default: 10000. */ + requestTimeout?: number; +} + +function emptyStats(): CollectionStats { + return { missing: 0, staleUpdates: 0, inSync: 0 }; +} + +export async function refresh( + db: Database, + config: ContrailConfig, + options?: RefreshOptions +): Promise { + const concurrency = options?.concurrency ?? 50; + const ignoreWindowMs = options?.ignoreWindowMs ?? 60_000; + const requestTimeout = options?.requestTimeout ?? REQUEST_TIMEOUT_MS; + const maxRetries = options?.maxRetries ?? 3; + const startedAt = Date.now(); + + // Default to every configured collection NSID. Profiles are already + // included because `resolveConfig` adds them to `config.collections`. + const nsids = + options?.nsids ?? + Object.values(config.collections).map((c) => c.collection); + + const byCollection: Record = {}; + for (const nsid of nsids) byCollection[nsid] = emptyStats(); + const total: CollectionStats = emptyStats(); + + // Known DIDs = every author we've ever written for. `backfills` is a + // superset (it also includes failed/pending users that we never got + // records from), which is actually what we want — if we tried and + // failed before, we might succeed now. + const didRows = await db + .prepare("SELECT DISTINCT did FROM backfills") + .all<{ did: string }>(); + const dids = (didRows.results ?? []) + .map((r) => r.did) + .filter((d) => isDid(d)); + + const usersTotal = dids.length; + let usersComplete = 0; + let usersFailed = 0; + let recordsScanned = 0; + + const ignoreBeforeUs = (Date.now() - ignoreWindowMs) * 1000; + + const processDid = async (did: string): Promise => { + let client: Client; + try { + client = await withTimeout( + () => getClient(did as Did, db), + requestTimeout + ); + } catch { + usersFailed++; + return; + } + + for (const nsid of nsids) { + if (!isNsid(nsid)) continue; + let cursor: string | undefined; + while (true) { + let pageRecords: Array<{ uri: string; cid: string; value: unknown }>; + let nextCursor: string | undefined; + try { + // Retry listRecords: transient PDS failures are expected during refresh + let attempt = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const res = await withTimeout( + () => + client.get("com.atproto.repo.listRecords", { + params: { + repo: did as Did, + collection: nsid as Nsid, + limit: PAGE_SIZE, + cursor, + }, + }), + requestTimeout + ); + if (!res.ok) { + // 400s on a collection the user doesn't have are fine; stop + // paging this collection for this user. + pageRecords = []; + nextCursor = undefined; + break; + } + pageRecords = res.data.records; + nextCursor = res.data.cursor ?? undefined; + break; + } catch (err) { + if (attempt >= maxRetries) throw err; + attempt++; + await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); + } + } + } catch { + // Give up on this collection for this user; keep going. + break; + } + + if (pageRecords.length === 0) break; + + const now = Date.now(); + const events: IngestEvent[] = pageRecords.map((r) => ({ + uri: r.uri, + did, + collection: nsid, + rkey: r.uri.split("/").pop()!, + operation: "create" as const, + cid: r.cid, + record: JSON.stringify(r.value), + time_us: now * 1000, + indexed_at: now * 1000, + })); + + const existing = await lookupExistingRecords( + db, + events.map((e) => ({ uri: e.uri, collection: e.collection })), + false, + config + ); + + for (const ev of events) { + const ex = existing.get(ev.uri); + if (!ex) { + byCollection[nsid].missing++; + total.missing++; + } else if (ex.cid !== ev.cid) { + const inWindow = + ex.indexed_at !== null && ex.indexed_at >= ignoreBeforeUs; + if (inWindow) { + byCollection[nsid].inSync++; + total.inSync++; + } else { + byCollection[nsid].staleUpdates++; + total.staleUpdates++; + } + } else { + byCollection[nsid].inSync++; + total.inSync++; + } + } + + // Upsert everything — even records "inside the ignore window" + // might genuinely have a new CID; we just don't count them as a + // miss-signal. Skip feed fanout since this is a catch-up, not a + // user-visible write. + await applyEvents(db, events, config, { skipFeedFanout: true }); + recordsScanned += events.length; + + cursor = nextCursor; + if (!cursor) break; + } + } + + usersComplete++; + options?.onProgress?.({ + usersComplete, + usersTotal, + usersFailed, + recordsScanned, + }); + }; + + for (let i = 0; i < dids.length; i += concurrency) { + const batch = dids.slice(i, i + concurrency); + await Promise.allSettled(batch.map(processDid)); + } + + return { + byCollection, + total, + usersScanned: usersComplete, + usersFailed, + ignoreWindowMs, + elapsedMs: Date.now() - startedAt, + }; +} diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index feff577..6665862 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -26,6 +26,12 @@ export { resolveConfig, validateConfig } from "./core/types"; export type { QueryOptions, SortOption } from "./core/db/records"; export type { BackfillProgress, BackfillAllOptions } from "./core/backfill"; export type { NotifyResult } from "./core/router/notify"; +export type { + RefreshOptions, + RefreshResult, + RefreshProgress, + CollectionStats, +} from "./core/refresh"; export { runPersistent } from "./core/persistent"; export type { PersistentIngestOptions } from "./core/persistent"; diff --git a/packages/contrail/src/workers/backfill.ts b/packages/contrail/src/workers/backfill.ts new file mode 100644 index 0000000..5f0ae6c --- /dev/null +++ b/packages/contrail/src/workers/backfill.ts @@ -0,0 +1,97 @@ +/** + * Wrangler-backed helpers for Cloudflare Workers deployments: + * + * - `backfillAll` — one-time bulk load from scratch (uses the `backfills` + * state table to resume across runs). + * - `refresh` — reconcile every known DID's PDS against our DB, report + * what's missing or stale. Use after outages or long idle periods. + * + * Both dynamically import `wrangler` (optional peer dep) and wire it to + * the user's D1 binding, then dispose the proxy on exit. + */ +import { Contrail } from "../contrail.js"; +import type { ContrailConfig, Database } from "../core/types.js"; +import type { BackfillAllOptions } from "../core/backfill.js"; +import type { RefreshOptions, RefreshResult } from "../core/refresh.js"; + +interface WranglerCommon { + config: ContrailConfig; + /** Use production D1 bindings. Equivalent to + * `getPlatformProxy({ environment: "production" })`. */ + remote?: boolean; + /** Name of the D1 binding in wrangler.jsonc. Default: `"DB"`. */ + binding?: string; +} + +export interface BackfillAllViaWranglerOptions extends WranglerCommon { + /** Passed through to `contrail.backfillAll()`. Default: 100. */ + concurrency?: number; + /** Override the built-in progress logging. */ + onProgress?: BackfillAllOptions["onProgress"]; +} + +export interface RefreshViaWranglerOptions extends WranglerCommon { + /** Passed through to `contrail.refresh()`. Default: 50. */ + concurrency?: number; + /** Ignore-window for stale-update classification, in ms. Default: 60_000. */ + ignoreWindowMs?: number; + /** Override the built-in progress logging. */ + onProgress?: RefreshOptions["onProgress"]; +} + +async function withWrangler( + opts: WranglerCommon, + fn: (contrail: Contrail, db: Database) => Promise +): Promise { + // Dynamic import so wrangler is only required when these helpers are + // actually called — keeps the main package usable in runtimes without it. + const { getPlatformProxy } = await import("wrangler"); + const binding = opts.binding ?? "DB"; + + const { env, dispose } = await getPlatformProxy({ + environment: opts.remote ? "production" : undefined, + }); + + const db = (env as Record)[binding] as Database | undefined; + if (!db) { + await dispose(); + throw new Error( + `No binding named "${binding}" in wrangler env. Add a d1_databases ` + + `entry to wrangler.jsonc, or pass { binding: "..." }.` + ); + } + + try { + const contrail = new Contrail(opts.config); + await contrail.init(db); + return await fn(contrail, db); + } finally { + await dispose(); + } +} + +export async function backfillAll( + opts: BackfillAllViaWranglerOptions +): Promise<{ discovered: number; backfilled: number }> { + return withWrangler(opts, (contrail, db) => + contrail.backfillAll( + { concurrency: opts.concurrency ?? 100, onProgress: opts.onProgress }, + db + ) + ); +} + +export async function refresh( + opts: RefreshViaWranglerOptions +): Promise { + return withWrangler(opts, (contrail, db) => + contrail.refresh( + { + concurrency: opts.concurrency ?? 50, + ignoreWindowMs: opts.ignoreWindowMs, + onProgress: opts.onProgress, + }, + db + ) + ); +} diff --git a/packages/contrail/tsup.config.ts b/packages/contrail/tsup.config.ts index e1ce7fa..fc11602 100644 --- a/packages/contrail/tsup.config.ts +++ b/packages/contrail/tsup.config.ts @@ -6,11 +6,14 @@ export default defineConfig({ "src/server.ts", "src/adapters/sqlite.ts", "src/adapters/postgres.ts", + "src/workers/backfill.ts", + "src/cli.ts", + "src/cli-config.ts", ], format: ["esm"], dts: true, sourcemap: true, clean: true, tsconfig: "tsconfig.build.json", - external: ["node:sqlite", "pg"], + external: ["node:sqlite", "pg", "wrangler"], }); diff --git a/packages/lexicons/package.json b/packages/lexicons/package.json index 547d36f..ab4f65f 100644 --- a/packages/lexicons/package.json +++ b/packages/lexicons/package.json @@ -46,10 +46,10 @@ "test": "vitest run" }, "dependencies": { - "@atcute/lex-cli": "^2.5.3" + "@atcute/lex-cli": "^2.5.3", + "@atmo-dev/contrail": "workspace:*" }, "devDependencies": { - "@atmo-dev/contrail": "workspace:*", "@types/node": "^25.5.0", "tsup": "^8.5.0", "typescript": "^5.7.3", diff --git a/packages/lexicons/src/cli.ts b/packages/lexicons/src/cli.ts index fb29109..7c42bff 100644 --- a/packages/lexicons/src/cli.ts +++ b/packages/lexicons/src/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node /** - * contrail-lex — CLI for generating lexicons from a Contrail config and - * (optionally) typegenning them via @atcute/lex-cli. + * contrail-lex — CLI for generating, typegenning, and publishing lexicons. * * Usage from a consumer project: * @@ -9,18 +8,24 @@ * contrail-lex pull # wraps `lex-cli pull` * contrail-lex types # wraps `lex-cli generate` * contrail-lex all [--no-types] [--config ...] # generate → pull → types + * contrail-lex publish [handle] [password] # publish lexicons to a PDS * - * Looks for a user config via --config , else ./contrail.config.ts, - * ./app/config.ts, ./src/lib/contrail/config.ts (first match wins). The - * config file must default-export or named-export `config: ContrailConfig`. + * Config auto-detects at ./contrail.config.ts, ./app/config.ts, or + * ./src/lib/contrail/config.ts (first match wins); override with --config. + * The file must default-export or named-export `config: ContrailConfig`. */ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve, join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { + findConfigFile, + loadConfig, + CONFIG_CANDIDATES_MESSAGE, +} from "@atmo-dev/contrail/cli-config"; import { generateLexicons } from "./generate.js"; +import { publishLexicons } from "./publish.js"; -type Subcommand = "generate" | "pull" | "types" | "all" | "help"; +type Subcommand = "generate" | "pull" | "types" | "all" | "publish" | "help"; const USAGE = `contrail-lex [options] @@ -29,14 +34,20 @@ Subcommands: pull Pull external lexicons (wraps \`lex-cli pull\`) types Generate TS types from lexicon JSON (wraps \`lex-cli generate\`) all generate → pull → generate → pull → types (full pipeline) + publish Publish lexicon JSON as com.atproto.lexicon.schema records on a PDS help Print this message Options: - --config Path to Contrail config file (TS or JS, default export - or named \`config\`). Default: auto-detect. - --root Project root (where lexicons/ and node_modules/ live). - Default: CWD. - --no-types In \`all\`, skip the final type-generation step. + --config Path to Contrail config file. Default: auto-detect. + --root Project root (where lexicons/ and node_modules/ live). Default: CWD. + --no-types In \`all\`, skip the final type-generation step. + --generated-dir For \`publish\`: dir of JSON to publish. Default: lexicons/generated. + --skip-confirm For \`publish\`: skip the "do you control these zones?" prompt. + --dry-run For \`publish\`: print what would be published + the DNS records needed, no writes. + +Environment variables (for \`publish\`): + LEXICON_ACCOUNT_IDENTIFIER handle or DID (falls back to positional arg 1) + LEXICON_ACCOUNT_PASSWORD app password (falls back to positional arg 2) `; function parseArgs(argv: string[]): { @@ -44,54 +55,35 @@ function parseArgs(argv: string[]): { config?: string; root: string; withTypes: boolean; + generatedDir: string; + skipConfirm: boolean; + dryRun: boolean; + positional: string[]; } { const args = argv.slice(2); const cmd = (args.shift() ?? "help") as Subcommand; let config: string | undefined; let root = process.cwd(); let withTypes = true; + let generatedDir = "lexicons/generated"; + let skipConfirm = false; + let dryRun = false; + const positional: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "--config") config = args[++i]; else if (a === "--root") root = args[++i]; else if (a === "--no-types") withTypes = false; - else if (a === "-h" || a === "--help") return { cmd: "help", root, withTypes: true }; + else if (a === "--generated-dir") generatedDir = args[++i]; + else if (a === "--skip-confirm") skipConfirm = true; + else if (a === "--dry-run") dryRun = true; + else if (a === "-h" || a === "--help") + return { cmd: "help", root, withTypes: true, generatedDir, skipConfirm, dryRun, positional }; + else positional.push(a); } - return { cmd, config, root, withTypes }; + return { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, positional }; } -function findConfigFile(root: string, explicit?: string): string | null { - if (explicit) { - const p = resolve(root, explicit); - return existsSync(p) ? p : null; - } - const candidates = [ - "contrail.config.ts", - "contrail.config.js", - "app/config.ts", - "src/lib/contrail/config.ts", - ]; - for (const c of candidates) { - const p = join(root, c); - if (existsSync(p)) return p; - } - return null; -} - -async function loadConfig(path: string): Promise { - // TS files are loaded via `tsx` if available; otherwise fall back to the - // Node loader (works for .js). We use dynamic import. - if (path.endsWith(".ts") || path.endsWith(".mts")) { - // `tsx` registers a hook when invoked via `tsx `. If this CLI is - // run through tsx (e.g. `npx tsx contrail-lex ...`), the import below - // will just work. Otherwise we spawn a tsx child below — but dynamic - // import is the preferred path for a programmatic config load. - const mod = await import(pathToFileURL(path).href); - return mod.config ?? mod.default; - } - const mod = await import(pathToFileURL(path).href); - return mod.config ?? mod.default; -} function runLexCli(args: string[], cwd: string): number { const result = spawnSync("npx", ["lex-cli", ...args], { @@ -104,20 +96,55 @@ function runLexCli(args: string[], cwd: string): number { async function cmdGenerate(configPath: string, root: string): Promise { const config = await loadConfig(configPath); - if (!config || typeof config !== "object") { - throw new Error(`Config at ${configPath} did not export a \`config\` object`); - } generateLexicons({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config: config as any, + config, rootDir: root, - outputDir: join(root, "lexicons-generated"), + outputDir: join(root, "lexicons", "generated"), writeRuntimeFiles: true, }); } +async function cmdPublish( + root: string, + generatedDir: string, + positional: string[], + skipConfirm: boolean, + dryRun: boolean +): Promise { + const identifier = positional[0] ?? process.env.LEXICON_ACCOUNT_IDENTIFIER; + const password = positional[1] ?? process.env.LEXICON_ACCOUNT_PASSWORD; + if (!dryRun && (!identifier || !password)) { + console.error( + "missing credentials. pass positional or set\n" + + "LEXICON_ACCOUNT_IDENTIFIER and LEXICON_ACCOUNT_PASSWORD.\n" + + "(not required with --dry-run)" + ); + return 1; + } + const dir = resolve(root, generatedDir); + if (!existsSync(dir)) { + console.error(`generated lexicons dir not found: ${dir}`); + return 1; + } + const result = await publishLexicons({ + generatedDir: dir, + identifier, + password, + skipConfirm, + dryRun, + }); + if (!dryRun) { + console.log( + `published ${result.published} lexicon(s)` + + (result.failed.length ? `, ${result.failed.length} failed` : "") + ); + } + return result.failed.length ? 1 : 0; +} + async function main(): Promise { - const { cmd, config, root, withTypes } = parseArgs(process.argv); + const { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, positional } = + parseArgs(process.argv); if (cmd === "help") { process.stdout.write(USAGE); @@ -126,12 +153,14 @@ async function main(): Promise { if (cmd === "pull") return runLexCli(["pull"], root); if (cmd === "types") return runLexCli(["generate"], root); + if (cmd === "publish") + return cmdPublish(root, generatedDir, positional, skipConfirm, dryRun); const configPath = findConfigFile(root, config); if (!configPath) { console.error( "Could not find a Contrail config. Pass --config or place one at\n" + - " contrail.config.ts | app/config.ts | src/lib/contrail/config.ts" + ` ${CONFIG_CANDIDATES_MESSAGE}` ); return 1; } diff --git a/packages/lexicons/src/generate.ts b/packages/lexicons/src/generate.ts index 276798d..d4e6f48 100644 --- a/packages/lexicons/src/generate.ts +++ b/packages/lexicons/src/generate.ts @@ -123,8 +123,8 @@ interface ReferenceDef { export function generateLexicons(options: GenerateOptions): Record { const { config, rootDir, outputDir, quiet } = options; const lexiconDirs = options.lexiconDirs ?? [ - join(rootDir, "lexicons"), - join(rootDir, "lexicons-pulled"), + join(rootDir, "lexicons", "custom"), + join(rootDir, "lexicons", "pulled"), ]; const log = quiet ? () => {} : console.log; @@ -1035,7 +1035,7 @@ export function generateLexicons(options: GenerateOptions): Record/com.atproto.lexicon.schema/`. * * Exposed as a library function so downstream deployments can wrap it with a - * one-line script pointing at their own `lexicons-generated/` directory. + * one-line script pointing at their own `lexicons/generated/` directory. */ import { readFileSync, readdirSync, statSync } from "node:fs"; @@ -110,12 +110,15 @@ export function nsidRootDomain(nsid: string): string { export interface PublishOptions { /** Directory to walk for `*.json` lexicon files. */ generatedDir: string; - /** PDS account handle or DID. */ - identifier: string; - /** App password (or equivalent on a non-bsky PDS). */ - password: string; + /** PDS account handle or DID. Not required when `dryRun` is true. */ + identifier?: string; + /** App password (or equivalent on a non-bsky PDS). Not required when `dryRun` is true. */ + password?: string; /** Skip the interactive "do you control these zones?" prompt. */ skipConfirm?: boolean; + /** Print what would be published + the DNS records that would be required, + * then return without logging in or writing anything to the PDS. */ + dryRun?: boolean; } export async function publishLexicons(opts: PublishOptions): Promise<{ @@ -139,7 +142,14 @@ export async function publishLexicons(opts: PublishOptions): Promise<{ } const sortedAuthorities = [...authorities].sort(); - console.log(`About to publish ${lexicons.length} lexicons from ${opts.generatedDir}.\n`); + const header = opts.dryRun + ? `[dry-run] Would publish ${lexicons.length} lexicons from ${opts.generatedDir}:` + : `About to publish ${lexicons.length} lexicons from ${opts.generatedDir}.`; + console.log(`${header}\n`); + if (opts.dryRun) { + for (const lex of lexicons) console.log(` ${lex.id}`); + console.log(""); + } console.log( `⚠ Do you control ${rootDomains.size === 1 ? "the DNS zone" : "these DNS zones"} below?` ); @@ -154,6 +164,29 @@ export async function publishLexicons(opts: PublishOptions): Promise<{ " requires a valid TXT record.\n" ); + if (opts.dryRun) { + console.log("─────────────────────────────────────────────────"); + console.log("DNS TXT records that would be needed for NSID resolution"); + console.log("─────────────────────────────────────────────────"); + console.log( + "Atproto NSID resolution does NOT walk up — each distinct authority\n" + + "needs its own TXT record. One record per unique authority below.\n" + + "(DID would be filled in at publish time, once logged in.)\n" + ); + for (const authority of sortedAuthorities) { + console.log(` host: _lexicon.${authority}`); + console.log(` value: did=`); + console.log(""); + } + console.log(`Total: ${sortedAuthorities.length} DNS TXT record(s) to add.`); + console.log("\n[dry-run] No PDS login or writes performed."); + return { published: 0, failed: [], authorities: sortedAuthorities, session: null as never }; + } + + if (!opts.identifier || !opts.password) { + throw new Error("`identifier` and `password` are required when `dryRun` is false"); + } + if (!opts.skipConfirm) { const rl = createInterface({ input, output }); const answer = (await rl.question("Continue? [y/N] ")).trim().toLowerCase(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89a1941..acce499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,20 +25,14 @@ importers: version: link:../../packages/contrail devDependencies: '@atcute/lex-cli': - specifier: ^2.5.3 + specifier: ^2.8.1 version: 2.8.1 - '@atcute/lexicon-doc': - specifier: ^2.1.2 - version: 2.2.0 '@atmo-dev/contrail-lexicons': specifier: workspace:* version: link:../../packages/lexicons '@cloudflare/workers-types': specifier: ^4.20250124.0 version: 4.20260424.1 - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -62,13 +56,13 @@ importers: version: link:../../packages/sync '@foxui/core': specifier: ^0.9.1 - version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/social': specifier: ^0.8.10 - version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/time': specifier: ^0.8.5 - version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) valibot: specifier: ^1.3.1 version: 1.3.1(typescript@6.0.3) @@ -111,22 +105,22 @@ importers: version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@sveltejs/adapter-cloudflare': specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) '@sveltejs/kit': specifier: ^2.55.0 - version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@sveltejs/vite-plugin-svelte': specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) bits-ui: specifier: ^2.16.4 - version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) eslint: specifier: ^10.1.0 version: 10.2.1(jiti@2.6.1) @@ -168,7 +162,7 @@ importers: version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) vite: specifier: ^8.0.3 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) wrangler: specifier: ^4.78.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) @@ -245,13 +239,13 @@ importers: version: link:../../packages/lexicons '@foxui/core': specifier: ^0.9.1 - version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/social': specifier: ^0.8.10 - version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@foxui/time': specifier: ^0.8.5 - version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) valibot: specifier: ^1.3.1 version: 1.3.1(typescript@6.0.3) @@ -294,22 +288,22 @@ importers: version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@sveltejs/adapter-cloudflare': specifier: ^7.2.8 - version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) '@sveltejs/kit': specifier: ^2.55.0 - version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@sveltejs/vite-plugin-svelte': specifier: ^7.0.0 - version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) bits-ui: specifier: ^2.16.4 - version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) eslint: specifier: ^10.1.0 version: 10.2.1(jiti@2.6.1) @@ -351,7 +345,7 @@ importers: version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) vite: specifier: ^8.0.3 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) wrangler: specifier: ^4.78.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) @@ -385,6 +379,9 @@ importers: hono: specifier: ^4.12.8 version: 4.12.15 + jiti: + specifier: ^2.4.0 + version: 2.6.1 devDependencies: '@cloudflare/workers-types': specifier: ^4.20250124.0 @@ -400,41 +397,44 @@ importers: version: 8.20.0 tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + wrangler: + specifier: ^4.63.0 + version: 4.84.1(@cloudflare/workers-types@4.20260424.1) packages/lexicons: dependencies: '@atcute/lex-cli': specifier: ^2.5.3 version: 2.8.1 - devDependencies: '@atmo-dev/contrail': specifier: workspace:* version: link:../contrail + devDependencies: '@types/node': specifier: ^25.5.0 version: 25.6.0 tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) packages/sync: devDependencies: tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -1105,89 +1105,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1349,36 +1365,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -1440,66 +1462,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -1621,24 +1656,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -2551,24 +2590,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4152,14 +4195,14 @@ snapshots: '@fontsource-variable/geist@5.2.8': {} - '@foxui/core@0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/core@0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: '@fontsource-variable/geist': 5.2.8 '@fontsource-variable/geist-mono': 5.2.7 '@jis3r/icons': 2.7.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) '@lucide/svelte': 1.9.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) '@number-flow/svelte': 0.4.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) clsx: 2.1.1 dompurify: 3.4.1 mode-watcher: 1.1.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) @@ -4176,15 +4219,15 @@ snapshots: - react-dom - vue - '@foxui/social@0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/social@0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: '@atcute/bluesky': 3.3.3 '@atcute/bluesky-richtext-segmenter': 3.0.0 - '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - '@foxui/text': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) - '@foxui/time': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/text': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/time': 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@use-gesture/vanilla': 10.3.1 - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) emoji-picker-element: 1.29.1 hls.js: 1.6.16 is-emoji-supported: 0.0.5 @@ -4201,10 +4244,10 @@ snapshots: - react-dom - vue - '@foxui/text@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/text@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: '@floating-ui/dom': 1.7.6 - '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@tiptap/extension-bubble-menu': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-code-block-lowlight': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(highlight.js@11.11.1)(lowlight@3.3.0) @@ -4224,7 +4267,7 @@ snapshots: '@tiptap/pm': 3.22.4 '@tiptap/starter-kit': 3.22.4 '@tiptap/suggestion': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) lowlight: 3.3.0 state: link:@tiptap/pm/state svelte: 5.55.5(@typescript-eslint/types@8.59.0) @@ -4240,11 +4283,11 @@ snapshots: - react-dom - vue - '@foxui/time@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': + '@foxui/time@0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4)': dependencies: - '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/core': 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) '@number-flow/svelte': 0.4.0(svelte@5.55.5(@typescript-eslint/types@8.59.0)) - bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) svelte: 5.55.5(@typescript-eslint/types@8.59.0) tailwindcss: 4.2.4 transitivePeerDependencies: @@ -4616,18 +4659,18 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1))': + '@sveltejs/adapter-cloudflare@7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1))': dependencies: '@cloudflare/workers-types': 4.20260424.1 - '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) worktop: 0.8.0-next.18 wrangler: 4.84.1(@cloudflare/workers-types@4.20260424.1) - '@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -4639,18 +4682,18 @@ snapshots: set-cookie-parser: 3.1.0 sirv: 3.0.2 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) optionalDependencies: typescript: 6.0.3 - '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) - vitefu: 1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) + vitefu: 1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@swc/helpers@0.5.21': dependencies: @@ -4722,12 +4765,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) '@tiptap/core@3.22.4(@tiptap/pm@3.22.4)': dependencies: @@ -5080,13 +5123,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -5151,15 +5194,15 @@ snapshots: dependencies: is-windows: 1.0.2 - bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): + bits-ui@2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/dom': 1.7.6 '@internationalized/date': 3.12.1 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) svelte: 5.55.5(@typescript-eslint/types@8.59.0) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -5885,14 +5928,13 @@ snapshots: optionalDependencies: postcss: 8.5.10 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@1.10.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.10 tsx: 4.21.0 - yaml: 1.10.3 postcss-safe-parser@7.0.1(postcss@8.5.10): dependencies: @@ -6107,14 +6149,14 @@ snapshots: esm-env: 1.2.2 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - runed@0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): + runed@0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.55.5(@typescript-eslint/types@8.59.0) optionalDependencies: - '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@sveltejs/kit': 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) sade@1.8.1: dependencies: @@ -6252,10 +6294,10 @@ snapshots: '@tiptap/pm': 3.22.4 svelte: 5.55.5(@typescript-eslint/types@8.59.0) - svelte-toolbelt@0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + runed: 0.35.1(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) style-to-object: 1.0.14 svelte: 5.55.5(@typescript-eslint/types@8.59.0) transitivePeerDependencies: @@ -6342,7 +6384,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@1.10.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -6353,7 +6395,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@1.10.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.60.2 source-map: 0.7.6 @@ -6433,7 +6475,7 @@ snapshots: optionalDependencies: typescript: 6.0.3 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -6446,16 +6488,15 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - yaml: 1.10.3 - vitefu@1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)): + vitefu@1.1.3(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) - vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)): + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -6472,7 +6513,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@1.10.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 -- 2.51.2 From f8fa6721cfdba93b197826e74a61f3e5cd3ed7c9 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:46:12 +0200 Subject: [PATCH 2/5] align with atproto listRecords and getRecords lexicons --- .changeset/atproto-record-shape.md | 28 ++ .changeset/drop-queryable-autodetect.md | 11 + README.md | 16 +- apps/cloudflare-workers/README.md | 4 +- .../com/example/event/getRecord.json | 169 ++++++++++++ .../com/example/event/listRecords.json | 221 ++++++++++++++++ .../generated/com/example/getCursor.json | 27 ++ .../generated/com/example/getOverview.json | 51 ++++ .../generated/com/example/getProfile.json | 138 ++++++++++ .../generated/com/example/notifyOfUpdate.json | 59 +++++ .../generated/com/example/permissionSet.json | 26 ++ .../lexicons/pulled/README.md | 5 + .../pulled/app/bsky/actor/profile.json | 67 +++++ .../community/lexicon/calendar/event.json | 142 +++++++++++ .../community/lexicon/location/address.json | 40 +++ .../community/lexicon/location/fsq.json | 28 ++ .../community/lexicon/location/geo.json | 27 ++ .../community/lexicon/location/hthree.json | 22 ++ .../tools/atmo/chat/channel/getRecord.json | 35 ++- .../tools/atmo/chat/channel/listRecords.json | 57 ++--- .../tools/atmo/chat/channel/watchRecords.json | 21 +- .../generated/tools/atmo/chat/getProfile.json | 15 +- .../tools/atmo/chat/message/getRecord.json | 35 ++- .../tools/atmo/chat/message/listRecords.json | 48 ++-- .../tools/atmo/chat/message/watchRecords.json | 12 +- .../tools/atmo/chat/server/getRecord.json | 35 ++- .../tools/atmo/chat/server/listRecords.json | 57 ++--- .../tools/atmo/chat/server/watchRecords.json | 21 +- apps/group-chat/src/lib/contrail/client.ts | 8 +- .../src/lib/rooms/profiles.svelte.ts | 4 +- .../src/lib/rooms/realtime.svelte.ts | 6 +- apps/group-chat/src/lib/rooms/watch.svelte.ts | 6 +- apps/group-chat/src/routes/+layout.server.ts | 4 +- apps/group-chat/src/routes/+page.server.ts | 2 +- .../routes/c/[communityDid]/+layout.server.ts | 10 +- .../routes/c/[communityDid]/+layout.svelte | 2 +- .../[communityDid]/[channelKey]/+page.svelte | 8 +- .../generated/rsvp/atmo/event/getRecord.json | 35 ++- .../rsvp/atmo/event/listRecords.json | 59 ++--- .../generated/rsvp/atmo/getProfile.json | 15 +- .../generated/rsvp/atmo/rsvp/getRecord.json | 35 ++- .../generated/rsvp/atmo/rsvp/listRecords.json | 36 +-- .../generated/statusphere/app/getProfile.json | 15 +- .../statusphere/app/permissionSet.json | 2 +- .../statusphere/app/status/getRecord.json | 35 ++- .../statusphere/app/status/listRecords.json | 36 +-- .../src/lib/contrail/client.ts | 8 +- .../src/routes/+page.server.ts | 6 +- docs/01-indexing.md | 47 +--- docs/02-querying.md | 167 ++++++++++++ docs/{02-lexicons.md => 03-lexicons.md} | 0 docs/{03-spaces.md => 04-spaces.md} | 0 docs/{04-communities.md => 05-communities.md} | 2 +- docs/{05-sync.md => 06-sync.md} | 0 docs/{06-examples.md => 07-examples.md} | 0 docs/frameworks/sveltekit-cloudflare.md | 240 ++++++++++++++++++ .../contrail/src/core/router/collection.ts | 4 +- packages/contrail/src/core/router/helpers.ts | 16 +- packages/contrail/src/core/router/profiles.ts | 14 +- packages/contrail/tests/helpers.test.ts | 6 +- packages/lexicons/src/generate.ts | 70 ++--- packages/sync/src/index.ts | 8 +- 62 files changed, 1806 insertions(+), 517 deletions(-) create mode 100644 .changeset/atproto-record-shape.md create mode 100644 .changeset/drop-queryable-autodetect.md create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/getCursor.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/getOverview.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/getProfile.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/notifyOfUpdate.json create mode 100644 apps/cloudflare-workers/lexicons/generated/com/example/permissionSet.json create mode 100644 apps/cloudflare-workers/lexicons/pulled/README.md create mode 100644 apps/cloudflare-workers/lexicons/pulled/app/bsky/actor/profile.json create mode 100644 apps/cloudflare-workers/lexicons/pulled/community/lexicon/calendar/event.json create mode 100644 apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/address.json create mode 100644 apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/fsq.json create mode 100644 apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/geo.json create mode 100644 apps/cloudflare-workers/lexicons/pulled/community/lexicon/location/hthree.json create mode 100644 docs/02-querying.md rename docs/{02-lexicons.md => 03-lexicons.md} (100%) rename docs/{03-spaces.md => 04-spaces.md} (100%) rename docs/{04-communities.md => 05-communities.md} (97%) rename docs/{05-sync.md => 06-sync.md} (100%) rename docs/{06-examples.md => 07-examples.md} (100%) create mode 100644 docs/frameworks/sveltekit-cloudflare.md 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, -- 2.51.2 From 3ee5ed4aec630cd7329589c4c8030f44d7bbe97d Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:05:04 +0200 Subject: [PATCH 3/5] commit --- .changeset/spaces-owner-no-delete-bypass.md | 11 + README.md | 11 +- apps/cloudflare-workers/README.md | 4 +- apps/rsvp-atmo/Dockerfile | 14 - apps/rsvp-atmo/lexicons/custom/README.md | 13 - .../generated/rsvp/atmo/community/adopt.json | 56 --- .../generated/rsvp/atmo/community/defs.json | 185 -------- .../generated/rsvp/atmo/community/delete.json | 47 --- .../rsvp/atmo/community/deleteRecord.json | 65 --- .../rsvp/atmo/community/getHealth.json | 49 --- .../generated/rsvp/atmo/community/list.json | 38 -- .../generated/rsvp/atmo/community/mint.json | 51 --- .../rsvp/atmo/community/putRecord.json | 73 ---- .../generated/rsvp/atmo/community/reauth.json | 60 --- .../rsvp/atmo/community/space/create.json | 58 --- .../rsvp/atmo/community/space/delete.json | 47 --- .../atmo/community/space/deleteRecord.json | 59 --- .../rsvp/atmo/community/space/grant.json | 60 --- .../atmo/community/space/listMembers.json | 58 --- .../rsvp/atmo/community/space/putRecord.json | 71 ---- .../rsvp/atmo/community/space/resync.json | 47 --- .../rsvp/atmo/community/space/revoke.json | 55 --- .../atmo/community/space/setAccessLevel.json | 60 --- .../generated/rsvp/atmo/event/getRecord.json | 284 ------------- .../rsvp/atmo/event/listRecords.json | 394 ------------------ .../generated/rsvp/atmo/getCursor.json | 27 -- .../generated/rsvp/atmo/getOverview.json | 51 --- .../generated/rsvp/atmo/getProfile.json | 138 ------ .../generated/rsvp/atmo/invite/create.json | 87 ---- .../generated/rsvp/atmo/invite/defs.json | 73 ---- .../generated/rsvp/atmo/invite/list.json | 52 --- .../generated/rsvp/atmo/invite/redeem.json | 60 --- .../generated/rsvp/atmo/invite/revoke.json | 51 --- .../generated/rsvp/atmo/notifyOfUpdate.json | 59 --- .../generated/rsvp/atmo/permissionSet.json | 64 --- .../generated/rsvp/atmo/rsvp/getRecord.json | 233 ----------- .../generated/rsvp/atmo/rsvp/listRecords.json | 287 ------------- .../generated/rsvp/atmo/space/addMember.json | 52 --- .../rsvp/atmo/space/createSpace.json | 55 --- .../generated/rsvp/atmo/space/defs.json | 210 ---------- .../rsvp/atmo/space/deleteRecord.json | 56 --- .../generated/rsvp/atmo/space/getBlob.json | 42 -- .../generated/rsvp/atmo/space/getRecord.json | 63 --- .../generated/rsvp/atmo/space/getSpace.json | 49 --- .../generated/rsvp/atmo/space/leaveSpace.json | 48 --- .../generated/rsvp/atmo/space/listBlobs.json | 57 --- .../rsvp/atmo/space/listMembers.json | 48 --- .../rsvp/atmo/space/listRecords.json | 74 ---- .../generated/rsvp/atmo/space/listSpaces.json | 62 --- .../generated/rsvp/atmo/space/putRecord.json | 68 --- .../rsvp/atmo/space/removeMember.json | 55 --- .../generated/rsvp/atmo/space/uploadBlob.json | 50 --- .../generated/rsvp/atmo/spaceExt/whoami.json | 55 --- apps/rsvp-atmo/lexicons/pulled/README.md | 5 - .../pulled/app/bsky/actor/profile.json | 67 --- .../community/lexicon/calendar/event.json | 142 ------- .../community/lexicon/calendar/rsvp.json | 43 -- .../community/lexicon/location/address.json | 40 -- .../community/lexicon/location/fsq.json | 28 -- .../community/lexicon/location/geo.json | 27 -- .../community/lexicon/location/hthree.json | 22 - apps/rsvp-atmo/package.json | 35 -- apps/rsvp-atmo/scripts/clean.ts | 26 -- apps/rsvp-atmo/scripts/dev.ts | 39 -- apps/rsvp-atmo/scripts/publish-lexicons.ts | 42 -- apps/rsvp-atmo/src/contrail.config.ts | 65 --- apps/rsvp-atmo/src/generate.ts | 18 - apps/rsvp-atmo/src/sync.ts | 68 --- apps/rsvp-atmo/src/worker.ts | 30 -- apps/rsvp-atmo/tsconfig.json | 7 - apps/rsvp-atmo/wrangler.jsonc | 21 - docs/01-indexing.md | 6 +- docs/04-auth.md | 139 ++++++ docs/{04-spaces.md => 05-spaces.md} | 12 +- docs/{05-communities.md => 06-communities.md} | 8 +- docs/{06-sync.md => 07-sync.md} | 0 docs/{07-examples.md => 08-examples.md} | 0 docs/frameworks/sveltekit-cloudflare.md | 5 +- packages/contrail/PERMISSIONED_DATA.md | 4 +- packages/contrail/src/core/spaces/acl.ts | 8 +- packages/contrail/tests/spaces-acl.test.ts | 21 +- 81 files changed, 199 insertions(+), 4825 deletions(-) create mode 100644 .changeset/spaces-owner-no-delete-bypass.md delete mode 100644 apps/rsvp-atmo/Dockerfile delete mode 100644 apps/rsvp-atmo/lexicons/custom/README.md delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/adopt.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/defs.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/delete.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/deleteRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/getHealth.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/list.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/mint.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/putRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/reauth.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/create.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/delete.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/deleteRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/grant.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/listMembers.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/putRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/resync.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/revoke.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/setAccessLevel.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getCursor.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getOverview.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/notifyOfUpdate.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/addMember.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/createSpace.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/defs.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/deleteRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getBlob.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getSpace.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/leaveSpace.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listBlobs.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listMembers.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listRecords.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listSpaces.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/putRecord.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/removeMember.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/uploadBlob.json delete mode 100644 apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/README.md delete mode 100644 apps/rsvp-atmo/lexicons/pulled/app/bsky/actor/profile.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/event.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/rsvp.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/address.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/fsq.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/geo.json delete mode 100644 apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/hthree.json delete mode 100644 apps/rsvp-atmo/package.json delete mode 100644 apps/rsvp-atmo/scripts/clean.ts delete mode 100644 apps/rsvp-atmo/scripts/dev.ts delete mode 100644 apps/rsvp-atmo/scripts/publish-lexicons.ts delete mode 100644 apps/rsvp-atmo/src/contrail.config.ts delete mode 100644 apps/rsvp-atmo/src/generate.ts delete mode 100644 apps/rsvp-atmo/src/sync.ts delete mode 100644 apps/rsvp-atmo/src/worker.ts delete mode 100644 apps/rsvp-atmo/tsconfig.json delete mode 100644 apps/rsvp-atmo/wrangler.jsonc create mode 100644 docs/04-auth.md rename docs/{04-spaces.md => 05-spaces.md} (65%) rename docs/{05-communities.md => 06-communities.md} (82%) rename docs/{06-sync.md => 07-sync.md} (100%) rename docs/{07-examples.md => 08-examples.md} (100%) diff --git a/.changeset/spaces-owner-no-delete-bypass.md b/.changeset/spaces-owner-no-delete-bypass.md new file mode 100644 index 0000000..32977fe --- /dev/null +++ b/.changeset/spaces-owner-no-delete-bypass.md @@ -0,0 +1,11 @@ +--- +"@atmo-dev/contrail": patch +--- + +tighten spaces ACL: owners no longer bypass the "own-record" rule on delete. everyone in the member list — owner included — can only delete records they authored. + +before: owner calling `space.deleteRecord` on someone else's record returned `200 { ok: true }` (ACL passed, but the adapter's SQL was already scoped to `did = caller`, so no rows were actually deleted — the response lied). + +after: that same call returns `403 { error: "Forbidden", reason: "not-own-record" }`. honest response; no behavior change at the storage layer. + +to wipe someone else's records in a space you own, delete the space itself. diff --git a/README.md b/README.md index 1a09429..84a3ffb 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ 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/04-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/05-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. @@ -92,10 +92,11 @@ returns every `community.lexicon.calendar.event` record published anywhere on at - [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 +- [Auth](./docs/04-auth.md) — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets +- [Spaces](./docs/05-spaces.md) — permissioned records stored by the appview +- [Communities](./docs/06-communities.md) — group-controlled atproto DIDs +- [Sync](./docs/07-sync.md) — reactive client-side store over `watchRecords` +- [Examples](./docs/08-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 94ed881..7790687 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/04-spaces.md). -- **group-controlled DIDs:** see [communities docs](../../docs/05-communities.md). +- **private records:** see [spaces docs](../../docs/05-spaces.md). +- **group-controlled DIDs:** see [communities docs](../../docs/06-communities.md). diff --git a/apps/rsvp-atmo/Dockerfile b/apps/rsvp-atmo/Dockerfile deleted file mode 100644 index bbc23ac..0000000 --- a/apps/rsvp-atmo/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:22-alpine - -RUN corepack enable && corepack prepare pnpm@latest --activate - -WORKDIR /app - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN pnpm install --frozen-lockfile - -COPY src/ src/ -COPY app/ app/ -COPY bin/ bin/ - -CMD ["npx", "tsx", "bin/ingest.ts"] diff --git a/apps/rsvp-atmo/lexicons/custom/README.md b/apps/rsvp-atmo/lexicons/custom/README.md deleted file mode 100644 index dc7779b..0000000 --- a/apps/rsvp-atmo/lexicons/custom/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Custom lexicons - -Place your own hand-authored lexicon JSON files here. - -## Folder conventions - -- **`lexicons/custom/`** (this folder) — Your own hand-authored lexicons. -- **`lexicons/pulled/`** — Downloaded via `lex-cli pull` from the atproto registry. - Generated from the `pull.sources` list in `lex.config.js`. -- **`lexicons/generated/`** — Auto-generated by `pnpm generate` from your Contrail config. - Don't edit by hand. - -All three are picked up by `lex-cli generate` (see `lex.config.js` `files` glob). diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/adopt.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/adopt.json deleted file mode 100644 index 9351b1e..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/adopt.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.adopt", - "defs": { - "main": { - "type": "procedure", - "description": "Adopt an existing ATProto account as a community identity. The provided app password is verified by creating a session, then stored encrypted. Creates the reserved `$admin` and `$publishers` spaces with the caller as `owner` in both.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "identifier", - "appPassword" - ], - "properties": { - "identifier": { - "type": "string", - "description": "Handle or DID of the account to adopt." - }, - "appPassword": { - "type": "string", - "description": "App password for the account; verified by createSession and stored encrypted." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "Unauthorized" - }, - { - "name": "AlreadyExists" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/defs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/defs.json deleted file mode 100644 index 6f64581..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/defs.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.defs", - "description": "Shared types for community-module XRPC methods.", - "defs": { - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "Module-internal access levels that govern community operations on a given space. Ordered: member < manager < admin < owner." - }, - "communityView": { - "type": "object", - "required": [ - "did", - "mode", - "createdAt" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "mode": { - "type": "string", - "knownValues": [ - "adopt", - "mint" - ] - }, - "identifier": { - "type": "string", - "description": "Handle or DID at adoption time (adopt only)." - }, - "createdAt": { - "type": "integer" - } - } - }, - "spaceView": { - "type": "object", - "required": [ - "uri", - "ownerDid", - "type", - "key", - "serviceDid", - "createdAt" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "ownerDid": { - "type": "string", - "format": "did" - }, - "type": { - "type": "string", - "format": "nsid" - }, - "key": { - "type": "string" - }, - "serviceDid": { - "type": "string" - }, - "createdAt": { - "type": "integer" - } - } - }, - "subject": { - "type": "object", - "description": "Exactly one of `did` or `spaceUri` must be set.", - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - }, - "memberRow": { - "type": "object", - "required": [ - "subject", - "accessLevel", - "grantedBy", - "grantedAt" - ], - "properties": { - "subject": { - "type": "ref", - "ref": "#subject" - }, - "accessLevel": { - "type": "ref", - "ref": "#accessLevel" - }, - "grantedBy": { - "type": "string", - "format": "did" - }, - "grantedAt": { - "type": "integer" - } - } - }, - "flatMember": { - "type": "object", - "required": [ - "did", - "addedAt" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "addedAt": { - "type": "integer" - } - } - }, - "inviteView": { - "type": "object", - "required": [ - "tokenHash", - "spaceUri", - "accessLevel", - "createdBy", - "createdAt", - "usedCount" - ], - "properties": { - "tokenHash": { - "type": "string", - "description": "SHA-256 of the raw token. Stable id for list/revoke; never grants access on its own." - }, - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "accessLevel": { - "type": "ref", - "ref": "#accessLevel" - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms. Omitted for no expiry." - }, - "maxUses": { - "type": "integer", - "description": "Omitted for unlimited." - }, - "usedCount": { - "type": "integer" - }, - "revokedAt": { - "type": "integer", - "description": "Unix ms. Omitted if not revoked." - }, - "note": { - "type": "string" - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/delete.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/delete.json deleted file mode 100644 index 85b158e..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/delete.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Soft-delete a community. Also soft-deletes all spaces owned by the community. Caller must have `owner` in the community's `$admin` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/deleteRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/deleteRecord.json deleted file mode 100644 index 1b70e29..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/deleteRecord.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a public record authored by the community from the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "collection", - "rkey" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "NotSupported" - }, - { - "name": "UpstreamFailure" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/getHealth.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/getHealth.json deleted file mode 100644 index f3c03e5..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/getHealth.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.getHealth", - "defs": { - "main": { - "type": "query", - "description": "Check whether stored credentials for the community are still usable. Adopted: attempts a live session creation. Minted: verifies the signing key can be decrypted. Caller must have at least `member` in the community's `$admin` space.", - "parameters": { - "type": "params", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "knownValues": [ - "healthy", - "degraded", - "expired" - ] - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/list.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/list.json deleted file mode 100644 index 520e45a..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/list.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.list", - "defs": { - "main": { - "type": "query", - "description": "List communities where the actor has any access level in any community-owned space. Defaults to the JWT issuer when `actor` is omitted.", - "parameters": { - "type": "params", - "properties": { - "actor": { - "type": "string", - "format": "did", - "description": "DID to query (defaults to the JWT issuer)." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communities" - ], - "properties": { - "communities": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#communityView" - } - } - } - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/mint.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/mint.json deleted file mode 100644 index ab2aea0..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/mint.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.mint", - "defs": { - "main": { - "type": "procedure", - "description": "Mint a fresh did:plc for a new community. Contrail generates three P-256 keypairs (signing, contrail rotation, creator rotation), submits a genesis op to the PLC directory, and stores the signing + contrail-rotation keys encrypted. The creator's rotation key is returned once as a recovery secret and never stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "handle": { - "type": "string", - "description": "Optional handle to include in alsoKnownAs (as at://)." - }, - "pdsEndpoint": { - "type": "string", - "description": "Optional PDS endpoint to include as the atproto_pds service." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "recoveryKey" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "recoveryKey": { - "type": "unknown", - "description": "Creator's rotation key as a private JWK. Shown once — cannot be retrieved later." - } - } - } - }, - "errors": [ - { - "name": "UpstreamFailure" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/putRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/putRecord.json deleted file mode 100644 index 43581a3..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/putRecord.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Publish a public record authored by the community via the community's PDS. Adopted communities only. Caller must be `member` or higher in the community's `$publishers` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "collection", - "record" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "record": { - "type": "unknown" - }, - "validate": { - "type": "boolean" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "NotSupported" - }, - { - "name": "UpstreamFailure" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/reauth.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/reauth.json deleted file mode 100644 index 87a05d4..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/reauth.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.reauth", - "defs": { - "main": { - "type": "procedure", - "description": "Replace the stored app password for an adopted community. Caller must have `owner` access in the community's `$admin` space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid", - "appPassword" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "appPassword": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "Unauthorized" - }, - { - "name": "NotSupported" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/create.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/create.json deleted file mode 100644 index 8c3bbaa..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/create.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, …) are rejected here.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "communityDid" - ], - "properties": { - "communityDid": { - "type": "string", - "format": "did" - }, - "key": { - "type": "string", - "description": "Space key. Auto-generated if omitted." - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "space" - ], - "properties": { - "space": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#spaceView" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "AlreadyExists" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/delete.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/delete.json deleted file mode 100644 index 17fae52..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/delete.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.delete", - "defs": { - "main": { - "type": "procedure", - "description": "Soft-delete a community-owned space. Caller must be `owner` on the space, or `admin`+ in the community's `$admin` space. Reserved spaces cannot be deleted.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/deleteRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/deleteRecord.json deleted file mode 100644 index abe2a68..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/deleteRecord.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete an in-space record authored by the community DID. Caller must be `admin` or higher in the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "rkey" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/grant.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/grant.json deleted file mode 100644 index acca868..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/grant.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.grant", - "defs": { - "main": { - "type": "procedure", - "description": "Grant or upsert a subject's access level on a space. Caller needs `manager` or higher; the granted level cannot exceed the caller's own. A subject can be a DID (member) or another space (delegated membership).", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "subject", - "accessLevel" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "subject": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#subject" - }, - "accessLevel": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#accessLevel" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/listMembers.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/listMembers.json deleted file mode 100644 index 130d72b..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/listMembers.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.listMembers", - "defs": { - "main": { - "type": "query", - "description": "List members of a community-owned space. By default returns the raw access-level rows; with `flatten=true` returns the flattened DID list (delegated memberships resolved).", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "flatten": { - "type": "boolean", - "description": "If true, return the flat DID list instead of raw rows." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "rows": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#memberRow" - }, - "description": "Present when flatten=false." - }, - "members": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#flatMember" - }, - "description": "Present when flatten=true." - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/putRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/putRecord.json deleted file mode 100644 index eca1cfe..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/putRecord.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Write an in-space record authored by the community DID (rather than the caller). Caller must be `admin` or higher in the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "record" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "record": { - "type": "unknown" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "rkey", - "authorDid", - "createdAt" - ], - "properties": { - "rkey": { - "type": "string" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/resync.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/resync.json deleted file mode 100644 index f0bba3b..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/resync.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.resync", - "defs": { - "main": { - "type": "procedure", - "description": "Manually recompute `spaces_members` for a community-owned space. Useful after a crash mid-reconcile leaves stale membership. Caller must have `admin` or higher.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/revoke.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/revoke.json deleted file mode 100644 index cff2c18..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/revoke.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Remove a subject's access grant from a space. Caller must have `manager` or higher and outrank the subject's current level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "subject" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "subject": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#subject" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/setAccessLevel.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/setAccessLevel.json deleted file mode 100644 index 1e41342..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/community/space/setAccessLevel.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.community.space.setAccessLevel", - "defs": { - "main": { - "type": "procedure", - "description": "Change the access level of an existing grant. Caller must outrank both the old and new level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "subject", - "accessLevel" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "subject": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#subject" - }, - "accessLevel": { - "type": "ref", - "ref": "rsvp.atmo.community.defs#accessLevel" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "InvalidRequest" - }, - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json deleted file mode 100644 index 101859b..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/getRecord.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.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" - }, - "spaceUri": { - "type": "string", - "format": "at-uri", - "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "hydrateRsvps": { - "type": "integer", - "minimum": 1, - "maximum": 50, - "description": "Number of rsvps records to embed" - } - } - }, - "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" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "rsvpsCount": { - "type": "integer", - "description": "Total rsvps count" - }, - "rsvpsInterestedCount": { - "type": "integer", - "description": "rsvps count where status = interested" - }, - "rsvpsGoingCount": { - "type": "integer", - "description": "rsvps count where status = going" - }, - "rsvpsNotgoingCount": { - "type": "integer", - "description": "rsvps count where status = notgoing" - }, - "rsvps": { - "type": "ref", - "ref": "#hydrateRsvps" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "hydrateRsvpsRecord": { - "type": "object", - "required": [ - "uri", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.rsvp#main" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space." - } - } - }, - "hydrateRsvps": { - "type": "object", - "properties": { - "interested": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - }, - "going": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - }, - "notgoing": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - }, - "other": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - } - } - }, - "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/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json deleted file mode 100644 index 52cbe9e..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/event/listRecords.json +++ /dev/null @@ -1,394 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.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" - }, - "spaceUri": { - "type": "string", - "format": "at-uri", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri — filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "search": { - "type": "string", - "description": "Full-text search across: name, description" - }, - "mode": { - "type": "string", - "description": "Filter by mode" - }, - "name": { - "type": "string", - "description": "Filter by name" - }, - "status": { - "type": "string", - "description": "Filter by status" - }, - "startsAtMin": { - "type": "string", - "description": "Minimum value for startsAt" - }, - "startsAtMax": { - "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" - }, - "createdAtMax": { - "type": "string", - "description": "Maximum value for createdAt" - }, - "rsvpsCountMin": { - "type": "integer", - "description": "Minimum total rsvps count" - }, - "hydrateRsvps": { - "type": "integer", - "minimum": 1, - "maximum": 50, - "description": "Number of rsvps records to embed per record" - }, - "rsvpsInterestedCountMin": { - "type": "integer", - "description": "Minimum rsvps count where status = interested" - }, - "rsvpsGoingCountMin": { - "type": "integer", - "description": "Minimum rsvps count where status = going" - }, - "rsvpsNotgoingCountMin": { - "type": "integer", - "description": "Minimum rsvps count where status = notgoing" - }, - "sort": { - "type": "string", - "knownValues": [ - "mode", - "name", - "status", - "startsAt", - "endsAt", - "createdAt", - "rsvpsCount", - "rsvpsInterestedCount", - "rsvpsGoingCount", - "rsvpsNotgoingCount" - ], - "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" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "rsvpsCount": { - "type": "integer", - "description": "Total rsvps count" - }, - "rsvpsInterestedCount": { - "type": "integer", - "description": "rsvps count where status = interested" - }, - "rsvpsGoingCount": { - "type": "integer", - "description": "rsvps count where status = going" - }, - "rsvpsNotgoingCount": { - "type": "integer", - "description": "rsvps count where status = notgoing" - }, - "rsvps": { - "type": "ref", - "ref": "#hydrateRsvps" - } - } - }, - "hydrateRsvpsRecord": { - "type": "object", - "required": [ - "uri", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.rsvp#main" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space." - } - } - }, - "hydrateRsvps": { - "type": "object", - "properties": { - "interested": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - }, - "going": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - }, - "notgoing": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - }, - "other": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateRsvpsRecord" - } - } - } - }, - "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/rsvp-atmo/lexicons/generated/rsvp/atmo/getCursor.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getCursor.json deleted file mode 100644 index 03fc0da..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getCursor.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.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/rsvp-atmo/lexicons/generated/rsvp/atmo/getOverview.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getOverview.json deleted file mode 100644 index e349ced..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getOverview.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.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/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json deleted file mode 100644 index 1b366ee..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/getProfile.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.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/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json deleted file mode 100644 index 9858929..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/create.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.invite.create", - "defs": { - "main": { - "type": "procedure", - "description": "Create an invite for a space. The service dispatches on space ownership: user-owned spaces take `kind` (default `join`); community-owned spaces take `accessLevel`. Exactly one of `kind` / `accessLevel` must be set. Returns the raw token once; only the hash is stored.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "kind": { - "type": "string", - "knownValues": [ - "join", - "read", - "read-join" - ], - "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." - }, - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms timestamp. Omit for no expiry." - }, - "maxUses": { - "type": "integer", - "minimum": 1 - }, - "note": { - "type": "string", - "maxLength": 500 - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token", - "invite" - ], - "properties": { - "token": { - "type": "string", - "description": "Raw token. Shown once — cannot be retrieved later." - }, - "invite": { - "type": "ref", - "ref": "rsvp.atmo.invite.defs#inviteView" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "InvalidRequest" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json deleted file mode 100644 index 050b3a2..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/defs.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.invite.defs", - "defs": { - "inviteView": { - "type": "object", - "description": "An invite row as exposed to clients. Either `kind` (user-owned space) or `accessLevel` (community-owned space) is set, never both.", - "required": [ - "tokenHash", - "spaceUri", - "createdBy", - "createdAt", - "usedCount" - ], - "properties": { - "tokenHash": { - "type": "string", - "description": "Stable identifier for list/revoke operations." - }, - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "kind": { - "type": "string", - "knownValues": [ - "join", - "read", - "read-join" - ], - "description": "Set for user-owned spaces. Absent for community-owned." - }, - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "Set for community-owned spaces. Absent for user-owned." - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer", - "description": "Unix ms." - }, - "expiresAt": { - "type": "integer", - "description": "Unix ms. Omitted for no expiry." - }, - "maxUses": { - "type": "integer", - "minimum": 1 - }, - "usedCount": { - "type": "integer" - }, - "revokedAt": { - "type": "integer", - "description": "Unix ms. Omitted if not revoked." - }, - "note": { - "type": "string", - "maxLength": 500 - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json deleted file mode 100644 index 2e4f6f7..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/list.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.invite.list", - "defs": { - "main": { - "type": "query", - "description": "List invites for a space. User-owned spaces: owner-only. Community-owned spaces: manager+.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "includeRevoked": { - "type": "boolean", - "default": false - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "invites" - ], - "properties": { - "invites": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.invite.defs#inviteView" - } - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json deleted file mode 100644 index 6968ec1..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/redeem.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.invite.redeem", - "defs": { - "main": { - "type": "procedure", - "description": "Redeem an invite token. User-owned spaces: caller becomes a member. Community-owned spaces: caller is granted the invite's access level.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "kind": { - "type": "string", - "description": "Set for user-owned spaces — echoes the invite kind consumed." - }, - "accessLevel": { - "type": "string", - "description": "Set for community-owned spaces — the level granted." - }, - "communityDid": { - "type": "string", - "format": "did", - "description": "Set for community-owned spaces." - } - } - } - }, - "errors": [ - { - "name": "InvalidInvite" - }, - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json deleted file mode 100644 index b5c97d5..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/invite/revoke.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.invite.revoke", - "defs": { - "main": { - "type": "procedure", - "description": "Revoke an invite by tokenHash. User-owned spaces: owner-only. Community-owned spaces: invite creator OR manager+ on the target space.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "tokenHash" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri", - "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." - }, - "tokenHash": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/notifyOfUpdate.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/notifyOfUpdate.json deleted file mode 100644 index a91c2e7..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/notifyOfUpdate.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.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/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json deleted file mode 100644 index 30572a6..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/permissionSet.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.permissionSet", - "defs": { - "main": { - "type": "permission-set", - "title": "rsvp.atmo", - "description": "All XRPC methods exposed by the rsvp.atmo service.", - "permissions": [ - { - "type": "permission", - "resource": "rpc", - "aud": "*", - "lxm": [ - "rsvp.atmo.community.adopt", - "rsvp.atmo.community.delete", - "rsvp.atmo.community.deleteRecord", - "rsvp.atmo.community.getHealth", - "rsvp.atmo.community.list", - "rsvp.atmo.community.mint", - "rsvp.atmo.community.putRecord", - "rsvp.atmo.community.reauth", - "rsvp.atmo.community.space.create", - "rsvp.atmo.community.space.delete", - "rsvp.atmo.community.space.deleteRecord", - "rsvp.atmo.community.space.grant", - "rsvp.atmo.community.space.listMembers", - "rsvp.atmo.community.space.putRecord", - "rsvp.atmo.community.space.resync", - "rsvp.atmo.community.space.revoke", - "rsvp.atmo.community.space.setAccessLevel", - "rsvp.atmo.event.getRecord", - "rsvp.atmo.event.listRecords", - "rsvp.atmo.getCursor", - "rsvp.atmo.getOverview", - "rsvp.atmo.getProfile", - "rsvp.atmo.invite.create", - "rsvp.atmo.invite.list", - "rsvp.atmo.invite.redeem", - "rsvp.atmo.invite.revoke", - "rsvp.atmo.notifyOfUpdate", - "rsvp.atmo.rsvp.getRecord", - "rsvp.atmo.rsvp.listRecords", - "rsvp.atmo.space.addMember", - "rsvp.atmo.space.createSpace", - "rsvp.atmo.space.deleteRecord", - "rsvp.atmo.space.getBlob", - "rsvp.atmo.space.getRecord", - "rsvp.atmo.space.getSpace", - "rsvp.atmo.space.leaveSpace", - "rsvp.atmo.space.listBlobs", - "rsvp.atmo.space.listMembers", - "rsvp.atmo.space.listRecords", - "rsvp.atmo.space.listSpaces", - "rsvp.atmo.space.putRecord", - "rsvp.atmo.space.removeMember", - "rsvp.atmo.space.uploadBlob", - "rsvp.atmo.spaceExt.whoami" - ] - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json deleted file mode 100644 index 6dbb232..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/getRecord.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.rsvp.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single community.lexicon.calendar.rsvp 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" - }, - "spaceUri": { - "type": "string", - "format": "at-uri", - "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "hydrateEvent": { - "type": "boolean", - "description": "Embed the referenced event record" - } - } - }, - "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.rsvp#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "event": { - "type": "ref", - "ref": "#refEventRecord" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "refEventRecord": { - "type": "object", - "required": [ - "uri", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.event#main" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space." - } - } - }, - "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/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json deleted file mode 100644 index 35917fe..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/rsvp/listRecords.json +++ /dev/null @@ -1,287 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.rsvp.listRecords", - "defs": { - "main": { - "type": "query", - "description": "Query community.lexicon.calendar.rsvp 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" - }, - "spaceUri": { - "type": "string", - "format": "at-uri", - "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only used with spaceUri — filter to records authored by this DID." - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." - }, - "status": { - "type": "string", - "description": "Filter by status" - }, - "subjectUri": { - "type": "string", - "description": "Filter by subject.uri" - }, - "hydrateEvent": { - "type": "boolean", - "description": "Embed the referenced event record" - }, - "sort": { - "type": "string", - "knownValues": [ - "status", - "subjectUri" - ], - "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.rsvp#main" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space; its value is the space URI." - }, - "event": { - "type": "ref", - "ref": "#refEventRecord" - } - } - }, - "refEventRecord": { - "type": "object", - "required": [ - "uri", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "ref", - "ref": "community.lexicon.calendar.event#main" - }, - "time_us": { - "type": "integer" - }, - "space": { - "type": "string", - "format": "at-uri", - "description": "Present when the record was read from a permissioned space." - } - } - }, - "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/rsvp-atmo/lexicons/generated/rsvp/atmo/space/addMember.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/addMember.json deleted file mode 100644 index d6308ff..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/addMember.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.addMember", - "defs": { - "main": { - "type": "procedure", - "description": "Add a member to a space. Caller must be the space owner.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "did" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/createSpace.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/createSpace.json deleted file mode 100644 index f17d161..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/createSpace.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.createSpace", - "defs": { - "main": { - "type": "procedure", - "description": "Create a new space owned by the JWT issuer. The caller is added as an owner-perm member.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "format": "nsid", - "description": "Space type NSID. Defaults to the service's configured type." - }, - "key": { - "type": "string", - "description": "Space key. Auto-generated (TID) if omitted." - }, - "appPolicyRef": { - "type": "string", - "format": "at-uri" - }, - "appPolicy": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#appPolicy" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "space" - ], - "properties": { - "space": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#spaceView" - } - } - } - }, - "errors": [ - { - "name": "AlreadyExists" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/defs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/defs.json deleted file mode 100644 index a9f68e3..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/defs.json +++ /dev/null @@ -1,210 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.defs", - "description": "Shared types for permissioned-space XRPC methods.", - "defs": { - "spaceView": { - "type": "object", - "required": [ - "uri", - "ownerDid", - "type", - "key", - "serviceDid", - "createdAt" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "ownerDid": { - "type": "string", - "format": "did" - }, - "type": { - "type": "string", - "format": "nsid" - }, - "key": { - "type": "string" - }, - "serviceDid": { - "type": "string" - }, - "appPolicyRef": { - "type": "string", - "format": "at-uri" - }, - "createdAt": { - "type": "integer" - }, - "appPolicy": { - "type": "ref", - "ref": "#appPolicy", - "description": "Owner-only" - } - } - }, - "memberView": { - "type": "object", - "required": [ - "did", - "addedAt" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "addedAt": { - "type": "integer" - }, - "addedBy": { - "type": "string", - "format": "did" - } - } - }, - "recordView": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "authorDid", - "rkey", - "record", - "createdAt" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "record": { - "type": "unknown" - }, - "createdAt": { - "type": "integer" - } - } - }, - "appPolicy": { - "type": "object", - "required": [ - "mode", - "apps" - ], - "properties": { - "mode": { - "type": "string", - "knownValues": [ - "allow", - "deny" - ], - "description": "'allow' = default-allow with apps[] as denylist; 'deny' = default-deny with apps[] as allowlist." - }, - "apps": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "blobInfo": { - "type": "object", - "required": [ - "cid", - "mimeType", - "size", - "authorDid", - "createdAt" - ], - "properties": { - "cid": { - "type": "string", - "format": "cid" - }, - "mimeType": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - } - } - }, - "inviteView": { - "type": "object", - "required": [ - "tokenHash", - "spaceUri", - "kind", - "usedCount", - "createdBy", - "createdAt" - ], - "properties": { - "tokenHash": { - "type": "string" - }, - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "kind": { - "type": "string", - "knownValues": [ - "join", - "read", - "read-join" - ] - }, - "expiresAt": { - "type": "integer" - }, - "maxUses": { - "type": "integer" - }, - "usedCount": { - "type": "integer" - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - }, - "revokedAt": { - "type": "integer" - }, - "note": { - "type": "string" - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/deleteRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/deleteRecord.json deleted file mode 100644 index c78bed7..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/deleteRecord.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.deleteRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Delete a record from a space. Callers can delete their own records; the space owner can delete any record (via a separate admin path).", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "rkey" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getBlob.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getBlob.json deleted file mode 100644 index a4b45c5..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getBlob.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.getBlob", - "defs": { - "main": { - "type": "query", - "description": "Read a blob from a space. Requires read access via a service-auth JWT or a read-grant invite token.", - "parameters": { - "type": "params", - "required": [ - "spaceUri", - "cid" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "cid": { - "type": "string", - "format": "cid" - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token for anonymous bearer access." - } - } - }, - "output": { - "encoding": "*/*" - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getRecord.json deleted file mode 100644 index 68a9aeb..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getRecord.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single record from a space.", - "parameters": { - "type": "params", - "required": [ - "spaceUri", - "collection", - "author", - "rkey" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "author": { - "type": "string", - "format": "did" - }, - "rkey": { - "type": "string" - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "record" - ], - "properties": { - "record": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#recordView" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getSpace.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getSpace.json deleted file mode 100644 index f89d15f..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/getSpace.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.getSpace", - "defs": { - "main": { - "type": "query", - "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "space" - ], - "properties": { - "space": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#spaceView" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/leaveSpace.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/leaveSpace.json deleted file mode 100644 index 3ea3458..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/leaveSpace.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.leaveSpace", - "defs": { - "main": { - "type": "procedure", - "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "InvalidRequest", - "description": "Raised if the caller is the space owner." - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listBlobs.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listBlobs.json deleted file mode 100644 index b19d743..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listBlobs.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.listBlobs", - "defs": { - "main": { - "type": "query", - "description": "List blob metadata for a space. Members only.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only blobs uploaded by this DID." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "cursor": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "blobs" - ], - "properties": { - "blobs": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#blobInfo" - } - }, - "cursor": { - "type": "string" - } - } - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listMembers.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listMembers.json deleted file mode 100644 index b1b88b4..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listMembers.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.listMembers", - "defs": { - "main": { - "type": "query", - "description": "List members of a space. Caller must be a member or the owner.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "members" - ], - "properties": { - "members": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#memberView" - } - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listRecords.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listRecords.json deleted file mode 100644 index 664b580..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listRecords.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.listRecords", - "defs": { - "main": { - "type": "query", - "description": "List records of a given collection within a space. Access is governed by the space's collection policy.", - "parameters": { - "type": "params", - "required": [ - "spaceUri", - "collection" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "byUser": { - "type": "string", - "format": "did", - "description": "Only return records authored by this DID." - }, - "cursor": { - "type": "string" - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - }, - "inviteToken": { - "type": "string", - "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#recordView" - } - }, - "cursor": { - "type": "string" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listSpaces.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listSpaces.json deleted file mode 100644 index 531ab35..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/listSpaces.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.listSpaces", - "defs": { - "main": { - "type": "query", - "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID — useful for listing all channels in a specific community the caller can access.", - "parameters": { - "type": "params", - "properties": { - "scope": { - "type": "string", - "knownValues": [ - "member", - "owner" - ], - "default": "member" - }, - "type": { - "type": "string", - "format": "nsid" - }, - "owner": { - "type": "string", - "format": "did", - "description": "With scope=member, filter to spaces owned by this DID. Ignored when scope=owner." - }, - "cursor": { - "type": "string" - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaces" - ], - "properties": { - "spaces": { - "type": "array", - "items": { - "type": "ref", - "ref": "rsvp.atmo.space.defs#spaceView" - } - }, - "cursor": { - "type": "string" - } - } - } - } - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/putRecord.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/putRecord.json deleted file mode 100644 index 65f9863..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/putRecord.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.putRecord", - "defs": { - "main": { - "type": "procedure", - "description": "Write a record into a space. The author is always the JWT issuer. If rkey is omitted, a TID is generated.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "collection", - "record" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "record": { - "type": "unknown" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "rkey", - "authorDid", - "createdAt" - ], - "properties": { - "rkey": { - "type": "string" - }, - "authorDid": { - "type": "string", - "format": "did" - }, - "createdAt": { - "type": "integer" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/removeMember.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/removeMember.json deleted file mode 100644 index 9f0d03c..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/removeMember.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.removeMember", - "defs": { - "main": { - "type": "procedure", - "description": "Remove a member from a space. Owner only. Cannot remove the owner.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "spaceUri", - "did" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "ok" - ], - "properties": { - "ok": { - "type": "boolean" - } - } - } - }, - "errors": [ - { - "name": "NotFound" - }, - { - "name": "Forbidden" - }, - { - "name": "InvalidRequest" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/uploadBlob.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/uploadBlob.json deleted file mode 100644 index c7517dc..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/space/uploadBlob.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.space.uploadBlob", - "defs": { - "main": { - "type": "procedure", - "description": "Upload a blob into a space. Returns a standard atproto BlobRef that records in this space can then reference. The record will be rejected at putRecord time if it references a blob that was not uploaded to the same space.", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - }, - "input": { - "encoding": "*/*" - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "blob" - ], - "properties": { - "blob": { - "type": "blob" - } - } - } - }, - "errors": [ - { - "name": "Forbidden" - }, - { - "name": "BlobTooLarge" - }, - { - "name": "InvalidMimeType" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json b/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json deleted file mode 100644 index 236d190..0000000 --- a/apps/rsvp-atmo/lexicons/generated/rsvp/atmo/spaceExt/whoami.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "lexicon": 1, - "id": "rsvp.atmo.spaceExt.whoami", - "defs": { - "main": { - "type": "query", - "description": "Caller's relationship to a space. For user-owned spaces membership is binary (`isMember`). For community-owned spaces `accessLevel` is also returned, resolved through the community's access-level ladder (including delegated grants).", - "parameters": { - "type": "params", - "required": [ - "spaceUri" - ], - "properties": { - "spaceUri": { - "type": "string", - "format": "at-uri" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "isOwner", - "isMember" - ], - "properties": { - "isOwner": { - "type": "boolean" - }, - "isMember": { - "type": "boolean" - }, - "accessLevel": { - "type": "string", - "knownValues": [ - "member", - "manager", - "admin", - "owner" - ], - "description": "Only set for community-owned spaces. Null when the caller has no resolvable access." - } - } - } - }, - "errors": [ - { - "name": "NotFound" - } - ] - } - } -} diff --git a/apps/rsvp-atmo/lexicons/pulled/README.md b/apps/rsvp-atmo/lexicons/pulled/README.md deleted file mode 100644 index 378ad0f..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# lexicon sources - -this directory contains lexicon documents pulled from the following sources: - -- atproto (nsids: app.bsky.actor.profile, community.lexicon.calendar.event, community.lexicon.calendar.rsvp, community.lexicon.location.address, community.lexicon.location.fsq, community.lexicon.location.geo, community.lexicon.location.hthree) diff --git a/apps/rsvp-atmo/lexicons/pulled/app/bsky/actor/profile.json b/apps/rsvp-atmo/lexicons/pulled/app/bsky/actor/profile.json deleted file mode 100644 index 1d22cc4..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/app/bsky/actor/profile.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "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/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/event.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/event.json deleted file mode 100644 index 96e54cb..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/event.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "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/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/rsvp.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/rsvp.json deleted file mode 100644 index ac61fd3..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/calendar/rsvp.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "id": "community.lexicon.calendar.rsvp", - "defs": { - "main": { - "key": "tid", - "type": "record", - "record": { - "type": "object", - "required": ["subject", "status"], - "properties": { - "status": { - "type": "string", - "default": "community.lexicon.calendar.rsvp#going", - "knownValues": [ - "community.lexicon.calendar.rsvp#interested", - "community.lexicon.calendar.rsvp#going", - "community.lexicon.calendar.rsvp#notgoing" - ] - }, - "subject": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - }, - "description": "An RSVP for an event." - }, - "going": { - "type": "token", - "description": "Going to the event" - }, - "notgoing": { - "type": "token", - "description": "Not going to the event" - }, - "interested": { - "type": "token", - "description": "Interested in the event" - } - }, - "$type": "com.atproto.lexicon.schema", - "lexicon": 1 -} diff --git a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/address.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/address.json deleted file mode 100644 index acfa0fc..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/address.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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/rsvp-atmo/lexicons/pulled/community/lexicon/location/fsq.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/fsq.json deleted file mode 100644 index 91faa7a..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/fsq.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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/rsvp-atmo/lexicons/pulled/community/lexicon/location/geo.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/geo.json deleted file mode 100644 index 8676479..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/geo.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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/rsvp-atmo/lexicons/pulled/community/lexicon/location/hthree.json b/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/hthree.json deleted file mode 100644 index fa6cb99..0000000 --- a/apps/rsvp-atmo/lexicons/pulled/community/lexicon/location/hthree.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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/rsvp-atmo/package.json b/apps/rsvp-atmo/package.json deleted file mode 100644 index 7c57671..0000000 --- a/apps/rsvp-atmo/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "rsvp-atmo", - "private": true, - "version": "0.0.0", - "type": "module", - "description": "Reference Cloudflare Workers deployment of contrail — indexes community.lexicon.calendar.event/rsvp on D1 with the full spaces/community/realtime surface enabled.", - "scripts": { - "dev": "wrangler dev --test-scheduled", - "dev:auto": "tsx scripts/dev.ts", - "deploy": "wrangler deploy", - "clean": "tsx scripts/clean.ts", - "generate": "tsx src/generate.ts", - "generate:pull": "contrail-lex all", - "publish-lexicons": "tsx scripts/publish-lexicons.ts", - "typecheck": "tsc --noEmit", - "ingest": "curl -s http://localhost:8787/__scheduled?cron=*/1+*+*+*+*", - "sync": "tsx src/sync.ts" - }, - "dependencies": { - "@atmo-dev/contrail": "workspace:*" - }, - "devDependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/client": "^4.2.1", - "@atcute/lex-cli": "^2.5.3", - "@atcute/lexicon-doc": "^2.1.2", - "@atcute/lexicons": "^1.2.9", - "@atmo-dev/contrail-lexicons": "workspace:*", - "@cloudflare/workers-types": "^4.20250124.0", - "@types/node": "^25.5.0", - "tsx": "^4.21.0", - "typescript": "^5.7.3", - "wrangler": "^4.63.0" - } -} diff --git a/apps/rsvp-atmo/scripts/clean.ts b/apps/rsvp-atmo/scripts/clean.ts deleted file mode 100644 index 7345b4c..0000000 --- a/apps/rsvp-atmo/scripts/clean.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Remove all generated files. - * - * Usage: npx tsx scripts/clean.ts - */ - -import { rmSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); - -const targets = [ - ".wrangler", - "lex.config.js", - "lexicons/pulled", - "lexicons/generated", - "src/lexicon-types", -]; - -for (const target of targets) { - rmSync(join(ROOT, target), { recursive: true, force: true }); - console.log(` removed ${target}`); -} - -console.log("Done."); diff --git a/apps/rsvp-atmo/scripts/dev.ts b/apps/rsvp-atmo/scripts/dev.ts deleted file mode 100644 index 7906b12..0000000 --- a/apps/rsvp-atmo/scripts/dev.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Start wrangler dev and auto-trigger the cron every 60s. - * - * Usage: npx tsx scripts/dev.ts - */ - -import { spawn } from "child_process"; - -async function main() { - const wrangler = spawn("npx", ["wrangler", "dev", "--test-scheduled"], { - stdio: "inherit", - shell: true, - }); - - // Wait for wrangler to start - await new Promise((resolve) => setTimeout(resolve, 3000)); - - console.log("Auto-ingestion started (every 60s)"); - - const interval = setInterval(async () => { - try { - await fetch("http://localhost:8787/__scheduled?cron=*/1+*+*+*+*"); - } catch { - // wrangler not ready yet or shutting down - } - }, 60_000); - - // Trigger once immediately - try { - await fetch("http://localhost:8787/__scheduled?cron=*/1+*+*+*+*"); - } catch {} - - wrangler.on("exit", () => { - clearInterval(interval); - process.exit(); - }); -} - -main(); diff --git a/apps/rsvp-atmo/scripts/publish-lexicons.ts b/apps/rsvp-atmo/scripts/publish-lexicons.ts deleted file mode 100644 index 88a7a5d..0000000 --- a/apps/rsvp-atmo/scripts/publish-lexicons.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * CLI wrapper for `publishLexicons` — publishes contrail's own generated - * lexicons (under `lexicons/generated/`) to the logged-in account's PDS. - * - * Usage: - * LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social \ - * LEXICON_ACCOUNT_PASSWORD=xxxx-xxxx-xxxx-xxxx \ - * pnpm publish-lexicons - * - * pnpm publish-lexicons - */ - -import { join } from "node:path"; -import { publishLexicons } from "@atmo-dev/contrail-lexicons"; - -const ROOT = join(import.meta.dirname, ".."); - -async function main(): Promise { - const identifier = process.argv[2] ?? process.env.LEXICON_ACCOUNT_IDENTIFIER; - const password = process.argv[3] ?? process.env.LEXICON_ACCOUNT_PASSWORD; - - if (!identifier || !password) { - console.error( - "Usage: pnpm publish-lexicons \n" + - " (or set LEXICON_ACCOUNT_IDENTIFIER and LEXICON_ACCOUNT_PASSWORD env vars)\n\n" + - "Generate an app password at https://bsky.app/settings/app-passwords\n" + - "(or equivalent on your PDS)." - ); - process.exit(1); - } - - await publishLexicons({ - generatedDir: join(ROOT, "lexicons", "generated"), - identifier, - password, - }); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/rsvp-atmo/src/contrail.config.ts b/apps/rsvp-atmo/src/contrail.config.ts deleted file mode 100644 index e5fa2e3..0000000 --- a/apps/rsvp-atmo/src/contrail.config.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ContrailConfig } from "@atmo-dev/contrail"; - -export const config: ContrailConfig = { - namespace: "rsvp.atmo", - jetstreams: ["wss://jetstream1.us-east.bsky.network"], - spaces: { - type: "tools.atmo.event.space", - serviceDid: "did:web:rsvp.atmo", - }, - community: { - // 32-byte master key for envelope-encrypting stored credentials (app - // passwords + minted signing/rotation keys). Provide via env: hex or - // base64 encoded. For Cloudflare Workers, wire from env.COMMUNITY_MASTER_KEY - // via a config factory pattern. The placeholder below is fine for `pnpm - // generate` (which never instantiates the cipher) but must be replaced - // before starting the server. - masterKey: - (typeof process !== "undefined" ? process.env.COMMUNITY_MASTER_KEY : undefined) ?? - "placeholder-set-me-before-running-server-not-at-build-time", - }, - collections: { - event: { - collection: "community.lexicon.calendar.event", - queryable: { - mode: {}, - name: {}, - status: {}, - startsAt: { type: "range" }, - endsAt: { type: "range" }, - createdAt: { type: "range" }, - }, - searchable: ["name", "description"], - relations: { - rsvps: { - collection: "rsvp", - groupBy: "status", - count: true, - countDistinct: "did", - groups: { - interested: "community.lexicon.calendar.rsvp#interested", - going: "community.lexicon.calendar.rsvp#going", - notgoing: "community.lexicon.calendar.rsvp#notgoing", - }, - }, - }, - }, - rsvp: { - collection: "community.lexicon.calendar.rsvp", - queryable: { - status: {}, - "subject.uri": {}, - }, - references: { - event: { - collection: "event", - field: "subject.uri", - }, - }, - }, - }, - - profiles: [ - "app.bsky.actor.profile" - ] -}; diff --git a/apps/rsvp-atmo/src/generate.ts b/apps/rsvp-atmo/src/generate.ts deleted file mode 100644 index f06f1c7..0000000 --- a/apps/rsvp-atmo/src/generate.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Generates lexicon files and lex.config.js from config. - * - * Usage: pnpm generate - */ -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import { config } from "./contrail.config"; -import { generateLexicons } from "@atmo-dev/contrail-lexicons"; - -const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); - -generateLexicons({ - config, - rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, "lexicons", "generated"), - writeRuntimeFiles: true, -}); diff --git a/apps/rsvp-atmo/src/sync.ts b/apps/rsvp-atmo/src/sync.ts deleted file mode 100644 index 6fda0d9..0000000 --- a/apps/rsvp-atmo/src/sync.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Discover users from relays and backfill their records from PDS. - * - * Usage: - * pnpm sync # local D1 - * pnpm sync --remote # prod D1 - */ -import { Contrail } from "@atmo-dev/contrail"; -import { config } from "./contrail.config"; -import { getPlatformProxy } from "wrangler"; - -function elapsed(start: number): string { - const ms = Date.now() - start; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const mins = Math.floor(ms / 60_000); - const secs = ((ms % 60_000) / 1000).toFixed(0); - return `${mins}m ${secs}s`; -} - -async function main() { - const remote = process.argv.includes("--remote"); - const syncStart = Date.now(); - - console.log(`=== Sync (${remote ? "remote/prod" : "local"} D1) ===\n`); - - const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>({ - environment: remote ? "production" : undefined, - }); - - const contrail = new Contrail({ ...config, db: env.DB }); - - try { - await contrail.init(); - - console.log("--- Discovery ---"); - const discoveryStart = Date.now(); - const discovered = await contrail.discover(); - console.log(` Done: ${discovered.length} users in ${elapsed(discoveryStart)}\n`); - - console.log("--- Backfill ---"); - const backfillStart = Date.now(); - const total = await contrail.backfill({ - concurrency: 100, - onProgress: ({ records, usersComplete, usersTotal, usersFailed }) => { - const secs = (Date.now() - backfillStart) / 1000; - const rate = secs > 0 ? Math.round(records / secs) : 0; - const failStr = usersFailed > 0 ? ` | ${usersFailed} failed` : ""; - process.stdout.write( - `\r ${records} records | ${usersComplete}/${usersTotal} users | ${rate}/s | ${elapsed(backfillStart)}${failStr} ` - ); - }, - }); - process.stdout.write("\n"); - console.log(` Done: ${total} records in ${elapsed(backfillStart)}\n`); - - console.log(`=== Finished in ${elapsed(syncStart)} ===`); - console.log(` Discovered: ${discovered.length} users`); - console.log(` Backfilled: ${total} records`); - } finally { - await dispose(); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/rsvp-atmo/src/worker.ts b/apps/rsvp-atmo/src/worker.ts deleted file mode 100644 index c0322c6..0000000 --- a/apps/rsvp-atmo/src/worker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; -import { config } from "./contrail.config"; - -const contrail = new Contrail(config); -const handle = createHandler(contrail); - -let initialized = false; - -export default { - async fetch(request: Request, env: { DB: D1Database }) { - if (!initialized) { - await contrail.init(env.DB); - initialized = true; - } - return handle(request, env.DB); - }, - - async scheduled( - _event: ScheduledEvent, - env: { DB: D1Database }, - ctx: ExecutionContext - ) { - if (!initialized) { - await contrail.init(env.DB); - initialized = true; - } - ctx.waitUntil(contrail.ingest({}, env.DB)); - }, -}; diff --git a/apps/rsvp-atmo/tsconfig.json b/apps/rsvp-atmo/tsconfig.json deleted file mode 100644 index bb57862..0000000 --- a/apps/rsvp-atmo/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["@cloudflare/workers-types", "node"] - }, - "include": ["src", "scripts"] -} diff --git a/apps/rsvp-atmo/wrangler.jsonc b/apps/rsvp-atmo/wrangler.jsonc deleted file mode 100644 index 9f223f2..0000000 --- a/apps/rsvp-atmo/wrangler.jsonc +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "contrail", - "main": "src/worker.ts", - "compatibility_date": "2025-12-25", - "observability": { - "enabled": true - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "contrail", - "database_id": "29cf6e32-d0b9-4646-ac52-bfbeef085c1a", - } - ], - "triggers": { - "crons": [ - "*/1 * * * *" - ] - } -} \ No newline at end of file diff --git a/docs/01-indexing.md b/docs/01-indexing.md index 20910fe..7baeeaa 100644 --- a/docs/01-indexing.md +++ b/docs/01-indexing.md @@ -198,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](./04-spaces.md) | -| `community` | — | See [Communities](./05-communities.md) | -| `realtime` | — | See [Sync](./06-sync.md) | +| `spaces` | — | See [Spaces](./05-spaces.md) | +| `community` | — | See [Communities](./06-communities.md) | +| `realtime` | — | See [Sync](./07-sync.md) | diff --git a/docs/04-auth.md b/docs/04-auth.md new file mode 100644 index 0000000..4f01922 --- /dev/null +++ b/docs/04-auth.md @@ -0,0 +1,139 @@ +# Auth + +Contrail has four auth mechanisms. Which one applies depends on who's calling and what they're asking for. + +| Mechanism | Used by | For | +|---|---|---| +| Anonymous | anyone | public reads | +| Service-auth JWT | third-party apps acting on behalf of a user | anything permissioned (spaces, communities) | +| In-process server client | your own server code | loaders / actions that skip HTTP entirely | +| Invite token | anonymous bearers | read-only access to a specific space | +| Watch ticket | browsers | realtime subscriptions (`watchRecords`) | + +## Service-auth JWTs + +The standard atproto mechanism. When a third-party app wants to call your contrail service as a user, it: + +1. Asks the user's PDS to mint a service-auth JWT with `com.atproto.server.getServiceAuth` — the token's claims include `iss` (user's DID), `aud` (your service DID), `lxm` (the specific method NSID), and a short expiry. +2. Sends the request to your service with `Authorization: Bearer ` and `Atproto-Proxy: #` so the PDS knows where to route. + +Contrail verifies every request against the public key in the issuer's DID doc (`@atcute/xrpc-server` does the heavy lifting). It checks: + +- Signature valid +- `aud` matches the `serviceDid` you configured +- `lxm` covers the method being called +- Token hasn't expired + +On pass, your handler sees a populated `serviceAuth = { issuer, audience, lxm, clientId }` context and can proceed. On fail, 401 or 403 with a structured reason. + +### The `serviceDid` gotcha + +Use the **plain DID** (no `#fragment`) when configuring contrail: + +```ts +spaces: { serviceDid: "did:web:example.com" } // right +spaces: { serviceDid: "did:web:example.com#com_example_space" } // wrong +``` + +Many PDS implementations reject `aud` values containing `#fragment` in `com.atproto.server.getServiceAuth`, and contrail does strict string equality on `aud`. The fragment form belongs only in your DID doc's `service` entry, where PDSes use it to resolve the service endpoint URL for `Atproto-Proxy` routing — that's separate from JWT audience validation. + +## In-process server client + +When your own server code wants to call contrail, the service-auth dance is pointless — it's your code talking to your code. `createServerClient` skips it: + +```ts +import { createServerClient } from "@atmo-dev/contrail/server"; + +const client = createServerClient(async (req) => handle(req, env.DB), userDid); + +// Calls bypass fetch entirely; acts as `userDid` for ACL purposes. +const res = await client.get("com.example.event.listRecords", { params: {...} }); +``` + +Pass `did` to act as that user; omit it for anonymous calls against public endpoints. This is a trust boundary — anything that actually crosses a network needs a real service-auth JWT, not this shortcut. + +See [SvelteKit + Cloudflare](./frameworks/sveltekit-cloudflare.md) for the typical loader pattern. + +## Invite tokens + +First-class auth for spaces. When a space owner creates an invite: + +``` +com.example.space.invite.create { spaceUri, ttl?, maxUses? } + → { token: "...plaintext..." } // returned once, never again +``` + +The plaintext token is handed to the user out-of-band (link, QR, email). Contrail stores only a SHA-256 hash. Redemption is one atomic UPDATE: `used_count++ WHERE hash = ? AND !revoked AND !expired AND !exhausted`. + +Three invite kinds, depending on what the token does: + +- **`join`** — redeemed via `com.example.space.invite.redeem` with a service-auth JWT. Adds the caller's DID to the member list. Members have full read + write inside the space; there's no per-member permission axis beyond "is a member." +- **`read`** — bearer-only. The token itself grants read access when passed as `?inviteToken=`, no DID, no redemption. Good for sharing a read-only link that doesn't add anyone to the member list. +- **`read-join`** — both. Works anonymously as a read token; can also be redeemed with a JWT to promote the caller to member. + +Tokens can be revoked (`invite.revoke`), expire automatically (`ttl`), and be exhausted (`maxUses`). + +## Watch tickets + +Realtime subscriptions (`watchRecords`) can't use regular service-auth JWTs for two reasons: the WebSocket upgrade can't carry arbitrary headers, and an open socket would outlive a 60s JWT TTL. So contrail uses separate short-lived tickets. + +Server-side minting: + +``` +com.example.realtime.ticket { spaceUri } + → { ticket: "...", expiresAt: 1234567890 } +``` + +The ticket is signed by `realtime.ticketSecret` (a 32-byte random, configured once), bound to `(did, spaceUri, querySpec)`. Client uses it on the SSE or WS handshake via `?ticket=...`. + +In the `@atmo-dev/contrail-sync` client: + +```ts +createWatchStore({ + url: "/xrpc/com.example.message.watchRecords?spaceUri=at://...", + mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()), +}); +``` + +Each reconnect mints a fresh ticket, so expiry doesn't matter for long-lived subscriptions. See [Sync](./07-sync.md) for the full flow. + +## OAuth permission sets + +When a user grants a third-party app permission to act as them in your contrail service, the consent screen is driven by a **permission set** — a lexicon that bundles every XRPC method you expose. Contrail auto-generates `{namespace}.permissionSet` for you; `contrail-lex` publishes it alongside the other lexicons. + +A third-party app requests scope by referencing your permission set's NSID in its OAuth metadata: + +```jsonc +"scope": "include:com.example.permissionSet" +``` + +The user's PDS fetches that lexicon (via DNS-backed NSID resolution), shows the user what methods are being requested, and mints scoped service-auth JWTs on confirmation. + +### DNS requirements + +Permission sets live under *your* namespace (`com.example.permissionSet`), so PDSes resolve the NSID via DNS. Resolution does **not** walk up subdomains — every authority in your NSID tree needs its own `TXT` record at `_lexicon.<reversed-domain-path>`. + +`contrail-lex publish` prints the exact records you need (also works as a `--dry-run`). Without them, permission sets can't be fetched, and users get errors instead of a consent screen. + +## Anonymous / public + +No auth needed for: + +- `listRecords` / `getRecord` without `spaceUri` — returns public records +- `getProfile`, `getCursor`, `getOverview` +- `notifyOfUpdate` (unless you set `notify: "some-bearer-token"` in config; then it needs `Authorization: Bearer <that>`) + +Public requests skip all verification middleware — no JWT parsing, no DID-doc fetch. Fast path. + +## How the pieces fit + +A typical flow for a third-party app acting as a user in a space: + +1. App registers OAuth client pointing at your permission set NSID. +2. User grants consent — PDS fetches your permission set lexicon via DNS, shows the user the methods, records the scope. +3. App calls `com.atproto.server.getServiceAuth` on the user's PDS: `{ aud: "did:web:example.com", lxm: "com.example.space.putRecord", exp: <60s> }`. PDS signs, returns JWT. +4. App sends `PUT /xrpc/com.example.space.putRecord` with `Authorization: Bearer <jwt>` and `Atproto-Proxy: did:web:example.com#com_example_space`. +5. User's PDS reads the `Atproto-Proxy` header, resolves the service endpoint from your DID doc, forwards the request. +6. Contrail verifies the JWT (signature, `aud`, `lxm`, expiry), runs ACL check (is this DID a member of that space?), dispatches the write. + +For your own loaders/actions, steps 3–6 collapse into a single `createServerClient({did}).post(...)` call. For a browser subscribing to a feed, steps 3–5 are replaced by a ticket mint from your server. Same auth model, different surface. diff --git a/docs/04-spaces.md b/docs/05-spaces.md similarity index 65% rename from docs/04-spaces.md rename to docs/05-spaces.md index 8bcb97f..2e6235d 100644 --- a/docs/04-spaces.md +++ b/docs/05-spaces.md @@ -7,7 +7,7 @@ Auth-gated store for records that can't live on public PDSes — private events, > A **space** is a bag of records with one lock. The **member list** says who has the key. - One owner (DID), one type (NSID), one key. Identified by `at://<owner>/<type>/<key>`. -- Members have `read` or `write`. Owner is implicit `write`. +- Every member (including owner) has read + write inside the space. Delete is scoped to your own records — no one can remove records they didn't author, owner included. To wipe everything, delete the space. - Optional **app policy** gates which OAuth clients can act in the space. Every permission boundary is its own space. No nested ACLs. Richer roles = more spaces or app-layer checks. @@ -35,9 +35,13 @@ public_only: { collection: "com.example.public", allowInSpaces: false } ## Auth -atproto service-auth JWTs via `@atcute/xrpc-server`. Middleware validates signature, `aud`, and `lxm` before the handler runs. Apps acting in a space mint a JWT against the user's PDS with `Atproto-Proxy: did:web:example.com#com_example_space`, forward to your service, it verifies and executes. +Spaces use the standard contrail auth surface — service-auth JWTs for third-party apps, in-process server clients for your own loaders, invite tokens for anonymous read-grant links. See [Auth](./04-auth.md) for the full picture. -**Note:** Use the plain DID (no `#fragment`) as `serviceDid` — the fragment form only belongs in your DID doc's service entry. +Space-specific wiring: + +- `serviceDid` in the config is the `aud` contrail expects on incoming JWTs. Plain DID, no `#fragment`. +- Apps acting in a space send `Atproto-Proxy: <serviceDid>#<service-id-from-your-did-doc>` so the user's PDS routes correctly. +- Invite redemption via service-auth JWT grants membership; via `?inviteToken=...` query param grants read-only bearer access to that space. ## Unified `listRecords` @@ -51,7 +55,7 @@ Filters, sorts, hydration, and references work across all three. Records from a ## Invites -Stored as hashed tokens. Redemption is a single atomic UPDATE that checks `!revoked && !expired && !exhausted`. Generate, hand out the plaintext once, verify later. +First-class primitive — see [Auth § Invite tokens](./04-auth.md#invite-tokens) for the mechanism. Space-specific: create via `com.example.space.invite.create`, redeem via `.redeem` (membership grant) or `?inviteToken=...` query param (read-only bearer grant). ## XRPCs diff --git a/docs/05-communities.md b/docs/06-communities.md similarity index 82% rename from docs/05-communities.md rename to docs/06-communities.md index 2fb520f..83836f4 100644 --- a/docs/05-communities.md +++ b/docs/06-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](./04-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](./05-spaces.md). ## When to use this @@ -29,13 +29,13 @@ Stored credentials (app passwords for adopted communities, signing keys for mint ## How it composes with spaces -A community *owns* spaces. Members of the community get access to community-owned spaces based on their level. Grant access per-space per-level: +A community *owns* spaces. Members of the community get access to community-owned spaces based on their access level. Grant access per-space per-level: ``` -community.space.grant { spaceUri, level: "admin", perms: "write" } +community.space.grant { spaceUri, subject: { did: "did:plc:..." }, accessLevel: "admin" } ``` -The spaces layer stays ignorant of access levels — it just sees "this DID is a member with these perms." The community layer projects member × level → space perms. +The spaces layer stays ignorant of access levels — it just sees "this DID is a member." The community layer projects member × level → membership in specific spaces. Once a DID is a member of a space (through a community grant or otherwise), they have full read + write inside it. ## XRPCs diff --git a/docs/06-sync.md b/docs/07-sync.md similarity index 100% rename from docs/06-sync.md rename to docs/07-sync.md diff --git a/docs/07-examples.md b/docs/08-examples.md similarity index 100% rename from docs/07-examples.md rename to docs/08-examples.md diff --git a/docs/frameworks/sveltekit-cloudflare.md b/docs/frameworks/sveltekit-cloudflare.md index e5508b2..691ca62 100644 --- a/docs/frameworks/sveltekit-cloudflare.md +++ b/docs/frameworks/sveltekit-cloudflare.md @@ -229,8 +229,9 @@ From now on: - [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` +- [Auth](../04-auth.md) — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets +- [Spaces](../05-spaces.md) / [Communities](../06-communities.md) — private records + group-controlled DIDs, which both slot into the same handler you just mounted +- [Sync](../07-sync.md) — reactive client-side subscriptions (`createWatchStore`) wrapped in Svelte `$state` ## Common gotchas diff --git a/packages/contrail/PERMISSIONED_DATA.md b/packages/contrail/PERMISSIONED_DATA.md index ca25eec..ead5964 100644 --- a/packages/contrail/PERMISSIONED_DATA.md +++ b/packages/contrail/PERMISSIONED_DATA.md @@ -16,7 +16,7 @@ Contrail's spaces feature is the middle path: a **centralized, auth-gated store* - Each space has one owner (DID), one type (classifying NSID), one key (string). Identified by an at-uri: `at://<owner>/<type>/<key>`. - A space holds records of *any* NSID. Same role a PDS repo plays, scoped to a shared context. -- Each member has a **perms** value: `"read"` or `"write"`. The space owner is implicit `write`. +- Every member has full read + write inside the space. No per-member permission axis — membership is the whole ACL. - Each space has an optional **app policy**: `{ mode: "allow" | "deny", apps: [client_id, …] }` — gates which OAuth clients can act in the space. That's it. Every feature (private channels, invite-only threads, shared albums) is one-space-per-permission-boundary with records of whatever NSIDs the app defines. If you need finer-grained access (e.g. "admins" vs "members"), model it as multiple spaces or enforce at the app layer. @@ -25,7 +25,7 @@ That's it. Every feature (private channels, invite-only threads, shared albums) **Each permission boundary = its own space.** No nested ACLs, no per-record permissions, no per-collection policies. Matches the blog's model; keeps the library generic. -**Read/write as the only permission axis.** The library enforces: read = any member, write = members with `"write"` (owner always). Apps that need richer roles layer them on top of multiple spaces or check `clientId` / authorDid in their handlers. +**Membership is the whole ACL.** Being in the member list = full read + write. No per-member permission granularity. Apps that need richer roles model them as multiple spaces or check `clientId` / `authorDid` in their handlers. **atproto service-auth JWTs.** Verification uses `@atcute/xrpc-server/auth.ServiceJwtVerifier`: validates signature against issuer's DID doc, checks `aud` matches the configured service DID, checks `lxm` covers the method. Same path third-party apps and our own code go through. diff --git a/packages/contrail/src/core/spaces/acl.ts b/packages/contrail/src/core/spaces/acl.ts index 0beafb0..6e0da0a 100644 --- a/packages/contrail/src/core/spaces/acl.ts +++ b/packages/contrail/src/core/spaces/acl.ts @@ -40,9 +40,10 @@ const hasMember = (space: SpaceRow, member: SpaceMemberRow | null, did: string) isOwner(space, did) || member != null; /** Space-level access check. - * Membership = access. Any member can read and write; the app filters - * writes it doesn't want on its own side. Delete keeps the owner/own-record - * rule so a random member can't nuke other people's records. */ + * Membership = access. Any member (including owner) can read and write. + * Delete is scoped to the caller's own records — owners don't get a bypass. + * A random member can't nuke other people's records, and neither can the + * owner. To remove a non-author record, delete the whole space. */ export function checkAccess(input: AclInput): AclResult { if (!checkAppPolicy(input.space.appPolicy, input.clientId)) { return { allow: false, reason: "app-not-allowed" }; @@ -55,7 +56,6 @@ export function checkAccess(input: AclInput): AclResult { } if (input.op === "delete") { - if (isOwner(input.space, input.callerDid)) return { allow: true }; if (!hasMember(input.space, input.member, input.callerDid)) { return { allow: false, reason: "not-member" }; } diff --git a/packages/contrail/tests/spaces-acl.test.ts b/packages/contrail/tests/spaces-acl.test.ts index 4f09ea5..83f42bf 100644 --- a/packages/contrail/tests/spaces-acl.test.ts +++ b/packages/contrail/tests/spaces-acl.test.ts @@ -22,9 +22,9 @@ function mkMember(did: string): SpaceMemberRow { } describe("spaces acl", () => { - it("owner can read/write/delete without a member row", () => { + it("owner can read/write without a member row", () => { const s = mkSpace(); - for (const op of ["read", "write", "delete"] as const) { + for (const op of ["read", "write"] as const) { const r = checkAccess({ op, space: s, @@ -35,6 +35,18 @@ describe("spaces acl", () => { } }); + it("owner can delete own record without a member row", () => { + const s = mkSpace(); + const r = checkAccess({ + op: "delete", + space: s, + callerDid: "did:plc:alice", + member: null, + targetAuthorDid: "did:plc:alice", + }); + expect(r.allow).toBe(true); + }); + it("non-member cannot read", () => { const s = mkSpace(); const r = checkAccess({ @@ -97,7 +109,7 @@ describe("spaces acl", () => { expect((r as any).reason).toBe("not-own-record"); }); - it("delete any: owner can delete anyone's record", () => { + it("owner cannot delete someone else's record (no bypass)", () => { const s = mkSpace(); const r = checkAccess({ op: "delete", @@ -106,7 +118,8 @@ describe("spaces acl", () => { member: null, targetAuthorDid: "did:plc:bob", }); - expect(r.allow).toBe(true); + expect(r.allow).toBe(false); + expect((r as any).reason).toBe("not-own-record"); }); it("delete by non-member: denied as not-member, not not-own-record", () => { -- 2.51.2 From ad3a61d68f25b916e1f4596df27941a3a2fb831e Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:24:18 +0200 Subject: [PATCH 4/5] commiat --- .changeset/contrail-dev-command.md | 19 +++ README.md | 1 - apps/cloudflare-workers/README.md | 6 +- .../com/example/event/getRecord.json | 6 +- .../com/example/event/listRecords.json | 6 +- apps/cloudflare-workers/package.json | 2 +- .../tools/atmo/chat/channel/getRecord.json | 6 +- .../tools/atmo/chat/channel/listRecords.json | 6 +- .../tools/atmo/chat/message/getRecord.json | 6 +- .../tools/atmo/chat/message/listRecords.json | 6 +- .../tools/atmo/chat/server/getRecord.json | 6 +- .../tools/atmo/chat/server/listRecords.json | 6 +- .../statusphere/app/status/getRecord.json | 6 +- .../statusphere/app/status/listRecords.json | 6 +- .../sveltekit-cloudflare-workers/package.json | 12 +- .../scripts/generate.ts | 13 -- .../scripts/sync.ts | 68 --------- .../src/lib/atproto/server/repo.remote.ts | 2 + .../src/routes/+layout.server.ts | 3 +- .../src/routes/+page.svelte | 6 +- docs/01-indexing.md | 2 + docs/08-examples.md | 65 --------- packages/contrail/src/cli.ts | 136 +++++++++++++++++- packages/lexicons/src/generate.ts | 12 +- pnpm-lock.yaml | 40 ------ 25 files changed, 235 insertions(+), 212 deletions(-) create mode 100644 .changeset/contrail-dev-command.md delete mode 100644 apps/sveltekit-cloudflare-workers/scripts/generate.ts delete mode 100644 apps/sveltekit-cloudflare-workers/scripts/sync.ts delete mode 100644 docs/08-examples.md diff --git a/.changeset/contrail-dev-command.md b/.changeset/contrail-dev-command.md new file mode 100644 index 0000000..5e5e715 --- /dev/null +++ b/.changeset/contrail-dev-command.md @@ -0,0 +1,19 @@ +--- +"@atmo-dev/contrail": minor +--- + +add `contrail dev` — local dev wrapper for cloudflare workers deployments. + +replaces `wrangler dev --test-scheduled` + a separate cron-trigger script with one command. on start it: + +1. connects to your local D1 via wrangler's `getPlatformProxy`, inspects state +2. prompts to run `backfillAll` if no completed backfills exist yet +3. prompts to run `refresh` if the ingest cursor is older than 60 minutes (configurable with `--stale-after`) +4. spawns `wrangler dev --test-scheduled` +5. fires `GET /__scheduled?cron=...` every 60 seconds so the cron actually runs in local dev (wrangler's scheduler only works in deployed production) + +flags: `--cron <expr>` (default `"*/1 * * * *"`), `--stale-after <min>` (default 60), `--yes` to auto-accept prompts, plus the standard `--config` / `--root` / `--binding`. + +prompts are skipped in non-TTY environments (default-declined). + +also adds `--yes` to the CLI-wide arg parser. diff --git a/README.md b/README.md index 84a3ffb..a360ae0 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,6 @@ returns every `community.lexicon.calendar.event` record published anywhere on at - [Spaces](./docs/05-spaces.md) — permissioned records stored by the appview - [Communities](./docs/06-communities.md) — group-controlled atproto DIDs - [Sync](./docs/07-sync.md) — reactive client-side store over `watchRecords` -- [Examples](./docs/08-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 7790687..1145387 100644 --- a/apps/cloudflare-workers/README.md +++ b/apps/cloudflare-workers/README.md @@ -17,8 +17,8 @@ backfills run via the `contrail` cli from the library (see `package.json` script ```bash pnpm install npx wrangler d1 create contrail # copy database_id into wrangler.jsonc -pnpm deploy # deploy the worker pnpm contrail backfill --remote # discover + backfill historical events +pnpm deploy # deploy the worker ``` then hit: @@ -30,10 +30,12 @@ GET https://<your-worker>.workers.dev/xrpc/com.example.event.listRecords?startsA ## local dev ```bash -pnpm dev # wrangler dev, cron fires every minute +pnpm dev # wraps wrangler dev + auto-fires cron + prompts for backfill/refresh if needed pnpm contrail backfill # backfill against the local D1 created by wrangler ``` +`pnpm dev` runs `contrail dev` under the hood. On start it inspects the local D1 and prompts to run backfill (if nothing's indexed yet) or refresh (if the ingest cursor is >1h old), then runs `wrangler dev --test-scheduled` with a 60s timer hitting `/__scheduled` so the cron actually fires locally. + ## extending - **add a collection:** append to `collections` in `src/contrail.config.ts`; redeploy; `pnpm contrail backfill --remote` to backfill the new one. 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 3d73338..47c29e1 100644 --- a/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json +++ b/apps/cloudflare-workers/lexicons/generated/com/example/event/getRecord.json @@ -28,7 +28,11 @@ "type": "object", "required": [ "uri", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 cbfe275..3191ac3 100644 --- a/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json +++ b/apps/cloudflare-workers/lexicons/generated/com/example/event/listRecords.json @@ -89,7 +89,11 @@ "required": [ "uri", "cid", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { diff --git a/apps/cloudflare-workers/package.json b/apps/cloudflare-workers/package.json index 48b55a0..99d2a6b 100644 --- a/apps/cloudflare-workers/package.json +++ b/apps/cloudflare-workers/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "wrangler dev --test-scheduled", + "dev": "contrail dev", "deploy": "wrangler deploy" }, "dependencies": { 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 3d6c40f..ee2685a 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,7 +37,11 @@ "type": "object", "required": [ "uri", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 903635a..a752e86 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 @@ -100,7 +100,11 @@ "required": [ "uri", "cid", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 2206480..33f728e 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,7 +37,11 @@ "type": "object", "required": [ "uri", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 c956d72..c1ddc12 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 @@ -99,7 +99,11 @@ "required": [ "uri", "cid", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 1a15c83..dcfc3ef 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,7 +37,11 @@ "type": "object", "required": [ "uri", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 aa145f6..dd2e09d 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 @@ -95,7 +95,11 @@ "required": [ "uri", "cid", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 8cb1446..c866e5e 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,7 +28,11 @@ "type": "object", "required": [ "uri", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { 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 9fea6dd..762f83f 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 @@ -90,7 +90,11 @@ "required": [ "uri", "cid", - "value" + "value", + "did", + "collection", + "rkey", + "time_us" ], "properties": { "uri": { diff --git a/apps/sveltekit-cloudflare-workers/package.json b/apps/sveltekit-cloudflare-workers/package.json index 939aba9..b9924c5 100644 --- a/apps/sveltekit-cloudflare-workers/package.json +++ b/apps/sveltekit-cloudflare-workers/package.json @@ -5,11 +5,13 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "tsx scripts/generate.ts && lex-cli generate && vite build && tsx scripts/append-scheduled.ts", - "generate": "tsx scripts/generate.ts", - "generate:pull": "tsx scripts/generate.ts && lex-cli pull && tsx scripts/generate.ts && lex-cli pull && lex-cli generate", - "sync": "tsx scripts/sync.ts", - "sync:remote": "tsx scripts/sync.ts --remote", + "build": "contrail-lex generate && contrail-lex types && vite build && tsx scripts/append-scheduled.ts", + "generate": "contrail-lex generate", + "generate:pull": "contrail-lex all", + "backfill": "contrail backfill", + "backfill:remote": "contrail backfill --remote", + "refresh": "contrail refresh", + "refresh:remote": "contrail refresh --remote", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/apps/sveltekit-cloudflare-workers/scripts/generate.ts b/apps/sveltekit-cloudflare-workers/scripts/generate.ts deleted file mode 100644 index b951669..0000000 --- a/apps/sveltekit-cloudflare-workers/scripts/generate.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { config } from '../src/lib/contrail.config'; -import { generateLexicons } from '@atmo-dev/contrail-lexicons'; - -const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); - -generateLexicons({ - config, - rootDir: ROOT_DIR, - outputDir: join(ROOT_DIR, 'lexicons', 'generated'), - writeRuntimeFiles: true -}); diff --git a/apps/sveltekit-cloudflare-workers/scripts/sync.ts b/apps/sveltekit-cloudflare-workers/scripts/sync.ts deleted file mode 100644 index c1f672f..0000000 --- a/apps/sveltekit-cloudflare-workers/scripts/sync.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Discover users from relays and backfill their records from PDS. - * - * Usage: - * pnpm sync # local D1 - * pnpm sync:remote # prod D1 - */ -import { Contrail } from '@atmo-dev/contrail'; -import { config } from '../src/lib/contrail.config'; -import { getPlatformProxy } from 'wrangler'; - -function elapsed(start: number): string { - const ms = Date.now() - start; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const mins = Math.floor(ms / 60_000); - const secs = ((ms % 60_000) / 1000).toFixed(0); - return `${mins}m ${secs}s`; -} - -async function main() { - const remote = process.argv.includes('--remote'); - const syncStart = Date.now(); - - console.log(`=== Sync (${remote ? 'remote/prod' : 'local'} D1) ===\n`); - - const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>({ - environment: remote ? 'production' : undefined - }); - - const contrail = new Contrail({ ...config, db: env.DB }); - - try { - await contrail.init(); - - console.log('--- Discovery ---'); - const discoveryStart = Date.now(); - const discovered = await contrail.discover(); - console.log(` Done: ${discovered.length} users in ${elapsed(discoveryStart)}\n`); - - console.log('--- Backfill ---'); - const backfillStart = Date.now(); - const total = await contrail.backfill({ - concurrency: 100, - onProgress: ({ records, usersComplete, usersTotal, usersFailed }) => { - const secs = (Date.now() - backfillStart) / 1000; - const rate = secs > 0 ? Math.round(records / secs) : 0; - const failStr = usersFailed > 0 ? ` | ${usersFailed} failed` : ''; - process.stdout.write( - `\r ${records} records | ${usersComplete}/${usersTotal} users | ${rate}/s | ${elapsed(backfillStart)}${failStr} ` - ); - } - }); - process.stdout.write('\n'); - console.log(` Done: ${total} records in ${elapsed(backfillStart)}\n`); - - console.log(`=== Finished in ${elapsed(syncStart)} ===`); - console.log(` Discovered: ${discovered.length} users`); - console.log(` Backfilled: ${total} records`); - } finally { - await dispose(); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/sveltekit-cloudflare-workers/src/lib/atproto/server/repo.remote.ts b/apps/sveltekit-cloudflare-workers/src/lib/atproto/server/repo.remote.ts index a9db6fb..fc7f4d8 100644 --- a/apps/sveltekit-cloudflare-workers/src/lib/atproto/server/repo.remote.ts +++ b/apps/sveltekit-cloudflare-workers/src/lib/atproto/server/repo.remote.ts @@ -33,6 +33,8 @@ export const putRecord = command( } }); + if (!response.ok) error(502, 'putRecord failed'); + // Immediately index the new/updated record in contrail const { platform } = getRequestEvent(); const db = platform?.env?.DB; diff --git a/apps/sveltekit-cloudflare-workers/src/routes/+layout.server.ts b/apps/sveltekit-cloudflare-workers/src/routes/+layout.server.ts index 756dc10..72b6c58 100644 --- a/apps/sveltekit-cloudflare-workers/src/routes/+layout.server.ts +++ b/apps/sveltekit-cloudflare-workers/src/routes/+layout.server.ts @@ -14,10 +14,11 @@ export const load: LayoutServerLoad = async ({ locals, platform }) => { }); if (!res.ok) return { did: locals.did, profile: null }; + const entry = res.data.profiles?.[0]; return { did: locals.did, - profile: extractProfile(res.data) + profile: entry ? extractProfile(entry) : null }; } catch { return { did: locals.did, profile: null }; diff --git a/apps/sveltekit-cloudflare-workers/src/routes/+page.svelte b/apps/sveltekit-cloudflare-workers/src/routes/+page.svelte index 0225014..2129ed5 100644 --- a/apps/sveltekit-cloudflare-workers/src/routes/+page.svelte +++ b/apps/sveltekit-cloudflare-workers/src/routes/+page.svelte @@ -39,9 +39,11 @@ if (profiles[did]) return; try { const res = await contrailClient.get('statusphere.app.getProfile', { - params: { actor: did } + params: { actor: did as `did:${string}:${string}` } }); - profiles[did] = extractProfile(res.data); + if (!res.ok) return; + const entry = res.data.profiles?.[0]; + if (entry) profiles[did] = extractProfile(entry); } catch { // ignore fetch errors } diff --git a/docs/01-indexing.md b/docs/01-indexing.md index 7baeeaa..0b8d70d 100644 --- a/docs/01-indexing.md +++ b/docs/01-indexing.md @@ -97,6 +97,8 @@ async scheduled(_ev, env, ctx) { `ingest()` connects to Jetstream, streams events since the saved cursor, stops when caught up. Running every minute is fine — the next fire resumes where this one left off. Each cycle is bounded, so it can't blow past the Worker time limit. +**Local dev:** wrangler's cron scheduler only runs in deployed production. For local dev use `pnpm contrail dev` — it runs `wrangler dev --test-scheduled`, fires `/__scheduled` on your configured cron interval, and prompts you to run backfill or refresh if the local DB looks stale on start. + ### Persistent (node / any long-lived server) If your runtime can keep a socket open, skip the cron entirely: diff --git a/docs/08-examples.md b/docs/08-examples.md deleted file mode 100644 index 4445cd7..0000000 --- a/docs/08-examples.md +++ /dev/null @@ -1,65 +0,0 @@ -# Examples - -Every example lives in [`apps/`](https://github.com/flo-bit/contrail/tree/main/apps) and pins contrail as `workspace:*`. Clone the repo, `pnpm install`, and each one runs. - -## `rsvp-atmo` — the reference deployment - -[`apps/rsvp-atmo`](https://github.com/flo-bit/contrail/tree/main/apps/rsvp-atmo) - -Cloudflare Workers + D1. Indexes `community.lexicon.calendar.event` and `rsvp`. Exposes the full spaces + community + realtime surface. Cron-driven Jetstream ingestion every minute. - -Use this if you're building on Workers and want a starting point that already has deploy config wired up. - -```bash -pnpm --filter rsvp-atmo dev # local wrangler + auto-cron -pnpm --filter rsvp-atmo deploy # requires D1 database created -pnpm --filter rsvp-atmo sync # discover + backfill against D1 -``` - -## `group-chat` — full app showcase - -[`apps/group-chat`](https://github.com/flo-bit/contrail/tree/main/apps/group-chat) - -SvelteKit + Cloudflare Workers. The one that exercises everything: permissioned spaces for private rooms, community-controlled DIDs for groups, client-side `contrail-sync` for reactive messages, Durable Object-hibernated WebSockets for realtime delivery, OAuth-based login. - -This is the canonical "what can contrail do" demo. If you're trying to understand how the pieces fit together end to end, read this app's code before anything else. - -```bash -pnpm --filter sveltekit-group-chat dev -``` - -## `postgres` — Node + PG minimal - -[`apps/postgres`](https://github.com/flo-bit/contrail/tree/main/apps/postgres) - -The smallest possible Node deployment. Docker Compose for Postgres, three scripts: `sync` (discover + backfill), `ingest` (persistent Jetstream), `serve` (HTTP handler). Skips spaces/communities/realtime. - -Use this as a template if you're running on a normal server and don't need Cloudflare's bells. - -```bash -cd apps/postgres -docker compose up -d -pnpm sync -pnpm serve -``` - -## `cloudflare-workers` — minimal Workers - -[`apps/cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/cloudflare-workers) - -The simplest working Worker. One collection (events), one HTTP handler, cron-driven ingest. No spaces, no communities. Good for reading top-to-bottom in one sitting to see what contrail does at minimum. - -## `sveltekit-cloudflare-workers` — SvelteKit Statusphere - -[`apps/sveltekit-cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/sveltekit-cloudflare-workers) - -A Statusphere-style SvelteKit app with OAuth login, contrail-indexed public records, and Cloudflare adapter. No spaces/communities — think "atproto blog or status post UI." Useful as a scaffold for public-only apps. - -## Choosing a starting point - -| Need | Start from | -|---|---| -| "Just index some records" | `cloudflare-workers` or `postgres` | -| "Index + SvelteKit UI, public only" | `sveltekit-cloudflare-workers` | -| "Private rooms / group chat / full stack" | `group-chat` | -| "Calendar-ish domain, Workers deploy" | `rsvp-atmo` | diff --git a/packages/contrail/src/cli.ts b/packages/contrail/src/cli.ts index 4f98dfb..9246dae 100644 --- a/packages/contrail/src/cli.ts +++ b/packages/contrail/src/cli.ts @@ -5,21 +5,28 @@ * Usage: * contrail backfill [--remote] [--binding DB] [--config path] * contrail refresh [--remote] [--ignore-window 60s] [--by-collection] + * contrail dev [--binding DB] [--cron '*\/1 * * * *'] * * Config auto-detects at contrail.config.ts, src/contrail.config.ts, * src/lib/contrail.config.ts, or app/contrail.config.ts (first match wins). */ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; import { findConfigFile, loadConfig, CONFIG_CANDIDATES_MESSAGE } from "./cli-config.js"; import { backfillAll, refresh } from "./workers/backfill.js"; +import { Contrail } from "./contrail.js"; import type { CollectionStats, RefreshResult } from "./core/refresh.js"; +import type { Database } from "./core/types.js"; -type Subcommand = "backfill" | "refresh" | "help"; +type Subcommand = "backfill" | "refresh" | "dev" | "help"; const USAGE = `contrail <subcommand> [options] Subcommands: backfill One-time bulk load from each known DID's PDS (resumable) refresh Fresh sweep: reconcile PDS vs DB, report missing + stale + dev Local wrangler dev + auto-trigger cron + backfill/refresh prompts help Print this message Options (backfill): @@ -37,6 +44,14 @@ Options (refresh): --concurrency <n> Passed to contrail.refresh(). Default: 50. --ignore-window <s> Seconds of grace for stale-update detection. Default: 60. --by-collection Print per-collection stats, not just totals. + +Options (dev): + --config <path> Path to Contrail config file (TS or JS). + --root <path> Project root for auto-detection. Default: CWD. + --binding <name> D1 binding name in wrangler.jsonc. Default: "DB". + --cron <expr> Cron expression to fire against /__scheduled. Default: "*/1 * * * *". + --stale-after <min> Prompt to run refresh if the ingest cursor is older than this. Default: 60. + --yes Accept all prompts without asking (CI-friendly). `; interface Args { @@ -48,6 +63,9 @@ interface Args { concurrency?: number; ignoreWindowMs?: number; byCollection: boolean; + cron?: string; + staleAfterMin?: number; + yes: boolean; } function parseArgs(argv: string[]): Args { @@ -60,6 +78,9 @@ function parseArgs(argv: string[]): Args { let concurrency: number | undefined; let ignoreWindowMs: number | undefined; let byCollection = false; + let cron: string | undefined; + let staleAfterMin: number | undefined; + let yes = false; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "--config") config = args[++i]; @@ -69,6 +90,9 @@ function parseArgs(argv: string[]): Args { else if (a === "--concurrency") concurrency = parseInt(args[++i], 10); else if (a === "--ignore-window") ignoreWindowMs = parseInt(args[++i], 10) * 1000; else if (a === "--by-collection") byCollection = true; + else if (a === "--cron") cron = args[++i]; + else if (a === "--stale-after") staleAfterMin = parseInt(args[++i], 10); + else if (a === "--yes" || a === "-y") yes = true; else if (a === "-h" || a === "--help") return { cmd: "help", @@ -79,9 +103,12 @@ function parseArgs(argv: string[]): Args { concurrency, ignoreWindowMs, byCollection, + cron, + staleAfterMin, + yes, }; } - return { cmd, config, root, remote, binding, concurrency, ignoreWindowMs, byCollection }; + return { cmd, config, root, remote, binding, concurrency, ignoreWindowMs, byCollection, cron, staleAfterMin, yes }; } function resolveConfigPath(opts: Args): string | null { @@ -95,6 +122,21 @@ function resolveConfigPath(opts: Args): string | null { return p; } +async function promptYesNo(question: string, defaultYes: boolean, autoYes: boolean): Promise<boolean> { + if (autoYes) return true; + // Non-TTY → default-decline; the user isn't there to answer. + if (!input.isTTY) return false; + const rl = createInterface({ input, output }); + try { + const hint = defaultYes ? "[Y/n]" : "[y/N]"; + const ans = (await rl.question(`${question} ${hint} `)).trim().toLowerCase(); + if (ans === "") return defaultYes; + return ans === "y" || ans === "yes"; + } finally { + rl.close(); + } +} + function formatStats(s: CollectionStats): string { return `${s.missing} missing, ${s.staleUpdates} stale updates, ${s.inSync} in sync`; } @@ -121,6 +163,94 @@ function printRefreshReport(result: RefreshResult, byCollection: boolean): void console.log(` (ignore window: ${(result.ignoreWindowMs / 1000).toFixed(0)}s)`); } +async function cmdDev(opts: Args): Promise<number> { + const configPath = resolveConfigPath(opts); + if (!configPath) return 1; + const config = await loadConfig(configPath); + + // Pre-flight: connect to local D1 via wrangler's platform proxy, inspect + // state, prompt. Then dispose before starting wrangler dev so the two + // miniflare processes don't fight over the sqlite file. + const { getPlatformProxy } = await import("wrangler"); + const { env, dispose } = await getPlatformProxy(); + const db = (env as Record<string, unknown>)[opts.binding] as Database | undefined; + + if (db) { + const contrail = new Contrail(config); + await contrail.init(db); + + const hasBackfilled = await db + .prepare("SELECT 1 FROM backfills WHERE completed = 1 LIMIT 1") + .first(); + + if (!hasBackfilled) { + console.log("no backfilled users in the local DB yet."); + if (await promptYesNo("run backfill now? (takes a few minutes)", true, opts.yes)) { + await contrail.backfillAll({ concurrency: opts.concurrency ?? 100 }, db); + } + } else { + const staleAfterMs = (opts.staleAfterMin ?? 60) * 60_000; + const row = await db + .prepare("SELECT time_us FROM cursor WHERE id = 1") + .first<{ time_us: number }>(); + if (row?.time_us) { + const ageMs = Date.now() - Math.floor(row.time_us / 1000); + if (ageMs > staleAfterMs) { + const hrs = (ageMs / 3_600_000).toFixed(1); + console.log(`ingest cursor is ${hrs}h old — you may have missed events.`); + if (await promptYesNo("run refresh first?", true, opts.yes)) { + await contrail.refresh({ ignoreWindowMs: opts.ignoreWindowMs }, db); + } + } + } + } + } + + await dispose(); + + // Start wrangler dev + fire /__scheduled on a loop so the cron actually + // runs in local dev (wrangler's cron scheduler only works in deployed + // production; --test-scheduled enables the manual-trigger endpoint). + const cron = opts.cron ?? "*/1 * * * *"; + const cronUrl = `http://localhost:8787/__scheduled?cron=${encodeURIComponent(cron)}`; + + const wrangler = spawn("npx", ["wrangler", "dev", "--test-scheduled"], { + stdio: "inherit", + shell: process.platform === "win32", + cwd: opts.root, + }); + + // Give wrangler a few seconds to bind the port, then start the cron loop. + const kickoff = setTimeout(() => { + fetch(cronUrl).catch(() => {}); // fire-and-forget; first run + console.log(`\nauto-ingest: firing ${cronUrl} every 60s\n`); + }, 3_000); + + const interval = setInterval(() => { + fetch(cronUrl).catch(() => {}); // wrangler may be restarting; swallow + }, 60_000); + + const cleanup = () => { + clearTimeout(kickoff); + clearInterval(interval); + try { + wrangler.kill("SIGINT"); + } catch { + /* ignore */ + } + }; + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + return new Promise<number>((resolve) => { + wrangler.on("exit", (code) => { + clearTimeout(kickoff); + clearInterval(interval); + resolve(code ?? 0); + }); + }); +} + async function main(): Promise<number> { const opts = parseArgs(process.argv); @@ -157,6 +287,8 @@ async function main(): Promise<number> { return 0; } + if (opts.cmd === "dev") return cmdDev(opts); + console.error(USAGE); return 1; } diff --git a/packages/lexicons/src/generate.ts b/packages/lexicons/src/generate.ts index 9e2c745..bf57b6a 100644 --- a/packages/lexicons/src/generate.ts +++ b/packages/lexicons/src/generate.ts @@ -247,7 +247,15 @@ export function generateLexicons(options: GenerateOptions): Record<string, objec properties[rd.refName] = { type: "ref", ref: `#ref${cap(rd.refName)}Record` }; } } - return { type: "object", required: ["uri", "cid", "value"], properties }; + // Required list is a superset of atproto's `#record` (`uri`, `cid`, `value`). + // Contrail always populates the extras too, so including them here gives + // contrail-aware clients strong typing without breaking atproto clients + // that only check the standard three. + return { + type: "object", + required: ["uri", "cid", "value", "did", "collection", "rkey", "time_us"], + properties, + }; } function buildHydrateDefs(relationDefs: RelationDef[]): Record<string, any> { @@ -738,7 +746,7 @@ export function generateLexicons(options: GenerateOptions): Record<string, objec writeLexicon(`${ns}.${shortName}.getRecord`, { lexicon: 1, id: `${ns}.${shortName}.getRecord`, defs: { - main: { type: "query", description: `Get a single ${collection} record by AT URI`, parameters: { type: "params", required: ["uri"], properties: getParams }, output: { encoding: "application/json", schema: { type: "object", required: ["uri", "value"], properties: { ...buildRecordDef(collectionRef, countFields, relationDefs, referenceDefs).properties, profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } } } } } }, + main: { type: "query", description: `Get a single ${collection} record by AT URI`, parameters: { type: "params", required: ["uri"], properties: getParams }, output: { encoding: "application/json", schema: { type: "object", required: ["uri", "value", "did", "collection", "rkey", "time_us"], properties: { ...buildRecordDef(collectionRef, countFields, relationDefs, referenceDefs).properties, profiles: { type: "array", items: { type: "ref", ref: "#profileEntry" } } } } } }, ...hydrateDefs, ...refDefs, ...profileDefs(), }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acce499..bb835ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,46 +186,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - apps/rsvp-atmo: - dependencies: - '@atmo-dev/contrail': - specifier: workspace:* - version: link:../../packages/contrail - devDependencies: - '@atcute/atproto': - specifier: ^3.1.10 - version: 3.1.11 - '@atcute/client': - specifier: ^4.2.1 - version: 4.2.1 - '@atcute/lex-cli': - specifier: ^2.5.3 - version: 2.8.1 - '@atcute/lexicon-doc': - specifier: ^2.1.2 - version: 2.2.0 - '@atcute/lexicons': - specifier: ^1.2.9 - version: 1.3.0 - '@atmo-dev/contrail-lexicons': - specifier: workspace:* - version: link:../../packages/lexicons - '@cloudflare/workers-types': - specifier: ^4.20250124.0 - version: 4.20260424.1 - '@types/node': - specifier: ^25.5.0 - version: 25.6.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - wrangler: - specifier: ^4.63.0 - version: 4.84.1(@cloudflare/workers-types@4.20260424.1) - apps/sveltekit-cloudflare-workers: dependencies: '@atcute/jetstream': -- 2.51.2 From ad3063a74b9d5a7a96bee7911b04a0eac25fb3b4 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:57:08 +0200 Subject: [PATCH 5/5] add lexicon endpoint --- .changeset/create-worker-lexicons-endpoint.md | 32 +++ README.md | 22 +- .../lexicons/generated/index.ts | 19 ++ apps/cloudflare-workers/src/worker.ts | 18 +- apps/group-chat/lexicons/generated/index.ts | 65 ++++++ .../lexicons/generated/index.ts | 15 ++ docs/03-lexicons.md | 32 ++- packages/contrail/package.json | 4 + packages/contrail/src/cli-config.ts | 10 +- packages/contrail/src/core/router/index.ts | 15 ++ packages/contrail/src/server.ts | 14 +- packages/contrail/src/worker/index.ts | 69 ++++++ packages/contrail/tests/cli-config.test.ts | 157 ++++++++++++++ .../contrail/tests/lexicons-endpoint.test.ts | 49 +++++ packages/contrail/tests/refresh.test.ts | 199 ++++++++++++++++++ packages/contrail/tests/worker.test.ts | 103 +++++++++ packages/contrail/tsup.config.ts | 1 + packages/lexicons/src/cli.ts | 108 ++++++++-- packages/lexicons/src/generate.ts | 36 +++- packages/lexicons/tests/generate.test.ts | 93 +++++++- 20 files changed, 1006 insertions(+), 55 deletions(-) create mode 100644 .changeset/create-worker-lexicons-endpoint.md create mode 100644 apps/cloudflare-workers/lexicons/generated/index.ts create mode 100644 apps/group-chat/lexicons/generated/index.ts create mode 100644 apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts create mode 100644 packages/contrail/src/worker/index.ts create mode 100644 packages/contrail/tests/cli-config.test.ts create mode 100644 packages/contrail/tests/lexicons-endpoint.test.ts create mode 100644 packages/contrail/tests/refresh.test.ts create mode 100644 packages/contrail/tests/worker.test.ts diff --git a/.changeset/create-worker-lexicons-endpoint.md b/.changeset/create-worker-lexicons-endpoint.md new file mode 100644 index 0000000..7ae8ac3 --- /dev/null +++ b/.changeset/create-worker-lexicons-endpoint.md @@ -0,0 +1,32 @@ +--- +"@atmo-dev/contrail": minor +"@atmo-dev/contrail-lexicons": minor +--- + +two new DX pieces: + +**`@atmo-dev/contrail/worker`** exports `createWorker(config, options?)` — a prebuilt Cloudflare Workers entry that collapses the ~12-line `{ fetch, scheduled }` boilerplate to one line: + +```ts +import { createWorker } from "@atmo-dev/contrail/worker"; +import { config } from "./contrail.config"; +import { lexicons } from "../lexicons/generated"; + +export default createWorker(config, { lexicons }); +``` + +options: `binding` (D1 binding name, default `"DB"`), `lexicons` (see below), `onInit` (one-shot app-specific setup). + +**`/xrpc/<ns>.lexicons` endpoint + `contrail-lex pull-service`** lets consumer apps typegen against a deployed contrail over HTTP, no PDS or DNS required: + +- `contrail-lex generate` now emits a barrel `lexicons/generated/index.ts` that imports every lexicon the deployment speaks: generated + pulled + custom. The pulled lexicons are needed so consumer typegen can resolve `$ref`s out of the generated schemas. +- Pass `{ lexicons }` to `createWorker` (or `createHandler(contrail, { lexicons })`) and the service exposes them at `GET /xrpc/<namespace>.lexicons`. +- From a consumer app: + ```bash + contrail-lex pull-service https://my-contrail.dev/xrpc/com.example.lexicons + # or + contrail-lex pull-service https://my-contrail.dev --namespace com.example + ``` + Fetches the manifest, writes each lexicon under `lexicons/pulled/`. Then `npx lex-cli generate` emits TS types. + +Path 1 of 4 of a set of DX improvements — path 2 (consumer typegen) works end-to-end but assumes the operator has regenerated. Paths 3 (one-command deploy) and 4 (fully vendored worker) are deferred. diff --git a/README.md b/README.md index a360ae0..64d09f0 100644 --- a/README.md +++ b/README.md @@ -37,28 +37,18 @@ export const config: ContrailConfig = { }; ``` -**`src/worker.ts`** — serves XRPC requests, pulls new records every minute: +**`src/worker.ts`** — four lines. `createWorker` wires up fetch + scheduled + lazy init: ```ts -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; +import { createWorker } from "@atmo-dev/contrail/worker"; import { config } from "./contrail.config"; +import { lexicons } from "../lexicons/generated"; -const contrail = new Contrail(config); -const handle = createHandler(contrail); -let ready = false; - -export default { - async fetch(req: Request, env: { DB: D1Database }) { - if (!ready) { await contrail.init(env.DB); ready = true; } // create tables once - return handle(req, env.DB); // xrpc routes - }, - async scheduled(_ev: ScheduledEvent, env: { DB: D1Database }, ctx: ExecutionContext) { - ctx.waitUntil(contrail.ingest({}, env.DB)); // pull new records - }, -}; +export default createWorker(config, { lexicons }); ``` +`lexicons/generated/` is produced by `contrail-lex generate`; passing `{ lexicons }` exposes them at `/lexicons` so consumer apps can typegen against your deployed service. Drop it if you don't need that. + and a d1 binding + cron in `wrangler.jsonc`: ```jsonc diff --git a/apps/cloudflare-workers/lexicons/generated/index.ts b/apps/cloudflare-workers/lexicons/generated/index.ts new file mode 100644 index 0000000..568ba45 --- /dev/null +++ b/apps/cloudflare-workers/lexicons/generated/index.ts @@ -0,0 +1,19 @@ +// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. +// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them +// at `/xrpc/<namespace>.lexicons` for consumer apps to typegen against. + +import _0 from "../pulled/app/bsky/actor/profile.json"; +import _1 from "../pulled/community/lexicon/calendar/event.json"; +import _2 from "../pulled/community/lexicon/location/address.json"; +import _3 from "../pulled/community/lexicon/location/fsq.json"; +import _4 from "../pulled/community/lexicon/location/geo.json"; +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 _12 from "./com/example/permissionSet.json"; + +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12]; diff --git a/apps/cloudflare-workers/src/worker.ts b/apps/cloudflare-workers/src/worker.ts index 8d9a216..35003cf 100644 --- a/apps/cloudflare-workers/src/worker.ts +++ b/apps/cloudflare-workers/src/worker.ts @@ -1,17 +1,5 @@ -import { Contrail } from "@atmo-dev/contrail"; -import { createHandler } from "@atmo-dev/contrail/server"; +import { createWorker } from "@atmo-dev/contrail/worker"; import { config } from "./contrail.config"; +import { lexicons } from "../lexicons/generated"; -const contrail = new Contrail(config); -const handle = createHandler(contrail); -let ready = false; - -export default { - async fetch(req: Request, env: { DB: D1Database }) { - if (!ready) { await contrail.init(env.DB); ready = true; } // create tables once - return handle(req, env.DB); // xrpc routes - }, - async scheduled(_ev: ScheduledEvent, env: { DB: D1Database }, ctx: ExecutionContext) { - ctx.waitUntil(contrail.ingest({}, env.DB)); // pull new records - }, -}; +export default createWorker(config, { lexicons }); diff --git a/apps/group-chat/lexicons/generated/index.ts b/apps/group-chat/lexicons/generated/index.ts new file mode 100644 index 0000000..aaa46b9 --- /dev/null +++ b/apps/group-chat/lexicons/generated/index.ts @@ -0,0 +1,65 @@ +// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. +// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them +// at `/xrpc/<namespace>.lexicons` for consumer apps to typegen against. + +import _0 from "../custom/tools/atmo/chat/channel.json"; +import _1 from "../custom/tools/atmo/chat/message.json"; +import _2 from "../custom/tools/atmo/chat/server.json"; +import _3 from "../pulled/app/bsky/actor/profile.json"; +import _4 from "./tools/atmo/chat/channel/getRecord.json"; +import _5 from "./tools/atmo/chat/channel/listRecords.json"; +import _6 from "./tools/atmo/chat/channel/watchRecords.json"; +import _7 from "./tools/atmo/chat/community/adopt.json"; +import _8 from "./tools/atmo/chat/community/defs.json"; +import _9 from "./tools/atmo/chat/community/delete.json"; +import _10 from "./tools/atmo/chat/community/deleteRecord.json"; +import _11 from "./tools/atmo/chat/community/getHealth.json"; +import _12 from "./tools/atmo/chat/community/list.json"; +import _13 from "./tools/atmo/chat/community/mint.json"; +import _14 from "./tools/atmo/chat/community/putRecord.json"; +import _15 from "./tools/atmo/chat/community/reauth.json"; +import _16 from "./tools/atmo/chat/community/space/create.json"; +import _17 from "./tools/atmo/chat/community/space/delete.json"; +import _18 from "./tools/atmo/chat/community/space/deleteRecord.json"; +import _19 from "./tools/atmo/chat/community/space/grant.json"; +import _20 from "./tools/atmo/chat/community/space/listMembers.json"; +import _21 from "./tools/atmo/chat/community/space/putRecord.json"; +import _22 from "./tools/atmo/chat/community/space/resync.json"; +import _23 from "./tools/atmo/chat/community/space/revoke.json"; +import _24 from "./tools/atmo/chat/community/space/setAccessLevel.json"; +import _25 from "./tools/atmo/chat/getCursor.json"; +import _26 from "./tools/atmo/chat/getOverview.json"; +import _27 from "./tools/atmo/chat/getProfile.json"; +import _28 from "./tools/atmo/chat/invite/create.json"; +import _29 from "./tools/atmo/chat/invite/defs.json"; +import _30 from "./tools/atmo/chat/invite/list.json"; +import _31 from "./tools/atmo/chat/invite/redeem.json"; +import _32 from "./tools/atmo/chat/invite/revoke.json"; +import _33 from "./tools/atmo/chat/message/getRecord.json"; +import _34 from "./tools/atmo/chat/message/listRecords.json"; +import _35 from "./tools/atmo/chat/message/watchRecords.json"; +import _36 from "./tools/atmo/chat/notifyOfUpdate.json"; +import _37 from "./tools/atmo/chat/permissionSet.json"; +import _38 from "./tools/atmo/chat/realtime/subscribe.json"; +import _39 from "./tools/atmo/chat/realtime/ticket.json"; +import _40 from "./tools/atmo/chat/server/getRecord.json"; +import _41 from "./tools/atmo/chat/server/listRecords.json"; +import _42 from "./tools/atmo/chat/server/watchRecords.json"; +import _43 from "./tools/atmo/chat/space/addMember.json"; +import _44 from "./tools/atmo/chat/space/createSpace.json"; +import _45 from "./tools/atmo/chat/space/defs.json"; +import _46 from "./tools/atmo/chat/space/deleteRecord.json"; +import _47 from "./tools/atmo/chat/space/getBlob.json"; +import _48 from "./tools/atmo/chat/space/getRecord.json"; +import _49 from "./tools/atmo/chat/space/getSpace.json"; +import _50 from "./tools/atmo/chat/space/leaveSpace.json"; +import _51 from "./tools/atmo/chat/space/listBlobs.json"; +import _52 from "./tools/atmo/chat/space/listMembers.json"; +import _53 from "./tools/atmo/chat/space/listRecords.json"; +import _54 from "./tools/atmo/chat/space/listSpaces.json"; +import _55 from "./tools/atmo/chat/space/putRecord.json"; +import _56 from "./tools/atmo/chat/space/removeMember.json"; +import _57 from "./tools/atmo/chat/space/uploadBlob.json"; +import _58 from "./tools/atmo/chat/spaceExt/whoami.json"; + +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22, _23, _24, _25, _26, _27, _28, _29, _30, _31, _32, _33, _34, _35, _36, _37, _38, _39, _40, _41, _42, _43, _44, _45, _46, _47, _48, _49, _50, _51, _52, _53, _54, _55, _56, _57, _58]; diff --git a/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts b/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts new file mode 100644 index 0000000..e1faba6 --- /dev/null +++ b/apps/sveltekit-cloudflare-workers/lexicons/generated/index.ts @@ -0,0 +1,15 @@ +// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit. +// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them +// at `/xrpc/<namespace>.lexicons` for consumer apps to typegen against. + +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 _6 from "./statusphere/app/permissionSet.json"; +import _7 from "./statusphere/app/status/getRecord.json"; +import _8 from "./statusphere/app/status/listRecords.json"; + +export const lexicons: object[] = [_0, _1, _2, _3, _4, _5, _6, _7, _8]; diff --git a/docs/03-lexicons.md b/docs/03-lexicons.md index 09cd550..d06c4e8 100644 --- a/docs/03-lexicons.md +++ b/docs/03-lexicons.md @@ -11,12 +11,13 @@ pnpm add -D @atmo-dev/contrail-lexicons @atcute/lex-cli ## CLI ```bash -contrail-lex generate # emit lexicon JSON from your Contrail config -contrail-lex pull # wraps `lex-cli pull` (fetch external lexicons) -contrail-lex types # wraps `lex-cli generate` (JSON → TS types) -contrail-lex all # generate → pull → generate → pull → types -contrail-lex all --no-types # same, skip the type step -contrail-lex publish # publish lexicons to your PDS (add --dry-run to preview) +contrail-lex generate # emit lexicon JSON from your Contrail config +contrail-lex pull # wraps `lex-cli pull` (fetch external lexicons) +contrail-lex types # wraps `lex-cli generate` (JSON → TS types) +contrail-lex all # generate → pull → generate → pull → types +contrail-lex all --no-types # same, skip the type step +contrail-lex publish # publish lexicons to your PDS (add --dry-run to preview) +contrail-lex pull-service <url> # consume a deployed contrail's /lexicons endpoint ``` Config is auto-detected at `contrail.config.ts`, `src/contrail.config.ts`, `src/lib/contrail.config.ts`, or `app/contrail.config.ts`. Override with `--config <path>`. @@ -36,6 +37,25 @@ src/lexicon-types/ # TS types from lex-cli generate `lexicons/custom/`, `lexicons/generated/`, and `lexicons/pulled/` should be **committed** — that way CI doesn't need network access. `lex.config.js` and `src/lexicon-types/` are regenerated on demand and safe to gitignore. +`lexicons/generated/index.ts` is also emitted on every run — a barrel that imports every lexicon the deployment speaks (generated + pulled + custom). Pass it to `createWorker(config, { lexicons })` to expose them at `/xrpc/<namespace>.lexicons` on your deployed service; consumer apps can then `pull-service` against it. + +## Consuming a deployed contrail + +If you're building a frontend that talks to someone's (or your own) deployed contrail, you don't need the backend's source code to get typed XRPC calls. Have the operator pass `{ lexicons }` to `createWorker`, then: + +```bash +contrail-lex pull-service https://my-contrail.dev/xrpc/com.example.lexicons +# or: contrail-lex pull-service https://my-contrail.dev --namespace com.example + +npx lex-cli generate +``` + +`pull-service` hits the manifest endpoint, writes each lexicon under `lexicons/pulled/<nsid>.json`, and `lex-cli generate` emits TypeScript types for `@atcute/client`. Override the output dir with `--out <path>`. + +The manifest includes the service's generated lexicons *plus* any external NSIDs the generator `$ref`s (e.g., `app.bsky.actor.profile`, `community.lexicon.calendar.event`) — so typegen resolves cleanly with no additional fetching from bsky / atproto registries. + +No PDS setup required, no DNS, no published lexicon records — just an HTTP endpoint and typegen. + ## Publishing Once your lexicons are generated and committed, publish them as `com.atproto.lexicon.schema` records on your PDS so other apps can resolve them: diff --git a/packages/contrail/package.json b/packages/contrail/package.json index 7253821..39493dd 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -31,6 +31,10 @@ "types": "./dist/workers/backfill.d.ts", "import": "./dist/workers/backfill.js" }, + "./worker": { + "types": "./dist/worker/index.d.ts", + "import": "./dist/worker/index.js" + }, "./cli-config": { "types": "./dist/cli-config.d.ts", "import": "./dist/cli-config.js" diff --git a/packages/contrail/src/cli-config.ts b/packages/contrail/src/cli-config.ts index 6482319..c422d08 100644 --- a/packages/contrail/src/cli-config.ts +++ b/packages/contrail/src/cli-config.ts @@ -46,7 +46,9 @@ export function findConfigFile(root: string, explicit?: string): string | null { /** Load a config file via jiti — handles TS + ESM + CJS transparently, * no tsx/ts-node hook required. Accepts either a named export `config` or - * a default export. Throws if neither is present. */ + * a default export. Validates the result has the minimum `ContrailConfig` + * shape (`namespace` + `collections`) so misnamed exports throw at load + * time rather than producing confusing "undefined.namespace" errors later. */ export async function loadConfig<T = ContrailConfig>(path: string): Promise<T> { const jiti = createJiti(import.meta.url, { interopDefault: true }); const mod = (await jiti.import(path)) as { config?: unknown; default?: unknown }; @@ -54,5 +56,11 @@ export async function loadConfig<T = ContrailConfig>(path: string): Promise<T> { if (!config || typeof config !== "object") { throw new Error(`Config at ${path} did not export a \`config\` object`); } + if (!("namespace" in config) || !("collections" in config)) { + throw new Error( + `Config at ${path} did not export a valid \`config\` — missing required fields (namespace, collections). ` + + `Make sure your file does \`export const config: ContrailConfig = { namespace: "…", collections: {…} }\`.` + ); + } return config as T; } diff --git a/packages/contrail/src/core/router/index.ts b/packages/contrail/src/core/router/index.ts index bf714fd..413161c 100644 --- a/packages/contrail/src/core/router/index.ts +++ b/packages/contrail/src/core/router/index.ts @@ -38,6 +38,11 @@ export interface CreateAppOptions { spacesDb?: Database; /** Full spaces context override (escape hatch for tests). */ spacesCtx?: SpacesContext | null; + /** Lexicon JSONs to serve at `/lexicons` so consumer apps can fetch + + * typegen against this deployment. Emit with `contrail-lex generate` — + * its `lexicons/generated/index.ts` exports the right shape. If omitted, + * the endpoint returns `404`. */ + lexicons?: object[]; } export function createApp( @@ -54,6 +59,16 @@ export function createApp( const ns = config.namespace; + // Lexicon manifest — lets consumer apps fetch every lexicon this + // deployment speaks (generated + pulled + custom) over HTTP and + // typegen clients, without needing a PDS or DNS resolution. Only + // registered when the caller passed bundled lexicons at build time + // via `contrail-lex generate`. + if (options.lexicons && options.lexicons.length > 0) { + const lexicons = options.lexicons; + app.get(`/xrpc/${ns}.lexicons`, (c) => c.json({ lexicons })); + } + app.get(`/xrpc/${ns}.getProfile`, async (c) => { const actor = c.req.query("actor"); if (!actor) return c.json({ error: "actor parameter required" }, 400); diff --git a/packages/contrail/src/server.ts b/packages/contrail/src/server.ts index 3e1bcf8..fd1eb03 100644 --- a/packages/contrail/src/server.ts +++ b/packages/contrail/src/server.ts @@ -16,16 +16,24 @@ import { markInProcess } from "./core/spaces/in-process"; * For most cases, prefer `contrail.handler()` directly when DBs are bound at * construction time. */ +export interface CreateHandlerOptions { + /** Bundled lexicon JSONs — if provided, served at `/lexicons` so + * consumer apps can fetch + typegen against this deployment. Generate + * with `contrail-lex generate` and import from `lexicons/generated`. */ + lexicons?: object[]; +} + export function createHandler( - contrail: Contrail + contrail: Contrail, + options: CreateHandlerOptions = {} ): (request: Request, db?: Database, spacesDb?: Database) => Promise<Response> { // When no per-request DBs are provided, build the app once and reuse it. let cached: ((request: Request) => Promise<Response>) | null = null; return (request: Request, db?: Database, spacesDb?: Database) => { if (db || spacesDb) { - return contrail.handler({ db, spacesDb })(request); + return contrail.handler({ db, spacesDb, lexicons: options.lexicons })(request); } - cached ??= contrail.handler(); + cached ??= contrail.handler({ lexicons: options.lexicons }); return cached(request); }; } diff --git a/packages/contrail/src/worker/index.ts b/packages/contrail/src/worker/index.ts new file mode 100644 index 0000000..b0d2ed1 --- /dev/null +++ b/packages/contrail/src/worker/index.ts @@ -0,0 +1,69 @@ +/** + * Prebuilt Cloudflare Workers entrypoint for a contrail deployment. + * + * Collapses the ~12-line boilerplate of `new Contrail()` + `createHandler()` + + * `{ fetch, scheduled }` down to: + * + * import { createWorker } from "@atmo-dev/contrail/worker"; + * import { config } from "./contrail.config"; + * import { lexicons } from "./lexicons/generated"; // optional, enables /lexicons + * export default createWorker(config, { lexicons }); + * + * The handler lazily inits the DB schema on first request per isolate, + * registers every XRPC route, and runs `contrail.ingest()` on the + * `scheduled` event. Pass `binding` if your D1 binding isn't named `DB`; + * pass `onInit` for app-specific one-shot setup that needs the DB. + */ +import { Contrail } from "../contrail.js"; +import { createHandler } from "../server.js"; +import type { ContrailConfig, Database } from "../core/types.js"; + +export interface CreateWorkerOptions { + /** D1 binding name in wrangler env. Default: `"DB"`. */ + binding?: string; + /** Bundled lexicon JSON — if provided, exposes them at `/lexicons` + * so consumer apps can typegen against the deployed service. Generate + * with `contrail-lex generate` (emits `lexicons/generated/index.ts`). */ + lexicons?: object[]; + /** Runs once per isolate, after schema init, before handling the first + * request. Use for app-specific setup that needs a live DB handle. */ + onInit?: (env: Record<string, unknown>, db: Database) => void | Promise<void>; +} + +type WorkerEnv = Record<string, unknown>; + +export function createWorker( + config: ContrailConfig, + options: CreateWorkerOptions = {} +) { + const binding = options.binding ?? "DB"; + const contrail = new Contrail(config); + const handle = createHandler(contrail, { + lexicons: options.lexicons, + }); + + let ready = false; + const ensureReady = async (env: WorkerEnv, db: Database): Promise<void> => { + if (ready) return; + await contrail.init(db); + await options.onInit?.(env, db); + ready = true; + }; + + return { + async fetch(request: Request, env: WorkerEnv): Promise<Response> { + const db = env[binding] as Database; + await ensureReady(env, db); + return (await handle(request, db)) as Response; + }, + async scheduled( + _event: ScheduledEvent, + env: WorkerEnv, + ctx: ExecutionContext + ): Promise<void> { + const db = env[binding] as Database; + await ensureReady(env, db); + ctx.waitUntil(contrail.ingest({}, db)); + }, + }; +} diff --git a/packages/contrail/tests/cli-config.test.ts b/packages/contrail/tests/cli-config.test.ts new file mode 100644 index 0000000..35ce9e4 --- /dev/null +++ b/packages/contrail/tests/cli-config.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + findConfigFile, + loadConfig, + CONFIG_CANDIDATES, +} from "../src/cli-config"; + +describe("findConfigFile", () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "contrail-cli-config-")); + }); + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("returns null when no candidates exist", () => { + expect(findConfigFile(root)).toBeNull(); + }); + + it("finds root-level contrail.config.ts", () => { + const path = join(root, "contrail.config.ts"); + writeFileSync(path, "export const config = {};"); + expect(findConfigFile(root)).toBe(path); + rmSync(path); + }); + + it("finds src/contrail.config.ts", () => { + mkdirSync(join(root, "src"), { recursive: true }); + const path = join(root, "src", "contrail.config.ts"); + writeFileSync(path, "export const config = {};"); + expect(findConfigFile(root)).toBe(path); + rmSync(path); + }); + + it("finds src/lib/contrail.config.ts", () => { + mkdirSync(join(root, "src", "lib"), { recursive: true }); + const path = join(root, "src", "lib", "contrail.config.ts"); + writeFileSync(path, "export const config = {};"); + expect(findConfigFile(root)).toBe(path); + rmSync(path); + }); + + it("finds app/contrail.config.ts", () => { + mkdirSync(join(root, "app"), { recursive: true }); + const path = join(root, "app", "contrail.config.ts"); + writeFileSync(path, "export const config = {};"); + expect(findConfigFile(root)).toBe(path); + rmSync(path); + }); + + it("returns the first match in candidate order", () => { + // Both src/ and app/ versions exist; src/ wins because it's earlier in the list. + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, "app"), { recursive: true }); + const winner = join(root, "src", "contrail.config.ts"); + const loser = join(root, "app", "contrail.config.ts"); + writeFileSync(winner, ""); + writeFileSync(loser, ""); + expect(findConfigFile(root)).toBe(winner); + rmSync(winner); + rmSync(loser); + }); + + it("respects an explicit override path (relative to root)", () => { + const explicit = join(root, "weird.config.ts"); + writeFileSync(explicit, ""); + expect(findConfigFile(root, "weird.config.ts")).toBe(explicit); + rmSync(explicit); + }); + + it("returns null for an explicit path that doesn't exist", () => { + expect(findConfigFile(root, "doesnotexist.ts")).toBeNull(); + }); + + it("supports both .ts and .js variants in candidates", () => { + expect(CONFIG_CANDIDATES).toContain("contrail.config.ts"); + expect(CONFIG_CANDIDATES).toContain("contrail.config.js"); + expect(CONFIG_CANDIDATES).toContain("src/contrail.config.ts"); + }); +}); + +describe("loadConfig", () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "contrail-load-config-")); + }); + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("loads a TS file with `export const config`", async () => { + const path = join(root, "named.config.ts"); + writeFileSync( + path, + `export const config = { namespace: "com.example", collections: {} };` + ); + const cfg = await loadConfig(path); + expect(cfg).toEqual({ namespace: "com.example", collections: {} }); + }); + + it("falls back to default export when no `config` named export", async () => { + const path = join(root, "default.config.ts"); + writeFileSync( + path, + `export default { namespace: "com.default", collections: {} };` + ); + const cfg = await loadConfig(path); + expect(cfg).toEqual({ namespace: "com.default", collections: {} }); + }); + + it("prefers named `config` over default export when both exist", async () => { + const path = join(root, "both.config.ts"); + writeFileSync( + path, + `export const config = { namespace: "named", collections: {} }; + export default { namespace: "default", collections: {} };` + ); + const cfg = await loadConfig<{ namespace: string }>(path); + expect(cfg.namespace).toBe("named"); + }); + + it("throws when no config-shaped export is found", async () => { + const path = join(root, "broken.config.ts"); + writeFileSync(path, `export const notConfig = "wrong";`); + await expect(loadConfig(path)).rejects.toThrow(/did not export a valid `config`/); + }); + + it("throws when config exports an object missing required fields", async () => { + const path = join(root, "missingfields.config.ts"); + writeFileSync(path, `export const config = { foo: "bar" };`); + await expect(loadConfig(path)).rejects.toThrow( + /missing required fields \(namespace, collections\)/ + ); + }); + + it("throws when default export is a primitive (not an object)", async () => { + const path = join(root, "primitive.config.ts"); + writeFileSync(path, `export default "just a string";`); + await expect(loadConfig(path)).rejects.toThrow(/did not export a `config` object/); + }); + + it("loads .js files too", async () => { + const path = join(root, "vanilla.config.js"); + writeFileSync( + path, + `export const config = { namespace: "js.example", collections: {} };` + ); + const cfg = await loadConfig(path); + expect(cfg).toEqual({ namespace: "js.example", collections: {} }); + }); +}); diff --git a/packages/contrail/tests/lexicons-endpoint.test.ts b/packages/contrail/tests/lexicons-endpoint.test.ts new file mode 100644 index 0000000..47a4208 --- /dev/null +++ b/packages/contrail/tests/lexicons-endpoint.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { createApp } from "../src/core/router"; +import { createTestDbWithSchema, TEST_CONFIG } from "./helpers"; + +describe("/xrpc/<ns>.lexicons endpoint", () => { + it("returns the bundled lexicons when passed", async () => { + const db = await createTestDbWithSchema(); + const lexicons = [ + { lexicon: 1, id: "com.example.event.listRecords", defs: { main: { type: "query" } } }, + { lexicon: 1, id: "com.example.event.getRecord", defs: { main: { type: "query" } } }, + ]; + const app = createApp(db, TEST_CONFIG, { lexicons }); + + const res = await app.fetch(new Request("http://localhost/xrpc/com.example.lexicons")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ lexicons }); + }); + + it("404s when no lexicons were passed (route not registered)", async () => { + const db = await createTestDbWithSchema(); + const app = createApp(db, TEST_CONFIG); // no lexicons + + const res = await app.fetch(new Request("http://localhost/xrpc/com.example.lexicons")); + expect(res.status).toBe(404); + }); + + it("404s when lexicons array is empty (treated as not-passed)", async () => { + const db = await createTestDbWithSchema(); + const app = createApp(db, TEST_CONFIG, { lexicons: [] }); + + const res = await app.fetch(new Request("http://localhost/xrpc/com.example.lexicons")); + expect(res.status).toBe(404); + }); + + it("uses the config's namespace in the path", async () => { + const db = await createTestDbWithSchema(); + const lexicons = [{ lexicon: 1, id: "x" }]; + const app = createApp(db, TEST_CONFIG, { lexicons }); + + // Wrong namespace → 404 + const wrong = await app.fetch(new Request("http://localhost/xrpc/wrong.ns.lexicons")); + expect(wrong.status).toBe(404); + + // Right namespace → 200 + const right = await app.fetch(new Request("http://localhost/xrpc/com.example.lexicons")); + expect(right.status).toBe(200); + }); +}); diff --git a/packages/contrail/tests/refresh.test.ts b/packages/contrail/tests/refresh.test.ts new file mode 100644 index 0000000..2f9179c --- /dev/null +++ b/packages/contrail/tests/refresh.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { refresh } from "../src/core/refresh"; +import { applyEvents, createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; +import type { Database } from "../src/core/types"; + +// We mock the PDS client so refresh() can be exercised without network IO. +// Each test sets the desired pageRecords for a given (did, collection) via +// the `pages` map below. +const pages = new Map<string, Array<{ uri: string; cid: string; value: object }>>(); + +vi.mock("../src/core/client", () => ({ + getClient: vi.fn(async (did: string) => ({ + get: async ( + _method: string, + opts: { params: { repo: string; collection: string; cursor?: string } } + ) => { + const key = `${opts.params.repo}|${opts.params.collection}`; + // Single page per (did, collection); cursor triggers empty page = done. + if (opts.params.cursor) return { ok: true, data: { records: [], cursor: undefined } }; + const records = pages.get(key) ?? []; + return { ok: true, data: { records, cursor: undefined } }; + }, + })), + getPDS: vi.fn(), +})); + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const EVENT_NSID = "community.lexicon.calendar.event"; + +function aliceEventUri(rkey: string): string { + return `at://${ALICE}/${EVENT_NSID}/${rkey}`; +} + +async function registerKnownDid(db: Database, did: string): Promise<void> { + // refresh() enumerates DIDs from the `backfills` table. + await db + .prepare( + "INSERT INTO backfills (did, collection, completed) VALUES (?, ?, 1) ON CONFLICT DO NOTHING" + ) + .bind(did, EVENT_NSID) + .run(); +} + +describe("refresh", () => { + beforeEach(() => { + pages.clear(); + }); + + it("classifies an unseen-by-DB record as missing", async () => { + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + + pages.set(`${ALICE}|${EVENT_NSID}`, [ + { + uri: aliceEventUri("new1"), + cid: "bafy-new", + value: { name: "Brand new", startsAt: "2026-04-01T10:00:00Z" }, + }, + ]); + + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 0 }); + expect(result.total.missing).toBe(1); + expect(result.total.staleUpdates).toBe(0); + expect(result.total.inSync).toBe(0); + expect(result.usersScanned).toBe(1); + }); + + it("classifies a same-CID record as in-sync", async () => { + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + + // Seed DB with a record at this URI. + await applyEvents(db, [ + makeEvent({ + did: ALICE, + rkey: "k1", + uri: aliceEventUri("k1"), + cid: "bafy-same", + record: { name: "Already here" }, + }), + ]); + + // PDS returns same URI + same CID. + pages.set(`${ALICE}|${EVENT_NSID}`, [ + { uri: aliceEventUri("k1"), cid: "bafy-same", value: { name: "Already here" } }, + ]); + + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 0 }); + expect(result.total.missing).toBe(0); + expect(result.total.staleUpdates).toBe(0); + expect(result.total.inSync).toBe(1); + }); + + it("classifies a different-CID record as a stale update (outside ignore window)", async () => { + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + + // Seed DB with an OLD record (indexed_at way in the past). + const dayAgoUs = (Date.now() - 86_400_000) * 1000; + await applyEvents(db, [ + makeEvent({ + did: ALICE, + rkey: "k2", + uri: aliceEventUri("k2"), + cid: "bafy-old", + time_us: dayAgoUs, + indexed_at: dayAgoUs, + record: { name: "Was online" }, + }), + ]); + + // PDS returns same URI but different CID. + pages.set(`${ALICE}|${EVENT_NSID}`, [ + { uri: aliceEventUri("k2"), cid: "bafy-NEW", value: { name: "Now in-person" } }, + ]); + + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 60_000 }); + expect(result.total.staleUpdates).toBe(1); + expect(result.total.missing).toBe(0); + expect(result.total.inSync).toBe(0); + }); + + it("skips stale-update classification when DB row is within the ignore window", async () => { + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + + // Seed with a record indexed JUST NOW. + const nowUs = Date.now() * 1000; + await applyEvents(db, [ + makeEvent({ + did: ALICE, + rkey: "k3", + uri: aliceEventUri("k3"), + cid: "bafy-recent", + time_us: nowUs, + indexed_at: nowUs, + record: { name: "Recent" }, + }), + ]); + + // PDS returns different CID — but DB row is fresh, so it counts as in-sync. + pages.set(`${ALICE}|${EVENT_NSID}`, [ + { uri: aliceEventUri("k3"), cid: "bafy-different", value: { name: "Recent v2" } }, + ]); + + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 60_000 }); + expect(result.total.staleUpdates).toBe(0); + expect(result.total.inSync).toBe(1); + }); + + it("aggregates stats per collection", async () => { + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + await registerKnownDid(db, BOB); + + pages.set(`${ALICE}|${EVENT_NSID}`, [ + { uri: aliceEventUri("a1"), cid: "x", value: {} }, + { uri: aliceEventUri("a2"), cid: "y", value: {} }, + ]); + pages.set(`${BOB}|${EVENT_NSID}`, [ + { uri: `at://${BOB}/${EVENT_NSID}/b1`, cid: "z", value: {} }, + ]); + + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 0 }); + expect(result.byCollection[EVENT_NSID]).toBeDefined(); + expect(result.byCollection[EVENT_NSID].missing).toBe(3); + expect(result.usersScanned).toBe(2); + }); + + it("counts a user as failed when getClient throws, and continues with others", async () => { + const { getClient } = await import("../src/core/client"); + (getClient as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce( + async () => { + throw new Error("PDS unreachable"); + } + ); + + const db = await createTestDbWithSchema(); + await registerKnownDid(db, ALICE); + await registerKnownDid(db, BOB); + pages.set(`${BOB}|${EVENT_NSID}`, [ + { uri: `at://${BOB}/${EVENT_NSID}/x`, cid: "c", value: {} }, + ]); + + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 0, concurrency: 1 }); + expect(result.usersFailed).toBe(1); + expect(result.usersScanned).toBe(1); + // The non-failing user's records still classified. + expect(result.total.missing).toBeGreaterThanOrEqual(1); + }); + + it("returns elapsed time and the configured ignore window", async () => { + const db = await createTestDbWithSchema(); + const result = await refresh(db, TEST_CONFIG, { ignoreWindowMs: 30_000 }); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + expect(result.ignoreWindowMs).toBe(30_000); + }); +}); diff --git a/packages/contrail/tests/worker.test.ts b/packages/contrail/tests/worker.test.ts new file mode 100644 index 0000000..e31b6fd --- /dev/null +++ b/packages/contrail/tests/worker.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from "vitest"; +import { createWorker } from "../src/worker"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import type { ContrailConfig } from "../src/core/types"; + +const MINIMAL_CONFIG: ContrailConfig = { + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { startsAt: { type: "range" } }, + }, + }, +}; + +describe("createWorker", () => { + it("returns an object with fetch + scheduled handlers", () => { + const worker = createWorker(MINIMAL_CONFIG); + expect(typeof worker.fetch).toBe("function"); + expect(typeof worker.scheduled).toBe("function"); + }); + + it("inits the DB schema lazily on the first fetch (and only once)", async () => { + const db = createSqliteDatabase(":memory:"); + const worker = createWorker(MINIMAL_CONFIG); + const env = { DB: db }; + + // Before first fetch: schema not present yet. + const tables = await db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursor'") + .first<{ name: string }>(); + expect(tables).toBeNull(); + + await worker.fetch(new Request("http://localhost/health"), env); + + // After first fetch: schema present. + const after = await db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursor'") + .first<{ name: string }>(); + expect(after?.name).toBe("cursor"); + + // Second fetch shouldn't re-init (idempotent regardless, but verify + // onInit fires only once per isolate via a probe). + const onInit = vi.fn(); + const w2 = createWorker(MINIMAL_CONFIG, { onInit }); + await w2.fetch(new Request("http://localhost/health"), env); + await w2.fetch(new Request("http://localhost/health"), env); + await w2.fetch(new Request("http://localhost/health"), env); + expect(onInit).toHaveBeenCalledTimes(1); + }); + + it("respects a custom binding name", async () => { + const db = createSqliteDatabase(":memory:"); + const worker = createWorker(MINIMAL_CONFIG, { binding: "MY_DB" }); + const env = { MY_DB: db }; + + const res = await worker.fetch(new Request("http://localhost/health"), env); + expect(res.status).toBe(200); + }); + + it("serves /xrpc/<ns>.lexicons when lexicons are passed", async () => { + const db = createSqliteDatabase(":memory:"); + const lexicons = [{ lexicon: 1, id: "com.example.foo" }]; + const worker = createWorker(MINIMAL_CONFIG, { lexicons }); + const env = { DB: db }; + + const res = await worker.fetch( + new Request("http://localhost/xrpc/com.example.lexicons"), + env + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ lexicons }); + }); + + it("does not serve /xrpc/<ns>.lexicons when lexicons are omitted", async () => { + const db = createSqliteDatabase(":memory:"); + const worker = createWorker(MINIMAL_CONFIG); + const env = { DB: db }; + + const res = await worker.fetch( + new Request("http://localhost/xrpc/com.example.lexicons"), + env + ); + expect(res.status).toBe(404); + }); + + it("scheduled handler hands the ingest promise to ctx.waitUntil", async () => { + const db = createSqliteDatabase(":memory:"); + const worker = createWorker(MINIMAL_CONFIG); + const env = { DB: db }; + + const waitUntil = vi.fn(); + const ctx = { waitUntil, passThroughOnException: vi.fn() } as unknown as ExecutionContext; + + // Schedule returns once init + waitUntil have been called. The actual + // ingest is a long-running promise that would try to connect to a real + // Jetstream — we don't drain it; we just verify the wire-up. + await worker.scheduled({} as ScheduledEvent, env, ctx); + + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(waitUntil.mock.calls[0][0]).toBeInstanceOf(Promise); + }); +}); diff --git a/packages/contrail/tsup.config.ts b/packages/contrail/tsup.config.ts index fc11602..7cd50c0 100644 --- a/packages/contrail/tsup.config.ts +++ b/packages/contrail/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "src/adapters/sqlite.ts", "src/adapters/postgres.ts", "src/workers/backfill.ts", + "src/worker/index.ts", "src/cli.ts", "src/cli-config.ts", ], diff --git a/packages/lexicons/src/cli.ts b/packages/lexicons/src/cli.ts index 7c42bff..081c01a 100644 --- a/packages/lexicons/src/cli.ts +++ b/packages/lexicons/src/cli.ts @@ -9,14 +9,15 @@ * contrail-lex types # wraps `lex-cli generate` * contrail-lex all [--no-types] [--config ...] # generate → pull → types * contrail-lex publish [handle] [password] # publish lexicons to a PDS + * contrail-lex pull-service <url> # fetch lexicons from a deployed contrail * * Config auto-detects at ./contrail.config.ts, ./app/config.ts, or * ./src/lib/contrail/config.ts (first match wins); override with --config. * The file must default-export or named-export `config: ContrailConfig`. */ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { resolve, join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { resolve, join, dirname } from "node:path"; import { findConfigFile, loadConfig, @@ -25,17 +26,18 @@ import { import { generateLexicons } from "./generate.js"; import { publishLexicons } from "./publish.js"; -type Subcommand = "generate" | "pull" | "types" | "all" | "publish" | "help"; +type Subcommand = "generate" | "pull" | "types" | "all" | "publish" | "pull-service" | "help"; const USAGE = `contrail-lex <subcommand> [options] Subcommands: - generate Emit lexicon JSON from Contrail config - pull Pull external lexicons (wraps \`lex-cli pull\`) - types Generate TS types from lexicon JSON (wraps \`lex-cli generate\`) - all generate → pull → generate → pull → types (full pipeline) - publish Publish lexicon JSON as com.atproto.lexicon.schema records on a PDS - help Print this message + generate Emit lexicon JSON from Contrail config + pull Pull external lexicons (wraps \`lex-cli pull\`) + types Generate TS types from lexicon JSON (wraps \`lex-cli generate\`) + all generate → pull → generate → pull → types (full pipeline) + publish Publish lexicon JSON as com.atproto.lexicon.schema records on a PDS + pull-service Fetch lexicons from a deployed contrail \`/lexicons\` endpoint + help Print this message Options: --config <path> Path to Contrail config file. Default: auto-detect. @@ -44,6 +46,8 @@ Options: --generated-dir For \`publish\`: dir of JSON to publish. Default: lexicons/generated. --skip-confirm For \`publish\`: skip the "do you control these zones?" prompt. --dry-run For \`publish\`: print what would be published + the DNS records needed, no writes. + --out <dir> For \`pull-service\`: where to write fetched lexicons. Default: lexicons/pulled. + --namespace <ns> For \`pull-service\`: construct the URL as \`<base>/xrpc/<ns>.lexicons\`. Or pass a full URL. Environment variables (for \`publish\`): LEXICON_ACCOUNT_IDENTIFIER handle or DID (falls back to positional arg 1) @@ -58,6 +62,8 @@ function parseArgs(argv: string[]): { generatedDir: string; skipConfirm: boolean; dryRun: boolean; + out?: string; + namespace?: string; positional: string[]; } { const args = argv.slice(2); @@ -68,6 +74,8 @@ function parseArgs(argv: string[]): { let generatedDir = "lexicons/generated"; let skipConfirm = false; let dryRun = false; + let out: string | undefined; + let namespace: string | undefined; const positional: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -77,11 +85,13 @@ function parseArgs(argv: string[]): { else if (a === "--generated-dir") generatedDir = args[++i]; else if (a === "--skip-confirm") skipConfirm = true; else if (a === "--dry-run") dryRun = true; + else if (a === "--out") out = args[++i]; + else if (a === "--namespace") namespace = args[++i]; else if (a === "-h" || a === "--help") - return { cmd: "help", root, withTypes: true, generatedDir, skipConfirm, dryRun, positional }; + return { cmd: "help", root, withTypes: true, generatedDir, skipConfirm, dryRun, out, namespace, positional }; else positional.push(a); } - return { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, positional }; + return { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, out, namespace, positional }; } @@ -142,8 +152,80 @@ async function cmdPublish( return result.failed.length ? 1 : 0; } +interface LexiconDoc { + id?: string; + [k: string]: unknown; +} + +async function cmdPullService( + root: string, + outDir: string, + namespace: string | undefined, + positional: string[] +): Promise<number> { + const url = positional[0]; + if (!url) { + console.error("usage: contrail-lex pull-service <url> [--namespace <ns>]"); + console.error( + " <url> is either a full URL to the lexicon endpoint\n" + + " e.g. https://my-contrail.dev/xrpc/com.example.lexicons\n" + + " or a base URL combined with --namespace:\n" + + " contrail-lex pull-service https://my-contrail.dev --namespace com.example" + ); + return 1; + } + let endpoint: string; + if (url.includes("/xrpc/")) { + endpoint = url; + } else if (namespace) { + endpoint = `${url.replace(/\/$/, "")}/xrpc/${namespace}.lexicons`; + } else { + console.error( + "pull-service: pass a full URL containing /xrpc/<ns>.lexicons, or use --namespace <ns> to construct one." + ); + return 1; + } + console.log(`fetching ${endpoint}…`); + + let docs: LexiconDoc[]; + try { + const res = await fetch(endpoint); + if (!res.ok) { + console.error(`request failed: ${res.status} ${res.statusText}`); + return 1; + } + const body = (await res.json()) as { lexicons?: LexiconDoc[] }; + docs = body.lexicons ?? []; + } catch (err) { + console.error(`fetch error: ${(err as Error).message}`); + return 1; + } + + if (docs.length === 0) { + console.error( + "no lexicons in response. the service must pass { lexicons } to createWorker() " + + "(emit with `contrail-lex generate` and import from ./lexicons/generated)." + ); + return 1; + } + + const absOut = resolve(root, outDir); + mkdirSync(absOut, { recursive: true }); + let written = 0; + for (const doc of docs) { + if (!doc.id || typeof doc.id !== "string") continue; + const filePath = join(absOut, ...doc.id.split(".")) + ".json"; + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(doc, null, 2) + "\n"); + written++; + } + console.log(`wrote ${written} lexicon(s) to ${absOut}`); + console.log("run `npx lex-cli generate` (or `contrail-lex types`) to emit TS types."); + return 0; +} + async function main(): Promise<number> { - const { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, positional } = + const { cmd, config, root, withTypes, generatedDir, skipConfirm, dryRun, out, namespace, positional } = parseArgs(process.argv); if (cmd === "help") { @@ -155,6 +237,8 @@ async function main(): Promise<number> { if (cmd === "types") return runLexCli(["generate"], root); if (cmd === "publish") return cmdPublish(root, generatedDir, positional, skipConfirm, dryRun); + if (cmd === "pull-service") + return cmdPullService(root, out ?? "lexicons/pulled", namespace, positional); const configPath = findConfigFile(root, config); if (!configPath) { diff --git a/packages/lexicons/src/generate.ts b/packages/lexicons/src/generate.ts index bf57b6a..cdf95d6 100644 --- a/packages/lexicons/src/generate.ts +++ b/packages/lexicons/src/generate.ts @@ -6,7 +6,7 @@ */ import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from "fs"; -import { join } from "path"; +import { join, relative } from "path"; import type { ContrailConfig } from "@atmo-dev/contrail"; /** Return the sorted list of XRPC method NSIDs (queries + procedures) in a @@ -1036,6 +1036,40 @@ export function generateLexicons(options: GenerateOptions): Record<string, objec } + // --- Bundle manifest --- + // Emit `index.ts` alongside the generated JSONs, importing every lexicon + // this deployment speaks: generated + pulled + custom. Consumers pass it + // to `createWorker(config, { lexicons })` to expose the full set at + // `/xrpc/<ns>.lexicons` — the pulled ones are needed so consumer typegen + // can resolve `$ref`s from the generated schemas. + if (outputDir) { + const paths = new Set<string>(); + + // Generated JSONs live in outputDir itself. + for (const nsid of Object.keys(generated)) { + paths.add("./" + nsid.split(".").join("/") + ".json"); + } + + // Pulled + custom JSONs live in the configured lexiconDirs (outside outputDir). + for (const dir of lexiconDirs) { + if (!existsSync(dir)) continue; + for (const file of walkJson(dir)) { + let rel = relative(outputDir, file); + if (!rel.startsWith(".")) rel = "./" + rel; + paths.add(rel); + } + } + + const sorted = [...paths].sort(); + const imports = sorted.map((p, i) => `import _${i} from "${p}";`).join("\n"); + const exportArray = `export const lexicons: object[] = [${sorted.map((_, i) => `_${i}`).join(", ")}];\n`; + const header = + "// Auto-generated by @atmo-dev/contrail-lexicons. Do not edit.\n" + + "// Pass `lexicons` to `createWorker(config, { lexicons })` to expose them\n" + + "// at `/xrpc/<namespace>.lexicons` for consumer apps to typegen against.\n\n"; + writeFileSync(join(outputDir, "index.ts"), header + imports + "\n\n" + exportArray); + } + log("\nDone!"); return generated; } diff --git a/packages/lexicons/tests/generate.test.ts b/packages/lexicons/tests/generate.test.ts index 28460ff..65b283c 100644 --- a/packages/lexicons/tests/generate.test.ts +++ b/packages/lexicons/tests/generate.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { join } from "path"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs"; +import { tmpdir } from "os"; import { generateLexicons, extractXrpcMethods, listXrpcMethods } from "../src/generate"; import type { ContrailConfig } from "@atmo-dev/contrail"; @@ -255,3 +257,92 @@ describe("extractXrpcMethods / listXrpcMethods", () => { expect(methods).toContain("test.comm.realtime.subscribe"); }); }); + +describe("manifest emission (lexicons/generated/index.ts)", () => { + let workdir: string; + let outDir: string; + + beforeAll(() => { + workdir = mkdtempSync(join(tmpdir(), "contrail-manifest-")); + outDir = join(workdir, "lexicons", "generated"); + }); + afterAll(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + it("emits index.ts with imports for every generated lexicon", () => { + generateLexicons({ + config: BASIC_CONFIG, + rootDir: workdir, + outputDir: outDir, + lexiconDirs: [], // skip pulled/custom for this test + quiet: true, + }); + const manifest = readFileSync(join(outDir, "index.ts"), "utf-8"); + + // One import per generated NSID — at minimum: collection-level + admin endpoints. + expect(manifest).toMatch(/import _0 from "\.\/test\/app\/.*\.json";/); + expect(manifest).toContain('export const lexicons: object[] ='); + + // Sorted, so getCursor (admin) appears before post.* (collection). + const importLines = manifest.split("\n").filter((l) => l.startsWith("import ")); + const paths = importLines.map((l) => l.match(/from "(.*?)"/)?.[1] ?? ""); + expect(paths).toEqual([...paths].sort()); + }); + + it("includes pulled lexicons in the manifest (with relative paths)", () => { + // Set up a fake pulled dir with one lexicon. + const pulledDir = join(workdir, "lexicons", "pulled"); + mkdirSync(join(pulledDir, "app", "bsky", "actor"), { recursive: true }); + writeFileSync( + join(pulledDir, "app", "bsky", "actor", "profile.json"), + JSON.stringify({ lexicon: 1, id: "app.bsky.actor.profile", defs: {} }) + ); + + rmSync(outDir, { recursive: true, force: true }); + generateLexicons({ + config: BASIC_CONFIG, + rootDir: workdir, + outputDir: outDir, + lexiconDirs: [pulledDir], + quiet: true, + }); + const manifest = readFileSync(join(outDir, "index.ts"), "utf-8"); + + // Pulled lexicon path is relative from outputDir, so it walks `..`. + expect(manifest).toContain('import _0 from "../pulled/app/bsky/actor/profile.json";'); + // Generated lexicons are still there with `./` paths. + expect(manifest).toMatch(/import _\d+ from "\.\/test\/app\//); + }); + + it("dedupes if the same path is somehow listed twice", () => { + rmSync(outDir, { recursive: true, force: true }); + generateLexicons({ + config: BASIC_CONFIG, + rootDir: workdir, + outputDir: outDir, + lexiconDirs: [], + quiet: true, + }); + const manifest = readFileSync(join(outDir, "index.ts"), "utf-8"); + const importLines = manifest.split("\n").filter((l) => l.startsWith("import ")); + const paths = importLines.map((l) => l.match(/from "(.*?)"/)?.[1] ?? ""); + expect(new Set(paths).size).toBe(paths.length); + }); + + it("does not emit a manifest when outputDir is omitted (in-memory only)", () => { + const isolatedDir = mkdtempSync(join(tmpdir(), "contrail-no-output-")); + try { + generateLexicons({ + config: BASIC_CONFIG, + rootDir: isolatedDir, + // no outputDir + lexiconDirs: [], + quiet: true, + }); + expect(existsSync(join(isolatedDir, "lexicons"))).toBe(false); + } finally { + rmSync(isolatedDir, { recursive: true, force: true }); + } + }); +});