[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

first commit

Florian (Mar 17, 2026, 2:07 PM +0100) b1828ed4

+5121
+10
.gitignore
··· 1 + node_modules 2 + .wrangler 3 + dist 4 + 5 + # Generated 6 + lex.config.js 7 + lexicons-pulled/ 8 + lexicons-generated/ 9 + src/lexicon-types/ 10 + src/core/queryable.generated.ts
+140
README.md
··· 1 + # Contrail 2 + 3 + > [!WARNING] 4 + > Work in progress! Pre-alpha, expect breaking changes. 5 + 6 + Define collections — get automatic Jetstream ingestion, PDS backfill, user discovery, and typed XRPC endpoints. Runs on Cloudflare Workers + D1. 7 + 8 + ## Quickstart 9 + 10 + ### Dev 11 + 12 + ```bash 13 + pnpm install 14 + # Edit src/config.ts with your collections 15 + pnpm generate:pull # pull lexicons from network, auto-detect fields, generate types 16 + pnpm dev:auto # start wrangler dev with auto-ingestion 17 + pnpm sync # discover users + backfill records from PDS 18 + ``` 19 + 20 + ### Production 21 + 22 + ```bash 23 + wrangler d1 create contrail 24 + # Add database_id to wrangler.toml 25 + wrangler secret put ADMIN_SECRET 26 + pnpm deploy 27 + # to sync in production, run it locally but set your d1 to remote, then run 28 + pnpm sync 29 + ``` 30 + 31 + Ingestion runs automatically via cron (`*/1 * * * *`). Schema is auto-initialized. 32 + 33 + ## Config 34 + 35 + Edit `src/config.ts` — this is the only file you need to touch: 36 + 37 + ```ts 38 + export const config: ContrailConfig = { 39 + collections: { 40 + "community.lexicon.calendar.event": { 41 + relations: { 42 + rsvps: { 43 + collection: "community.lexicon.calendar.rsvp", 44 + groupBy: "status", // materialized counts by status 45 + }, 46 + }, 47 + }, 48 + "community.lexicon.calendar.rsvp": {}, 49 + }, 50 + // profiles: ["app.bsky.actor.profile"], ← default 51 + // jetstreams: [...] ← default: 4 Bluesky jetstream endpoints 52 + // relays: [...] ← default: 2 Bluesky relay endpoints 53 + }; 54 + ``` 55 + 56 + ### What's auto-detected from lexicons 57 + 58 + When you run `pnpm generate`, queryable fields are derived from each collection's lexicon: 59 + 60 + - **String fields** → equality filter (`?status=going`) 61 + - **Datetime/integer fields** → range filter (`?min=startsAt:2026-03-16`) 62 + - **StrongRef fields** → `.uri` equality filter (`?subjectUri=at://...`) 63 + 64 + You can override any auto-detected field by specifying `queryable` manually in config. 65 + 66 + ### Collection options 67 + 68 + | Option | Default | Description | 69 + |--------|---------|-------------| 70 + | `queryable` | auto-detected | Override auto-detected queryable fields | 71 + | `discover` | `true` | Find users via relays. `false` = only track known DIDs | 72 + | `relations` | `{}` | Many-to-one relationships with materialized counts | 73 + | `relations.*.field` | `"subject.uri"` | Field in the related record to match against | 74 + | `relations.*.match` | `"uri"` | Match against parent's `"uri"` or `"did"` | 75 + | `relations.*.groupBy` | — | Split counts by this field's value | 76 + | `queries` | `{}` | Custom query handlers | 77 + 78 + ### Profiles 79 + 80 + `profiles` is a top-level config array of collection NSIDs that contain profile records (rkey `self`). Defaults to `["app.bsky.actor.profile"]`. These are auto-added to `collections` with `{ discover: false }`. Use `?profiles=true` on any endpoint to include a `profiles` map in the response, keyed by DID, with handle and profile record data. 81 + 82 + ## API 83 + 84 + All endpoints at `/xrpc/{nsid}.{method}`: 85 + 86 + | Endpoint | Description | 87 + |----------|-------------| 88 + | `{nsid}.getRecords` | List/filter records | 89 + | `{nsid}.getRecord` | Get single record by URI | 90 + | `{nsid}.getUsers` | List users by record count | 91 + | `{nsid}.getStats` | Collection statistics | 92 + | `contrail.admin.sync` | Discover + backfill (requires `ADMIN_SECRET`) | 93 + | `contrail.admin.getCursor` | Current cursor position | 94 + | `contrail.admin.getOverview` | All collections summary | 95 + 96 + ### Query parameters 97 + 98 + **Filtering:** 99 + 100 + | Param | Example | Description | 101 + |-------|---------|-------------| 102 + | `actor` | `?actor=did:plc:...` or `?actor=alice.bsky.social` | Filter by DID or handle (triggers on-demand backfill) | 103 + | `profiles` | `?profiles=true` | Include profile + identity info keyed by DID | 104 + | `{field}` | `?status=going` | Equality filter on queryable string field | 105 + | `min` | `?min=startsAt:2026-03-16` | Range minimum (datetime/integer fields) or count minimum | 106 + | `max` | `?max=endsAt:2026-04-01` | Range maximum (datetime/integer fields) | 107 + | `hydrate` | `?hydrate=rsvps:10` | Embed latest N related records per record | 108 + | `limit` | `?limit=25` | Page size (1-100, default 50) | 109 + | `cursor` | `?cursor=...` | Pagination cursor | 110 + 111 + **Count filters** use the same `min`/`max` syntax with the count type as key: 112 + 113 + ``` 114 + # events with 10+ "going" RSVPs, 115 + # take care to parse the `#` character correctly in your HTTP client 116 + ?min=community.lexicon.calendar.rsvp#going:10 117 + ``` 118 + 119 + **Hydration** returns related records grouped by `groupBy` value: 120 + 121 + ``` 122 + ?hydrate=rsvps:5 # latest 5 per group (going, interested, etc.) 123 + ?hydrate=rsvps:5&hydrate=followers:10 # multiple hydrations 124 + ``` 125 + 126 + ### Examples (events) 127 + 128 + ``` 129 + # Upcoming events with 10+ going, with RSVP records and profiles 130 + /xrpc/community.lexicon.calendar.event.getRecords?min=startsAt:2026-03-16&min=community.lexicon.calendar.rsvp#going:10&hydrate=rsvps:5&profiles=true 131 + 132 + # Events for a specific user (by handle) 133 + /xrpc/community.lexicon.calendar.event.getRecords?actor=alice.bsky.social&profiles=true 134 + 135 + # Single event with counts, RSVPs, and profiles 136 + /xrpc/community.lexicon.calendar.event.getRecord?uri=at://did:plc:.../community.lexicon.calendar.event/...&hydrate=rsvps:10&profiles=true 137 + 138 + # RSVPs for a specific event with profiles 139 + /xrpc/community.lexicon.calendar.rsvp.getRecords?subjectUri=at://did:plc:.../community.lexicon.calendar.event/...&profiles=true 140 + ```
+35
package.json
··· 1 + { 2 + "name": "contrail", 3 + "version": "1.0.0", 4 + "private": true, 5 + "scripts": { 6 + "dev": "wrangler dev --test-scheduled", 7 + "dev:auto": "tsx scripts/dev.ts", 8 + "deploy": "wrangler deploy", 9 + "clean": "tsx scripts/clean.ts", 10 + "generate": "tsx scripts/generate-lexicons.ts && lex-cli generate", 11 + "generate:pull": "tsx scripts/generate-lexicons.ts && lex-cli pull && tsx scripts/generate-lexicons.ts && lex-cli pull && lex-cli generate", 12 + "typecheck": "tsc --noEmit", 13 + "ingest": "curl -s http://localhost:8787/__scheduled?cron=*/1+*+*+*+*", 14 + "sync": "tsx scripts/sync.ts" 15 + }, 16 + "dependencies": { 17 + "@atcute/atproto": "^3.1.10", 18 + "@atcute/client": "^4.2.1", 19 + "@atcute/identity-resolver": "^1.2.2", 20 + "@atcute/jetstream": "^1.0.2", 21 + "@atcute/lexicons": "^1.2.7", 22 + "hono": "^4.12.8" 23 + }, 24 + "devDependencies": { 25 + "@atcute/lex-cli": "^2.5.3", 26 + "@atcute/lexicon-doc": "^2.1.2", 27 + "@cloudflare/workers-types": "^4.20250124.0", 28 + "@types/better-sqlite3": "^7.6.13", 29 + "better-sqlite3": "^12.8.0", 30 + "tsx": "^4.21.0", 31 + "typescript": "^5.7.3", 32 + "vitest": "^4.1.0", 33 + "wrangler": "^4.63.0" 34 + } 35 + }
+2248
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + dependencies: 11 + '@atcute/atproto': 12 + specifier: ^3.1.10 13 + version: 3.1.10 14 + '@atcute/client': 15 + specifier: ^4.2.1 16 + version: 4.2.1 17 + '@atcute/identity-resolver': 18 + specifier: ^1.2.2 19 + version: 1.2.2(@atcute/identity@1.1.3) 20 + '@atcute/jetstream': 21 + specifier: ^1.0.2 22 + version: 1.1.2 23 + '@atcute/lexicons': 24 + specifier: ^1.2.7 25 + version: 1.2.7 26 + hono: 27 + specifier: ^4.12.8 28 + version: 4.12.8 29 + devDependencies: 30 + '@atcute/lex-cli': 31 + specifier: ^2.5.3 32 + version: 2.5.3 33 + '@atcute/lexicon-doc': 34 + specifier: ^2.1.2 35 + version: 2.1.2 36 + '@cloudflare/workers-types': 37 + specifier: ^4.20250124.0 38 + version: 4.20260205.0 39 + '@types/better-sqlite3': 40 + specifier: ^7.6.13 41 + version: 7.6.13 42 + better-sqlite3: 43 + specifier: ^12.8.0 44 + version: 12.8.0 45 + tsx: 46 + specifier: ^4.21.0 47 + version: 4.21.0 48 + typescript: 49 + specifier: ^5.7.3 50 + version: 5.9.3 51 + vitest: 52 + specifier: ^4.1.0 53 + version: 4.1.0(@types/node@25.2.1)(vite@8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0)) 54 + wrangler: 55 + specifier: ^4.63.0 56 + version: 4.63.0(@cloudflare/workers-types@4.20260205.0) 57 + 58 + packages/core: 59 + dependencies: 60 + '@atcute/atproto': 61 + specifier: ^3.1.10 62 + version: 3.1.10 63 + '@atcute/client': 64 + specifier: ^4.2.1 65 + version: 4.2.1 66 + '@atcute/identity-resolver': 67 + specifier: ^1.2.2 68 + version: 1.2.2(@atcute/identity@1.1.3) 69 + '@atcute/jetstream': 70 + specifier: ^1.0.2 71 + version: 1.1.2 72 + '@atcute/lexicons': 73 + specifier: ^1.2.7 74 + version: 1.2.7 75 + devDependencies: 76 + '@types/better-sqlite3': 77 + specifier: ^7.6.0 78 + version: 7.6.13 79 + better-sqlite3: 80 + specifier: ^11.0.0 81 + version: 11.10.0 82 + typescript: 83 + specifier: ^5.7.3 84 + version: 5.9.3 85 + 86 + packages/worker: 87 + dependencies: 88 + '@jetstream/core': 89 + specifier: workspace:* 90 + version: link:../core 91 + devDependencies: 92 + '@cloudflare/workers-types': 93 + specifier: ^4.20250124.0 94 + version: 4.20260205.0 95 + typescript: 96 + specifier: ^5.7.3 97 + version: 5.9.3 98 + wrangler: 99 + specifier: ^4.63.0 100 + version: 4.63.0(@cloudflare/workers-types@4.20260205.0) 101 + 102 + packages: 103 + 104 + '@atcute/atproto@3.1.10': 105 + resolution: {integrity: sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==, tarball: https://registry.npmjs.org/@atcute/atproto/-/atproto-3.1.10.tgz} 106 + 107 + '@atcute/car@5.1.1': 108 + resolution: {integrity: sha512-MeRUJNXYgAHrJZw7mMoZJb9xIqv3LZLQw90rRRAVAo8SGNdICwyqe6Bf2LGesX73QM04MBuYO6Kqhvold3TFfg==, tarball: https://registry.npmjs.org/@atcute/car/-/car-5.1.1.tgz} 109 + 110 + '@atcute/cbor@2.3.2': 111 + resolution: {integrity: sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ==, tarball: https://registry.npmjs.org/@atcute/cbor/-/cbor-2.3.2.tgz} 112 + 113 + '@atcute/cid@2.4.1': 114 + resolution: {integrity: sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ==, tarball: https://registry.npmjs.org/@atcute/cid/-/cid-2.4.1.tgz} 115 + 116 + '@atcute/client@4.2.1': 117 + resolution: {integrity: sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==, tarball: https://registry.npmjs.org/@atcute/client/-/client-4.2.1.tgz} 118 + 119 + '@atcute/crypto@2.4.0': 120 + resolution: {integrity: sha512-XtEeDaSgfr92C7b1VDRvd3F9pI8tVUyy8PJAeu8IWQC7+e/GXZOSl58uWh5YP/9p1Lsa0I16uKHwogygxEwlMQ==, tarball: https://registry.npmjs.org/@atcute/crypto/-/crypto-2.4.0.tgz} 121 + 122 + '@atcute/identity-resolver@1.2.2': 123 + resolution: {integrity: sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw==, tarball: https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.2.2.tgz} 124 + peerDependencies: 125 + '@atcute/identity': ^1.0.0 126 + 127 + '@atcute/identity@1.1.3': 128 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==, tarball: https://registry.npmjs.org/@atcute/identity/-/identity-1.1.3.tgz} 129 + 130 + '@atcute/jetstream@1.1.2': 131 + resolution: {integrity: sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==, tarball: https://registry.npmjs.org/@atcute/jetstream/-/jetstream-1.1.2.tgz} 132 + 133 + '@atcute/lex-cli@2.5.3': 134 + resolution: {integrity: sha512-829rvezMOfRkJQRKvupNT8TWT/YYffJ2QsB80D9aPjkXSogrETZA7xZcPaMZBXg+mJaVbLO9S4ThPQmlF0L4UQ==, tarball: https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-2.5.3.tgz} 135 + hasBin: true 136 + 137 + '@atcute/lexicon-doc@2.1.2': 138 + resolution: {integrity: sha512-jTLcOka7b8BIn2SnIZm2m7l6unlJ0gpgW1MnRpSqNbly/AvyRUR/GREduh/QmjT4SGasDm8vdhrM0kOSPFpDLQ==, tarball: https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-2.1.2.tgz} 139 + 140 + '@atcute/lexicon-resolver@0.1.6': 141 + resolution: {integrity: sha512-wJC/ChmpP7k+ywpOd07CMvioXjIGaFpF3bDwXLi/086LYjSWHOvtW6pyC+mqP5wLhjyH2hn4wmi77Buew1l1aw==, tarball: https://registry.npmjs.org/@atcute/lexicon-resolver/-/lexicon-resolver-0.1.6.tgz} 142 + peerDependencies: 143 + '@atcute/identity': ^1.1.0 144 + '@atcute/identity-resolver': ^1.1.3 145 + 146 + '@atcute/lexicons@1.2.7': 147 + resolution: {integrity: sha512-gCvkSMI1F1zx7xXa59iPiSKMH3L5Hga6iurGqQjaQbE2V/np/2QuDqQzt96TNbWfaFAXE9f9oY+0z3ljf/bweA==, tarball: https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.7.tgz} 148 + 149 + '@atcute/lexicons@1.2.9': 150 + resolution: {integrity: sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==, tarball: https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.9.tgz} 151 + 152 + '@atcute/mst@1.0.0': 153 + resolution: {integrity: sha512-pMce2efib+dmKtnGnIvJZitVncJkpr3AmhyfgfYllni8KzsaDGsJmuGavSVpuojAhQe+6jYwHFtpm/beiiH4uw==, tarball: https://registry.npmjs.org/@atcute/mst/-/mst-1.0.0.tgz} 154 + 155 + '@atcute/multibase@1.1.8': 156 + resolution: {integrity: sha512-pJgtImMZKCjqwRbu+2GzB+4xQjKBXDwdZOzeqe0u97zYKRGftpGYGvYv3+pMe2xXe+msDyu7Nv8iJp+U14otTA==, tarball: https://registry.npmjs.org/@atcute/multibase/-/multibase-1.1.8.tgz} 157 + 158 + '@atcute/repo@0.1.3': 159 + resolution: {integrity: sha512-kN4gkrkQgJwI5xkVQ9zSdI8ULR47uH48EdJdjMvKAWsSDu8zKYd8ZQ6n778qa6o3WYEq02v9QVf7pzeU+3fYVA==, tarball: https://registry.npmjs.org/@atcute/repo/-/repo-0.1.3.tgz} 160 + 161 + '@atcute/uint8array@1.1.0': 162 + resolution: {integrity: sha512-JtHXIVW6LPU9FMWp7SgE4HbUs3uV2WdfkK/2RWdEGjr4EgMV50P3FdU6fPeGlTfDNBJVYMIsuD2wwaKRPV/Aqg==, tarball: https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.0.tgz} 163 + 164 + '@atcute/uint8array@1.1.1': 165 + resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==, tarball: https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.1.tgz} 166 + 167 + '@atcute/util-fetch@1.0.5': 168 + resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==, tarball: https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.5.tgz} 169 + 170 + '@atcute/util-text@1.1.0': 171 + resolution: {integrity: sha512-34G9KD5Z9f7oEdFpZOmqrMnU86p8ne6LlxJowfZzKNszRcl1GH+FtEPh3N1woelJT2SkPXMK2anwT8DESTluwA==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.1.0.tgz} 172 + 173 + '@atcute/util-text@1.1.1': 174 + resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.1.1.tgz} 175 + 176 + '@atcute/varint@2.0.0': 177 + resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==, tarball: https://registry.npmjs.org/@atcute/varint/-/varint-2.0.0.tgz} 178 + 179 + '@badrap/valita@0.4.6': 180 + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==, tarball: https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz} 181 + engines: {node: '>= 18'} 182 + 183 + '@cloudflare/kv-asset-handler@0.4.2': 184 + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==, tarball: https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz} 185 + engines: {node: '>=18.0.0'} 186 + 187 + '@cloudflare/unenv-preset@2.12.0': 188 + resolution: {integrity: sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==, tarball: https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz} 189 + peerDependencies: 190 + unenv: 2.0.0-rc.24 191 + workerd: ^1.20260115.0 192 + peerDependenciesMeta: 193 + workerd: 194 + optional: true 195 + 196 + '@cloudflare/workerd-darwin-64@1.20260205.0': 197 + resolution: {integrity: sha512-ToOItqcirmWPwR+PtT+Q4bdjTn/63ZxhJKEfW4FNn7FxMTS1Tw5dml0T0mieOZbCpcvY8BdvPKFCSlJuI8IVHQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260205.0.tgz} 198 + engines: {node: '>=16'} 199 + cpu: [x64] 200 + os: [darwin] 201 + 202 + '@cloudflare/workerd-darwin-arm64@1.20260205.0': 203 + resolution: {integrity: sha512-402ZqLz+LrG0NDXp7Hn7IZbI0DyhjNfjAlVenb0K3yod9KCuux0u3NksNBvqJx0mIGHvVR4K05h+jfT5BTHqGA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260205.0.tgz} 204 + engines: {node: '>=16'} 205 + cpu: [arm64] 206 + os: [darwin] 207 + 208 + '@cloudflare/workerd-linux-64@1.20260205.0': 209 + resolution: {integrity: sha512-rz9jBzazIA18RHY+osa19hvsPfr0LZI1AJzIjC6UqkKKphcTpHBEQ25Xt8cIA34ivMIqeENpYnnmpDFesLkfcQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260205.0.tgz} 210 + engines: {node: '>=16'} 211 + cpu: [x64] 212 + os: [linux] 213 + 214 + '@cloudflare/workerd-linux-arm64@1.20260205.0': 215 + resolution: {integrity: sha512-jr6cKpMM/DBEbL+ATJ9rYue758CKp0SfA/nXt5vR32iINVJrb396ye9iat2y9Moa/PgPKnTrFgmT6urUmG3IUg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260205.0.tgz} 216 + engines: {node: '>=16'} 217 + cpu: [arm64] 218 + os: [linux] 219 + 220 + '@cloudflare/workerd-windows-64@1.20260205.0': 221 + resolution: {integrity: sha512-SMPW5jCZYOG7XFIglSlsgN8ivcl0pCrSAYxCwxtWvZ88whhcDB/aISNtiQiDZujPH8tIo2hE5dEkxW7tGEwc3A==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260205.0.tgz} 222 + engines: {node: '>=16'} 223 + cpu: [x64] 224 + os: [win32] 225 + 226 + '@cloudflare/workers-types@4.20260205.0': 227 + resolution: {integrity: sha512-LTnpvcodmiuMwxmbrO2Fd0+Avbm2UVLLJxT8J2pRWPfoM44gmbIecXwOPZmDAMeadKWrBsQ+B0sloQAhUu5fpA==, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260205.0.tgz} 228 + 229 + '@cspotcode/source-map-support@0.8.1': 230 + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, tarball: https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz} 231 + engines: {node: '>=12'} 232 + 233 + '@emnapi/core@1.9.0': 234 + resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz} 235 + 236 + '@emnapi/runtime@1.8.1': 237 + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz} 238 + 239 + '@emnapi/wasi-threads@1.2.0': 240 + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==, tarball: https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz} 241 + 242 + '@esbuild/aix-ppc64@0.27.0': 243 + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz} 244 + engines: {node: '>=18'} 245 + cpu: [ppc64] 246 + os: [aix] 247 + 248 + '@esbuild/android-arm64@0.27.0': 249 + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz} 250 + engines: {node: '>=18'} 251 + cpu: [arm64] 252 + os: [android] 253 + 254 + '@esbuild/android-arm@0.27.0': 255 + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz} 256 + engines: {node: '>=18'} 257 + cpu: [arm] 258 + os: [android] 259 + 260 + '@esbuild/android-x64@0.27.0': 261 + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz} 262 + engines: {node: '>=18'} 263 + cpu: [x64] 264 + os: [android] 265 + 266 + '@esbuild/darwin-arm64@0.27.0': 267 + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz} 268 + engines: {node: '>=18'} 269 + cpu: [arm64] 270 + os: [darwin] 271 + 272 + '@esbuild/darwin-x64@0.27.0': 273 + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz} 274 + engines: {node: '>=18'} 275 + cpu: [x64] 276 + os: [darwin] 277 + 278 + '@esbuild/freebsd-arm64@0.27.0': 279 + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz} 280 + engines: {node: '>=18'} 281 + cpu: [arm64] 282 + os: [freebsd] 283 + 284 + '@esbuild/freebsd-x64@0.27.0': 285 + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz} 286 + engines: {node: '>=18'} 287 + cpu: [x64] 288 + os: [freebsd] 289 + 290 + '@esbuild/linux-arm64@0.27.0': 291 + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz} 292 + engines: {node: '>=18'} 293 + cpu: [arm64] 294 + os: [linux] 295 + 296 + '@esbuild/linux-arm@0.27.0': 297 + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz} 298 + engines: {node: '>=18'} 299 + cpu: [arm] 300 + os: [linux] 301 + 302 + '@esbuild/linux-ia32@0.27.0': 303 + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz} 304 + engines: {node: '>=18'} 305 + cpu: [ia32] 306 + os: [linux] 307 + 308 + '@esbuild/linux-loong64@0.27.0': 309 + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz} 310 + engines: {node: '>=18'} 311 + cpu: [loong64] 312 + os: [linux] 313 + 314 + '@esbuild/linux-mips64el@0.27.0': 315 + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz} 316 + engines: {node: '>=18'} 317 + cpu: [mips64el] 318 + os: [linux] 319 + 320 + '@esbuild/linux-ppc64@0.27.0': 321 + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz} 322 + engines: {node: '>=18'} 323 + cpu: [ppc64] 324 + os: [linux] 325 + 326 + '@esbuild/linux-riscv64@0.27.0': 327 + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz} 328 + engines: {node: '>=18'} 329 + cpu: [riscv64] 330 + os: [linux] 331 + 332 + '@esbuild/linux-s390x@0.27.0': 333 + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz} 334 + engines: {node: '>=18'} 335 + cpu: [s390x] 336 + os: [linux] 337 + 338 + '@esbuild/linux-x64@0.27.0': 339 + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz} 340 + engines: {node: '>=18'} 341 + cpu: [x64] 342 + os: [linux] 343 + 344 + '@esbuild/netbsd-arm64@0.27.0': 345 + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz} 346 + engines: {node: '>=18'} 347 + cpu: [arm64] 348 + os: [netbsd] 349 + 350 + '@esbuild/netbsd-x64@0.27.0': 351 + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz} 352 + engines: {node: '>=18'} 353 + cpu: [x64] 354 + os: [netbsd] 355 + 356 + '@esbuild/openbsd-arm64@0.27.0': 357 + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz} 358 + engines: {node: '>=18'} 359 + cpu: [arm64] 360 + os: [openbsd] 361 + 362 + '@esbuild/openbsd-x64@0.27.0': 363 + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz} 364 + engines: {node: '>=18'} 365 + cpu: [x64] 366 + os: [openbsd] 367 + 368 + '@esbuild/openharmony-arm64@0.27.0': 369 + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz} 370 + engines: {node: '>=18'} 371 + cpu: [arm64] 372 + os: [openharmony] 373 + 374 + '@esbuild/sunos-x64@0.27.0': 375 + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz} 376 + engines: {node: '>=18'} 377 + cpu: [x64] 378 + os: [sunos] 379 + 380 + '@esbuild/win32-arm64@0.27.0': 381 + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz} 382 + engines: {node: '>=18'} 383 + cpu: [arm64] 384 + os: [win32] 385 + 386 + '@esbuild/win32-ia32@0.27.0': 387 + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz} 388 + engines: {node: '>=18'} 389 + cpu: [ia32] 390 + os: [win32] 391 + 392 + '@esbuild/win32-x64@0.27.0': 393 + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz} 394 + engines: {node: '>=18'} 395 + cpu: [x64] 396 + os: [win32] 397 + 398 + '@img/colour@1.0.0': 399 + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==, tarball: https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz} 400 + engines: {node: '>=18'} 401 + 402 + '@img/sharp-darwin-arm64@0.34.5': 403 + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, tarball: https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz} 404 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 405 + cpu: [arm64] 406 + os: [darwin] 407 + 408 + '@img/sharp-darwin-x64@0.34.5': 409 + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, tarball: https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz} 410 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 411 + cpu: [x64] 412 + os: [darwin] 413 + 414 + '@img/sharp-libvips-darwin-arm64@1.2.4': 415 + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz} 416 + cpu: [arm64] 417 + os: [darwin] 418 + 419 + '@img/sharp-libvips-darwin-x64@1.2.4': 420 + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz} 421 + cpu: [x64] 422 + os: [darwin] 423 + 424 + '@img/sharp-libvips-linux-arm64@1.2.4': 425 + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz} 426 + cpu: [arm64] 427 + os: [linux] 428 + 429 + '@img/sharp-libvips-linux-arm@1.2.4': 430 + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz} 431 + cpu: [arm] 432 + os: [linux] 433 + 434 + '@img/sharp-libvips-linux-ppc64@1.2.4': 435 + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz} 436 + cpu: [ppc64] 437 + os: [linux] 438 + 439 + '@img/sharp-libvips-linux-riscv64@1.2.4': 440 + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz} 441 + cpu: [riscv64] 442 + os: [linux] 443 + 444 + '@img/sharp-libvips-linux-s390x@1.2.4': 445 + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz} 446 + cpu: [s390x] 447 + os: [linux] 448 + 449 + '@img/sharp-libvips-linux-x64@1.2.4': 450 + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz} 451 + cpu: [x64] 452 + os: [linux] 453 + 454 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': 455 + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz} 456 + cpu: [arm64] 457 + os: [linux] 458 + 459 + '@img/sharp-libvips-linuxmusl-x64@1.2.4': 460 + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, tarball: https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz} 461 + cpu: [x64] 462 + os: [linux] 463 + 464 + '@img/sharp-linux-arm64@0.34.5': 465 + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz} 466 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 467 + cpu: [arm64] 468 + os: [linux] 469 + 470 + '@img/sharp-linux-arm@0.34.5': 471 + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, tarball: https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz} 472 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 473 + cpu: [arm] 474 + os: [linux] 475 + 476 + '@img/sharp-linux-ppc64@0.34.5': 477 + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, tarball: https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz} 478 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 479 + cpu: [ppc64] 480 + os: [linux] 481 + 482 + '@img/sharp-linux-riscv64@0.34.5': 483 + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, tarball: https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz} 484 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 485 + cpu: [riscv64] 486 + os: [linux] 487 + 488 + '@img/sharp-linux-s390x@0.34.5': 489 + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, tarball: https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz} 490 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 491 + cpu: [s390x] 492 + os: [linux] 493 + 494 + '@img/sharp-linux-x64@0.34.5': 495 + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, tarball: https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz} 496 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 497 + cpu: [x64] 498 + os: [linux] 499 + 500 + '@img/sharp-linuxmusl-arm64@0.34.5': 501 + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz} 502 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 503 + cpu: [arm64] 504 + os: [linux] 505 + 506 + '@img/sharp-linuxmusl-x64@0.34.5': 507 + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, tarball: https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz} 508 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 509 + cpu: [x64] 510 + os: [linux] 511 + 512 + '@img/sharp-wasm32@0.34.5': 513 + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, tarball: https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz} 514 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 515 + cpu: [wasm32] 516 + 517 + '@img/sharp-win32-arm64@0.34.5': 518 + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, tarball: https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz} 519 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 520 + cpu: [arm64] 521 + os: [win32] 522 + 523 + '@img/sharp-win32-ia32@0.34.5': 524 + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, tarball: https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz} 525 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 526 + cpu: [ia32] 527 + os: [win32] 528 + 529 + '@img/sharp-win32-x64@0.34.5': 530 + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, tarball: https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz} 531 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 532 + cpu: [x64] 533 + os: [win32] 534 + 535 + '@jridgewell/resolve-uri@3.1.2': 536 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} 537 + engines: {node: '>=6.0.0'} 538 + 539 + '@jridgewell/sourcemap-codec@1.5.5': 540 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz} 541 + 542 + '@jridgewell/trace-mapping@0.3.9': 543 + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz} 544 + 545 + '@mary-ext/event-iterator@1.0.0': 546 + resolution: {integrity: sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==, tarball: https://registry.npmjs.org/@mary-ext/event-iterator/-/event-iterator-1.0.0.tgz} 547 + 548 + '@mary-ext/simple-event-emitter@1.0.0': 549 + resolution: {integrity: sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==, tarball: https://registry.npmjs.org/@mary-ext/simple-event-emitter/-/simple-event-emitter-1.0.0.tgz} 550 + 551 + '@napi-rs/wasm-runtime@1.1.1': 552 + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz} 553 + 554 + '@noble/secp256k1@3.0.0': 555 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==, tarball: https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz} 556 + 557 + '@optique/core@0.6.11': 558 + resolution: {integrity: sha512-GVLFihzBA1j78NFlkU5N1Lu0jRqET0k6Z66WK8VQKG/a3cxmCInVGSKMIdQG8i6pgC8wD5OizF6Y3QMztmhAxg==, tarball: https://registry.npmjs.org/@optique/core/-/core-0.6.11.tgz} 559 + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 560 + 561 + '@optique/run@0.6.11': 562 + resolution: {integrity: sha512-tsXBEygGSzNpFK2gjsRlXBn7FiScUeLFWIZNpoAZ8iG85Km0/3K9xgqlQAXoQ+uEZBe4XplnzyCDvmEgbyNT8w==, tarball: https://registry.npmjs.org/@optique/run/-/run-0.6.11.tgz} 563 + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 564 + 565 + '@oxc-project/runtime@0.115.0': 566 + resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==, tarball: https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz} 567 + engines: {node: ^20.19.0 || >=22.12.0} 568 + 569 + '@oxc-project/types@0.115.0': 570 + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==, tarball: https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz} 571 + 572 + '@poppinss/colors@4.1.6': 573 + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==, tarball: https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz} 574 + 575 + '@poppinss/dumper@0.6.5': 576 + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==, tarball: https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz} 577 + 578 + '@poppinss/exception@1.2.3': 579 + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==, tarball: https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz} 580 + 581 + '@rolldown/binding-android-arm64@1.0.0-rc.9': 582 + resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==, tarball: https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz} 583 + engines: {node: ^20.19.0 || >=22.12.0} 584 + cpu: [arm64] 585 + os: [android] 586 + 587 + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': 588 + resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz} 589 + engines: {node: ^20.19.0 || >=22.12.0} 590 + cpu: [arm64] 591 + os: [darwin] 592 + 593 + '@rolldown/binding-darwin-x64@1.0.0-rc.9': 594 + resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz} 595 + engines: {node: ^20.19.0 || >=22.12.0} 596 + cpu: [x64] 597 + os: [darwin] 598 + 599 + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': 600 + resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==, tarball: https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz} 601 + engines: {node: ^20.19.0 || >=22.12.0} 602 + cpu: [x64] 603 + os: [freebsd] 604 + 605 + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': 606 + resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz} 607 + engines: {node: ^20.19.0 || >=22.12.0} 608 + cpu: [arm] 609 + os: [linux] 610 + 611 + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': 612 + resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz} 613 + engines: {node: ^20.19.0 || >=22.12.0} 614 + cpu: [arm64] 615 + os: [linux] 616 + 617 + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': 618 + resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz} 619 + engines: {node: ^20.19.0 || >=22.12.0} 620 + cpu: [arm64] 621 + os: [linux] 622 + 623 + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': 624 + resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz} 625 + engines: {node: ^20.19.0 || >=22.12.0} 626 + cpu: [ppc64] 627 + os: [linux] 628 + 629 + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': 630 + resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz} 631 + engines: {node: ^20.19.0 || >=22.12.0} 632 + cpu: [s390x] 633 + os: [linux] 634 + 635 + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': 636 + resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz} 637 + engines: {node: ^20.19.0 || >=22.12.0} 638 + cpu: [x64] 639 + os: [linux] 640 + 641 + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': 642 + resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz} 643 + engines: {node: ^20.19.0 || >=22.12.0} 644 + cpu: [x64] 645 + os: [linux] 646 + 647 + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': 648 + resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==, tarball: https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz} 649 + engines: {node: ^20.19.0 || >=22.12.0} 650 + cpu: [arm64] 651 + os: [openharmony] 652 + 653 + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': 654 + resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==, tarball: https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz} 655 + engines: {node: '>=14.0.0'} 656 + cpu: [wasm32] 657 + 658 + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': 659 + resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz} 660 + engines: {node: ^20.19.0 || >=22.12.0} 661 + cpu: [arm64] 662 + os: [win32] 663 + 664 + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': 665 + resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz} 666 + engines: {node: ^20.19.0 || >=22.12.0} 667 + cpu: [x64] 668 + os: [win32] 669 + 670 + '@rolldown/pluginutils@1.0.0-rc.9': 671 + resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==, tarball: https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz} 672 + 673 + '@sindresorhus/is@7.2.0': 674 + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==, tarball: https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz} 675 + engines: {node: '>=18'} 676 + 677 + '@speed-highlight/core@1.2.14': 678 + resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==, tarball: https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz} 679 + 680 + '@standard-schema/spec@1.1.0': 681 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} 682 + 683 + '@tybys/wasm-util@0.10.1': 684 + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz} 685 + 686 + '@types/better-sqlite3@7.6.13': 687 + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==, tarball: https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz} 688 + 689 + '@types/chai@5.2.3': 690 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, tarball: https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz} 691 + 692 + '@types/deep-eql@4.0.2': 693 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, tarball: https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz} 694 + 695 + '@types/estree@1.0.8': 696 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz} 697 + 698 + '@types/node@25.2.1': 699 + resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==, tarball: https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz} 700 + 701 + '@vitest/expect@4.1.0': 702 + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==, tarball: https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz} 703 + 704 + '@vitest/mocker@4.1.0': 705 + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==, tarball: https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz} 706 + peerDependencies: 707 + msw: ^2.4.9 708 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 709 + peerDependenciesMeta: 710 + msw: 711 + optional: true 712 + vite: 713 + optional: true 714 + 715 + '@vitest/pretty-format@4.1.0': 716 + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==, tarball: https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz} 717 + 718 + '@vitest/runner@4.1.0': 719 + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==, tarball: https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz} 720 + 721 + '@vitest/snapshot@4.1.0': 722 + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==, tarball: https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz} 723 + 724 + '@vitest/spy@4.1.0': 725 + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==, tarball: https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz} 726 + 727 + '@vitest/utils@4.1.0': 728 + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz} 729 + 730 + assertion-error@2.0.1: 731 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, tarball: https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz} 732 + engines: {node: '>=12'} 733 + 734 + base64-js@1.5.1: 735 + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, tarball: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz} 736 + 737 + better-sqlite3@11.10.0: 738 + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==, tarball: https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz} 739 + 740 + better-sqlite3@12.8.0: 741 + resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==, tarball: https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz} 742 + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} 743 + 744 + bindings@1.5.0: 745 + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, tarball: https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz} 746 + 747 + bl@4.1.0: 748 + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==, tarball: https://registry.npmjs.org/bl/-/bl-4.1.0.tgz} 749 + 750 + blake3-wasm@2.1.5: 751 + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz} 752 + 753 + buffer@5.7.1: 754 + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==, tarball: https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz} 755 + 756 + chai@6.2.2: 757 + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==, tarball: https://registry.npmjs.org/chai/-/chai-6.2.2.tgz} 758 + engines: {node: '>=18'} 759 + 760 + chownr@1.1.4: 761 + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==, tarball: https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz} 762 + 763 + convert-source-map@2.0.0: 764 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, tarball: https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz} 765 + 766 + cookie@1.1.1: 767 + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz} 768 + engines: {node: '>=18'} 769 + 770 + decompress-response@6.0.0: 771 + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==, tarball: https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz} 772 + engines: {node: '>=10'} 773 + 774 + deep-extend@0.6.0: 775 + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==, tarball: https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz} 776 + engines: {node: '>=4.0.0'} 777 + 778 + detect-libc@2.1.2: 779 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, tarball: https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz} 780 + engines: {node: '>=8'} 781 + 782 + end-of-stream@1.4.5: 783 + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, tarball: https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz} 784 + 785 + error-stack-parser-es@1.0.5: 786 + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==, tarball: https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz} 787 + 788 + es-module-lexer@2.0.0: 789 + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==, tarball: https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz} 790 + 791 + esbuild@0.27.0: 792 + resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz} 793 + engines: {node: '>=18'} 794 + hasBin: true 795 + 796 + esm-env@1.2.2: 797 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==, tarball: https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz} 798 + 799 + estree-walker@3.0.3: 800 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, tarball: https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz} 801 + 802 + event-target-polyfill@0.0.4: 803 + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==, tarball: https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz} 804 + 805 + expand-template@2.0.3: 806 + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==, tarball: https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz} 807 + engines: {node: '>=6'} 808 + 809 + expect-type@1.3.0: 810 + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==, tarball: https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz} 811 + engines: {node: '>=12.0.0'} 812 + 813 + fdir@6.5.0: 814 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz} 815 + engines: {node: '>=12.0.0'} 816 + peerDependencies: 817 + picomatch: ^3 || ^4 818 + peerDependenciesMeta: 819 + picomatch: 820 + optional: true 821 + 822 + file-uri-to-path@1.0.0: 823 + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==, tarball: https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz} 824 + 825 + fs-constants@1.0.0: 826 + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==, tarball: https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz} 827 + 828 + fsevents@2.3.3: 829 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} 830 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 831 + os: [darwin] 832 + 833 + get-tsconfig@4.13.6: 834 + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==, tarball: https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz} 835 + 836 + github-from-package@0.0.0: 837 + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==, tarball: https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz} 838 + 839 + hono@4.12.8: 840 + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==, tarball: https://registry.npmjs.org/hono/-/hono-4.12.8.tgz} 841 + engines: {node: '>=16.9.0'} 842 + 843 + ieee754@1.2.1: 844 + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, tarball: https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz} 845 + 846 + inherits@2.0.4: 847 + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, tarball: https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz} 848 + 849 + ini@1.3.8: 850 + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==, tarball: https://registry.npmjs.org/ini/-/ini-1.3.8.tgz} 851 + 852 + kleur@4.1.5: 853 + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==, tarball: https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz} 854 + engines: {node: '>=6'} 855 + 856 + lightningcss-android-arm64@1.32.0: 857 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} 858 + engines: {node: '>= 12.0.0'} 859 + cpu: [arm64] 860 + os: [android] 861 + 862 + lightningcss-darwin-arm64@1.32.0: 863 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, tarball: https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz} 864 + engines: {node: '>= 12.0.0'} 865 + cpu: [arm64] 866 + os: [darwin] 867 + 868 + lightningcss-darwin-x64@1.32.0: 869 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, tarball: https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz} 870 + engines: {node: '>= 12.0.0'} 871 + cpu: [x64] 872 + os: [darwin] 873 + 874 + lightningcss-freebsd-x64@1.32.0: 875 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, tarball: https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz} 876 + engines: {node: '>= 12.0.0'} 877 + cpu: [x64] 878 + os: [freebsd] 879 + 880 + lightningcss-linux-arm-gnueabihf@1.32.0: 881 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, tarball: https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz} 882 + engines: {node: '>= 12.0.0'} 883 + cpu: [arm] 884 + os: [linux] 885 + 886 + lightningcss-linux-arm64-gnu@1.32.0: 887 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz} 888 + engines: {node: '>= 12.0.0'} 889 + cpu: [arm64] 890 + os: [linux] 891 + 892 + lightningcss-linux-arm64-musl@1.32.0: 893 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz} 894 + engines: {node: '>= 12.0.0'} 895 + cpu: [arm64] 896 + os: [linux] 897 + 898 + lightningcss-linux-x64-gnu@1.32.0: 899 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz} 900 + engines: {node: '>= 12.0.0'} 901 + cpu: [x64] 902 + os: [linux] 903 + 904 + lightningcss-linux-x64-musl@1.32.0: 905 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz} 906 + engines: {node: '>= 12.0.0'} 907 + cpu: [x64] 908 + os: [linux] 909 + 910 + lightningcss-win32-arm64-msvc@1.32.0: 911 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, tarball: https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz} 912 + engines: {node: '>= 12.0.0'} 913 + cpu: [arm64] 914 + os: [win32] 915 + 916 + lightningcss-win32-x64-msvc@1.32.0: 917 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, tarball: https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz} 918 + engines: {node: '>= 12.0.0'} 919 + cpu: [x64] 920 + os: [win32] 921 + 922 + lightningcss@1.32.0: 923 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==, tarball: https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz} 924 + engines: {node: '>= 12.0.0'} 925 + 926 + magic-string@0.30.21: 927 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz} 928 + 929 + mimic-response@3.1.0: 930 + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==, tarball: https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz} 931 + engines: {node: '>=10'} 932 + 933 + miniflare@4.20260205.0: 934 + resolution: {integrity: sha512-jG1TknEDeFqcq/z5gsOm1rKeg4cNG7ruWxEuiPxl3pnQumavxo8kFpeQC6XKVpAhh2PI9ODGyIYlgd77sTHl5g==, tarball: https://registry.npmjs.org/miniflare/-/miniflare-4.20260205.0.tgz} 935 + engines: {node: '>=18.0.0'} 936 + hasBin: true 937 + 938 + minimist@1.2.8: 939 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz} 940 + 941 + mkdirp-classic@0.5.3: 942 + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==, tarball: https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz} 943 + 944 + nanoid@3.3.11: 945 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz} 946 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 947 + hasBin: true 948 + 949 + napi-build-utils@2.0.0: 950 + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==, tarball: https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz} 951 + 952 + node-abi@3.89.0: 953 + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==, tarball: https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz} 954 + engines: {node: '>=10'} 955 + 956 + obug@2.1.1: 957 + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz} 958 + 959 + once@1.4.0: 960 + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, tarball: https://registry.npmjs.org/once/-/once-1.4.0.tgz} 961 + 962 + partysocket@1.1.11: 963 + resolution: {integrity: sha512-P0EtOQiAwvLriqLgdThcSaREfz3bP77LkLSdmXq680BosPKvGSoGTh/d0g3S+UNmaqcw89Ad7JXHHKyRx3xU9Q==, tarball: https://registry.npmjs.org/partysocket/-/partysocket-1.1.11.tgz} 964 + 965 + path-to-regexp@6.3.0: 966 + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz} 967 + 968 + pathe@2.0.3: 969 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} 970 + 971 + picocolors@1.1.1: 972 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} 973 + 974 + picomatch@4.0.3: 975 + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz} 976 + engines: {node: '>=12'} 977 + 978 + postcss@8.5.8: 979 + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz} 980 + engines: {node: ^10 || ^12 || >=14} 981 + 982 + prebuild-install@7.1.3: 983 + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==, tarball: https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz} 984 + engines: {node: '>=10'} 985 + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. 986 + hasBin: true 987 + 988 + prettier@3.8.1: 989 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==, tarball: https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz} 990 + engines: {node: '>=14'} 991 + hasBin: true 992 + 993 + pump@3.0.4: 994 + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==, tarball: https://registry.npmjs.org/pump/-/pump-3.0.4.tgz} 995 + 996 + rc@1.2.8: 997 + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==, tarball: https://registry.npmjs.org/rc/-/rc-1.2.8.tgz} 998 + hasBin: true 999 + 1000 + readable-stream@3.6.2: 1001 + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz} 1002 + engines: {node: '>= 6'} 1003 + 1004 + resolve-pkg-maps@1.0.0: 1005 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} 1006 + 1007 + rolldown@1.0.0-rc.9: 1008 + resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==, tarball: https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz} 1009 + engines: {node: ^20.19.0 || >=22.12.0} 1010 + hasBin: true 1011 + 1012 + safe-buffer@5.2.1: 1013 + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, tarball: https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz} 1014 + 1015 + semver@7.7.4: 1016 + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==, tarball: https://registry.npmjs.org/semver/-/semver-7.7.4.tgz} 1017 + engines: {node: '>=10'} 1018 + hasBin: true 1019 + 1020 + sharp@0.34.5: 1021 + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==, tarball: https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz} 1022 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 1023 + 1024 + siginfo@2.0.0: 1025 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, tarball: https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz} 1026 + 1027 + simple-concat@1.0.1: 1028 + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==, tarball: https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz} 1029 + 1030 + simple-get@4.0.1: 1031 + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==, tarball: https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz} 1032 + 1033 + source-map-js@1.2.1: 1034 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} 1035 + engines: {node: '>=0.10.0'} 1036 + 1037 + stackback@0.0.2: 1038 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, tarball: https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz} 1039 + 1040 + std-env@4.0.0: 1041 + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==, tarball: https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz} 1042 + 1043 + string_decoder@1.3.0: 1044 + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, tarball: https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz} 1045 + 1046 + strip-json-comments@2.0.1: 1047 + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==, tarball: https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz} 1048 + engines: {node: '>=0.10.0'} 1049 + 1050 + supports-color@10.2.2: 1051 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz} 1052 + engines: {node: '>=18'} 1053 + 1054 + tar-fs@2.1.4: 1055 + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==, tarball: https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz} 1056 + 1057 + tar-stream@2.2.0: 1058 + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==, tarball: https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz} 1059 + engines: {node: '>=6'} 1060 + 1061 + tinybench@2.9.0: 1062 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, tarball: https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz} 1063 + 1064 + tinyexec@1.0.4: 1065 + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==, tarball: https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz} 1066 + engines: {node: '>=18'} 1067 + 1068 + tinyglobby@0.2.15: 1069 + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz} 1070 + engines: {node: '>=12.0.0'} 1071 + 1072 + tinyrainbow@3.1.0: 1073 + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==, tarball: https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz} 1074 + engines: {node: '>=14.0.0'} 1075 + 1076 + tslib@2.8.1: 1077 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} 1078 + 1079 + tsx@4.21.0: 1080 + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==, tarball: https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz} 1081 + engines: {node: '>=18.0.0'} 1082 + hasBin: true 1083 + 1084 + tunnel-agent@0.6.0: 1085 + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==, tarball: https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz} 1086 + 1087 + type-fest@4.41.0: 1088 + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==, tarball: https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz} 1089 + engines: {node: '>=16'} 1090 + 1091 + typescript@5.9.3: 1092 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} 1093 + engines: {node: '>=14.17'} 1094 + hasBin: true 1095 + 1096 + undici-types@7.16.0: 1097 + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz} 1098 + 1099 + undici@7.18.2: 1100 + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==, tarball: https://registry.npmjs.org/undici/-/undici-7.18.2.tgz} 1101 + engines: {node: '>=20.18.1'} 1102 + 1103 + unenv@2.0.0-rc.24: 1104 + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==, tarball: https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz} 1105 + 1106 + unicode-segmenter@0.14.5: 1107 + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==, tarball: https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz} 1108 + 1109 + util-deprecate@1.0.2: 1110 + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} 1111 + 1112 + vite@8.0.0: 1113 + resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==, tarball: https://registry.npmjs.org/vite/-/vite-8.0.0.tgz} 1114 + engines: {node: ^20.19.0 || >=22.12.0} 1115 + hasBin: true 1116 + peerDependencies: 1117 + '@types/node': ^20.19.0 || >=22.12.0 1118 + '@vitejs/devtools': ^0.0.0-alpha.31 1119 + esbuild: ^0.27.0 1120 + jiti: '>=1.21.0' 1121 + less: ^4.0.0 1122 + sass: ^1.70.0 1123 + sass-embedded: ^1.70.0 1124 + stylus: '>=0.54.8' 1125 + sugarss: ^5.0.0 1126 + terser: ^5.16.0 1127 + tsx: ^4.8.1 1128 + yaml: ^2.4.2 1129 + peerDependenciesMeta: 1130 + '@types/node': 1131 + optional: true 1132 + '@vitejs/devtools': 1133 + optional: true 1134 + esbuild: 1135 + optional: true 1136 + jiti: 1137 + optional: true 1138 + less: 1139 + optional: true 1140 + sass: 1141 + optional: true 1142 + sass-embedded: 1143 + optional: true 1144 + stylus: 1145 + optional: true 1146 + sugarss: 1147 + optional: true 1148 + terser: 1149 + optional: true 1150 + tsx: 1151 + optional: true 1152 + yaml: 1153 + optional: true 1154 + 1155 + vitest@4.1.0: 1156 + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==, tarball: https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz} 1157 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 1158 + hasBin: true 1159 + peerDependencies: 1160 + '@edge-runtime/vm': '*' 1161 + '@opentelemetry/api': ^1.9.0 1162 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 1163 + '@vitest/browser-playwright': 4.1.0 1164 + '@vitest/browser-preview': 4.1.0 1165 + '@vitest/browser-webdriverio': 4.1.0 1166 + '@vitest/ui': 4.1.0 1167 + happy-dom: '*' 1168 + jsdom: '*' 1169 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 1170 + peerDependenciesMeta: 1171 + '@edge-runtime/vm': 1172 + optional: true 1173 + '@opentelemetry/api': 1174 + optional: true 1175 + '@types/node': 1176 + optional: true 1177 + '@vitest/browser-playwright': 1178 + optional: true 1179 + '@vitest/browser-preview': 1180 + optional: true 1181 + '@vitest/browser-webdriverio': 1182 + optional: true 1183 + '@vitest/ui': 1184 + optional: true 1185 + happy-dom: 1186 + optional: true 1187 + jsdom: 1188 + optional: true 1189 + 1190 + why-is-node-running@2.3.0: 1191 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, tarball: https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz} 1192 + engines: {node: '>=8'} 1193 + hasBin: true 1194 + 1195 + workerd@1.20260205.0: 1196 + resolution: {integrity: sha512-CcMH5clHwrH8VlY7yWS9C/G/C8g9czIz1yU3akMSP9Z3CkEMFSoC3GGdj5G7Alw/PHEeez1+1IrlYger4pwu+w==, tarball: https://registry.npmjs.org/workerd/-/workerd-1.20260205.0.tgz} 1197 + engines: {node: '>=16'} 1198 + hasBin: true 1199 + 1200 + wrangler@4.63.0: 1201 + resolution: {integrity: sha512-+R04jF7Eb8K3KRMSgoXpcIdLb8GC62eoSGusYh1pyrSMm/10E0hbKkd7phMJO4HxXc6R7mOHC5SSoX9eof30Uw==, tarball: https://registry.npmjs.org/wrangler/-/wrangler-4.63.0.tgz} 1202 + engines: {node: '>=20.0.0'} 1203 + hasBin: true 1204 + peerDependencies: 1205 + '@cloudflare/workers-types': ^4.20260205.0 1206 + peerDependenciesMeta: 1207 + '@cloudflare/workers-types': 1208 + optional: true 1209 + 1210 + wrappy@1.0.2: 1211 + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz} 1212 + 1213 + ws@8.18.0: 1214 + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==, tarball: https://registry.npmjs.org/ws/-/ws-8.18.0.tgz} 1215 + engines: {node: '>=10.0.0'} 1216 + peerDependencies: 1217 + bufferutil: ^4.0.1 1218 + utf-8-validate: '>=5.0.2' 1219 + peerDependenciesMeta: 1220 + bufferutil: 1221 + optional: true 1222 + utf-8-validate: 1223 + optional: true 1224 + 1225 + yocto-queue@1.2.2: 1226 + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz} 1227 + engines: {node: '>=12.20'} 1228 + 1229 + youch-core@0.3.3: 1230 + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==, tarball: https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz} 1231 + 1232 + youch@4.1.0-beta.10: 1233 + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==, tarball: https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz} 1234 + 1235 + snapshots: 1236 + 1237 + '@atcute/atproto@3.1.10': 1238 + dependencies: 1239 + '@atcute/lexicons': 1.2.7 1240 + 1241 + '@atcute/car@5.1.1': 1242 + dependencies: 1243 + '@atcute/cbor': 2.3.2 1244 + '@atcute/cid': 2.4.1 1245 + '@atcute/uint8array': 1.1.1 1246 + '@atcute/varint': 2.0.0 1247 + 1248 + '@atcute/cbor@2.3.2': 1249 + dependencies: 1250 + '@atcute/cid': 2.4.1 1251 + '@atcute/multibase': 1.1.8 1252 + '@atcute/uint8array': 1.1.1 1253 + 1254 + '@atcute/cid@2.4.1': 1255 + dependencies: 1256 + '@atcute/multibase': 1.1.8 1257 + '@atcute/uint8array': 1.1.1 1258 + 1259 + '@atcute/client@4.2.1': 1260 + dependencies: 1261 + '@atcute/identity': 1.1.3 1262 + '@atcute/lexicons': 1.2.7 1263 + 1264 + '@atcute/crypto@2.4.0': 1265 + dependencies: 1266 + '@atcute/multibase': 1.1.8 1267 + '@atcute/uint8array': 1.1.1 1268 + '@noble/secp256k1': 3.0.0 1269 + 1270 + '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3)': 1271 + dependencies: 1272 + '@atcute/identity': 1.1.3 1273 + '@atcute/lexicons': 1.2.7 1274 + '@atcute/util-fetch': 1.0.5 1275 + '@badrap/valita': 0.4.6 1276 + 1277 + '@atcute/identity@1.1.3': 1278 + dependencies: 1279 + '@atcute/lexicons': 1.2.7 1280 + '@badrap/valita': 0.4.6 1281 + 1282 + '@atcute/jetstream@1.1.2': 1283 + dependencies: 1284 + '@atcute/lexicons': 1.2.7 1285 + '@badrap/valita': 0.4.6 1286 + '@mary-ext/event-iterator': 1.0.0 1287 + '@mary-ext/simple-event-emitter': 1.0.0 1288 + partysocket: 1.1.11 1289 + type-fest: 4.41.0 1290 + yocto-queue: 1.2.2 1291 + 1292 + '@atcute/lex-cli@2.5.3': 1293 + dependencies: 1294 + '@atcute/identity': 1.1.3 1295 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 1296 + '@atcute/lexicon-doc': 2.1.2 1297 + '@atcute/lexicon-resolver': 0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3) 1298 + '@atcute/lexicons': 1.2.9 1299 + '@badrap/valita': 0.4.6 1300 + '@optique/core': 0.6.11 1301 + '@optique/run': 0.6.11 1302 + picocolors: 1.1.1 1303 + prettier: 3.8.1 1304 + 1305 + '@atcute/lexicon-doc@2.1.2': 1306 + dependencies: 1307 + '@atcute/identity': 1.1.3 1308 + '@atcute/lexicons': 1.2.9 1309 + '@atcute/uint8array': 1.1.1 1310 + '@atcute/util-text': 1.1.1 1311 + '@badrap/valita': 0.4.6 1312 + 1313 + '@atcute/lexicon-resolver@0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)': 1314 + dependencies: 1315 + '@atcute/crypto': 2.4.0 1316 + '@atcute/identity': 1.1.3 1317 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 1318 + '@atcute/lexicon-doc': 2.1.2 1319 + '@atcute/lexicons': 1.2.9 1320 + '@atcute/repo': 0.1.3 1321 + '@atcute/util-fetch': 1.0.5 1322 + '@badrap/valita': 0.4.6 1323 + 1324 + '@atcute/lexicons@1.2.7': 1325 + dependencies: 1326 + '@atcute/uint8array': 1.1.0 1327 + '@atcute/util-text': 1.1.0 1328 + '@standard-schema/spec': 1.1.0 1329 + esm-env: 1.2.2 1330 + 1331 + '@atcute/lexicons@1.2.9': 1332 + dependencies: 1333 + '@atcute/uint8array': 1.1.1 1334 + '@atcute/util-text': 1.1.1 1335 + '@standard-schema/spec': 1.1.0 1336 + esm-env: 1.2.2 1337 + 1338 + '@atcute/mst@1.0.0': 1339 + dependencies: 1340 + '@atcute/cbor': 2.3.2 1341 + '@atcute/cid': 2.4.1 1342 + '@atcute/uint8array': 1.1.1 1343 + 1344 + '@atcute/multibase@1.1.8': 1345 + dependencies: 1346 + '@atcute/uint8array': 1.1.1 1347 + 1348 + '@atcute/repo@0.1.3': 1349 + dependencies: 1350 + '@atcute/car': 5.1.1 1351 + '@atcute/cbor': 2.3.2 1352 + '@atcute/cid': 2.4.1 1353 + '@atcute/crypto': 2.4.0 1354 + '@atcute/lexicons': 1.2.9 1355 + '@atcute/mst': 1.0.0 1356 + '@atcute/uint8array': 1.1.1 1357 + 1358 + '@atcute/uint8array@1.1.0': {} 1359 + 1360 + '@atcute/uint8array@1.1.1': {} 1361 + 1362 + '@atcute/util-fetch@1.0.5': 1363 + dependencies: 1364 + '@badrap/valita': 0.4.6 1365 + 1366 + '@atcute/util-text@1.1.0': 1367 + dependencies: 1368 + unicode-segmenter: 0.14.5 1369 + 1370 + '@atcute/util-text@1.1.1': 1371 + dependencies: 1372 + unicode-segmenter: 0.14.5 1373 + 1374 + '@atcute/varint@2.0.0': {} 1375 + 1376 + '@badrap/valita@0.4.6': {} 1377 + 1378 + '@cloudflare/kv-asset-handler@0.4.2': {} 1379 + 1380 + '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0)': 1381 + dependencies: 1382 + unenv: 2.0.0-rc.24 1383 + optionalDependencies: 1384 + workerd: 1.20260205.0 1385 + 1386 + '@cloudflare/workerd-darwin-64@1.20260205.0': 1387 + optional: true 1388 + 1389 + '@cloudflare/workerd-darwin-arm64@1.20260205.0': 1390 + optional: true 1391 + 1392 + '@cloudflare/workerd-linux-64@1.20260205.0': 1393 + optional: true 1394 + 1395 + '@cloudflare/workerd-linux-arm64@1.20260205.0': 1396 + optional: true 1397 + 1398 + '@cloudflare/workerd-windows-64@1.20260205.0': 1399 + optional: true 1400 + 1401 + '@cloudflare/workers-types@4.20260205.0': {} 1402 + 1403 + '@cspotcode/source-map-support@0.8.1': 1404 + dependencies: 1405 + '@jridgewell/trace-mapping': 0.3.9 1406 + 1407 + '@emnapi/core@1.9.0': 1408 + dependencies: 1409 + '@emnapi/wasi-threads': 1.2.0 1410 + tslib: 2.8.1 1411 + optional: true 1412 + 1413 + '@emnapi/runtime@1.8.1': 1414 + dependencies: 1415 + tslib: 2.8.1 1416 + optional: true 1417 + 1418 + '@emnapi/wasi-threads@1.2.0': 1419 + dependencies: 1420 + tslib: 2.8.1 1421 + optional: true 1422 + 1423 + '@esbuild/aix-ppc64@0.27.0': 1424 + optional: true 1425 + 1426 + '@esbuild/android-arm64@0.27.0': 1427 + optional: true 1428 + 1429 + '@esbuild/android-arm@0.27.0': 1430 + optional: true 1431 + 1432 + '@esbuild/android-x64@0.27.0': 1433 + optional: true 1434 + 1435 + '@esbuild/darwin-arm64@0.27.0': 1436 + optional: true 1437 + 1438 + '@esbuild/darwin-x64@0.27.0': 1439 + optional: true 1440 + 1441 + '@esbuild/freebsd-arm64@0.27.0': 1442 + optional: true 1443 + 1444 + '@esbuild/freebsd-x64@0.27.0': 1445 + optional: true 1446 + 1447 + '@esbuild/linux-arm64@0.27.0': 1448 + optional: true 1449 + 1450 + '@esbuild/linux-arm@0.27.0': 1451 + optional: true 1452 + 1453 + '@esbuild/linux-ia32@0.27.0': 1454 + optional: true 1455 + 1456 + '@esbuild/linux-loong64@0.27.0': 1457 + optional: true 1458 + 1459 + '@esbuild/linux-mips64el@0.27.0': 1460 + optional: true 1461 + 1462 + '@esbuild/linux-ppc64@0.27.0': 1463 + optional: true 1464 + 1465 + '@esbuild/linux-riscv64@0.27.0': 1466 + optional: true 1467 + 1468 + '@esbuild/linux-s390x@0.27.0': 1469 + optional: true 1470 + 1471 + '@esbuild/linux-x64@0.27.0': 1472 + optional: true 1473 + 1474 + '@esbuild/netbsd-arm64@0.27.0': 1475 + optional: true 1476 + 1477 + '@esbuild/netbsd-x64@0.27.0': 1478 + optional: true 1479 + 1480 + '@esbuild/openbsd-arm64@0.27.0': 1481 + optional: true 1482 + 1483 + '@esbuild/openbsd-x64@0.27.0': 1484 + optional: true 1485 + 1486 + '@esbuild/openharmony-arm64@0.27.0': 1487 + optional: true 1488 + 1489 + '@esbuild/sunos-x64@0.27.0': 1490 + optional: true 1491 + 1492 + '@esbuild/win32-arm64@0.27.0': 1493 + optional: true 1494 + 1495 + '@esbuild/win32-ia32@0.27.0': 1496 + optional: true 1497 + 1498 + '@esbuild/win32-x64@0.27.0': 1499 + optional: true 1500 + 1501 + '@img/colour@1.0.0': {} 1502 + 1503 + '@img/sharp-darwin-arm64@0.34.5': 1504 + optionalDependencies: 1505 + '@img/sharp-libvips-darwin-arm64': 1.2.4 1506 + optional: true 1507 + 1508 + '@img/sharp-darwin-x64@0.34.5': 1509 + optionalDependencies: 1510 + '@img/sharp-libvips-darwin-x64': 1.2.4 1511 + optional: true 1512 + 1513 + '@img/sharp-libvips-darwin-arm64@1.2.4': 1514 + optional: true 1515 + 1516 + '@img/sharp-libvips-darwin-x64@1.2.4': 1517 + optional: true 1518 + 1519 + '@img/sharp-libvips-linux-arm64@1.2.4': 1520 + optional: true 1521 + 1522 + '@img/sharp-libvips-linux-arm@1.2.4': 1523 + optional: true 1524 + 1525 + '@img/sharp-libvips-linux-ppc64@1.2.4': 1526 + optional: true 1527 + 1528 + '@img/sharp-libvips-linux-riscv64@1.2.4': 1529 + optional: true 1530 + 1531 + '@img/sharp-libvips-linux-s390x@1.2.4': 1532 + optional: true 1533 + 1534 + '@img/sharp-libvips-linux-x64@1.2.4': 1535 + optional: true 1536 + 1537 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': 1538 + optional: true 1539 + 1540 + '@img/sharp-libvips-linuxmusl-x64@1.2.4': 1541 + optional: true 1542 + 1543 + '@img/sharp-linux-arm64@0.34.5': 1544 + optionalDependencies: 1545 + '@img/sharp-libvips-linux-arm64': 1.2.4 1546 + optional: true 1547 + 1548 + '@img/sharp-linux-arm@0.34.5': 1549 + optionalDependencies: 1550 + '@img/sharp-libvips-linux-arm': 1.2.4 1551 + optional: true 1552 + 1553 + '@img/sharp-linux-ppc64@0.34.5': 1554 + optionalDependencies: 1555 + '@img/sharp-libvips-linux-ppc64': 1.2.4 1556 + optional: true 1557 + 1558 + '@img/sharp-linux-riscv64@0.34.5': 1559 + optionalDependencies: 1560 + '@img/sharp-libvips-linux-riscv64': 1.2.4 1561 + optional: true 1562 + 1563 + '@img/sharp-linux-s390x@0.34.5': 1564 + optionalDependencies: 1565 + '@img/sharp-libvips-linux-s390x': 1.2.4 1566 + optional: true 1567 + 1568 + '@img/sharp-linux-x64@0.34.5': 1569 + optionalDependencies: 1570 + '@img/sharp-libvips-linux-x64': 1.2.4 1571 + optional: true 1572 + 1573 + '@img/sharp-linuxmusl-arm64@0.34.5': 1574 + optionalDependencies: 1575 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 1576 + optional: true 1577 + 1578 + '@img/sharp-linuxmusl-x64@0.34.5': 1579 + optionalDependencies: 1580 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 1581 + optional: true 1582 + 1583 + '@img/sharp-wasm32@0.34.5': 1584 + dependencies: 1585 + '@emnapi/runtime': 1.8.1 1586 + optional: true 1587 + 1588 + '@img/sharp-win32-arm64@0.34.5': 1589 + optional: true 1590 + 1591 + '@img/sharp-win32-ia32@0.34.5': 1592 + optional: true 1593 + 1594 + '@img/sharp-win32-x64@0.34.5': 1595 + optional: true 1596 + 1597 + '@jridgewell/resolve-uri@3.1.2': {} 1598 + 1599 + '@jridgewell/sourcemap-codec@1.5.5': {} 1600 + 1601 + '@jridgewell/trace-mapping@0.3.9': 1602 + dependencies: 1603 + '@jridgewell/resolve-uri': 3.1.2 1604 + '@jridgewell/sourcemap-codec': 1.5.5 1605 + 1606 + '@mary-ext/event-iterator@1.0.0': 1607 + dependencies: 1608 + yocto-queue: 1.2.2 1609 + 1610 + '@mary-ext/simple-event-emitter@1.0.0': {} 1611 + 1612 + '@napi-rs/wasm-runtime@1.1.1': 1613 + dependencies: 1614 + '@emnapi/core': 1.9.0 1615 + '@emnapi/runtime': 1.8.1 1616 + '@tybys/wasm-util': 0.10.1 1617 + optional: true 1618 + 1619 + '@noble/secp256k1@3.0.0': {} 1620 + 1621 + '@optique/core@0.6.11': {} 1622 + 1623 + '@optique/run@0.6.11': 1624 + dependencies: 1625 + '@optique/core': 0.6.11 1626 + 1627 + '@oxc-project/runtime@0.115.0': {} 1628 + 1629 + '@oxc-project/types@0.115.0': {} 1630 + 1631 + '@poppinss/colors@4.1.6': 1632 + dependencies: 1633 + kleur: 4.1.5 1634 + 1635 + '@poppinss/dumper@0.6.5': 1636 + dependencies: 1637 + '@poppinss/colors': 4.1.6 1638 + '@sindresorhus/is': 7.2.0 1639 + supports-color: 10.2.2 1640 + 1641 + '@poppinss/exception@1.2.3': {} 1642 + 1643 + '@rolldown/binding-android-arm64@1.0.0-rc.9': 1644 + optional: true 1645 + 1646 + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': 1647 + optional: true 1648 + 1649 + '@rolldown/binding-darwin-x64@1.0.0-rc.9': 1650 + optional: true 1651 + 1652 + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': 1653 + optional: true 1654 + 1655 + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': 1656 + optional: true 1657 + 1658 + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': 1659 + optional: true 1660 + 1661 + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': 1662 + optional: true 1663 + 1664 + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': 1665 + optional: true 1666 + 1667 + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': 1668 + optional: true 1669 + 1670 + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': 1671 + optional: true 1672 + 1673 + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': 1674 + optional: true 1675 + 1676 + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': 1677 + optional: true 1678 + 1679 + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': 1680 + dependencies: 1681 + '@napi-rs/wasm-runtime': 1.1.1 1682 + optional: true 1683 + 1684 + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': 1685 + optional: true 1686 + 1687 + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': 1688 + optional: true 1689 + 1690 + '@rolldown/pluginutils@1.0.0-rc.9': {} 1691 + 1692 + '@sindresorhus/is@7.2.0': {} 1693 + 1694 + '@speed-highlight/core@1.2.14': {} 1695 + 1696 + '@standard-schema/spec@1.1.0': {} 1697 + 1698 + '@tybys/wasm-util@0.10.1': 1699 + dependencies: 1700 + tslib: 2.8.1 1701 + optional: true 1702 + 1703 + '@types/better-sqlite3@7.6.13': 1704 + dependencies: 1705 + '@types/node': 25.2.1 1706 + 1707 + '@types/chai@5.2.3': 1708 + dependencies: 1709 + '@types/deep-eql': 4.0.2 1710 + assertion-error: 2.0.1 1711 + 1712 + '@types/deep-eql@4.0.2': {} 1713 + 1714 + '@types/estree@1.0.8': {} 1715 + 1716 + '@types/node@25.2.1': 1717 + dependencies: 1718 + undici-types: 7.16.0 1719 + 1720 + '@vitest/expect@4.1.0': 1721 + dependencies: 1722 + '@standard-schema/spec': 1.1.0 1723 + '@types/chai': 5.2.3 1724 + '@vitest/spy': 4.1.0 1725 + '@vitest/utils': 4.1.0 1726 + chai: 6.2.2 1727 + tinyrainbow: 3.1.0 1728 + 1729 + '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0))': 1730 + dependencies: 1731 + '@vitest/spy': 4.1.0 1732 + estree-walker: 3.0.3 1733 + magic-string: 0.30.21 1734 + optionalDependencies: 1735 + vite: 8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0) 1736 + 1737 + '@vitest/pretty-format@4.1.0': 1738 + dependencies: 1739 + tinyrainbow: 3.1.0 1740 + 1741 + '@vitest/runner@4.1.0': 1742 + dependencies: 1743 + '@vitest/utils': 4.1.0 1744 + pathe: 2.0.3 1745 + 1746 + '@vitest/snapshot@4.1.0': 1747 + dependencies: 1748 + '@vitest/pretty-format': 4.1.0 1749 + '@vitest/utils': 4.1.0 1750 + magic-string: 0.30.21 1751 + pathe: 2.0.3 1752 + 1753 + '@vitest/spy@4.1.0': {} 1754 + 1755 + '@vitest/utils@4.1.0': 1756 + dependencies: 1757 + '@vitest/pretty-format': 4.1.0 1758 + convert-source-map: 2.0.0 1759 + tinyrainbow: 3.1.0 1760 + 1761 + assertion-error@2.0.1: {} 1762 + 1763 + base64-js@1.5.1: {} 1764 + 1765 + better-sqlite3@11.10.0: 1766 + dependencies: 1767 + bindings: 1.5.0 1768 + prebuild-install: 7.1.3 1769 + 1770 + better-sqlite3@12.8.0: 1771 + dependencies: 1772 + bindings: 1.5.0 1773 + prebuild-install: 7.1.3 1774 + 1775 + bindings@1.5.0: 1776 + dependencies: 1777 + file-uri-to-path: 1.0.0 1778 + 1779 + bl@4.1.0: 1780 + dependencies: 1781 + buffer: 5.7.1 1782 + inherits: 2.0.4 1783 + readable-stream: 3.6.2 1784 + 1785 + blake3-wasm@2.1.5: {} 1786 + 1787 + buffer@5.7.1: 1788 + dependencies: 1789 + base64-js: 1.5.1 1790 + ieee754: 1.2.1 1791 + 1792 + chai@6.2.2: {} 1793 + 1794 + chownr@1.1.4: {} 1795 + 1796 + convert-source-map@2.0.0: {} 1797 + 1798 + cookie@1.1.1: {} 1799 + 1800 + decompress-response@6.0.0: 1801 + dependencies: 1802 + mimic-response: 3.1.0 1803 + 1804 + deep-extend@0.6.0: {} 1805 + 1806 + detect-libc@2.1.2: {} 1807 + 1808 + end-of-stream@1.4.5: 1809 + dependencies: 1810 + once: 1.4.0 1811 + 1812 + error-stack-parser-es@1.0.5: {} 1813 + 1814 + es-module-lexer@2.0.0: {} 1815 + 1816 + esbuild@0.27.0: 1817 + optionalDependencies: 1818 + '@esbuild/aix-ppc64': 0.27.0 1819 + '@esbuild/android-arm': 0.27.0 1820 + '@esbuild/android-arm64': 0.27.0 1821 + '@esbuild/android-x64': 0.27.0 1822 + '@esbuild/darwin-arm64': 0.27.0 1823 + '@esbuild/darwin-x64': 0.27.0 1824 + '@esbuild/freebsd-arm64': 0.27.0 1825 + '@esbuild/freebsd-x64': 0.27.0 1826 + '@esbuild/linux-arm': 0.27.0 1827 + '@esbuild/linux-arm64': 0.27.0 1828 + '@esbuild/linux-ia32': 0.27.0 1829 + '@esbuild/linux-loong64': 0.27.0 1830 + '@esbuild/linux-mips64el': 0.27.0 1831 + '@esbuild/linux-ppc64': 0.27.0 1832 + '@esbuild/linux-riscv64': 0.27.0 1833 + '@esbuild/linux-s390x': 0.27.0 1834 + '@esbuild/linux-x64': 0.27.0 1835 + '@esbuild/netbsd-arm64': 0.27.0 1836 + '@esbuild/netbsd-x64': 0.27.0 1837 + '@esbuild/openbsd-arm64': 0.27.0 1838 + '@esbuild/openbsd-x64': 0.27.0 1839 + '@esbuild/openharmony-arm64': 0.27.0 1840 + '@esbuild/sunos-x64': 0.27.0 1841 + '@esbuild/win32-arm64': 0.27.0 1842 + '@esbuild/win32-ia32': 0.27.0 1843 + '@esbuild/win32-x64': 0.27.0 1844 + 1845 + esm-env@1.2.2: {} 1846 + 1847 + estree-walker@3.0.3: 1848 + dependencies: 1849 + '@types/estree': 1.0.8 1850 + 1851 + event-target-polyfill@0.0.4: {} 1852 + 1853 + expand-template@2.0.3: {} 1854 + 1855 + expect-type@1.3.0: {} 1856 + 1857 + fdir@6.5.0(picomatch@4.0.3): 1858 + optionalDependencies: 1859 + picomatch: 4.0.3 1860 + 1861 + file-uri-to-path@1.0.0: {} 1862 + 1863 + fs-constants@1.0.0: {} 1864 + 1865 + fsevents@2.3.3: 1866 + optional: true 1867 + 1868 + get-tsconfig@4.13.6: 1869 + dependencies: 1870 + resolve-pkg-maps: 1.0.0 1871 + 1872 + github-from-package@0.0.0: {} 1873 + 1874 + hono@4.12.8: {} 1875 + 1876 + ieee754@1.2.1: {} 1877 + 1878 + inherits@2.0.4: {} 1879 + 1880 + ini@1.3.8: {} 1881 + 1882 + kleur@4.1.5: {} 1883 + 1884 + lightningcss-android-arm64@1.32.0: 1885 + optional: true 1886 + 1887 + lightningcss-darwin-arm64@1.32.0: 1888 + optional: true 1889 + 1890 + lightningcss-darwin-x64@1.32.0: 1891 + optional: true 1892 + 1893 + lightningcss-freebsd-x64@1.32.0: 1894 + optional: true 1895 + 1896 + lightningcss-linux-arm-gnueabihf@1.32.0: 1897 + optional: true 1898 + 1899 + lightningcss-linux-arm64-gnu@1.32.0: 1900 + optional: true 1901 + 1902 + lightningcss-linux-arm64-musl@1.32.0: 1903 + optional: true 1904 + 1905 + lightningcss-linux-x64-gnu@1.32.0: 1906 + optional: true 1907 + 1908 + lightningcss-linux-x64-musl@1.32.0: 1909 + optional: true 1910 + 1911 + lightningcss-win32-arm64-msvc@1.32.0: 1912 + optional: true 1913 + 1914 + lightningcss-win32-x64-msvc@1.32.0: 1915 + optional: true 1916 + 1917 + lightningcss@1.32.0: 1918 + dependencies: 1919 + detect-libc: 2.1.2 1920 + optionalDependencies: 1921 + lightningcss-android-arm64: 1.32.0 1922 + lightningcss-darwin-arm64: 1.32.0 1923 + lightningcss-darwin-x64: 1.32.0 1924 + lightningcss-freebsd-x64: 1.32.0 1925 + lightningcss-linux-arm-gnueabihf: 1.32.0 1926 + lightningcss-linux-arm64-gnu: 1.32.0 1927 + lightningcss-linux-arm64-musl: 1.32.0 1928 + lightningcss-linux-x64-gnu: 1.32.0 1929 + lightningcss-linux-x64-musl: 1.32.0 1930 + lightningcss-win32-arm64-msvc: 1.32.0 1931 + lightningcss-win32-x64-msvc: 1.32.0 1932 + 1933 + magic-string@0.30.21: 1934 + dependencies: 1935 + '@jridgewell/sourcemap-codec': 1.5.5 1936 + 1937 + mimic-response@3.1.0: {} 1938 + 1939 + miniflare@4.20260205.0: 1940 + dependencies: 1941 + '@cspotcode/source-map-support': 0.8.1 1942 + sharp: 0.34.5 1943 + undici: 7.18.2 1944 + workerd: 1.20260205.0 1945 + ws: 8.18.0 1946 + youch: 4.1.0-beta.10 1947 + transitivePeerDependencies: 1948 + - bufferutil 1949 + - utf-8-validate 1950 + 1951 + minimist@1.2.8: {} 1952 + 1953 + mkdirp-classic@0.5.3: {} 1954 + 1955 + nanoid@3.3.11: {} 1956 + 1957 + napi-build-utils@2.0.0: {} 1958 + 1959 + node-abi@3.89.0: 1960 + dependencies: 1961 + semver: 7.7.4 1962 + 1963 + obug@2.1.1: {} 1964 + 1965 + once@1.4.0: 1966 + dependencies: 1967 + wrappy: 1.0.2 1968 + 1969 + partysocket@1.1.11: 1970 + dependencies: 1971 + event-target-polyfill: 0.0.4 1972 + 1973 + path-to-regexp@6.3.0: {} 1974 + 1975 + pathe@2.0.3: {} 1976 + 1977 + picocolors@1.1.1: {} 1978 + 1979 + picomatch@4.0.3: {} 1980 + 1981 + postcss@8.5.8: 1982 + dependencies: 1983 + nanoid: 3.3.11 1984 + picocolors: 1.1.1 1985 + source-map-js: 1.2.1 1986 + 1987 + prebuild-install@7.1.3: 1988 + dependencies: 1989 + detect-libc: 2.1.2 1990 + expand-template: 2.0.3 1991 + github-from-package: 0.0.0 1992 + minimist: 1.2.8 1993 + mkdirp-classic: 0.5.3 1994 + napi-build-utils: 2.0.0 1995 + node-abi: 3.89.0 1996 + pump: 3.0.4 1997 + rc: 1.2.8 1998 + simple-get: 4.0.1 1999 + tar-fs: 2.1.4 2000 + tunnel-agent: 0.6.0 2001 + 2002 + prettier@3.8.1: {} 2003 + 2004 + pump@3.0.4: 2005 + dependencies: 2006 + end-of-stream: 1.4.5 2007 + once: 1.4.0 2008 + 2009 + rc@1.2.8: 2010 + dependencies: 2011 + deep-extend: 0.6.0 2012 + ini: 1.3.8 2013 + minimist: 1.2.8 2014 + strip-json-comments: 2.0.1 2015 + 2016 + readable-stream@3.6.2: 2017 + dependencies: 2018 + inherits: 2.0.4 2019 + string_decoder: 1.3.0 2020 + util-deprecate: 1.0.2 2021 + 2022 + resolve-pkg-maps@1.0.0: {} 2023 + 2024 + rolldown@1.0.0-rc.9: 2025 + dependencies: 2026 + '@oxc-project/types': 0.115.0 2027 + '@rolldown/pluginutils': 1.0.0-rc.9 2028 + optionalDependencies: 2029 + '@rolldown/binding-android-arm64': 1.0.0-rc.9 2030 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 2031 + '@rolldown/binding-darwin-x64': 1.0.0-rc.9 2032 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 2033 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 2034 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 2035 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 2036 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 2037 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 2038 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 2039 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 2040 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 2041 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 2042 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 2043 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 2044 + 2045 + safe-buffer@5.2.1: {} 2046 + 2047 + semver@7.7.4: {} 2048 + 2049 + sharp@0.34.5: 2050 + dependencies: 2051 + '@img/colour': 1.0.0 2052 + detect-libc: 2.1.2 2053 + semver: 7.7.4 2054 + optionalDependencies: 2055 + '@img/sharp-darwin-arm64': 0.34.5 2056 + '@img/sharp-darwin-x64': 0.34.5 2057 + '@img/sharp-libvips-darwin-arm64': 1.2.4 2058 + '@img/sharp-libvips-darwin-x64': 1.2.4 2059 + '@img/sharp-libvips-linux-arm': 1.2.4 2060 + '@img/sharp-libvips-linux-arm64': 1.2.4 2061 + '@img/sharp-libvips-linux-ppc64': 1.2.4 2062 + '@img/sharp-libvips-linux-riscv64': 1.2.4 2063 + '@img/sharp-libvips-linux-s390x': 1.2.4 2064 + '@img/sharp-libvips-linux-x64': 1.2.4 2065 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 2066 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 2067 + '@img/sharp-linux-arm': 0.34.5 2068 + '@img/sharp-linux-arm64': 0.34.5 2069 + '@img/sharp-linux-ppc64': 0.34.5 2070 + '@img/sharp-linux-riscv64': 0.34.5 2071 + '@img/sharp-linux-s390x': 0.34.5 2072 + '@img/sharp-linux-x64': 0.34.5 2073 + '@img/sharp-linuxmusl-arm64': 0.34.5 2074 + '@img/sharp-linuxmusl-x64': 0.34.5 2075 + '@img/sharp-wasm32': 0.34.5 2076 + '@img/sharp-win32-arm64': 0.34.5 2077 + '@img/sharp-win32-ia32': 0.34.5 2078 + '@img/sharp-win32-x64': 0.34.5 2079 + 2080 + siginfo@2.0.0: {} 2081 + 2082 + simple-concat@1.0.1: {} 2083 + 2084 + simple-get@4.0.1: 2085 + dependencies: 2086 + decompress-response: 6.0.0 2087 + once: 1.4.0 2088 + simple-concat: 1.0.1 2089 + 2090 + source-map-js@1.2.1: {} 2091 + 2092 + stackback@0.0.2: {} 2093 + 2094 + std-env@4.0.0: {} 2095 + 2096 + string_decoder@1.3.0: 2097 + dependencies: 2098 + safe-buffer: 5.2.1 2099 + 2100 + strip-json-comments@2.0.1: {} 2101 + 2102 + supports-color@10.2.2: {} 2103 + 2104 + tar-fs@2.1.4: 2105 + dependencies: 2106 + chownr: 1.1.4 2107 + mkdirp-classic: 0.5.3 2108 + pump: 3.0.4 2109 + tar-stream: 2.2.0 2110 + 2111 + tar-stream@2.2.0: 2112 + dependencies: 2113 + bl: 4.1.0 2114 + end-of-stream: 1.4.5 2115 + fs-constants: 1.0.0 2116 + inherits: 2.0.4 2117 + readable-stream: 3.6.2 2118 + 2119 + tinybench@2.9.0: {} 2120 + 2121 + tinyexec@1.0.4: {} 2122 + 2123 + tinyglobby@0.2.15: 2124 + dependencies: 2125 + fdir: 6.5.0(picomatch@4.0.3) 2126 + picomatch: 4.0.3 2127 + 2128 + tinyrainbow@3.1.0: {} 2129 + 2130 + tslib@2.8.1: 2131 + optional: true 2132 + 2133 + tsx@4.21.0: 2134 + dependencies: 2135 + esbuild: 0.27.0 2136 + get-tsconfig: 4.13.6 2137 + optionalDependencies: 2138 + fsevents: 2.3.3 2139 + 2140 + tunnel-agent@0.6.0: 2141 + dependencies: 2142 + safe-buffer: 5.2.1 2143 + 2144 + type-fest@4.41.0: {} 2145 + 2146 + typescript@5.9.3: {} 2147 + 2148 + undici-types@7.16.0: {} 2149 + 2150 + undici@7.18.2: {} 2151 + 2152 + unenv@2.0.0-rc.24: 2153 + dependencies: 2154 + pathe: 2.0.3 2155 + 2156 + unicode-segmenter@0.14.5: {} 2157 + 2158 + util-deprecate@1.0.2: {} 2159 + 2160 + vite@8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0): 2161 + dependencies: 2162 + '@oxc-project/runtime': 0.115.0 2163 + lightningcss: 1.32.0 2164 + picomatch: 4.0.3 2165 + postcss: 8.5.8 2166 + rolldown: 1.0.0-rc.9 2167 + tinyglobby: 0.2.15 2168 + optionalDependencies: 2169 + '@types/node': 25.2.1 2170 + esbuild: 0.27.0 2171 + fsevents: 2.3.3 2172 + tsx: 4.21.0 2173 + 2174 + vitest@4.1.0(@types/node@25.2.1)(vite@8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0)): 2175 + dependencies: 2176 + '@vitest/expect': 4.1.0 2177 + '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0)) 2178 + '@vitest/pretty-format': 4.1.0 2179 + '@vitest/runner': 4.1.0 2180 + '@vitest/snapshot': 4.1.0 2181 + '@vitest/spy': 4.1.0 2182 + '@vitest/utils': 4.1.0 2183 + es-module-lexer: 2.0.0 2184 + expect-type: 1.3.0 2185 + magic-string: 0.30.21 2186 + obug: 2.1.1 2187 + pathe: 2.0.3 2188 + picomatch: 4.0.3 2189 + std-env: 4.0.0 2190 + tinybench: 2.9.0 2191 + tinyexec: 1.0.4 2192 + tinyglobby: 0.2.15 2193 + tinyrainbow: 3.1.0 2194 + vite: 8.0.0(@types/node@25.2.1)(esbuild@0.27.0)(tsx@4.21.0) 2195 + why-is-node-running: 2.3.0 2196 + optionalDependencies: 2197 + '@types/node': 25.2.1 2198 + transitivePeerDependencies: 2199 + - msw 2200 + 2201 + why-is-node-running@2.3.0: 2202 + dependencies: 2203 + siginfo: 2.0.0 2204 + stackback: 0.0.2 2205 + 2206 + workerd@1.20260205.0: 2207 + optionalDependencies: 2208 + '@cloudflare/workerd-darwin-64': 1.20260205.0 2209 + '@cloudflare/workerd-darwin-arm64': 1.20260205.0 2210 + '@cloudflare/workerd-linux-64': 1.20260205.0 2211 + '@cloudflare/workerd-linux-arm64': 1.20260205.0 2212 + '@cloudflare/workerd-windows-64': 1.20260205.0 2213 + 2214 + wrangler@4.63.0(@cloudflare/workers-types@4.20260205.0): 2215 + dependencies: 2216 + '@cloudflare/kv-asset-handler': 0.4.2 2217 + '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0) 2218 + blake3-wasm: 2.1.5 2219 + esbuild: 0.27.0 2220 + miniflare: 4.20260205.0 2221 + path-to-regexp: 6.3.0 2222 + unenv: 2.0.0-rc.24 2223 + workerd: 1.20260205.0 2224 + optionalDependencies: 2225 + '@cloudflare/workers-types': 4.20260205.0 2226 + fsevents: 2.3.3 2227 + transitivePeerDependencies: 2228 + - bufferutil 2229 + - utf-8-validate 2230 + 2231 + wrappy@1.0.2: {} 2232 + 2233 + ws@8.18.0: {} 2234 + 2235 + yocto-queue@1.2.2: {} 2236 + 2237 + youch-core@0.3.3: 2238 + dependencies: 2239 + '@poppinss/exception': 1.2.3 2240 + error-stack-parser-es: 1.0.5 2241 + 2242 + youch@4.1.0-beta.10: 2243 + dependencies: 2244 + '@poppinss/colors': 4.1.6 2245 + '@poppinss/dumper': 0.6.5 2246 + '@speed-highlight/core': 1.2.14 2247 + cookie: 1.1.1 2248 + youch-core: 0.3.3
+17
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "moduleResolution": "bundler", 6 + "lib": ["ES2022"], 7 + "types": ["@cloudflare/workers-types"], 8 + "strict": true, 9 + "noEmit": true, 10 + "skipLibCheck": true, 11 + "forceConsistentCasingInFileNames": true, 12 + "resolveJsonModule": true, 13 + "isolatedModules": true 14 + }, 15 + "include": ["src"], 16 + "exclude": ["src/adapters/sqlite.ts"] 17 + }
+19
wrangler.jsonc
··· 1 + { 2 + "$schema": "node_modules/wrangler/config-schema.json", 3 + "name": "contrail", 4 + "main": "src/adapters/worker.ts", 5 + "compatibility_date": "2025-12-25", 6 + "observability": { 7 + "enabled": true 8 + }, 9 + "d1_databases": [ 10 + { 11 + "binding": "DB", 12 + "database_name": "contrail", 13 + "database_id": "FILL_IN_DATABASE_ID" 14 + } 15 + ], 16 + "triggers": { 17 + "crons": ["*/1 * * * *"] 18 + } 19 + }
+26
scripts/clean.ts
··· 1 + /** 2 + * Remove all generated files. 3 + * 4 + * Usage: npx tsx scripts/clean.ts 5 + */ 6 + 7 + import { rmSync } from "fs"; 8 + import { join } from "path"; 9 + 10 + const ROOT = join(__dirname, ".."); 11 + 12 + const targets = [ 13 + ".wrangler", 14 + "lex.config.js", 15 + "lexicons-pulled", 16 + "lexicons-generated", 17 + "src/lexicon-types", 18 + "src/core/queryable.generated.ts", 19 + ]; 20 + 21 + for (const target of targets) { 22 + rmSync(join(ROOT, target), { recursive: true, force: true }); 23 + console.log(` removed ${target}`); 24 + } 25 + 26 + console.log("Done.");
+39
scripts/dev.ts
··· 1 + /** 2 + * Start wrangler dev and auto-trigger the cron every 60s. 3 + * 4 + * Usage: npx tsx scripts/dev.ts 5 + */ 6 + 7 + import { spawn } from "child_process"; 8 + 9 + async function main() { 10 + const wrangler = spawn("npx", ["wrangler", "dev", "--test-scheduled"], { 11 + stdio: "inherit", 12 + shell: true, 13 + }); 14 + 15 + // Wait for wrangler to start 16 + await new Promise((resolve) => setTimeout(resolve, 3000)); 17 + 18 + console.log("Auto-ingestion started (every 60s)"); 19 + 20 + const interval = setInterval(async () => { 21 + try { 22 + await fetch("http://localhost:8787/__scheduled?cron=*/1+*+*+*+*"); 23 + } catch { 24 + // wrangler not ready yet or shutting down 25 + } 26 + }, 60_000); 27 + 28 + // Trigger once immediately 29 + try { 30 + await fetch("http://localhost:8787/__scheduled?cron=*/1+*+*+*+*"); 31 + } catch {} 32 + 33 + wrangler.on("exit", () => { 34 + clearInterval(interval); 35 + process.exit(); 36 + }); 37 + } 38 + 39 + main();
+551
scripts/generate-lexicons.ts
··· 1 + /** 2 + * Generates lexicon TypeScript files from the Contrail config. 3 + * 4 + * For each collection, generates: 5 + * - {nsid}.getRecords — query with queryable field params 6 + * - {nsid}.getUsers — query with limit/cursor 7 + * - {nsid}.getStats — query returning collection stats 8 + * 9 + * Plus admin endpoints: 10 + * - contrail.admin.getCursor 11 + * - contrail.admin.getOverview 12 + * - contrail.admin.discover 13 + * - contrail.admin.backfill 14 + * 15 + * Usage: npx tsx scripts/generate-lexicons.ts 16 + */ 17 + 18 + import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from "fs"; 19 + import { join } from "path"; 20 + import { config } from "../src/config"; 21 + 22 + const ROOT_DIR = join(__dirname, ".."); 23 + const USER_LEXICONS_DIR = join(ROOT_DIR, "lexicons"); 24 + const PULLED_LEXICONS_DIR = join(ROOT_DIR, "lexicons-pulled"); 25 + const GENERATED_DIR = join(ROOT_DIR, "lexicons-generated"); 26 + 27 + function fieldToParam(field: string): string { 28 + return field.replace(/\.(\w)/g, (_, c) => c.toUpperCase()); 29 + } 30 + 31 + interface QueryableField { 32 + type?: "range"; 33 + } 34 + 35 + // Find a collection's lexicon file (user-provided takes priority over pulled) 36 + function findCollectionLexicon(collection: string): string | null { 37 + const segments = collection.split("."); 38 + for (const dir of [USER_LEXICONS_DIR, PULLED_LEXICONS_DIR]) { 39 + const filePath = join(dir, ...segments) + ".json"; 40 + if (existsSync(filePath)) return filePath; 41 + } 42 + return null; 43 + } 44 + 45 + // Analyze a collection's lexicon and return auto-detected queryable fields 46 + function detectQueryableFields(collection: string): Record<string, QueryableField> { 47 + const filePath = findCollectionLexicon(collection); 48 + if (!filePath) return {}; 49 + try { 50 + const doc = JSON.parse(readFileSync(filePath, "utf-8")); 51 + const mainRecord = doc.defs?.main?.record; 52 + if (!mainRecord?.properties) return {}; 53 + return analyzeProperties(doc.defs, mainRecord.properties, ""); 54 + } catch { 55 + return {}; 56 + } 57 + } 58 + 59 + function analyzeProperties( 60 + defs: Record<string, any>, 61 + properties: Record<string, any>, 62 + prefix: string 63 + ): Record<string, QueryableField> { 64 + const result: Record<string, QueryableField> = {}; 65 + 66 + for (const [field, def] of Object.entries(properties)) { 67 + const path = prefix ? `${prefix}.${field}` : field; 68 + 69 + if (def.type === "string") { 70 + if (def.format === "datetime") { 71 + result[path] = { type: "range" }; 72 + } else if (def.format !== "uri" && def.format !== "at-uri") { 73 + // Regular strings (enums, free text) → equality 74 + result[path] = {}; 75 + } 76 + } else if (def.type === "integer" || def.type === "number") { 77 + result[path] = { type: "range" }; 78 + } else if (def.type === "ref" && def.ref === "com.atproto.repo.strongRef") { 79 + result[`${path}.uri`] = {}; 80 + } else if (def.type === "union" && Array.isArray(def.refs) && def.refs.includes("com.atproto.repo.strongRef")) { 81 + result[`${path}.uri`] = {}; 82 + } else if (def.type === "ref" && def.ref) { 83 + // Resolve local ref (e.g. #mode → defs.mode) 84 + const refId = def.ref.includes("#") ? def.ref.split("#")[1] : null; 85 + if (refId && defs[refId]) { 86 + const resolved = defs[refId]; 87 + if (resolved.type === "string") { 88 + // String enum (knownValues) → equality 89 + result[path] = {}; 90 + } 91 + } 92 + } 93 + } 94 + 95 + return result; 96 + } 97 + 98 + // Clean generated dir (user-provided lexicons/ is untouched) 99 + rmSync(GENERATED_DIR, { recursive: true, force: true }); 100 + 101 + function nsidToPath(nsid: string): string { 102 + return join(GENERATED_DIR, ...nsid.split(".")) + ".json"; 103 + } 104 + 105 + // Check if a collection lexicon exists (user-provided or pulled) 106 + function getCollectionLexiconRef(collection: string): string | null { 107 + const filePath = findCollectionLexicon(collection); 108 + if (!filePath) return null; 109 + try { 110 + const doc = JSON.parse(readFileSync(filePath, "utf-8")); 111 + if (doc.defs?.main) return `${collection}#main`; 112 + } catch {} 113 + return null; 114 + } 115 + 116 + function ensureDir(filePath: string) { 117 + mkdirSync(join(filePath, ".."), { recursive: true }); 118 + } 119 + 120 + function writeLexicon(nsid: string, doc: object) { 121 + const filePath = nsidToPath(nsid); 122 + ensureDir(filePath); 123 + writeFileSync(filePath, JSON.stringify(doc, null, 2) + "\n"); 124 + console.log(` ${nsid}`); 125 + } 126 + 127 + // Build record output shape, optionally typing the record field 128 + function buildRecordDef(collectionRef: string | null) { 129 + return { 130 + type: "object", 131 + required: ["uri", "did", "collection", "rkey", "time_us"], 132 + properties: { 133 + uri: { type: "string", format: "at-uri" }, 134 + did: { type: "string", format: "did" }, 135 + collection: { type: "string", format: "nsid" }, 136 + rkey: { type: "string" }, 137 + cid: { type: "string" }, 138 + record: collectionRef 139 + ? { type: "ref", ref: collectionRef } 140 + : { type: "unknown" }, 141 + time_us: { type: "integer" }, 142 + }, 143 + }; 144 + } 145 + 146 + // --- Admin endpoints --- 147 + 148 + console.log("Generating admin endpoints..."); 149 + 150 + writeLexicon("contrail.admin.getCursor", { 151 + lexicon: 1, 152 + id: "contrail.admin.getCursor", 153 + defs: { 154 + main: { 155 + type: "query", 156 + description: "Get the current cursor position", 157 + output: { 158 + encoding: "application/json", 159 + schema: { 160 + type: "object", 161 + properties: { 162 + time_us: { type: "integer" }, 163 + date: { type: "string" }, 164 + seconds_ago: { type: "integer" }, 165 + }, 166 + }, 167 + }, 168 + }, 169 + }, 170 + }); 171 + 172 + writeLexicon("contrail.admin.getOverview", { 173 + lexicon: 1, 174 + id: "contrail.admin.getOverview", 175 + defs: { 176 + main: { 177 + type: "query", 178 + description: "Get an overview of all indexed collections", 179 + output: { 180 + encoding: "application/json", 181 + schema: { 182 + type: "object", 183 + required: ["total_records", "collections"], 184 + properties: { 185 + total_records: { type: "integer" }, 186 + collections: { 187 + type: "array", 188 + items: { type: "ref", ref: "#collectionStats" }, 189 + }, 190 + }, 191 + }, 192 + }, 193 + }, 194 + collectionStats: { 195 + type: "object", 196 + required: ["collection", "records", "unique_users"], 197 + properties: { 198 + collection: { type: "string" }, 199 + records: { type: "integer" }, 200 + unique_users: { type: "integer" }, 201 + }, 202 + }, 203 + }, 204 + }); 205 + 206 + writeLexicon("contrail.admin.sync", { 207 + lexicon: 1, 208 + id: "contrail.admin.sync", 209 + defs: { 210 + main: { 211 + type: "query", 212 + description: "Discover users from relays and backfill their records from PDS", 213 + parameters: { 214 + type: "params", 215 + properties: { 216 + concurrency: { 217 + type: "integer", 218 + minimum: 1, 219 + maximum: 50, 220 + default: 10, 221 + }, 222 + }, 223 + }, 224 + output: { 225 + encoding: "application/json", 226 + schema: { 227 + type: "object", 228 + required: ["discovered", "backfilled", "remaining", "done"], 229 + properties: { 230 + discovered: { type: "integer" }, 231 + backfilled: { type: "integer" }, 232 + remaining: { type: "integer" }, 233 + done: { type: "boolean" }, 234 + }, 235 + }, 236 + }, 237 + }, 238 + }, 239 + }); 240 + 241 + // --- Per-collection endpoints --- 242 + 243 + console.log("Generating collection endpoints..."); 244 + 245 + // Collect resolved queryable fields for all collections 246 + const resolvedQueryable: Record<string, Record<string, { type?: "range" }>> = {}; 247 + 248 + for (const [collection, colConfig] of Object.entries(config.collections)) { 249 + const collectionRef = getCollectionLexiconRef(collection); 250 + if (collectionRef) { 251 + console.log(` → ${collection} record typed via lexicon`); 252 + } 253 + 254 + // Auto-detect queryable fields from lexicon, then merge manual overrides 255 + const autoDetected = detectQueryableFields(collection); 256 + const manual = colConfig.queryable ?? {}; 257 + const merged = { ...autoDetected, ...manual }; 258 + resolvedQueryable[collection] = merged; 259 + 260 + if (Object.keys(autoDetected).length > 0) { 261 + const autoOnly = Object.keys(autoDetected).filter((k) => !manual[k]); 262 + if (autoOnly.length > 0) { 263 + console.log(` → auto-detected queryable: ${autoOnly.join(", ")}`); 264 + } 265 + } 266 + 267 + // --- getRecords --- 268 + const getRecordsParamProps: Record<string, any> = { 269 + limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, 270 + cursor: { type: "string" }, 271 + did: { type: "string", format: "did" }, 272 + include: { 273 + type: "string", 274 + description: "Comma-separated include names", 275 + }, 276 + }; 277 + 278 + for (const [field, fieldConfig] of Object.entries(merged)) { 279 + if (fieldConfig.type !== "range") { 280 + getRecordsParamProps[fieldToParam(field)] = { 281 + type: "string", 282 + description: `Filter by ${field}`, 283 + }; 284 + } 285 + } 286 + 287 + getRecordsParamProps["min"] = { 288 + type: "string", 289 + description: "Min filter as field:value (range fields or count types). Repeatable.", 290 + }; 291 + getRecordsParamProps["max"] = { 292 + type: "string", 293 + description: "Max filter as field:value (range fields). Repeatable.", 294 + }; 295 + 296 + for (const relName of Object.keys(colConfig.relations ?? {})) { 297 + getRecordsParamProps[`${relName}Preview`] = { 298 + type: "integer", 299 + minimum: 1, 300 + maximum: 50, 301 + description: `Number of ${relName} previews per record`, 302 + }; 303 + } 304 + 305 + writeLexicon(`${collection}.getRecords`, { 306 + lexicon: 1, 307 + id: `${collection}.getRecords`, 308 + defs: { 309 + main: { 310 + type: "query", 311 + description: `Query ${collection} records with filters`, 312 + parameters: { 313 + type: "params", 314 + properties: getRecordsParamProps, 315 + }, 316 + output: { 317 + encoding: "application/json", 318 + schema: { 319 + type: "object", 320 + required: ["records"], 321 + properties: { 322 + records: { type: "array", items: { type: "ref", ref: "#record" } }, 323 + cursor: { type: "string" }, 324 + }, 325 + }, 326 + }, 327 + }, 328 + record: buildRecordDef(collectionRef), 329 + }, 330 + }); 331 + 332 + // --- getRecord --- 333 + const getRecordParamProps: Record<string, any> = { 334 + uri: { type: "string", format: "at-uri", description: "AT URI of the record" }, 335 + }; 336 + for (const relName of Object.keys(colConfig.relations ?? {})) { 337 + getRecordParamProps[`${relName}Preview`] = { 338 + type: "integer", 339 + minimum: 1, 340 + maximum: 50, 341 + description: `Number of ${relName} previews per group`, 342 + }; 343 + } 344 + 345 + writeLexicon(`${collection}.getRecord`, { 346 + lexicon: 1, 347 + id: `${collection}.getRecord`, 348 + defs: { 349 + main: { 350 + type: "query", 351 + description: `Get a single ${collection} record by AT URI`, 352 + parameters: { 353 + type: "params", 354 + required: ["uri"], 355 + properties: getRecordParamProps, 356 + }, 357 + output: { 358 + encoding: "application/json", 359 + schema: { type: "ref", ref: "#record" }, 360 + }, 361 + }, 362 + record: buildRecordDef(collectionRef), 363 + }, 364 + }); 365 + 366 + // --- getUsers --- 367 + writeLexicon(`${collection}.getUsers`, { 368 + lexicon: 1, 369 + id: `${collection}.getUsers`, 370 + defs: { 371 + main: { 372 + type: "query", 373 + description: `List users who have ${collection} records`, 374 + parameters: { 375 + type: "params", 376 + properties: { 377 + limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, 378 + cursor: { type: "string" }, 379 + }, 380 + }, 381 + output: { 382 + encoding: "application/json", 383 + schema: { 384 + type: "object", 385 + required: ["users"], 386 + properties: { 387 + users: { 388 + type: "array", 389 + items: { type: "ref", ref: "#userRecord" }, 390 + }, 391 + cursor: { type: "string" }, 392 + }, 393 + }, 394 + }, 395 + }, 396 + userRecord: { 397 + type: "object", 398 + required: ["did", "record_count"], 399 + properties: { 400 + did: { type: "string", format: "did" }, 401 + record_count: { type: "integer" }, 402 + }, 403 + }, 404 + }, 405 + }); 406 + 407 + // --- getStats --- 408 + writeLexicon(`${collection}.getStats`, { 409 + lexicon: 1, 410 + id: `${collection}.getStats`, 411 + defs: { 412 + main: { 413 + type: "query", 414 + description: `Get stats for ${collection}`, 415 + output: { 416 + encoding: "application/json", 417 + schema: { 418 + type: "object", 419 + required: ["collection", "unique_users", "total_records"], 420 + properties: { 421 + collection: { type: "string" }, 422 + unique_users: { type: "integer" }, 423 + total_records: { type: "integer" }, 424 + last_record_time_us: { type: "integer" }, 425 + }, 426 + }, 427 + }, 428 + }, 429 + }, 430 + }); 431 + 432 + // --- Custom queries --- 433 + for (const queryName of Object.keys(colConfig.queries ?? {})) { 434 + writeLexicon(`${collection}.${queryName}`, { 435 + lexicon: 1, 436 + id: `${collection}.${queryName}`, 437 + defs: { 438 + main: { 439 + type: "query", 440 + description: `Custom query: ${queryName}`, 441 + output: { 442 + encoding: "application/json", 443 + schema: { type: "object", properties: {} }, 444 + }, 445 + }, 446 + }, 447 + }); 448 + } 449 + } 450 + 451 + // --- Auto-generate lex.config.js --- 452 + 453 + // Collect all collection NSIDs from config 454 + const collectionNsids = Object.keys(config.collections); 455 + 456 + // Scan pulled lexicons for external refs to find transitive deps 457 + function findRefsInLexicon(filePath: string): string[] { 458 + try { 459 + const content = readFileSync(filePath, "utf-8"); 460 + const refs: string[] = []; 461 + // Match all "ref": "some.nsid.here" or "refs": ["some.nsid.here"] 462 + const refPattern = /"ref":\s*"([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)(?:#\w+)?"/g; 463 + let match; 464 + while ((match = refPattern.exec(content)) !== null) { 465 + refs.push(match[1]); 466 + } 467 + // Also match refs arrays 468 + const refsArrayPattern = /"refs":\s*\[([^\]]+)\]/g; 469 + while ((match = refsArrayPattern.exec(content)) !== null) { 470 + const inner = match[1]; 471 + const nsidPattern = /"([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)(?:#\w+)?"/g; 472 + let innerMatch; 473 + while ((innerMatch = nsidPattern.exec(inner)) !== null) { 474 + refs.push(innerMatch[1]); 475 + } 476 + } 477 + return refs; 478 + } catch { 479 + return []; 480 + } 481 + } 482 + 483 + function scanLexiconsDir(dir: string): string[] { 484 + const files: string[] = []; 485 + if (!existsSync(dir)) return files; 486 + for (const entry of readdirSync(dir, { withFileTypes: true })) { 487 + const fullPath = join(dir, entry.name); 488 + if (entry.isDirectory()) { 489 + files.push(...scanLexiconsDir(fullPath)); 490 + } else if (entry.name.endsWith(".json")) { 491 + files.push(fullPath); 492 + } 493 + } 494 + return files; 495 + } 496 + 497 + // Find all NSIDs referenced by pulled lexicons 498 + const pulledFiles = [ 499 + ...scanLexiconsDir(USER_LEXICONS_DIR), 500 + ...scanLexiconsDir(PULLED_LEXICONS_DIR), 501 + ]; 502 + const allRefs = new Set<string>(); 503 + for (const file of pulledFiles) { 504 + for (const ref of findRefsInLexicon(file)) { 505 + allRefs.add(ref); 506 + } 507 + } 508 + 509 + // Merge: collection NSIDs + transitive deps (excluding com.atproto.* which comes from imports) 510 + const pullNsids = new Set(collectionNsids); 511 + for (const ref of allRefs) { 512 + if (!ref.startsWith("com.atproto.")) { 513 + pullNsids.add(ref); 514 + } 515 + } 516 + 517 + const sortedNsids = [...pullNsids].sort(); 518 + 519 + const lexConfigContent = `import { defineLexiconConfig } from "@atcute/lex-cli"; 520 + 521 + export default defineLexiconConfig({ 522 + files: ["lexicons/**/*.json", "lexicons-pulled/**/*.json", "lexicons-generated/**/*.json"], 523 + outdir: "src/lexicon-types/", 524 + imports: ["@atcute/atproto"], 525 + pull: { 526 + outdir: "lexicons-pulled/", 527 + sources: [ 528 + { 529 + type: "atproto", 530 + mode: "nsids", 531 + nsids: ${JSON.stringify(sortedNsids, null, 10).replace(/^/gm, " ").trim()}, 532 + }, 533 + ], 534 + }, 535 + }); 536 + `; 537 + 538 + writeFileSync(join(ROOT_DIR, "lex.config.js"), lexConfigContent); 539 + console.log(`\nGenerated lex.config.js with ${sortedNsids.length} pull NSIDs`); 540 + 541 + // Generate resolved queryable config for runtime use 542 + const queryableContent = `// Auto-generated — do not edit. Run \`pnpm generate\` to regenerate. 543 + import type { QueryableField } from "./types"; 544 + 545 + export const resolvedQueryable: Record<string, Record<string, QueryableField>> = ${JSON.stringify(resolvedQueryable, null, 2)}; 546 + `; 547 + 548 + writeFileSync(join(ROOT_DIR, "src", "core", "queryable.generated.ts"), queryableContent); 549 + console.log("Generated src/core/queryable.generated.ts"); 550 + 551 + console.log("\nDone!");
+56
scripts/sync.ts
··· 1 + /** 2 + * Sync: discover users from relays and backfill their records from PDS. 3 + * Calls the sync endpoint in a loop until done. 4 + * 5 + * Usage: npx tsx scripts/sync.ts [base_url] [admin_secret] 6 + */ 7 + 8 + async function main() { 9 + const base = process.argv[2] || "http://localhost:8787"; 10 + const secret = process.argv[3]; 11 + 12 + const headers: Record<string, string> = {}; 13 + if (secret) { 14 + headers["Authorization"] = `Bearer ${secret}`; 15 + } 16 + 17 + console.log("=== Syncing (discover + backfill) ==="); 18 + 19 + let staleCount = 0; 20 + let lastRemaining = -1; 21 + 22 + while (true) { 23 + const res = await fetch(`${base}/xrpc/contrail.admin.sync`, { headers }); 24 + const result = (await res.json()) as { 25 + discovered: number; 26 + backfilled: number; 27 + remaining: number; 28 + done: boolean; 29 + }; 30 + 31 + console.log( 32 + ` Discovered ${result.discovered}, backfilled ${result.backfilled} records, ${result.remaining} remaining` 33 + ); 34 + 35 + if (result.done) { 36 + console.log(" Sync complete."); 37 + break; 38 + } 39 + 40 + // Detect stuck state — no progress after several attempts 41 + if (result.remaining === lastRemaining && result.discovered === 0 && result.backfilled === 0) { 42 + staleCount++; 43 + if (staleCount >= 3) { 44 + console.log(" No progress after 3 attempts, stopping. Run again to retry."); 45 + break; 46 + } 47 + } else { 48 + staleCount = 0; 49 + } 50 + lastRemaining = result.remaining; 51 + } 52 + 53 + console.log("\n=== Done ==="); 54 + } 55 + 56 + main();
+16
src/config.ts
··· 1 + import type { ContrailConfig } from "./core/types"; 2 + 3 + export const config: ContrailConfig = { 4 + collections: { 5 + "community.lexicon.calendar.event": { 6 + relations: { 7 + rsvps: { 8 + collection: "community.lexicon.calendar.rsvp", 9 + groupBy: "status", 10 + }, 11 + }, 12 + }, 13 + "community.lexicon.calendar.rsvp": {}, 14 + "app.blento.card": {} 15 + }, 16 + };
+44
src/adapters/sqlite.ts
··· 1 + import BetterSqlite3 from "better-sqlite3"; 2 + import type { Database, Statement } from "../core/types"; 3 + 4 + export function createSqliteDatabase(path: string): Database { 5 + const raw = new BetterSqlite3(path); 6 + raw.pragma("journal_mode = WAL"); 7 + 8 + function wrapStatement( 9 + stmt: BetterSqlite3.Statement, 10 + boundValues: any[] = [] 11 + ): Statement { 12 + return { 13 + bind(...values: any[]): Statement { 14 + return wrapStatement(stmt, values); 15 + }, 16 + async run() { 17 + return stmt.run(...boundValues); 18 + }, 19 + async all<T>() { 20 + return { results: stmt.all(...boundValues) as T[] }; 21 + }, 22 + async first<T>() { 23 + return (stmt.get(...boundValues) as T) ?? null; 24 + }, 25 + }; 26 + } 27 + 28 + return { 29 + prepare(sql: string): Statement { 30 + const stmt = raw.prepare(sql); 31 + return wrapStatement(stmt); 32 + }, 33 + async batch(stmts: Statement[]): Promise<any[]> { 34 + const results: any[] = []; 35 + const transaction = raw.transaction(() => { 36 + for (const stmt of stmts) { 37 + results.push(stmt.run()); 38 + } 39 + }); 40 + transaction(); 41 + return results; 42 + }, 43 + }; 44 + }
+38
src/adapters/worker.ts
··· 1 + import { createApp } from "../core/router"; 2 + import { initSchema } from "../core/db"; 3 + import { runIngestCycle } from "../core/jetstream"; 4 + import { config as rawConfig } from "../config"; 5 + import { validateConfig, resolveConfig } from "../core/types"; 6 + 7 + const config = resolveConfig(rawConfig); 8 + validateConfig(config); 9 + 10 + let schemaReady = false; 11 + 12 + async function ensureSchema(db: D1Database): Promise<void> { 13 + if (!schemaReady) { 14 + await initSchema(db, config); 15 + schemaReady = true; 16 + } 17 + } 18 + 19 + interface Env { 20 + DB: D1Database; 21 + ADMIN_SECRET?: string; 22 + } 23 + 24 + export default { 25 + async fetch(request: Request, env: Env): Promise<Response> { 26 + await ensureSchema(env.DB); 27 + return createApp(env.DB, config, env.ADMIN_SECRET).fetch(request); 28 + }, 29 + 30 + async scheduled( 31 + _event: ScheduledEvent, 32 + env: Env, 33 + ctx: ExecutionContext 34 + ): Promise<void> { 35 + await ensureSchema(env.DB); 36 + ctx.waitUntil(runIngestCycle(env.DB, config)); 37 + }, 38 + };
+318
src/core/backfill.ts
··· 1 + import { type Did } from "@atcute/lexicons"; 2 + import { isDid, isNsid } from "@atcute/lexicons/syntax"; 3 + 4 + import type { ContrailConfig, Database, IngestEvent } from "./types"; 5 + import { getDiscoverableCollections, getDependentCollections, DEFAULT_RELAYS } from "./types"; 6 + import { applyEvents } from "./db"; 7 + import { getClient } from "./client"; 8 + 9 + const PAGE_SIZE = 100; 10 + const BATCH_SIZE = 50; 11 + const MAX_RETRIES = 5; 12 + 13 + async function withRetry<T>( 14 + fn: () => Promise<T>, 15 + label: string, 16 + maxRetries = 3 17 + ): Promise<T> { 18 + let lastError: unknown; 19 + for (let attempt = 0; attempt <= maxRetries; attempt++) { 20 + try { 21 + return await fn(); 22 + } catch (err) { 23 + lastError = err; 24 + if (attempt < maxRetries) { 25 + const delay = Math.min(1000 * 2 ** attempt, 10000); 26 + console.warn( 27 + `[retry] ${label} failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms: ${err}` 28 + ); 29 + await new Promise((r) => setTimeout(r, delay)); 30 + } 31 + } 32 + } 33 + throw lastError; 34 + } 35 + 36 + async function markFailed( 37 + db: Database, 38 + did: string, 39 + collection: string, 40 + error: string 41 + ): Promise<void> { 42 + await db 43 + .prepare( 44 + "UPDATE backfills SET retries = retries + 1, last_error = ?, completed = CASE WHEN retries + 1 >= ? THEN 1 ELSE completed END WHERE did = ? AND collection = ?" 45 + ) 46 + .bind(error, MAX_RETRIES, did, collection) 47 + .run(); 48 + } 49 + 50 + export async function backfillUser( 51 + db: Database, 52 + did: string, 53 + collection: string, 54 + deadline: number, 55 + config?: ContrailConfig 56 + ): Promise<number> { 57 + if (Date.now() >= deadline) return 0; 58 + 59 + const status = await db 60 + .prepare( 61 + "SELECT completed, pds_cursor, retries FROM backfills WHERE did = ? AND collection = ?" 62 + ) 63 + .bind(did, collection) 64 + .first<{ completed: number; pds_cursor: string | null; retries: number }>(); 65 + 66 + if (status?.completed) return 0; 67 + 68 + if (!status) { 69 + await db 70 + .prepare( 71 + "INSERT INTO backfills (did, collection, completed) VALUES (?, ?, 0) ON CONFLICT DO NOTHING" 72 + ) 73 + .bind(did, collection) 74 + .run(); 75 + } 76 + 77 + let currentCursor: string | undefined = status?.pds_cursor ?? undefined; 78 + 79 + console.log( 80 + `Backfilling ${collection} for ${did} (cursor: ${currentCursor ?? "start"}, retries: ${status?.retries ?? 0})` 81 + ); 82 + 83 + if (!isDid(did)) { 84 + await markFailed(db, did, collection, `Invalid DID: ${did}`); 85 + return 0; 86 + } 87 + 88 + if (!isNsid(collection)) { 89 + await markFailed(db, did, collection, `Invalid NSID: ${collection}`); 90 + return 0; 91 + } 92 + 93 + let client; 94 + try { 95 + client = await withRetry( 96 + () => getClient(did as Did, db), 97 + `getClient(${did})` 98 + ); 99 + } catch (err) { 100 + await markFailed(db, did, collection, String(err)); 101 + return 0; 102 + } 103 + 104 + let totalInserted = 0; 105 + let done = false; 106 + 107 + try { 108 + while (Date.now() < deadline) { 109 + const response = await withRetry( 110 + () => 111 + client.get("com.atproto.repo.listRecords", { 112 + params: { 113 + repo: did as Did, 114 + collection, 115 + limit: PAGE_SIZE, 116 + cursor: currentCursor, 117 + }, 118 + }), 119 + `listRecords(${did}/${collection})` 120 + ); 121 + 122 + if (!response.ok) { 123 + await markFailed( 124 + db, 125 + did, 126 + collection, 127 + `listRecords status ${response.status}` 128 + ); 129 + return totalInserted; 130 + } 131 + 132 + if (response.data.records.length === 0) { 133 + done = true; 134 + break; 135 + } 136 + 137 + const now = Date.now(); 138 + const events: IngestEvent[] = response.data.records.map((r) => ({ 139 + uri: r.uri, 140 + did, 141 + collection, 142 + rkey: r.uri.split("/").pop()!, 143 + operation: "create" as const, 144 + cid: r.cid, 145 + record: JSON.stringify(r.value), 146 + time_us: now * 1000, 147 + indexed_at: now * 1000, 148 + })); 149 + 150 + for (let i = 0; i < events.length; i += BATCH_SIZE) { 151 + const batch = events.slice(i, i + BATCH_SIZE); 152 + await applyEvents(db, batch, config); 153 + totalInserted += batch.length; 154 + } 155 + 156 + currentCursor = response.data.cursor ?? undefined; 157 + 158 + await db 159 + .prepare( 160 + "UPDATE backfills SET pds_cursor = ? WHERE did = ? AND collection = ?" 161 + ) 162 + .bind(currentCursor ?? null, did, collection) 163 + .run(); 164 + 165 + if (!currentCursor) { 166 + done = true; 167 + break; 168 + } 169 + } 170 + } catch (err) { 171 + await markFailed(db, did, collection, String(err)); 172 + return totalInserted; 173 + } 174 + 175 + if (done) { 176 + await db 177 + .prepare( 178 + "UPDATE backfills SET completed = 1 WHERE did = ? AND collection = ?" 179 + ) 180 + .bind(did, collection) 181 + .run(); 182 + console.log( 183 + `Backfill complete: ${totalInserted} records for ${did}/${collection}` 184 + ); 185 + } else { 186 + console.log( 187 + `Backfill paused: ${totalInserted} records for ${did}/${collection}, will resume` 188 + ); 189 + } 190 + 191 + return totalInserted; 192 + } 193 + 194 + // --- Discovery --- 195 + 196 + interface DiscoveryPage { 197 + repos: { did: string }[]; 198 + cursor?: string; 199 + } 200 + 201 + async function fetchPage( 202 + relay: string, 203 + collection: string, 204 + cursor?: string 205 + ): Promise<DiscoveryPage | null> { 206 + const url = new URL( 207 + `/xrpc/com.atproto.sync.listReposByCollection`, 208 + relay 209 + ); 210 + url.searchParams.set("collection", collection); 211 + url.searchParams.set("limit", "1000"); 212 + if (cursor) { 213 + url.searchParams.set("cursor", cursor); 214 + } 215 + 216 + try { 217 + return await withRetry( 218 + async () => { 219 + const response = await fetch(url.toString()); 220 + if (!response.ok) { 221 + throw new Error(`HTTP ${response.status}`); 222 + } 223 + return (await response.json()) as DiscoveryPage; 224 + }, 225 + `fetchPage(${relay}, ${collection})` 226 + ); 227 + } catch (err) { 228 + console.error( 229 + `Discovery failed for ${collection} from ${relay} after retries: ${err}` 230 + ); 231 + return null; 232 + } 233 + } 234 + 235 + async function insertDiscoveredDIDs( 236 + db: Database, 237 + dids: string[], 238 + collection: string 239 + ): Promise<void> { 240 + if (dids.length === 0) return; 241 + 242 + const stmt = db.prepare( 243 + "INSERT INTO backfills (did, collection, completed) VALUES (?, ?, 0) ON CONFLICT DO NOTHING" 244 + ); 245 + 246 + const batch = dids.map((did) => stmt.bind(did, collection)); 247 + 248 + for (let i = 0; i < batch.length; i += 50) { 249 + await db.batch(batch.slice(i, i + 50)); 250 + } 251 + } 252 + 253 + async function saveDiscoveryState( 254 + db: Database, 255 + collection: string, 256 + relay: string, 257 + cursor: string | null, 258 + completed: boolean 259 + ): Promise<void> { 260 + await db 261 + .prepare( 262 + "INSERT INTO discovery (collection, relay, cursor, completed) VALUES (?, ?, ?, ?) ON CONFLICT(collection, relay) DO UPDATE SET cursor = excluded.cursor, completed = excluded.completed" 263 + ) 264 + .bind(collection, relay, cursor, completed ? 1 : 0) 265 + .run(); 266 + } 267 + 268 + export async function discoverDIDs( 269 + db: Database, 270 + config: ContrailConfig, 271 + deadline: number 272 + ): Promise<string[]> { 273 + const collections = getDiscoverableCollections(config); 274 + const relays = config.relays ?? DEFAULT_RELAYS; 275 + if (relays.length === 0 || collections.length === 0) return []; 276 + 277 + const discovered: string[] = []; 278 + 279 + for (const collection of collections) { 280 + if (Date.now() >= deadline) break; 281 + 282 + let data: DiscoveryPage | null = null; 283 + let relay: string | null = null; 284 + 285 + for (const r of relays) { 286 + const row = await db 287 + .prepare( 288 + "SELECT cursor, completed FROM discovery WHERE collection = ? AND relay = ?" 289 + ) 290 + .bind(collection, r) 291 + .first<{ cursor: string | null; completed: number }>(); 292 + 293 + if (row?.completed) continue; 294 + 295 + data = await fetchPage(r, collection, row?.cursor ?? undefined); 296 + if (data) { 297 + relay = r; 298 + break; 299 + } else { 300 + await saveDiscoveryState(db, collection, r, null, true); 301 + } 302 + } 303 + if (!data || !relay) continue; 304 + 305 + const dids = data.repos?.map((r) => r.did) ?? []; 306 + await insertDiscoveredDIDs(db, dids, collection); 307 + discovered.push(...dids); 308 + 309 + for (const depCollection of getDependentCollections(config)) { 310 + await insertDiscoveredDIDs(db, dids, depCollection); 311 + } 312 + 313 + const completed = !data.cursor; 314 + await saveDiscoveryState(db, collection, relay, data.cursor ?? null, completed); 315 + } 316 + 317 + return discovered; 318 + }
+112
src/core/client.ts
··· 1 + import { 2 + CompositeDidDocumentResolver, 3 + PlcDidDocumentResolver, 4 + WebDidDocumentResolver, 5 + } from "@atcute/identity-resolver"; 6 + import { type Did } from "@atcute/lexicons"; 7 + import { Client, simpleFetchHandler } from "@atcute/client"; 8 + import type {} from "@atcute/atproto"; 9 + import type { Database } from "./types"; 10 + 11 + // Slingshot-first PDS resolution with fallback to DID document resolution 12 + const SLINGSHOT_URL = 13 + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; 14 + 15 + export interface ResolvedIdentity { 16 + did: string; 17 + handle: string | null; 18 + pds: string | null; 19 + } 20 + 21 + async function resolveViaSlingshot( 22 + identifier: string 23 + ): Promise<ResolvedIdentity | undefined> { 24 + const url = new URL(SLINGSHOT_URL); 25 + url.searchParams.set("identifier", identifier); 26 + 27 + try { 28 + const response = await fetch(url.toString()); 29 + if (!response.ok) return undefined; 30 + const data = (await response.json()) as { 31 + did?: string; 32 + handle?: string; 33 + pds?: string; 34 + }; 35 + if (!data.did && !data.pds) return undefined; 36 + return { 37 + did: data.did ?? identifier, 38 + handle: data.handle ?? null, 39 + pds: data.pds ?? null, 40 + }; 41 + } catch { 42 + return undefined; 43 + } 44 + } 45 + 46 + const didResolver = new CompositeDidDocumentResolver({ 47 + methods: { 48 + plc: new PlcDidDocumentResolver(), 49 + web: new WebDidDocumentResolver(), 50 + }, 51 + }); 52 + 53 + async function getPDSViaDidDoc(did: Did): Promise<string | undefined> { 54 + const doc = await didResolver.resolve(did as Did<"plc"> | Did<"web">); 55 + return doc.service 56 + ?.find((s) => s.id === "#atproto_pds") 57 + ?.serviceEndpoint.toString(); 58 + } 59 + 60 + /** 61 + * Resolve identity info (did, handle, pds) for a DID or handle. 62 + * Uses slingshot first, falls back to DID doc for PDS. 63 + */ 64 + export async function resolvePDS( 65 + identifier: string 66 + ): Promise<ResolvedIdentity | undefined> { 67 + const result = await resolveViaSlingshot(identifier); 68 + if (result?.pds) return result; 69 + 70 + // Fall back to DID doc resolution (only works for DIDs, not handles) 71 + if (identifier.startsWith("did:")) { 72 + try { 73 + const pds = await getPDSViaDidDoc(identifier as Did); 74 + if (pds) { 75 + return { 76 + did: identifier, 77 + handle: result?.handle ?? null, 78 + pds, 79 + }; 80 + } 81 + } catch { 82 + // ignore 83 + } 84 + } 85 + 86 + return result; 87 + } 88 + 89 + export async function getPDS( 90 + did: Did, 91 + db?: Database 92 + ): Promise<string | undefined> { 93 + // Check cached PDS first 94 + if (db) { 95 + const cached = await db 96 + .prepare("SELECT pds FROM identities WHERE did = ? AND pds IS NOT NULL") 97 + .bind(did) 98 + .first<{ pds: string }>(); 99 + if (cached?.pds) return cached.pds; 100 + } 101 + 102 + const resolved = await resolvePDS(did); 103 + return resolved?.pds ?? undefined; 104 + } 105 + 106 + export async function getClient(did: Did, db?: Database): Promise<Client> { 107 + const pds = await getPDS(did, db); 108 + if (!pds) throw new Error(`PDS not found for ${did}`); 109 + return new Client({ 110 + handler: simpleFetchHandler({ service: pds }), 111 + }); 112 + }
+159
src/core/identity.ts
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import type { Database } from "./types"; 3 + import { isDid, isHandle } from "@atcute/lexicons/syntax"; 4 + import { resolvePDS } from "./client"; 5 + 6 + const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours 7 + 8 + export interface Identity { 9 + did: string; 10 + handle: string | null; 11 + pds: string | null; 12 + resolved_at: number; 13 + } 14 + 15 + async function saveIdentity(db: Database, identity: Identity): Promise<void> { 16 + await db 17 + .prepare( 18 + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, pds = excluded.pds, resolved_at = excluded.resolved_at" 19 + ) 20 + .bind(identity.did, identity.handle, identity.pds, identity.resolved_at) 21 + .run(); 22 + } 23 + 24 + function isStale(resolvedAt: number): boolean { 25 + return Date.now() - resolvedAt >= STALE_MS; 26 + } 27 + 28 + async function fetchAndSave( 29 + db: Database, 30 + identifier: string, 31 + cached?: Identity | null 32 + ): Promise<Identity> { 33 + const resolved = await resolvePDS(identifier); 34 + const identity: Identity = { 35 + did: resolved?.did ?? identifier, 36 + handle: resolved?.handle ?? cached?.handle ?? null, 37 + pds: resolved?.pds ?? cached?.pds ?? null, 38 + resolved_at: Date.now(), 39 + }; 40 + await saveIdentity(db, identity); 41 + return identity; 42 + } 43 + 44 + export async function resolveIdentity( 45 + db: Database, 46 + did: Did 47 + ): Promise<Identity> { 48 + const cached = await db 49 + .prepare("SELECT did, handle, pds, resolved_at FROM identities WHERE did = ?") 50 + .bind(did) 51 + .first<Identity>(); 52 + 53 + if (cached && !isStale(cached.resolved_at)) return cached; 54 + 55 + return fetchAndSave(db, did, cached); 56 + } 57 + 58 + export async function resolveIdentities( 59 + db: Database, 60 + dids: string[] 61 + ): Promise<Map<string, Identity>> { 62 + const map = new Map<string, Identity>(); 63 + if (dids.length === 0) return map; 64 + 65 + // Batch lookup from DB 66 + const BATCH = 50; 67 + for (let i = 0; i < dids.length; i += BATCH) { 68 + const chunk = dids.slice(i, i + BATCH); 69 + const placeholders = chunk.map(() => "?").join(","); 70 + const rows = await db 71 + .prepare(`SELECT did, handle, pds, resolved_at FROM identities WHERE did IN (${placeholders})`) 72 + .bind(...chunk) 73 + .all<Identity>(); 74 + for (const row of rows.results ?? []) { 75 + map.set(row.did, row); 76 + } 77 + } 78 + 79 + // Resolve missing via slingshot directly (no redundant DB lookup) 80 + for (const did of dids) { 81 + if (map.has(did) || !isDid(did)) continue; 82 + try { 83 + const identity = await fetchAndSave(db, did); 84 + map.set(did, identity); 85 + } catch (err) { 86 + console.warn(`Failed to resolve identity for ${did}: ${err}`); 87 + } 88 + } 89 + 90 + return map; 91 + } 92 + 93 + export async function resolveActor( 94 + db: Database, 95 + actor: string 96 + ): Promise<string | null> { 97 + if (isDid(actor)) return actor; 98 + if (!isHandle(actor)) return null; 99 + 100 + // Look up handle in identities table 101 + const cached = await db 102 + .prepare("SELECT did, resolved_at FROM identities WHERE handle = ?") 103 + .bind(actor) 104 + .first<{ did: string; resolved_at: number }>(); 105 + 106 + if (cached && !isStale(cached.resolved_at)) return cached.did; 107 + 108 + // Resolve via slingshot 109 + const resolved = await resolvePDS(actor); 110 + if (!resolved?.did || !isDid(resolved.did)) return null; 111 + 112 + await saveIdentity(db, { 113 + did: resolved.did, 114 + handle: resolved.handle ?? actor, 115 + pds: resolved.pds ?? null, 116 + resolved_at: Date.now(), 117 + }); 118 + 119 + return resolved.did; 120 + } 121 + 122 + export async function refreshStaleIdentities( 123 + db: Database, 124 + dids: string[] 125 + ): Promise<void> { 126 + if (dids.length === 0) return; 127 + 128 + const unique = [...new Set(dids)].filter(isDid); 129 + if (unique.length === 0) return; 130 + 131 + const staleThreshold = Date.now() - STALE_MS; 132 + const toRefresh: string[] = []; 133 + 134 + const BATCH = 50; 135 + for (let i = 0; i < unique.length; i += BATCH) { 136 + const chunk = unique.slice(i, i + BATCH); 137 + const placeholders = chunk.map(() => "?").join(","); 138 + const rows = await db 139 + .prepare(`SELECT did, resolved_at FROM identities WHERE did IN (${placeholders})`) 140 + .bind(...chunk) 141 + .all<{ did: string; resolved_at: number }>(); 142 + 143 + const found = new Map((rows.results ?? []).map((r) => [r.did, r.resolved_at])); 144 + for (const did of chunk) { 145 + const resolvedAt = found.get(did); 146 + if (resolvedAt === undefined || resolvedAt < staleThreshold) { 147 + toRefresh.push(did); 148 + } 149 + } 150 + } 151 + 152 + for (const did of toRefresh) { 153 + try { 154 + await fetchAndSave(db, did); 155 + } catch (err) { 156 + console.warn(`Failed to refresh identity for ${did}: ${err}`); 157 + } 158 + } 159 + }
+142
src/core/jetstream.ts
··· 1 + import { JetstreamSubscription } from "@atcute/jetstream"; 2 + import type { ContrailConfig, IngestEvent, Database } from "./types"; 3 + import { getCollectionNames, getDependentCollections } from "./types"; 4 + import { initSchema, getLastCursor, saveCursor, applyEvents } from "./db"; 5 + import { refreshStaleIdentities } from "./identity"; 6 + 7 + const BATCH_SIZE = 50; 8 + 9 + export async function ingestEvents( 10 + config: ContrailConfig, 11 + cursor: number | null, 12 + safetyTimeoutMs: number = 25_000, 13 + knownDids?: Set<string> 14 + ): Promise<{ events: IngestEvent[]; lastCursor: number | null }> { 15 + const startTimeUs = Date.now() * 1000; 16 + const deadline = Date.now() + safetyTimeoutMs; 17 + const collected: IngestEvent[] = []; 18 + 19 + const collections = getCollectionNames(config); 20 + const dependentCollections = new Set(getDependentCollections(config)); 21 + const urls = config.jetstreams ?? []; 22 + 23 + const subscription = new JetstreamSubscription({ 24 + url: urls, 25 + wantedCollections: collections, 26 + ...(cursor !== null ? { cursor } : {}), 27 + onConnectionOpen() { 28 + console.log("Connected to Jetstream"); 29 + }, 30 + onConnectionClose(event) { 31 + console.log( 32 + `Disconnected from Jetstream: ${event.code} ${event.reason}` 33 + ); 34 + }, 35 + onConnectionError(event) { 36 + console.error("Jetstream error:", event.error); 37 + }, 38 + }); 39 + 40 + for await (const event of subscription) { 41 + if (event.kind === "commit") { 42 + const { commit } = event; 43 + 44 + if (dependentCollections.has(commit.collection) && knownDids) { 45 + if (!knownDids.has(event.did)) continue; 46 + } 47 + 48 + const now = Date.now(); 49 + const uri = `at://${event.did}/${commit.collection}/${commit.rkey}`; 50 + 51 + collected.push({ 52 + uri, 53 + did: event.did, 54 + time_us: event.time_us, 55 + collection: commit.collection, 56 + operation: commit.operation as "create" | "update" | "delete", 57 + rkey: commit.rkey, 58 + cid: commit.operation === "delete" ? null : commit.cid, 59 + record: 60 + commit.operation === "delete" 61 + ? null 62 + : JSON.stringify(commit.record), 63 + indexed_at: now * 1000, 64 + }); 65 + 66 + if (knownDids && !dependentCollections.has(commit.collection)) { 67 + knownDids.add(event.did); 68 + } 69 + } 70 + 71 + if (event.time_us >= startTimeUs) { 72 + console.log("Caught up to present, stopping ingestion"); 73 + break; 74 + } 75 + 76 + if (Date.now() >= deadline) { 77 + console.log("Safety timeout reached, stopping ingestion"); 78 + break; 79 + } 80 + } 81 + 82 + const lastCursor = subscription.cursor || null; 83 + return { events: collected, lastCursor }; 84 + } 85 + 86 + // Run a full ingest cycle: init schema, load cursor, ingest, apply, save cursor 87 + export async function runIngestCycle( 88 + db: Database, 89 + config: ContrailConfig, 90 + timeoutMs: number = 25_000 91 + ): Promise<void> { 92 + await initSchema(db, config); 93 + 94 + const cursor = await getLastCursor(db); 95 + const collections = getCollectionNames(config); 96 + 97 + console.log( 98 + `Starting ingestion. Cursor: ${cursor ?? "none"}, Collections: ${collections.join(", ")}` 99 + ); 100 + 101 + // Load known DIDs for filtering dependent collections 102 + const dependentCollections = getDependentCollections(config); 103 + let knownDids: Set<string> | undefined; 104 + 105 + if (dependentCollections.length > 0) { 106 + const result = await db 107 + .prepare("SELECT DISTINCT did FROM records") 108 + .all<{ did: string }>(); 109 + knownDids = new Set((result.results ?? []).map((r) => r.did)); 110 + } 111 + 112 + const { events, lastCursor } = await ingestEvents( 113 + config, 114 + cursor, 115 + timeoutMs, 116 + knownDids 117 + ); 118 + 119 + console.log(`Received ${events.length} events from Jetstream`); 120 + 121 + for (let i = 0; i < events.length; i += BATCH_SIZE) { 122 + const batch = events.slice(i, i + BATCH_SIZE); 123 + await applyEvents(db, batch, config); 124 + } 125 + 126 + // Refresh stale/missing identities for DIDs in this batch 127 + const uniqueDids = [...new Set(events.map((e) => e.did))]; 128 + if (uniqueDids.length > 0) { 129 + try { 130 + await refreshStaleIdentities(db, uniqueDids); 131 + } catch (err) { 132 + console.warn(`Identity refresh failed: ${err}`); 133 + } 134 + } 135 + 136 + if (lastCursor !== null) { 137 + await saveCursor(db, lastCursor); 138 + console.log(`Saved cursor: ${lastCursor}`); 139 + } 140 + 141 + console.log(`Ingestion complete. Stored ${events.length} events.`); 142 + }
+161
src/core/types.ts
··· 1 + // Database interface — D1 implements this natively 2 + export interface Database { 3 + prepare(sql: string): Statement; 4 + batch(stmts: Statement[]): Promise<any[]>; 5 + } 6 + 7 + export interface Statement { 8 + bind(...values: any[]): Statement; 9 + run(): Promise<any>; 10 + all<T = any>(): Promise<{ results: T[] }>; 11 + first<T = any>(): Promise<T | null>; 12 + } 13 + 14 + // Config types 15 + 16 + export interface QueryableField { 17 + type?: "range"; 18 + } 19 + 20 + export interface RelationConfig { 21 + collection: string; 22 + field?: string; 23 + match?: "uri" | "did"; 24 + groupBy?: string; 25 + } 26 + 27 + export type CustomQueryHandler = ( 28 + db: Database, 29 + params: URLSearchParams, 30 + config: ContrailConfig 31 + ) => Promise<Response>; 32 + 33 + export interface CollectionConfig { 34 + discover?: boolean; 35 + queryable?: Record<string, QueryableField>; 36 + relations?: Record<string, RelationConfig>; 37 + queries?: Record<string, CustomQueryHandler>; 38 + } 39 + 40 + export const DEFAULT_PROFILES = ["app.bsky.actor.profile"]; 41 + 42 + export const DEFAULT_JETSTREAMS = [ 43 + "wss://jetstream1.us-east.bsky.network", 44 + "wss://jetstream2.us-east.bsky.network", 45 + "wss://jetstream1.us-west.bsky.network", 46 + "wss://jetstream2.us-west.bsky.network", 47 + ]; 48 + 49 + export const DEFAULT_RELAYS = [ 50 + "https://relay1.us-east.bsky.network", 51 + "https://relay2.us-east.bsky.network", 52 + ]; 53 + 54 + export interface ContrailConfig { 55 + collections: Record<string, CollectionConfig>; 56 + profiles?: string[]; 57 + relays?: string[]; 58 + jetstreams?: string[]; 59 + } 60 + 61 + /** 62 + * Resolve config: apply defaults and auto-add profile collections. 63 + */ 64 + export function resolveConfig(config: ContrailConfig): ContrailConfig { 65 + const profiles = config.profiles ?? DEFAULT_PROFILES; 66 + const collections = { ...config.collections }; 67 + for (const col of profiles) { 68 + if (!collections[col]) { 69 + collections[col] = { discover: false }; 70 + } 71 + } 72 + 73 + return { 74 + ...config, 75 + collections, 76 + profiles, 77 + jetstreams: config.jetstreams ?? DEFAULT_JETSTREAMS, 78 + relays: config.relays ?? DEFAULT_RELAYS, 79 + }; 80 + } 81 + 82 + // Record types 83 + 84 + export interface RecordRow { 85 + uri: string; 86 + did: string; 87 + collection: string; 88 + rkey: string; 89 + cid: string | null; 90 + record: string | null; 91 + time_us: number; 92 + indexed_at: number; 93 + } 94 + 95 + export interface IngestEvent { 96 + uri: string; 97 + did: string; 98 + collection: string; 99 + rkey: string; 100 + operation: "create" | "update" | "delete"; 101 + cid: string | null; 102 + record: string | null; 103 + time_us: number; 104 + indexed_at: number; 105 + } 106 + 107 + // Validation 108 + 109 + const SAFE_FIELD_NAME = /^[a-zA-Z0-9_.]+$/; 110 + 111 + export function validateFieldName(field: string): string { 112 + if (!SAFE_FIELD_NAME.test(field)) { 113 + throw new Error(`Invalid field name: ${field}`); 114 + } 115 + return field; 116 + } 117 + 118 + export function validateConfig(config: ContrailConfig): void { 119 + for (const [collection, colConfig] of Object.entries(config.collections)) { 120 + for (const field of Object.keys(colConfig.queryable ?? {})) { 121 + validateFieldName(field); 122 + } 123 + for (const [, rel] of Object.entries(colConfig.relations ?? {})) { 124 + if (rel.field) validateFieldName(rel.field); 125 + if (rel.groupBy) validateFieldName(rel.groupBy); 126 + } 127 + } 128 + } 129 + 130 + // Helpers 131 + 132 + export function getNestedValue(obj: any, path: string): any { 133 + let current = obj; 134 + for (const key of path.split(".")) { 135 + if (current == null) return undefined; 136 + current = current[key]; 137 + } 138 + return current; 139 + } 140 + 141 + const DEFAULT_RELATION_FIELD = "subject.uri"; 142 + 143 + export function getRelationField(rel: RelationConfig): string { 144 + return rel.field ?? DEFAULT_RELATION_FIELD; 145 + } 146 + 147 + export function getCollectionNames(config: ContrailConfig): string[] { 148 + return Object.keys(config.collections); 149 + } 150 + 151 + export function getDependentCollections(config: ContrailConfig): string[] { 152 + return Object.entries(config.collections) 153 + .filter(([, c]) => c.discover === false) 154 + .map(([name]) => name); 155 + } 156 + 157 + export function getDiscoverableCollections(config: ContrailConfig): string[] { 158 + return Object.entries(config.collections) 159 + .filter(([, c]) => c.discover !== false) 160 + .map(([name]) => name); 161 + }
+3
src/core/db/index.ts
··· 1 + export { initSchema } from "./schema"; 2 + export { getLastCursor, saveCursor, applyEvents, queryRecords, getUsersByCollection } from "./records"; 3 + export type { QueryOptions, UserRecord } from "./records";
+280
src/core/db/records.ts
··· 1 + import type { 2 + ContrailConfig, 3 + RelationConfig, 4 + Database, 5 + Statement, 6 + IngestEvent, 7 + RecordRow, 8 + } from "../types"; 9 + import { getNestedValue, getRelationField } from "../types"; 10 + 11 + // --- Counts --- 12 + 13 + function getInboundRelations( 14 + config: ContrailConfig, 15 + foreignCollection: string 16 + ): RelationConfig[] { 17 + const results: RelationConfig[] = []; 18 + for (const [, colConfig] of Object.entries(config.collections)) { 19 + for (const [, rel] of Object.entries(colConfig.relations ?? {})) { 20 + if (rel.collection === foreignCollection) { 21 + results.push(rel); 22 + } 23 + } 24 + } 25 + return results; 26 + } 27 + 28 + function buildCountStatements( 29 + db: Database, 30 + event: IngestEvent, 31 + config: ContrailConfig 32 + ): Statement[] { 33 + if (event.operation !== "create" && event.operation !== "delete") return []; 34 + 35 + const inbound = getInboundRelations(config, event.collection); 36 + if (inbound.length === 0) return []; 37 + 38 + const record = event.record ? JSON.parse(event.record) : null; 39 + if (!record && event.operation === "create") return []; 40 + 41 + const isCreate = event.operation === "create"; 42 + const statements: Statement[] = []; 43 + 44 + for (const rel of inbound) { 45 + const targetUri = getNestedValue(record, getRelationField(rel)); 46 + if (!targetUri) continue; 47 + 48 + const types = ["_total"]; 49 + if (rel.groupBy) { 50 + const groupValue = getNestedValue(record, rel.groupBy); 51 + if (groupValue != null) types.push(String(groupValue)); 52 + } 53 + 54 + for (const type of types) { 55 + statements.push( 56 + isCreate 57 + ? db 58 + .prepare( 59 + "INSERT INTO counts (uri, type, count) VALUES (?, ?, 1) ON CONFLICT(uri, type) DO UPDATE SET count = count + 1" 60 + ) 61 + .bind(targetUri, type) 62 + : db 63 + .prepare( 64 + "UPDATE counts SET count = MAX(count - 1, 0) WHERE uri = ? AND type = ?" 65 + ) 66 + .bind(targetUri, type) 67 + ); 68 + } 69 + } 70 + 71 + return statements; 72 + } 73 + 74 + // --- Cursor --- 75 + 76 + export async function getLastCursor(db: Database): Promise<number | null> { 77 + const row = await db 78 + .prepare("SELECT time_us FROM cursor WHERE id = 1") 79 + .first<{ time_us: number }>(); 80 + return row ? row.time_us : null; 81 + } 82 + 83 + export async function saveCursor( 84 + db: Database, 85 + timeUs: number 86 + ): Promise<void> { 87 + await db 88 + .prepare( 89 + "INSERT INTO cursor (id, time_us) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET time_us = excluded.time_us" 90 + ) 91 + .bind(timeUs) 92 + .run(); 93 + } 94 + 95 + // --- Events --- 96 + 97 + export async function applyEvents( 98 + db: Database, 99 + events: IngestEvent[], 100 + config?: ContrailConfig 101 + ): Promise<void> { 102 + if (events.length === 0) return; 103 + 104 + const upsertStmt = db.prepare( 105 + "INSERT INTO records (uri, did, collection, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, time_us = excluded.time_us, indexed_at = excluded.indexed_at" 106 + ); 107 + const deleteStmt = db.prepare("DELETE FROM records WHERE uri = ?"); 108 + 109 + const batch: Statement[] = []; 110 + 111 + for (const e of events) { 112 + if (e.operation === "delete") { 113 + batch.push(deleteStmt.bind(e.uri)); 114 + } else { 115 + batch.push( 116 + upsertStmt.bind( 117 + e.uri, 118 + e.did, 119 + e.collection, 120 + e.rkey, 121 + e.cid, 122 + e.record, 123 + e.time_us, 124 + e.indexed_at 125 + ) 126 + ); 127 + } 128 + 129 + if (config) { 130 + batch.push(...buildCountStatements(db, e, config)); 131 + } 132 + } 133 + 134 + await db.batch(batch); 135 + } 136 + 137 + // --- Query --- 138 + 139 + export interface QueryOptions { 140 + collection: string; 141 + did?: string; 142 + limit?: number; 143 + cursor?: number; 144 + filters?: Record<string, string>; 145 + rangeFilters?: Record<string, { min?: string; max?: string }>; 146 + countFilters?: Record<string, number>; 147 + } 148 + 149 + export async function queryRecords( 150 + db: Database, 151 + config: ContrailConfig, 152 + options: QueryOptions 153 + ): Promise<{ records: (RecordRow & { counts?: Record<string, number> })[]; cursor?: string }> { 154 + const { 155 + collection, 156 + did, 157 + limit: rawLimit, 158 + cursor, 159 + filters = {}, 160 + rangeFilters = {}, 161 + countFilters = {}, 162 + } = options; 163 + 164 + const limit = Math.min(Math.max(1, rawLimit ?? 50), 100); 165 + const conditions: string[] = ["r.collection = ?"]; 166 + const bindings: (string | number)[] = [collection]; 167 + 168 + if (did) { 169 + conditions.push("r.did = ?"); 170 + bindings.push(did); 171 + } 172 + 173 + if (cursor) { 174 + conditions.push("r.time_us < ?"); 175 + bindings.push(cursor); 176 + } 177 + 178 + for (const [field, value] of Object.entries(filters)) { 179 + conditions.push(`json_extract(r.record, '$.${field}') = ?`); 180 + bindings.push(value); 181 + } 182 + 183 + for (const [field, range] of Object.entries(rangeFilters)) { 184 + if (range.min != null) { 185 + conditions.push(`json_extract(r.record, '$.${field}') >= ?`); 186 + bindings.push(range.min); 187 + } 188 + if (range.max != null) { 189 + conditions.push(`json_extract(r.record, '$.${field}') <= ?`); 190 + bindings.push(range.max); 191 + } 192 + } 193 + 194 + const colConfig = config.collections[collection]; 195 + const relations = colConfig?.relations ?? {}; 196 + const needsCounts = Object.keys(relations).length > 0 || Object.keys(countFilters).length > 0; 197 + 198 + const countHaving: string[] = []; 199 + const countHavingBindings: (string | number)[] = []; 200 + for (const [type, minCount] of Object.entries(countFilters)) { 201 + countHaving.push(`COALESCE(SUM(CASE WHEN c.type = ? THEN c.count END), 0) >= ?`); 202 + countHavingBindings.push(type, minCount); 203 + } 204 + 205 + const where = conditions.join(" AND "); 206 + const select = needsCounts 207 + ? "r.uri, r.did, r.collection, r.rkey, r.cid, r.record, r.time_us, r.indexed_at, GROUP_CONCAT(c.type || ':' || c.count) as _counts" 208 + : "r.uri, r.did, r.collection, r.rkey, r.cid, r.record, r.time_us, r.indexed_at"; 209 + const join = needsCounts ? "LEFT JOIN counts c ON c.uri = r.uri" : ""; 210 + const group = needsCounts ? "GROUP BY r.uri" : ""; 211 + const having = countHaving.length > 0 ? `HAVING ${countHaving.join(" AND ")}` : ""; 212 + 213 + if (needsCounts) bindings.push(...countHavingBindings); 214 + bindings.push(limit); 215 + 216 + const query = `SELECT ${select} FROM records r ${join} WHERE ${where} ${group} ${having} ORDER BY r.time_us DESC LIMIT ?`; 217 + 218 + const result = await db 219 + .prepare(query) 220 + .bind(...bindings) 221 + .all<RecordRow & { _counts?: string }>(); 222 + 223 + const records = (result.results ?? []).map((row) => { 224 + const { _counts, ...rest } = row; 225 + const counts = parseCounts(_counts); 226 + return counts ? { ...rest, counts } : rest; 227 + }); 228 + 229 + const nextCursor = 230 + records.length === limit 231 + ? String(records[records.length - 1].time_us) 232 + : undefined; 233 + 234 + return { records, cursor: nextCursor }; 235 + } 236 + 237 + function parseCounts(raw?: string | null): Record<string, number> | undefined { 238 + if (!raw) return undefined; 239 + const counts: Record<string, number> = {}; 240 + for (const part of raw.split(",")) { 241 + const sep = part.lastIndexOf(":"); 242 + if (sep === -1) continue; 243 + const type = part.slice(0, sep); 244 + const count = parseInt(part.slice(sep + 1), 10); 245 + if (type && !isNaN(count)) { 246 + counts[type] = count; 247 + } 248 + } 249 + return Object.keys(counts).length > 0 ? counts : undefined; 250 + } 251 + 252 + // --- Users --- 253 + 254 + export interface UserRecord { 255 + did: string; 256 + record_count: number; 257 + } 258 + 259 + export async function getUsersByCollection( 260 + db: Database, 261 + collection: string, 262 + limit: number, 263 + cursor?: number 264 + ): Promise<{ users: UserRecord[]; cursor?: string }> { 265 + const clampedLimit = Math.min(Math.max(1, limit), 100); 266 + const offset = cursor ?? 0; 267 + 268 + const result = await db 269 + .prepare( 270 + "SELECT did, COUNT(*) AS record_count FROM records WHERE collection = ? GROUP BY did ORDER BY record_count DESC LIMIT ? OFFSET ?" 271 + ) 272 + .bind(collection, clampedLimit, offset) 273 + .all<UserRecord>(); 274 + 275 + const users = result.results ?? []; 276 + const nextCursor = 277 + users.length === clampedLimit ? String(offset + clampedLimit) : undefined; 278 + 279 + return { users, cursor: nextCursor }; 280 + }
+107
src/core/db/schema.ts
··· 1 + import type { ContrailConfig, Database } from "../types"; 2 + import { getRelationField } from "../types"; 3 + import { resolvedQueryable } from "../queryable.generated"; 4 + 5 + const BASE_SCHEMA = ` 6 + CREATE TABLE IF NOT EXISTS records ( 7 + uri TEXT PRIMARY KEY, 8 + did TEXT NOT NULL, 9 + collection TEXT NOT NULL, 10 + rkey TEXT NOT NULL, 11 + cid TEXT, 12 + record TEXT, 13 + time_us INTEGER NOT NULL, 14 + indexed_at INTEGER NOT NULL 15 + ); 16 + CREATE INDEX IF NOT EXISTS idx_records_collection_time ON records(collection, time_us DESC); 17 + CREATE INDEX IF NOT EXISTS idx_records_collection_did ON records(collection, did); 18 + CREATE TABLE IF NOT EXISTS counts ( 19 + uri TEXT NOT NULL, 20 + type TEXT NOT NULL, 21 + count INTEGER DEFAULT 0, 22 + PRIMARY KEY (uri, type) 23 + ); 24 + CREATE TABLE IF NOT EXISTS backfills ( 25 + did TEXT NOT NULL, 26 + collection TEXT NOT NULL, 27 + completed INTEGER NOT NULL DEFAULT 0, 28 + pds_cursor TEXT, 29 + retries INTEGER NOT NULL DEFAULT 0, 30 + last_error TEXT, 31 + PRIMARY KEY (did, collection) 32 + ); 33 + CREATE TABLE IF NOT EXISTS discovery ( 34 + collection TEXT NOT NULL, 35 + relay TEXT NOT NULL, 36 + cursor TEXT, 37 + completed INTEGER NOT NULL DEFAULT 0, 38 + PRIMARY KEY (collection, relay) 39 + ); 40 + CREATE TABLE IF NOT EXISTS cursor ( 41 + id INTEGER PRIMARY KEY CHECK (id = 1), 42 + time_us INTEGER NOT NULL 43 + ); 44 + CREATE TABLE IF NOT EXISTS identities ( 45 + did TEXT PRIMARY KEY, 46 + handle TEXT, 47 + pds TEXT, 48 + resolved_at INTEGER NOT NULL 49 + ); 50 + CREATE INDEX IF NOT EXISTS idx_identities_handle ON identities(handle); 51 + `; 52 + 53 + function sanitizeName(name: string): string { 54 + return name.replace(/[^a-zA-Z0-9]/g, "_"); 55 + } 56 + 57 + function buildDynamicIndexes(config: ContrailConfig): string[] { 58 + const indexes: string[] = []; 59 + for (const [collection, colConfig] of Object.entries(config.collections)) { 60 + const queryable = resolvedQueryable[collection] ?? colConfig.queryable ?? {}; 61 + for (const field of Object.keys(queryable)) { 62 + const idxName = `idx_${sanitizeName(collection)}_${sanitizeName(field)}`; 63 + indexes.push( 64 + `CREATE INDEX IF NOT EXISTS ${idxName} ON records(collection, json_extract(record, '$.${field}'))` 65 + ); 66 + } 67 + 68 + for (const [, rel] of Object.entries(colConfig.relations ?? {})) { 69 + const on = getRelationField(rel); 70 + const idxName = `idx_${sanitizeName(rel.collection)}_${sanitizeName(on)}`; 71 + indexes.push( 72 + `CREATE INDEX IF NOT EXISTS ${idxName} ON records(collection, json_extract(record, '$.${on}'))` 73 + ); 74 + } 75 + } 76 + return indexes; 77 + } 78 + 79 + const MIGRATIONS = [ 80 + "ALTER TABLE backfills ADD COLUMN retries INTEGER NOT NULL DEFAULT 0", 81 + "ALTER TABLE backfills ADD COLUMN last_error TEXT", 82 + ]; 83 + 84 + async function runMigrations(db: Database): Promise<void> { 85 + for (const sql of MIGRATIONS) { 86 + try { 87 + await db.prepare(sql).run(); 88 + } catch { 89 + // Column already exists — ignore 90 + } 91 + } 92 + } 93 + 94 + export async function initSchema( 95 + db: Database, 96 + config: ContrailConfig 97 + ): Promise<void> { 98 + const baseStatements = BASE_SCHEMA.split(";") 99 + .map((s) => s.trim()) 100 + .filter((s) => s.length > 0); 101 + 102 + const indexStatements = buildDynamicIndexes(config); 103 + const all = [...baseStatements, ...indexStatements]; 104 + 105 + await db.batch(all.map((s) => db.prepare(s))); 106 + await runMigrations(db); 107 + }
+122
src/core/router/admin.ts
··· 1 + import type { Hono, Context, Next } from "hono"; 2 + import type { ContrailConfig, Database } from "../types"; 3 + import { getCollectionNames } from "../types"; 4 + import { getLastCursor } from "../db"; 5 + import { backfillUser, discoverDIDs } from "../backfill"; 6 + import { parseIntParam } from "./helpers"; 7 + 8 + export function registerAdminRoutes( 9 + app: Hono, 10 + db: Database, 11 + config: ContrailConfig, 12 + adminSecret?: string 13 + ): void { 14 + const requireAdmin = async (c: Context, next: Next) => { 15 + if (adminSecret) { 16 + const auth = c.req.header("Authorization"); 17 + if (auth !== `Bearer ${adminSecret}`) 18 + return c.json({ error: "Unauthorized" }, 401); 19 + } 20 + await next(); 21 + }; 22 + 23 + app.get("/xrpc/contrail.admin.getCursor", async (c) => { 24 + const cursor = await getLastCursor(db); 25 + if (cursor === null) return c.json({ cursor: null }); 26 + 27 + const dateMs = Math.floor(cursor / 1000); 28 + return c.json({ 29 + time_us: cursor, 30 + date: new Date(dateMs).toISOString(), 31 + seconds_ago: Math.floor((Date.now() - dateMs) / 1000), 32 + }); 33 + }); 34 + 35 + app.get("/xrpc/contrail.admin.getOverview", async (c) => { 36 + const result = await db 37 + .prepare( 38 + "SELECT collection, COUNT(*) as records, COUNT(DISTINCT did) as unique_users FROM records GROUP BY collection" 39 + ) 40 + .all<{ collection: string; records: number; unique_users: number }>(); 41 + 42 + const collections = result.results ?? []; 43 + return c.json({ 44 + total_records: collections.reduce((sum, col) => sum + col.records, 0), 45 + collections, 46 + }); 47 + }); 48 + 49 + app.get("/xrpc/contrail.admin.sync", requireAdmin, async (c) => { 50 + const deadline = Date.now() + 25_000; 51 + const concurrency = parseIntParam(c.req.query("concurrency"), 25) ?? 25; 52 + 53 + // Phase 1: Discover DIDs from relays 54 + const dids = await discoverDIDs(db, config, deadline); 55 + 56 + const discoverableCount = getCollectionNames(config).filter( 57 + (col) => config.collections[col]?.discover !== false 58 + ).length; 59 + const discoveryRows = await db 60 + .prepare("SELECT COUNT(*) as count FROM discovery") 61 + .first<{ count: number }>(); 62 + const pendingDiscovery = await db 63 + .prepare("SELECT COUNT(*) as count FROM discovery WHERE completed = 0") 64 + .first<{ count: number }>(); 65 + const discoveryDone = 66 + (discoveryRows?.count ?? 0) >= discoverableCount && 67 + (pendingDiscovery?.count ?? 0) === 0; 68 + 69 + // Ensure dependent collections have backfill entries for all known DIDs 70 + const dependentCollections = getCollectionNames(config).filter( 71 + (col) => config.collections[col]?.discover === false 72 + ); 73 + if (dependentCollections.length > 0 && Date.now() < deadline) { 74 + for (const depCol of dependentCollections) { 75 + await db 76 + .prepare( 77 + `INSERT OR IGNORE INTO backfills (did, collection, completed) 78 + SELECT DISTINCT r.did, ?, 0 FROM records r 79 + LEFT JOIN backfills b ON b.did = r.did AND b.collection = ? 80 + WHERE b.did IS NULL` 81 + ) 82 + .bind(depCol, depCol) 83 + .run(); 84 + } 85 + } 86 + 87 + // Phase 2: Backfill records from PDS 88 + let backfilled = 0; 89 + if (Date.now() < deadline) { 90 + const pending = await db 91 + .prepare( 92 + "SELECT did, collection FROM backfills WHERE completed = 0 LIMIT 500" 93 + ) 94 + .all<{ did: string; collection: string }>(); 95 + 96 + const rows = pending.results ?? []; 97 + for (let i = 0; i < rows.length; i += concurrency) { 98 + if (Date.now() >= deadline) break; 99 + const batch = rows.slice(i, i + concurrency); 100 + const results = await Promise.allSettled( 101 + batch.map((row) => 102 + backfillUser(db, row.did, row.collection, deadline, config) 103 + ) 104 + ); 105 + for (const r of results) { 106 + if (r.status === "fulfilled") backfilled += r.value; 107 + } 108 + } 109 + } 110 + 111 + const remaining = await db 112 + .prepare("SELECT COUNT(*) as count FROM backfills WHERE completed = 0") 113 + .first<{ count: number }>(); 114 + 115 + return c.json({ 116 + discovered: dids.length, 117 + backfilled, 118 + remaining: remaining?.count ?? 0, 119 + done: discoveryDone && (remaining?.count ?? 0) === 0, 120 + }); 121 + }); 122 + }
+208
src/core/router/collection.ts
··· 1 + import type { Hono } from "hono"; 2 + import type { ContrailConfig, Database, RecordRow, QueryableField } from "../types"; 3 + import { getCollectionNames } from "../types"; 4 + import { resolvedQueryable } from "../queryable.generated"; 5 + import { queryRecords, getUsersByCollection } from "../db"; 6 + import { backfillUser } from "../backfill"; 7 + import { resolveHydrates } from "./hydrate"; 8 + import { resolveProfiles, collectDids } from "./profiles"; 9 + import { resolveActor } from "../identity"; 10 + import type { FormattedRecord } from "./helpers"; 11 + import { formatRecord, parseIntParam, fieldToParam } from "./helpers"; 12 + 13 + export function registerCollectionRoutes( 14 + app: Hono, 15 + db: Database, 16 + config: ContrailConfig 17 + ): void { 18 + for (const collection of getCollectionNames(config)) { 19 + const colConfig = config.collections[collection]; 20 + const relations = colConfig.relations ?? {}; 21 + const queryableFields: Record<string, QueryableField> = 22 + resolvedQueryable[collection] ?? colConfig.queryable ?? {}; 23 + 24 + app.get(`/xrpc/${collection}.getRecords`, async (c) => { 25 + const params = new URL(c.req.url).searchParams; 26 + const limit = parseIntParam(params.get("limit"), 50); 27 + const cursor = parseIntParam(params.get("cursor")); 28 + const actor = params.get("actor") || params.get("did") || undefined; 29 + const wantProfiles = params.get("profiles") === "true"; 30 + 31 + let did: string | undefined; 32 + if (actor) { 33 + const resolved = await resolveActor(db, actor); 34 + if (!resolved) return c.json({ error: "Could not resolve actor" }, 400); 35 + did = resolved; 36 + await backfillUser(db, did, collection, Date.now() + 10_000, config); 37 + } 38 + 39 + const filters: Record<string, string> = {}; 40 + for (const [field, fieldConfig] of Object.entries(queryableFields)) { 41 + if (fieldConfig.type === "range") continue; 42 + const value = params.get(fieldToParam(field)); 43 + if (value) filters[field] = value; 44 + } 45 + 46 + const rangeFilters: Record<string, { min?: string; max?: string }> = {}; 47 + const countFilters: Record<string, number> = {}; 48 + const rangeFields = new Set( 49 + Object.entries(queryableFields) 50 + .filter(([, c]) => c.type === "range") 51 + .map(([f]) => f) 52 + ); 53 + parseMinMaxParams( 54 + params.getAll("min"), 55 + params.getAll("max"), 56 + rangeFields, 57 + rangeFilters, 58 + countFilters 59 + ); 60 + 61 + const result = await queryRecords(db, config, { 62 + collection, 63 + did, 64 + limit, 65 + cursor, 66 + filters, 67 + rangeFilters, 68 + countFilters, 69 + }); 70 + 71 + const rows = result.records; 72 + const hydrates = await resolveHydrates( 73 + db, 74 + relations, 75 + params.getAll("hydrate"), 76 + rows 77 + ); 78 + 79 + const formattedRecords: FormattedRecord[] = rows.map((row) => { 80 + const formatted = formatRecord(row); 81 + if (row.counts) formatted.counts = row.counts; 82 + const h = hydrates[row.uri]; 83 + if (h) formatted.hydrates = h; 84 + return formatted; 85 + }); 86 + 87 + const allDids = collectDids(rows, hydrates); 88 + const profiles = wantProfiles 89 + ? await resolveProfiles(db, config, allDids) 90 + : undefined; 91 + 92 + return c.json({ 93 + records: formattedRecords, 94 + cursor: result.cursor, 95 + ...(profiles ? { profiles } : {}), 96 + }); 97 + }); 98 + 99 + app.get(`/xrpc/${collection}.getRecord`, async (c) => { 100 + const uri = c.req.query("uri"); 101 + if (!uri) return c.json({ error: "uri parameter required" }, 400); 102 + 103 + const row = await db 104 + .prepare( 105 + "SELECT uri, did, collection, rkey, cid, record, time_us, indexed_at FROM records WHERE uri = ? AND collection = ?" 106 + ) 107 + .bind(uri, collection) 108 + .first<RecordRow>(); 109 + 110 + if (!row) return c.json({ error: "Record not found" }, 404); 111 + 112 + const countRows = await db 113 + .prepare("SELECT type, count FROM counts WHERE uri = ?") 114 + .bind(uri) 115 + .all<{ type: string; count: number }>(); 116 + 117 + const formatted = formatRecord(row); 118 + if (countRows.results?.length) { 119 + const counts: Record<string, number> = {}; 120 + for (const cr of countRows.results) counts[cr.type] = cr.count; 121 + formatted.counts = counts; 122 + } 123 + 124 + const params = new URL(c.req.url).searchParams; 125 + const wantProfilesSingle = params.get("profiles") === "true"; 126 + 127 + const hydrates = await resolveHydrates( 128 + db, 129 + relations, 130 + params.getAll("hydrate"), 131 + [row] 132 + ); 133 + const h = hydrates[row.uri]; 134 + if (h) formatted.hydrates = h; 135 + 136 + const allDids = collectDids([row], hydrates); 137 + const profilesSingle = wantProfilesSingle 138 + ? await resolveProfiles(db, config, allDids) 139 + : undefined; 140 + 141 + return c.json({ 142 + ...formatted, 143 + ...(profilesSingle ? { profiles: profilesSingle } : {}), 144 + }); 145 + }); 146 + 147 + app.get(`/xrpc/${collection}.getUsers`, async (c) => { 148 + const limit = parseIntParam(c.req.query("limit"), 50) ?? 50; 149 + const cursor = parseIntParam(c.req.query("cursor")); 150 + return c.json(await getUsersByCollection(db, collection, limit, cursor)); 151 + }); 152 + 153 + app.get(`/xrpc/${collection}.getStats`, async (c) => { 154 + const row = await db 155 + .prepare( 156 + "SELECT COUNT(DISTINCT did) as unique_users, COUNT(*) as total_records, MAX(time_us) as last_record_time_us FROM records WHERE collection = ?" 157 + ) 158 + .bind(collection) 159 + .first<{ 160 + unique_users: number; 161 + total_records: number; 162 + last_record_time_us: number | null; 163 + }>(); 164 + 165 + return c.json({ 166 + collection, 167 + unique_users: row?.unique_users ?? 0, 168 + total_records: row?.total_records ?? 0, 169 + last_record_time_us: row?.last_record_time_us ?? null, 170 + }); 171 + }); 172 + 173 + for (const [queryName, handler] of Object.entries( 174 + colConfig.queries ?? {} 175 + )) { 176 + app.get(`/xrpc/${collection}.${queryName}`, async (c) => { 177 + const params = new URL(c.req.url).searchParams; 178 + return handler(db, params, config); 179 + }); 180 + } 181 + } 182 + } 183 + 184 + function parseMinMaxParams( 185 + minValues: string[], 186 + maxValues: string[], 187 + rangeFields: Set<string>, 188 + rangeFilters: Record<string, { min?: string; max?: string }>, 189 + countFilters: Record<string, number> 190 + ): void { 191 + for (const [side, values] of [ 192 + ["min", minValues], 193 + ["max", maxValues], 194 + ] as const) { 195 + for (const raw of values) { 196 + const sep = raw.indexOf(":"); 197 + if (sep === -1) continue; 198 + const key = raw.slice(0, sep); 199 + const val = raw.slice(sep + 1); 200 + if (rangeFields.has(key)) { 201 + (rangeFilters[key] ??= {})[side] = val; 202 + } else if (side === "min") { 203 + const num = parseInt(val, 10); 204 + if (!isNaN(num)) countFilters[key] = num; 205 + } 206 + } 207 + } 208 + }
+68
src/core/router/helpers.ts
··· 1 + import type { Database, RecordRow } from "../types"; 2 + 3 + export interface FormattedRecord { 4 + uri: string; 5 + did: string; 6 + collection: string; 7 + rkey: string; 8 + cid: string | null; 9 + record: any; 10 + time_us: number; 11 + counts?: Record<string, number>; 12 + hydrates?: Record<string, Record<string, any[]>>; 13 + [key: string]: any; 14 + } 15 + 16 + export function formatRecord(row: RecordRow): FormattedRecord { 17 + let record = null; 18 + if (row.record) { 19 + try { 20 + record = JSON.parse(row.record); 21 + } catch { 22 + record = row.record; 23 + } 24 + } 25 + return { 26 + uri: row.uri, 27 + did: row.did, 28 + collection: row.collection, 29 + rkey: row.rkey, 30 + cid: row.cid, 31 + record, 32 + time_us: row.time_us, 33 + }; 34 + } 35 + 36 + export function parseIntParam( 37 + value: string | null | undefined, 38 + defaultValue?: number 39 + ): number | undefined { 40 + if (!value) return defaultValue; 41 + const parsed = parseInt(value, 10); 42 + return isNaN(parsed) ? defaultValue : parsed; 43 + } 44 + 45 + export function fieldToParam(field: string): string { 46 + return field.replace(/\.(\w)/g, (_, c) => c.toUpperCase()); 47 + } 48 + 49 + const BATCH_SIZE = 50; 50 + 51 + export async function batchedInQuery<T>( 52 + db: Database, 53 + sql: string, 54 + prefixBindings: (string | number)[], 55 + inValues: string[] 56 + ): Promise<T[]> { 57 + const results: T[] = []; 58 + for (let i = 0; i < inValues.length; i += BATCH_SIZE) { 59 + const chunk = inValues.slice(i, i + BATCH_SIZE); 60 + const query = sql.replace("__IN__", chunk.map(() => "?").join(",")); 61 + const rows = await db 62 + .prepare(query) 63 + .bind(...prefixBindings, ...chunk) 64 + .all<T>(); 65 + results.push(...(rows.results ?? [])); 66 + } 67 + return results; 68 + }
+85
src/core/router/hydrate.ts
··· 1 + import type { RelationConfig, RecordRow, Database } from "../types"; 2 + import { getNestedValue, getRelationField } from "../types"; 3 + import { batchedInQuery, formatRecord } from "./helpers"; 4 + 5 + // --- Hydration: embed related records that point at the parent --- 6 + 7 + function parseHydrateParams( 8 + values: string[], 9 + relations: Record<string, RelationConfig> 10 + ): Record<string, number> { 11 + const hydrates: Record<string, number> = {}; 12 + for (const val of values) { 13 + const sep = val.indexOf(":"); 14 + const name = sep === -1 ? val : val.slice(0, sep); 15 + const limit = sep === -1 ? 10 : parseInt(val.slice(sep + 1), 10); 16 + if (relations[name] && !isNaN(limit) && limit > 0) { 17 + hydrates[name] = limit; 18 + } 19 + } 20 + return hydrates; 21 + } 22 + 23 + export async function resolveHydrates( 24 + db: Database, 25 + relations: Record<string, RelationConfig>, 26 + hydrateParams: string[], 27 + records: RecordRow[] 28 + ): Promise<Record<string, Record<string, Record<string, any[]>>>> { 29 + const requested = parseHydrateParams(hydrateParams, relations); 30 + if (Object.keys(requested).length === 0 || records.length === 0) return {}; 31 + 32 + const result: Record<string, Record<string, Record<string, any[]>>> = {}; 33 + 34 + for (const [relName, hydrateLimit] of Object.entries(requested)) { 35 + const rel = relations[relName]; 36 + const field = getRelationField(rel); 37 + const matchMode = rel.match ?? "uri"; 38 + 39 + const matchValues = matchMode === "did" 40 + ? [...new Set(records.map((r) => r.did))] 41 + : records.map((r) => r.uri); 42 + 43 + if (matchValues.length === 0) continue; 44 + 45 + const maxRows = matchValues.length * hydrateLimit; 46 + const relatedRows = await batchedInQuery<RecordRow>( 47 + db, 48 + `SELECT uri, did, collection, rkey, record, time_us FROM records 49 + WHERE collection = ? AND json_extract(record, '$.${field}') IN (__IN__) 50 + ORDER BY time_us DESC 51 + LIMIT ${maxRows}`, 52 + [rel.collection], 53 + matchValues 54 + ); 55 + 56 + for (const row of relatedRows) { 57 + const record = row.record ? JSON.parse(row.record) : null; 58 + const matchedValue = getNestedValue(record, field); 59 + if (!matchedValue) continue; 60 + 61 + const parentUris = matchMode === "did" 62 + ? records.filter((r) => r.did === matchedValue).map((r) => r.uri) 63 + : [matchedValue]; 64 + 65 + const groupValue = rel.groupBy 66 + ? String(getNestedValue(record, rel.groupBy) ?? "_other") 67 + : "_all"; 68 + 69 + for (const parentUri of parentUris) { 70 + const targetUri = matchMode === "did" ? parentUri : matchedValue; 71 + 72 + if (!result[targetUri]) result[targetUri] = {}; 73 + if (!result[targetUri][relName]) result[targetUri][relName] = {}; 74 + if (!result[targetUri][relName][groupValue]) result[targetUri][relName][groupValue] = []; 75 + 76 + const group = result[targetUri][relName][groupValue]; 77 + if (group.length < hydrateLimit) { 78 + group.push(formatRecord(row)); 79 + } 80 + } 81 + } 82 + } 83 + 84 + return result; 85 + }
+23
src/core/router/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { cors } from "hono/cors"; 3 + import type { Database, ContrailConfig } from "../types"; 4 + import { registerAdminRoutes } from "./admin"; 5 + import { registerCollectionRoutes } from "./collection"; 6 + 7 + export function createApp( 8 + db: Database, 9 + config: ContrailConfig, 10 + adminSecret?: string 11 + ): Hono { 12 + const app = new Hono(); 13 + app.use("*", cors()); 14 + 15 + app.get("/", (c) => c.json({ status: "ok" })); 16 + app.get("/health", (c) => c.json({ status: "ok" })); 17 + app.get("/xrpc/_health", (c) => c.json({ status: "ok" })); 18 + 19 + registerAdminRoutes(app, db, config, adminSecret); 20 + registerCollectionRoutes(app, db, config); 21 + 22 + return app; 23 + }
+94
src/core/router/profiles.ts
··· 1 + import type { Database, ContrailConfig, RecordRow } from "../types"; 2 + import { resolveIdentities } from "../identity"; 3 + import { batchedInQuery } from "./helpers"; 4 + 5 + export interface ProfileEntry { 6 + did: string; 7 + handle: string | null; 8 + uri?: string; 9 + collection?: string; 10 + rkey?: string; 11 + cid?: string | null; 12 + record?: any; 13 + } 14 + 15 + export function collectDids( 16 + records: RecordRow[], 17 + hydrates: Record<string, Record<string, Record<string, any[]>>> 18 + ): string[] { 19 + const dids = new Set(records.map((r) => r.did)); 20 + for (const groups of Object.values(hydrates)) { 21 + for (const byGroup of Object.values(groups)) { 22 + for (const items of Object.values(byGroup)) { 23 + for (const item of items) { 24 + if (item.did) dids.add(item.did); 25 + } 26 + } 27 + } 28 + } 29 + return [...dids]; 30 + } 31 + 32 + export async function resolveProfiles( 33 + db: Database, 34 + config: ContrailConfig, 35 + dids: string[] 36 + ): Promise<Record<string, ProfileEntry>> { 37 + if (dids.length === 0 || !config.profiles || config.profiles.length === 0) { 38 + return {}; 39 + } 40 + 41 + const result: Record<string, ProfileEntry> = {}; 42 + 43 + // Batch-lookup profile records for each configured profile collection (first match wins) 44 + for (const collection of config.profiles) { 45 + const remaining = dids.filter((d) => !result[d]); 46 + if (remaining.length === 0) break; 47 + 48 + const uris = remaining.map((did) => `at://${did}/${collection}/self`); 49 + 50 + const rows = await batchedInQuery<RecordRow>( 51 + db, 52 + `SELECT uri, did, collection, rkey, cid, record FROM records WHERE uri IN (__IN__)`, 53 + [], 54 + uris 55 + ); 56 + 57 + for (const row of rows) { 58 + let record = null; 59 + if (row.record) { 60 + try { 61 + record = JSON.parse(row.record); 62 + } catch { 63 + record = row.record; 64 + } 65 + } 66 + result[row.did] = { 67 + did: row.did, 68 + handle: null, // filled below 69 + uri: row.uri, 70 + collection: row.collection, 71 + rkey: row.rkey, 72 + cid: row.cid, 73 + record, 74 + }; 75 + } 76 + } 77 + 78 + // Resolve identities for all DIDs 79 + const identities = await resolveIdentities(db, dids); 80 + 81 + // Fill in handles and create entries for DIDs without profile records 82 + for (const did of dids) { 83 + const identity = identities.get(did); 84 + const handle = identity?.handle ?? null; 85 + 86 + if (result[did]) { 87 + result[did].handle = handle; 88 + } else { 89 + result[did] = { did, handle }; 90 + } 91 + } 92 + 93 + return result; 94 + }