From 8a73117b5d2dd0ed0a5617d957719cf48de80b7c Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:18:57 +0100 Subject: [PATCH] make example copy-pastable --- app/config.ts | 42 ++++++++++++ .../cloudflare-workers => app}/generate.ts | 7 +- app/sync.ts | 68 +++++++++++++++++++ app/worker.ts | 30 ++++++++ examples/cloudflare-workers/README.md | 63 +++++++++++++++++ examples/cloudflare-workers/config.ts | 11 +-- examples/cloudflare-workers/package.json | 21 ++++++ examples/cloudflare-workers/sync.ts | 10 ++- examples/cloudflare-workers/tsconfig.json | 14 ++++ examples/cloudflare-workers/worker.ts | 7 +- examples/cloudflare-workers/wrangler.jsonc | 20 ++++++ package.json | 7 +- tsconfig.json | 2 +- wrangler.jsonc | 2 +- 14 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 app/config.ts rename {examples/cloudflare-workers => app}/generate.ts (75%) create mode 100644 app/sync.ts create mode 100644 app/worker.ts create mode 100644 examples/cloudflare-workers/README.md create mode 100644 examples/cloudflare-workers/package.json create mode 100644 examples/cloudflare-workers/tsconfig.json create mode 100644 examples/cloudflare-workers/wrangler.jsonc diff --git a/app/config.ts b/app/config.ts new file mode 100644 index 0000000..0879d4c --- /dev/null +++ b/app/config.ts @@ -0,0 +1,42 @@ +import type { ContrailConfig } from "../src/index"; + +export const config: ContrailConfig = { + namespace: "rsvp.atmo", + collections: { + "community.lexicon.calendar.event": { + queryable: { + mode: {}, + name: {}, + status: {}, + startsAt: { type: "range" }, + endsAt: { type: "range" }, + createdAt: { type: "range" }, + }, + searchable: ["name", "description"], + relations: { + rsvps: { + collection: "community.lexicon.calendar.rsvp", + groupBy: "status", + count: true, + groups: { + interested: "community.lexicon.calendar.rsvp#interested", + going: "community.lexicon.calendar.rsvp#going", + notgoing: "community.lexicon.calendar.rsvp#notgoing", + }, + }, + }, + }, + "community.lexicon.calendar.rsvp": { + queryable: { + status: {}, + "subject.uri": {}, + }, + references: { + event: { + collection: "community.lexicon.calendar.event", + field: "subject.uri", + }, + }, + }, + }, +}; diff --git a/examples/cloudflare-workers/generate.ts b/app/generate.ts similarity index 75% rename from examples/cloudflare-workers/generate.ts rename to app/generate.ts index e100806..c359b24 100644 --- a/examples/cloudflare-workers/generate.ts +++ b/app/generate.ts @@ -1,15 +1,14 @@ /** * Generates lexicon files and lex.config.js from config. * - * Usage: npx tsx examples/cloudflare-workers/generate.ts + * Usage: pnpm generate */ - import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { config } from "./config"; -import { generateLexicons } from "../../src/generate"; +import { generateLexicons } from "../src/generate"; -const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); generateLexicons({ config, diff --git a/app/sync.ts b/app/sync.ts new file mode 100644 index 0000000..d6f6580 --- /dev/null +++ b/app/sync.ts @@ -0,0 +1,68 @@ +/** + * Discover users from relays and backfill their records from PDS. + * + * Usage: + * pnpm sync # local D1 + * pnpm sync --remote # prod D1 + */ +import { Contrail } from "../src/index"; +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/app/worker.ts b/app/worker.ts new file mode 100644 index 0000000..00a60a8 --- /dev/null +++ b/app/worker.ts @@ -0,0 +1,30 @@ +import { Contrail } from "../src/index"; +import { createHandler } from "../src/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/examples/cloudflare-workers/README.md b/examples/cloudflare-workers/README.md new file mode 100644 index 0000000..64c8602 --- /dev/null +++ b/examples/cloudflare-workers/README.md @@ -0,0 +1,63 @@ +# Contrail — Cloudflare Workers Example + +A complete example of using Contrail to index AT Protocol calendar events and RSVPs on Cloudflare Workers + D1. + +## Setup + +```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 +``` + +Copy the `database_id` from the output into `wrangler.jsonc`. + +## Configure + +Edit `config.ts` to define your collections, queryable fields, relations, and references. See the [Contrail README](../../README.md) for all options. + +## Develop + +```bash +# Discover users from relays and backfill their records +npm run sync + +# Start the dev server (ingests from Jetstream every minute) +npm run dev +``` + +Your XRPC API is now available at `http://localhost:8787`: + +``` +# List events sorted by RSVP count +/xrpc/community.lexicon.calendar.event.listRecords?sort=rsvpsCount + +# Upcoming events with 10+ going RSVPs +/xrpc/community.lexicon.calendar.event.listRecords?startsAtMin=2026-03-16&rsvpsGoingCountMin=10 + +# Single event with hydrated RSVPs and profiles +/xrpc/community.lexicon.calendar.event.getRecord?uri=at://...&hydrateRsvps=10&profiles=true + +# Search events +/xrpc/community.lexicon.calendar.event.listRecords?search=meetup + +# RSVPs for a specific event +/xrpc/community.lexicon.calendar.rsvp.listRecords?subjectUri=at://... +``` + +## Deploy + +```bash +npm run deploy + +# Sync against production D1 +npm run sync:remote +``` + +Ingestion runs automatically via cron (`*/1 * * * *`). diff --git a/examples/cloudflare-workers/config.ts b/examples/cloudflare-workers/config.ts index 9cb9265..31c3ca1 100644 --- a/examples/cloudflare-workers/config.ts +++ b/examples/cloudflare-workers/config.ts @@ -1,4 +1,4 @@ -import type { ContrailConfig } from "../../src/index"; +import type { ContrailConfig } from "contrail"; export const config: ContrailConfig = { namespace: "rsvp.atmo", @@ -39,13 +39,4 @@ export const config: ContrailConfig = { }, }, }, - // feeds: { - // following: { - // follow: "app.bsky.graph.follow", - // targets: [ - // "community.lexicon.calendar.event", - // "community.lexicon.calendar.rsvp", - // ], - // }, - // }, }; diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json new file mode 100644 index 0000000..eaf4e85 --- /dev/null +++ b/examples/cloudflare-workers/package.json @@ -0,0 +1,21 @@ +{ + "name": "contrail-cloudflare-workers-example", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --test-scheduled", + "deploy": "wrangler deploy", + "sync": "tsx sync.ts", + "sync:remote": "tsx sync.ts --remote" + }, + "dependencies": { + "contrail": "github:flo-bit/contrail" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250124.0", + "tsx": "^4.21.0", + "typescript": "^5.7.3", + "wrangler": "^4.63.0" + } +} diff --git a/examples/cloudflare-workers/sync.ts b/examples/cloudflare-workers/sync.ts index 323af32..8ec436d 100644 --- a/examples/cloudflare-workers/sync.ts +++ b/examples/cloudflare-workers/sync.ts @@ -1,14 +1,12 @@ /** - * Example: CLI sync script using Contrail as a library. + * Discover users from relays and backfill their records from PDS. * * Usage: - * npx tsx examples/cloudflare-workers/sync.ts # local D1 - * npx tsx examples/cloudflare-workers/sync.ts --remote # prod D1 + * npx tsx sync.ts # local D1 + * npx tsx sync.ts --remote # prod D1 */ -import { Contrail } from "../../src/index"; +import { Contrail } from "contrail"; import { config } from "./config"; - -// For Cloudflare D1, use wrangler's getPlatformProxy: import { getPlatformProxy } from "wrangler"; function elapsed(start: number): string { diff --git a/examples/cloudflare-workers/tsconfig.json b/examples/cloudflare-workers/tsconfig.json new file mode 100644 index 0000000..dcfa099 --- /dev/null +++ b/examples/cloudflare-workers/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["."] +} diff --git a/examples/cloudflare-workers/worker.ts b/examples/cloudflare-workers/worker.ts index 729d07f..ca565a0 100644 --- a/examples/cloudflare-workers/worker.ts +++ b/examples/cloudflare-workers/worker.ts @@ -1,8 +1,5 @@ -/** - * Example: Cloudflare Worker using Contrail as a library. - */ -import { Contrail } from "../../src/index"; -import { createHandler } from "../../src/server"; +import { Contrail } from "contrail"; +import { createHandler } from "contrail/server"; import { config } from "./config"; const contrail = new Contrail(config); diff --git a/examples/cloudflare-workers/wrangler.jsonc b/examples/cloudflare-workers/wrangler.jsonc new file mode 100644 index 0000000..0b403a5 --- /dev/null +++ b/examples/cloudflare-workers/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "contrail", + "main": "worker.ts", + "compatibility_date": "2025-12-25", + "observability": { + "enabled": true + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "contrail", + "database_id": "YOUR_DATABASE_ID" + } + ], + "triggers": { + "crons": [ + "*/1 * * * *" + ] + } +} diff --git a/package.json b/package.json index f77da72..826ecd0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.2", "private": true, "type": "module", + "files": ["src"], "exports": { ".": "./src/index.ts", "./server": "./src/server.ts", @@ -13,11 +14,11 @@ "dev:auto": "tsx scripts/dev.ts", "deploy": "wrangler deploy", "clean": "tsx scripts/clean.ts", - "generate": "tsx examples/cloudflare-workers/generate.ts", - "generate:pull": "tsx examples/cloudflare-workers/generate.ts && lex-cli pull && tsx examples/cloudflare-workers/generate.ts && lex-cli pull", + "generate": "tsx app/generate.ts", + "generate:pull": "tsx app/generate.ts && lex-cli pull && tsx app/generate.ts && lex-cli pull", "typecheck": "tsc --noEmit", "ingest": "curl -s http://localhost:8787/__scheduled?cron=*/1+*+*+*+*", - "sync": "tsx examples/cloudflare-workers/sync.ts", + "sync": "tsx app/sync.ts", "test": "vitest run", "test:watch": "vitest" }, diff --git a/tsconfig.json b/tsconfig.json index 330eb14..2c00c47 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "resolveJsonModule": true, "isolatedModules": true }, - "include": ["src", "examples"], + "include": ["src", "app"], "exclude": ["src/adapters/sqlite.ts", "src/generate.ts"] } diff --git a/wrangler.jsonc b/wrangler.jsonc index 73bc147..121abe1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "contrail", - "main": "examples/cloudflare-workers/worker.ts", + "main": "app/worker.ts", "compatibility_date": "2025-12-25", "observability": { "enabled": true -- 2.51.2