From 93ada33ba493aa46216538555332c4fa46fcc00b Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:18:00 +0100 Subject: [PATCH] add tests, fixes --- .claude/settings.local.json | 4 +- AGENTS.md | 12 +- .../app/blento/card/getRecord.json | 193 ------------ .../app/blento/card/getRecords.json | 217 ------------- .../app/blento/card/getStats.json | 35 -- .../app/blento/card/getUsers.json | 61 ---- .../lexicon/calendar/event/getRecord.json | 28 +- .../lexicon/calendar/event/getStats.json | 35 -- .../lexicon/calendar/event/getUsers.json | 61 ---- .../{getRecords.json => listRecords.json} | 61 ++-- .../lexicon/calendar/rsvp/getRecord.json | 82 +++-- .../lexicon/calendar/rsvp/getStats.json | 35 -- .../lexicon/calendar/rsvp/getUsers.json | 61 ---- .../{getRecords.json => listRecords.json} | 105 ++++-- lexicons/app/blento/profile.json | 29 -- package.json | 4 +- scripts/generate-lexicons.ts | 153 +++------ src/adapters/sqlite.ts | 25 +- src/config.ts | 11 +- src/core/db/index.ts | 4 +- src/core/db/records.ts | 101 ++++-- src/core/router/collection.ts | 73 +++-- src/core/router/hydrate.ts | 35 +- src/core/router/profiles.ts | 15 +- tests/helpers.test.ts | 85 +++++ tests/helpers.ts | 65 ++++ tests/hydrate.test.ts | 186 +++++++++++ tests/records.test.ts | 298 ++++++++++++++++++ tests/schema.test.ts | 57 ++++ tests/types.test.ts | 181 +++++++++++ vitest.config.ts | 7 + 31 files changed, 1278 insertions(+), 1041 deletions(-) delete mode 100644 lexicons-generated/app/blento/card/getRecord.json delete mode 100644 lexicons-generated/app/blento/card/getRecords.json delete mode 100644 lexicons-generated/app/blento/card/getStats.json delete mode 100644 lexicons-generated/app/blento/card/getUsers.json delete mode 100644 lexicons-generated/community/lexicon/calendar/event/getStats.json delete mode 100644 lexicons-generated/community/lexicon/calendar/event/getUsers.json rename lexicons-generated/community/lexicon/calendar/event/{getRecords.json => listRecords.json} (91%) delete mode 100644 lexicons-generated/community/lexicon/calendar/rsvp/getStats.json delete mode 100644 lexicons-generated/community/lexicon/calendar/rsvp/getUsers.json rename lexicons-generated/community/lexicon/calendar/rsvp/{getRecords.json => listRecords.json} (72%) delete mode 100644 lexicons/app/blento/profile.json create mode 100644 tests/helpers.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/hydrate.test.ts create mode 100644 tests/records.test.ts create mode 100644 tests/schema.test.ts create mode 100644 tests/types.test.ts create mode 100644 vitest.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6dfce06..dc335a9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(pnpm generate:*)", "Bash(pnpm typecheck:*)", "Bash(npx tsx:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(pnpm test:*)", + "Bash(npx vitest:*)" ] } } diff --git a/AGENTS.md b/AGENTS.md index c85958e..47d5ef9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ pnpm deploy # Deploy to Cloudflare Workers ### Router (`src/core/router/`) - **`index.ts`** — Hono app factory with CORS, health checks, admin routes, and per-collection routes. -- **`collection.ts`** — Registers dynamic XRPC endpoints per collection: `getRecords`, `getRecord`, `getUsers`, `getStats`, plus custom queries. +- **`collection.ts`** — Registers dynamic XRPC endpoints per collection: `getRecords`, `getRecord`, plus custom queries. - **`admin.ts`** — Admin endpoints (`sync`, `getCursor`, `getOverview`) protected by `ADMIN_SECRET`. - **`hydrate.ts`** — Hydration: embedding related records into responses. - **`profiles.ts`** — Profile resolution: attaching handle + profile data to responses. @@ -59,3 +59,13 @@ This script reads `src/config.ts`, auto-detects queryable fields from lexicon JS - **Database abstraction**: `Database`/`Statement` interfaces in `types.ts` let the same core logic run against both Cloudflare D1 and local SQLite. - **Dependent collections**: Collections with `discover: false` (e.g., profiles) only index records for DIDs already known from discoverable collections. - **Relations**: Materialized counts in the `counts` table, updated on every ingest event. `groupBy` splits counts by a field value. + +## Testing workflow + +Always follow TDD when making changes: + +1. **Write tests first** — add or update tests that cover the new behavior. Run them and confirm they **fail**. +2. **Update code** — implement the change. +3. **Run tests again** — confirm all tests **pass** (including existing ones). + +Tests live in `tests/` and use Vitest. Run with `npx vitest run`. diff --git a/lexicons-generated/app/blento/card/getRecord.json b/lexicons-generated/app/blento/card/getRecord.json deleted file mode 100644 index 0fcc26e..0000000 --- a/lexicons-generated/app/blento/card/getRecord.json +++ /dev/null @@ -1,193 +0,0 @@ -{ - "lexicon": 1, - "id": "app.blento.card.getRecord", - "defs": { - "main": { - "type": "query", - "description": "Get a single app.blento.card record by AT URI", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the record" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "uri", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" - }, - "time_us": { - "type": "integer" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "union", - "refs": [ - "#appBskyActorProfile", - "#appBlentoProfile" - ] - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - }, - "appBlentoProfile": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000 - } - } - } - } -} diff --git a/lexicons-generated/app/blento/card/getRecords.json b/lexicons-generated/app/blento/card/getRecords.json deleted file mode 100644 index 245d4ec..0000000 --- a/lexicons-generated/app/blento/card/getRecords.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "lexicon": 1, - "id": "app.blento.card.getRecords", - "defs": { - "main": { - "type": "query", - "description": "Query app.blento.card records with filters", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string" - }, - "actor": { - "type": "string", - "format": "at-identifier", - "description": "Filter by DID or handle (triggers on-demand backfill)" - }, - "profiles": { - "type": "boolean", - "description": "Include profile + identity info keyed by DID" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "records" - ], - "properties": { - "records": { - "type": "array", - "items": { - "type": "ref", - "ref": "#record" - } - }, - "cursor": { - "type": "string" - }, - "profiles": { - "type": "array", - "items": { - "type": "ref", - "ref": "#profileEntry" - } - } - } - } - } - }, - "record": { - "type": "object", - "required": [ - "uri", - "did", - "collection", - "rkey", - "time_us" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri" - }, - "did": { - "type": "string", - "format": "did" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "unknown" - }, - "time_us": { - "type": "integer" - } - } - }, - "profileEntry": { - "type": "object", - "required": [ - "did" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "handle": { - "type": "string" - }, - "uri": { - "type": "string", - "format": "at-uri" - }, - "collection": { - "type": "string", - "format": "nsid" - }, - "rkey": { - "type": "string" - }, - "cid": { - "type": "string" - }, - "record": { - "type": "union", - "refs": [ - "#appBskyActorProfile", - "#appBlentoProfile" - ] - } - } - }, - "appBskyActorProfile": { - "type": "object", - "properties": { - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" - }, - "banner": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000, - "description": "Larger horizontal image to display behind profile view." - }, - "labels": { - "refs": [ - "com.atproto.label.defs#selfLabels" - ], - "type": "union", - "description": "Self-label values, specific to the Bluesky application, on the overall account." - }, - "website": { - "type": "string", - "format": "uri" - }, - "pronouns": { - "type": "string", - "maxLength": 200, - "description": "Free-form pronouns text.", - "maxGraphemes": 20 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "pinnedPost": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - }, - "description": { - "type": "string", - "maxLength": 2560, - "description": "Free-form profile description text.", - "maxGraphemes": 256 - }, - "displayName": { - "type": "string", - "maxLength": 640, - "maxGraphemes": 64 - }, - "joinedViaStarterPack": { - "ref": "com.atproto.repo.strongRef", - "type": "ref" - } - } - }, - "appBlentoProfile": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000 - } - } - } - } -} diff --git a/lexicons-generated/app/blento/card/getStats.json b/lexicons-generated/app/blento/card/getStats.json deleted file mode 100644 index 7202b82..0000000 --- a/lexicons-generated/app/blento/card/getStats.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "app.blento.card.getStats", - "defs": { - "main": { - "type": "query", - "description": "Get stats for app.blento.card", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "collection", - "unique_users", - "total_records" - ], - "properties": { - "collection": { - "type": "string" - }, - "unique_users": { - "type": "integer" - }, - "total_records": { - "type": "integer" - }, - "last_record_time_us": { - "type": "integer" - } - } - } - } - } - } -} diff --git a/lexicons-generated/app/blento/card/getUsers.json b/lexicons-generated/app/blento/card/getUsers.json deleted file mode 100644 index d3a1185..0000000 --- a/lexicons-generated/app/blento/card/getUsers.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "lexicon": 1, - "id": "app.blento.card.getUsers", - "defs": { - "main": { - "type": "query", - "description": "List users who have app.blento.card records", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "users" - ], - "properties": { - "users": { - "type": "array", - "items": { - "type": "ref", - "ref": "#userRecord" - } - }, - "cursor": { - "type": "string" - } - } - } - } - }, - "userRecord": { - "type": "object", - "required": [ - "did", - "record_count" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "record_count": { - "type": "integer" - } - } - } - } -} diff --git a/lexicons-generated/community/lexicon/calendar/event/getRecord.json b/lexicons-generated/community/lexicon/calendar/event/getRecord.json index d4fe9f5..0519d64 100644 --- a/lexicons-generated/community/lexicon/calendar/event/getRecord.json +++ b/lexicons-generated/community/lexicon/calendar/event/getRecord.json @@ -194,11 +194,8 @@ "type": "string" }, "record": { - "type": "union", - "refs": [ - "#appBskyActorProfile", - "#appBlentoProfile" - ] + "type": "ref", + "ref": "#appBskyActorProfile" } } }, @@ -264,27 +261,6 @@ "type": "ref" } } - }, - "appBlentoProfile": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000 - } - } } } } diff --git a/lexicons-generated/community/lexicon/calendar/event/getStats.json b/lexicons-generated/community/lexicon/calendar/event/getStats.json deleted file mode 100644 index 6768d79..0000000 --- a/lexicons-generated/community/lexicon/calendar/event/getStats.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "community.lexicon.calendar.event.getStats", - "defs": { - "main": { - "type": "query", - "description": "Get stats for community.lexicon.calendar.event", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "collection", - "unique_users", - "total_records" - ], - "properties": { - "collection": { - "type": "string" - }, - "unique_users": { - "type": "integer" - }, - "total_records": { - "type": "integer" - }, - "last_record_time_us": { - "type": "integer" - } - } - } - } - } - } -} diff --git a/lexicons-generated/community/lexicon/calendar/event/getUsers.json b/lexicons-generated/community/lexicon/calendar/event/getUsers.json deleted file mode 100644 index 224b5c9..0000000 --- a/lexicons-generated/community/lexicon/calendar/event/getUsers.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "lexicon": 1, - "id": "community.lexicon.calendar.event.getUsers", - "defs": { - "main": { - "type": "query", - "description": "List users who have community.lexicon.calendar.event records", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "users" - ], - "properties": { - "users": { - "type": "array", - "items": { - "type": "ref", - "ref": "#userRecord" - } - }, - "cursor": { - "type": "string" - } - } - } - } - }, - "userRecord": { - "type": "object", - "required": [ - "did", - "record_count" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "record_count": { - "type": "integer" - } - } - } - } -} diff --git a/lexicons-generated/community/lexicon/calendar/event/getRecords.json b/lexicons-generated/community/lexicon/calendar/event/listRecords.json similarity index 91% rename from lexicons-generated/community/lexicon/calendar/event/getRecords.json rename to lexicons-generated/community/lexicon/calendar/event/listRecords.json index 9350121..b46c849 100644 --- a/lexicons-generated/community/lexicon/calendar/event/getRecords.json +++ b/lexicons-generated/community/lexicon/calendar/event/listRecords.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "community.lexicon.calendar.event.getRecords", + "id": "community.lexicon.calendar.event.listRecords", "defs": { "main": { "type": "query", @@ -76,12 +76,6 @@ "maximum": 50, "description": "Number of rsvps records to embed per record" }, - "rsvpsPreview": { - "type": "integer", - "minimum": 1, - "maximum": 50, - "description": "Number of rsvps previews per record" - }, "rsvpsInterestedCountMin": { "type": "integer", "description": "Minimum rsvps count where status = interested" @@ -93,6 +87,31 @@ "rsvpsNotgoingCountMin": { "type": "integer", "description": "Minimum rsvps count where status = notgoing" + }, + "sort": { + "type": "string", + "knownValues": [ + "mode", + "name", + "endsAt", + "status", + "startsAt", + "createdAt", + "description", + "rsvpsCount", + "rsvpsInterestedCount", + "rsvpsGoingCount", + "rsvpsNotgoingCount" + ], + "description": "Field to sort by (default: time_us)" + }, + "order": { + "type": "string", + "knownValues": [ + "asc", + "desc" + ], + "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" } } }, @@ -280,11 +299,8 @@ "type": "string" }, "record": { - "type": "union", - "refs": [ - "#appBskyActorProfile", - "#appBlentoProfile" - ] + "type": "ref", + "ref": "#appBskyActorProfile" } } }, @@ -350,27 +366,6 @@ "type": "ref" } } - }, - "appBlentoProfile": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000 - } - } } } } diff --git a/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json b/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json index 05053b2..b2fda74 100644 --- a/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json +++ b/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json @@ -19,6 +19,12 @@ "profiles": { "type": "boolean", "description": "Include profile + identity info keyed by DID" + }, + "hydrateEvent": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Number of event records to embed" } } }, @@ -59,6 +65,17 @@ "time_us": { "type": "integer" }, + "eventCount": { + "type": "integer", + "description": "Total event count" + }, + "event": { + "type": "array", + "items": { + "type": "ref", + "ref": "#hydrateEventRecord" + } + }, "profiles": { "type": "array", "items": { @@ -70,6 +87,43 @@ } } }, + "hydrateEventRecord": { + "type": "object", + "required": [ + "uri", + "did", + "collection", + "rkey", + "time_us" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "cid": { + "type": "string" + }, + "record": { + "type": "ref", + "ref": "community.lexicon.calendar.event#main" + }, + "time_us": { + "type": "integer" + } + } + }, "profileEntry": { "type": "object", "required": [ @@ -98,11 +152,8 @@ "type": "string" }, "record": { - "type": "union", - "refs": [ - "#appBskyActorProfile", - "#appBlentoProfile" - ] + "type": "ref", + "ref": "#appBskyActorProfile" } } }, @@ -168,27 +219,6 @@ "type": "ref" } } - }, - "appBlentoProfile": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000 - } - } } } } diff --git a/lexicons-generated/community/lexicon/calendar/rsvp/getStats.json b/lexicons-generated/community/lexicon/calendar/rsvp/getStats.json deleted file mode 100644 index 4308aea..0000000 --- a/lexicons-generated/community/lexicon/calendar/rsvp/getStats.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "lexicon": 1, - "id": "community.lexicon.calendar.rsvp.getStats", - "defs": { - "main": { - "type": "query", - "description": "Get stats for community.lexicon.calendar.rsvp", - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "collection", - "unique_users", - "total_records" - ], - "properties": { - "collection": { - "type": "string" - }, - "unique_users": { - "type": "integer" - }, - "total_records": { - "type": "integer" - }, - "last_record_time_us": { - "type": "integer" - } - } - } - } - } - } -} diff --git a/lexicons-generated/community/lexicon/calendar/rsvp/getUsers.json b/lexicons-generated/community/lexicon/calendar/rsvp/getUsers.json deleted file mode 100644 index dd12160..0000000 --- a/lexicons-generated/community/lexicon/calendar/rsvp/getUsers.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "lexicon": 1, - "id": "community.lexicon.calendar.rsvp.getUsers", - "defs": { - "main": { - "type": "query", - "description": "List users who have community.lexicon.calendar.rsvp records", - "parameters": { - "type": "params", - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string" - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "users" - ], - "properties": { - "users": { - "type": "array", - "items": { - "type": "ref", - "ref": "#userRecord" - } - }, - "cursor": { - "type": "string" - } - } - } - } - }, - "userRecord": { - "type": "object", - "required": [ - "did", - "record_count" - ], - "properties": { - "did": { - "type": "string", - "format": "did" - }, - "record_count": { - "type": "integer" - } - } - } - } -} diff --git a/lexicons-generated/community/lexicon/calendar/rsvp/getRecords.json b/lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json similarity index 72% rename from lexicons-generated/community/lexicon/calendar/rsvp/getRecords.json rename to lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json index b5e8c75..464ed99 100644 --- a/lexicons-generated/community/lexicon/calendar/rsvp/getRecords.json +++ b/lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "community.lexicon.calendar.rsvp.getRecords", + "id": "community.lexicon.calendar.rsvp.listRecords", "defs": { "main": { "type": "query", @@ -33,6 +33,33 @@ "subjectUri": { "type": "string", "description": "Filter by subject.uri" + }, + "eventCountMin": { + "type": "integer", + "description": "Minimum total event count" + }, + "hydrateEvent": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Number of event records to embed per record" + }, + "sort": { + "type": "string", + "knownValues": [ + "status", + "subjectUri", + "eventCount" + ], + "description": "Field to sort by (default: time_us)" + }, + "order": { + "type": "string", + "knownValues": [ + "asc", + "desc" + ], + "description": "Sort direction (default: desc for dates/numbers/counts, asc for strings)" } } }, @@ -97,6 +124,54 @@ "type": "ref", "ref": "community.lexicon.calendar.rsvp#main" }, + "time_us": { + "type": "integer" + }, + "eventCount": { + "type": "integer", + "description": "Total event count" + }, + "event": { + "type": "array", + "items": { + "type": "ref", + "ref": "#hydrateEventRecord" + } + } + } + }, + "hydrateEventRecord": { + "type": "object", + "required": [ + "uri", + "did", + "collection", + "rkey", + "time_us" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "did": { + "type": "string", + "format": "did" + }, + "collection": { + "type": "string", + "format": "nsid" + }, + "rkey": { + "type": "string" + }, + "cid": { + "type": "string" + }, + "record": { + "type": "ref", + "ref": "community.lexicon.calendar.event#main" + }, "time_us": { "type": "integer" } @@ -130,11 +205,8 @@ "type": "string" }, "record": { - "type": "union", - "refs": [ - "#appBskyActorProfile", - "#appBlentoProfile" - ] + "type": "ref", + "ref": "#appBskyActorProfile" } } }, @@ -200,27 +272,6 @@ "type": "ref" } } - }, - "appBlentoProfile": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": [ - "image/png", - "image/jpeg" - ], - "maxSize": 1000000 - } - } } } } diff --git a/lexicons/app/blento/profile.json b/lexicons/app/blento/profile.json deleted file mode 100644 index 5dd143c..0000000 --- a/lexicons/app/blento/profile.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lexicon": 1, - "id": "app.blento.profile", - "defs": { - "main": { - "type": "record", - "key": "literal:self", - "record": { - "type": "object", - "properties": { - "displayName": { - "type": "string", - "maxLength": 640 - }, - "description": { - "type": "string", - "maxLength": 2560 - }, - "avatar": { - "type": "blob", - "accept": ["image/png", "image/jpeg"], - "maxSize": 1000000 - } - } - }, - "description": "A Blento profile record." - } - } -} diff --git a/package.json b/package.json index 45e735f..7a09d96 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "generate:pull": "tsx scripts/generate-lexicons.ts && lex-cli pull && tsx scripts/generate-lexicons.ts && lex-cli pull && lex-cli generate", "typecheck": "tsc --noEmit", "ingest": "curl -s http://localhost:8787/__scheduled?cron=*/1+*+*+*+*", - "sync": "tsx scripts/sync.ts" + "sync": "tsx scripts/sync.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@atcute/atproto": "^3.1.10", diff --git a/scripts/generate-lexicons.ts b/scripts/generate-lexicons.ts index 9aa405e..a40a290 100644 --- a/scripts/generate-lexicons.ts +++ b/scripts/generate-lexicons.ts @@ -2,7 +2,7 @@ * Generates lexicon TypeScript files from the Contrail config. * * For each collection, generates: - * - {nsid}.getRecords — query with queryable field params + * - {nsid}.listRecords — query with queryable field params * - {nsid}.getUsers — query with limit/cursor * - {nsid}.getStats — query returning collection stats * @@ -187,10 +187,17 @@ function buildRecordDef( if (relationDefs && relationDefs.length > 0) { for (const rd of relationDefs) { const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - properties[rd.relName] = { - type: "ref", - ref: `#hydrate${capitalize(rd.relName)}`, - }; + if (rd.groupBy && Object.keys(rd.groups).length > 0) { + properties[rd.relName] = { + type: "ref", + ref: `#hydrate${capitalize(rd.relName)}`, + }; + } else { + properties[rd.relName] = { + type: "array", + items: { type: "ref", ref: `#hydrate${capitalize(rd.relName)}Record` }, + }; + } } } @@ -226,11 +233,10 @@ function buildHydrateDefs(relationDefs: RelationDef[]): Record { }, }; - // Def for the group structure - const groupDefName = `hydrate${capitalize(rd.relName)}`; - const groupProperties: Record = {}; - if (rd.groupBy && Object.keys(rd.groups).length > 0) { + // Grouped relation: object with group keys + const groupDefName = `hydrate${capitalize(rd.relName)}`; + const groupProperties: Record = {}; for (const shortName of Object.keys(rd.groups)) { groupProperties[shortName] = { type: "array", @@ -241,17 +247,12 @@ function buildHydrateDefs(relationDefs: RelationDef[]): Record { type: "array", items: { type: "ref", ref: `#${recordDefName}` }, }; - } else { - groupProperties["_all"] = { - type: "array", - items: { type: "ref", ref: `#${recordDefName}` }, + defs[groupDefName] = { + type: "object", + properties: groupProperties, }; } - - defs[groupDefName] = { - type: "object", - properties: groupProperties, - }; + // Ungrouped relations are typed directly as arrays on the record (no wrapper def needed) } return defs; @@ -456,8 +457,8 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { } } - // --- getRecords --- - const getRecordsParamProps: Record = { + // --- listRecords --- + const listRecordsParamProps: Record = { limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, cursor: { type: "string" }, actor: { type: "string", format: "at-identifier", description: "Filter by DID or handle (triggers on-demand backfill)" }, @@ -467,16 +468,16 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { for (const [field, fieldConfig] of Object.entries(merged)) { const param = fieldToParam(field); if (fieldConfig.type === "range") { - getRecordsParamProps[`${param}Min`] = { + listRecordsParamProps[`${param}Min`] = { type: "string", description: `Minimum value for ${field}`, }; - getRecordsParamProps[`${param}Max`] = { + listRecordsParamProps[`${param}Max`] = { type: "string", description: `Maximum value for ${field}`, }; } else { - getRecordsParamProps[param] = { + listRecordsParamProps[param] = { type: "string", description: `Filter by ${field}`, }; @@ -491,26 +492,19 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { // Total count countFields.push({ name: `${relName}Count`, description: `Total ${relName} count` }); - getRecordsParamProps[`${relName}CountMin`] = { + listRecordsParamProps[`${relName}CountMin`] = { type: "integer", description: `Minimum total ${relName} count`, }; // Per-relation hydrate param (e.g. hydrateRsvps=5) - getRecordsParamProps[`hydrate${capitalize(relName)}`] = { + listRecordsParamProps[`hydrate${capitalize(relName)}`] = { type: "integer", minimum: 1, maximum: 50, description: `Number of ${relName} records to embed per record`, }; - getRecordsParamProps[`${relName}Preview`] = { - type: "integer", - minimum: 1, - maximum: 50, - description: `Number of ${relName} previews per record`, - }; - // Per-group counts from knownValues const groupMapping: Record = {}; if (rel.groupBy) { @@ -522,7 +516,7 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { name: `${relName}${capitalize(shortName)}Count`, description: `${relName} count where ${rel.groupBy} = ${shortName}`, }); - getRecordsParamProps[`${relName}${capitalize(shortName)}CountMin`] = { + listRecordsParamProps[`${relName}${capitalize(shortName)}CountMin`] = { type: "integer", description: `Minimum ${relName} count where ${rel.groupBy} = ${shortName}`, }; @@ -544,18 +538,40 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { }); } + // Build sortable field values: queryable fields + count fields + const sortableValues: string[] = []; + for (const field of Object.keys(merged)) { + sortableValues.push(fieldToParam(field)); + } + for (const cf of countFields) { + sortableValues.push(cf.name); + } + + if (sortableValues.length > 0) { + listRecordsParamProps["sort"] = { + type: "string", + knownValues: sortableValues, + description: "Field to sort by (default: time_us)", + }; + listRecordsParamProps["order"] = { + type: "string", + knownValues: ["asc", "desc"], + description: "Sort direction (default: desc for dates/numbers/counts, asc for strings)", + }; + } + const hydrateDefs = buildHydrateDefs(relationDefs); - writeLexicon(`${collection}.getRecords`, { + writeLexicon(`${collection}.listRecords`, { lexicon: 1, - id: `${collection}.getRecords`, + id: `${collection}.listRecords`, defs: { main: { type: "query", description: `Query ${collection} records with filters`, parameters: { type: "params", - properties: getRecordsParamProps, + properties: listRecordsParamProps, }, output: { encoding: "application/json", @@ -622,71 +638,6 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { }, }); - // --- getUsers --- - writeLexicon(`${collection}.getUsers`, { - lexicon: 1, - id: `${collection}.getUsers`, - defs: { - main: { - type: "query", - description: `List users who have ${collection} records`, - parameters: { - type: "params", - properties: { - limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, - cursor: { type: "string" }, - }, - }, - output: { - encoding: "application/json", - schema: { - type: "object", - required: ["users"], - properties: { - users: { - type: "array", - items: { type: "ref", ref: "#userRecord" }, - }, - cursor: { type: "string" }, - }, - }, - }, - }, - userRecord: { - type: "object", - required: ["did", "record_count"], - properties: { - did: { type: "string", format: "did" }, - record_count: { type: "integer" }, - }, - }, - }, - }); - - // --- getStats --- - writeLexicon(`${collection}.getStats`, { - lexicon: 1, - id: `${collection}.getStats`, - defs: { - main: { - type: "query", - description: `Get stats for ${collection}`, - output: { - encoding: "application/json", - schema: { - type: "object", - required: ["collection", "unique_users", "total_records"], - properties: { - collection: { type: "string" }, - unique_users: { type: "integer" }, - total_records: { type: "integer" }, - last_record_time_us: { type: "integer" }, - }, - }, - }, - }, - }, - }); // --- Custom queries --- for (const queryName of Object.keys(colConfig.queries ?? {})) { diff --git a/src/adapters/sqlite.ts b/src/adapters/sqlite.ts index 5db3a21..74af363 100644 --- a/src/adapters/sqlite.ts +++ b/src/adapters/sqlite.ts @@ -5,39 +5,32 @@ export function createSqliteDatabase(path: string): Database { const raw = new BetterSqlite3(path); raw.pragma("journal_mode = WAL"); - function wrapStatement( - stmt: BetterSqlite3.Statement, - boundValues: any[] = [] - ): Statement { + function wrapStatement(sql: string, boundValues: any[] = []): Statement { return { bind(...values: any[]): Statement { - return wrapStatement(stmt, values); + return wrapStatement(sql, values); }, async run() { - return stmt.run(...boundValues); + return raw.prepare(sql).run(...boundValues); }, async all() { - return { results: stmt.all(...boundValues) as T[] }; + return { results: raw.prepare(sql).all(...boundValues) as T[] }; }, async first() { - return (stmt.get(...boundValues) as T) ?? null; + return (raw.prepare(sql).get(...boundValues) as T) ?? null; }, }; } return { prepare(sql: string): Statement { - const stmt = raw.prepare(sql); - return wrapStatement(stmt); + return wrapStatement(sql); }, async batch(stmts: Statement[]): Promise { const results: any[] = []; - const transaction = raw.transaction(() => { - for (const stmt of stmts) { - results.push(stmt.run()); - } - }); - transaction(); + for (const stmt of stmts) { + results.push(await stmt.run()); + } return results; }, }; diff --git a/src/config.ts b/src/config.ts index 8fbfd12..e7ccba4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,8 +10,13 @@ export const config: ContrailConfig = { }, }, }, - "community.lexicon.calendar.rsvp": {}, - "app.blento.card": {} + "community.lexicon.calendar.rsvp": { + relations: { + event: { + collection: "community.lexicon.calendar.event", + field: "subject.uri", + }, + }, + }, }, - profiles: ["app.bsky.actor.profile", "app.blento.profile"], }; diff --git a/src/core/db/index.ts b/src/core/db/index.ts index 3a250d5..776271f 100644 --- a/src/core/db/index.ts +++ b/src/core/db/index.ts @@ -1,3 +1,3 @@ export { initSchema } from "./schema"; -export { getLastCursor, saveCursor, applyEvents, queryRecords, getUsersByCollection } from "./records"; -export type { QueryOptions, UserRecord } from "./records"; +export { getLastCursor, saveCursor, applyEvents, queryRecords } from "./records"; +export type { QueryOptions, SortOption } from "./records"; diff --git a/src/core/db/records.ts b/src/core/db/records.ts index 17b55e2..a40c45d 100644 --- a/src/core/db/records.ts +++ b/src/core/db/records.ts @@ -136,14 +136,21 @@ export async function applyEvents( // --- Query --- +export interface SortOption { + recordField?: string; // json path, e.g. "startsAt" — sorts by json_extract + countType?: string; // count type, e.g. collection NSID — sorts by aggregated count + direction: "asc" | "desc"; +} + export interface QueryOptions { collection: string; did?: string; limit?: number; - cursor?: number; + cursor?: string; filters?: Record; rangeFilters?: Record; countFilters?: Record; + sort?: SortOption; } export async function queryRecords( @@ -159,6 +166,7 @@ export async function queryRecords( filters = {}, rangeFilters = {}, countFilters = {}, + sort, } = options; const limit = Math.min(Math.max(1, rawLimit ?? 50), 100); @@ -170,9 +178,28 @@ export async function queryRecords( bindings.push(did); } + // Cursor = AT URI of last seen record. Look it up to get keyset values. if (cursor) { - conditions.push("r.time_us < ?"); - bindings.push(cursor); + const cursorRow = await db + .prepare("SELECT record, time_us FROM records WHERE uri = ?") + .bind(cursor) + .first<{ record: string | null; time_us: number }>(); + + if (cursorRow) { + if (sort?.recordField) { + const cursorRecord = cursorRow.record ? JSON.parse(cursorRow.record) : null; + const sortValue = cursorRecord ? getNestedValue(cursorRecord, sort.recordField) : null; + const field = `json_extract(r.record, '$.${sort.recordField}')`; + const cmp = sort.direction === "desc" ? "<" : ">"; + conditions.push(`(${field} ${cmp} ? OR (${field} = ? AND r.time_us < ?))`); + bindings.push(sortValue ?? "", sortValue ?? "", cursorRow.time_us); + } else if (sort?.countType) { + // Count sort cursor — look up the count value, handled in HAVING below + } else { + conditions.push("r.time_us < ?"); + bindings.push(cursorRow.time_us); + } + } } for (const [field, value] of Object.entries(filters)) { @@ -193,7 +220,8 @@ export async function queryRecords( const colConfig = config.collections[collection]; const relations = colConfig?.relations ?? {}; - const needsCounts = Object.keys(relations).length > 0 || Object.keys(countFilters).length > 0; + const sortByCount = sort?.countType != null; + const needsCounts = Object.keys(relations).length > 0 || Object.keys(countFilters).length > 0 || sortByCount; const countHaving: string[] = []; const countHavingBindings: (string | number)[] = []; @@ -202,6 +230,26 @@ export async function queryRecords( countHavingBindings.push(type, minCount); } + // Cursor condition for count sort — keyset pagination in HAVING + if (cursor && sort?.countType) { + const countRow = await db + .prepare("SELECT COALESCE(count, 0) as count FROM counts WHERE uri = ? AND type = ?") + .bind(cursor, sort.countType) + .first<{ count: number }>(); + const cursorTimeRow = await db + .prepare("SELECT time_us FROM records WHERE uri = ?") + .bind(cursor) + .first<{ time_us: number }>(); + + if (cursorTimeRow) { + const countValue = countRow?.count ?? 0; + const countExpr = `COALESCE(SUM(CASE WHEN c.type = ? THEN c.count END), 0)`; + const cmp = sort.direction === "desc" ? "<" : ">"; + countHaving.push(`(${countExpr} ${cmp} ? OR (${countExpr} = ? AND r.time_us < ?))`); + countHavingBindings.push(sort.countType, countValue, sort.countType, countValue, cursorTimeRow.time_us); + } + } + const where = conditions.join(" AND "); const select = needsCounts ? "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" @@ -210,10 +258,24 @@ export async function queryRecords( const group = needsCounts ? "GROUP BY r.uri" : ""; const having = countHaving.length > 0 ? `HAVING ${countHaving.join(" AND ")}` : ""; + let orderBy: string; + const orderBindings: (string | number)[] = []; + if (sort?.recordField) { + const dir = sort.direction === "desc" ? "DESC" : "ASC"; + orderBy = `json_extract(r.record, '$.${sort.recordField}') ${dir}, r.time_us DESC`; + } else if (sort?.countType) { + const dir = sort.direction === "desc" ? "DESC" : "ASC"; + orderBy = `COALESCE(SUM(CASE WHEN c.type = ? THEN c.count END), 0) ${dir}, r.time_us DESC`; + orderBindings.push(sort.countType); + } else { + orderBy = "r.time_us DESC"; + } + if (needsCounts) bindings.push(...countHavingBindings); + bindings.push(...orderBindings); bindings.push(limit); - const query = `SELECT ${select} FROM records r ${join} WHERE ${where} ${group} ${having} ORDER BY r.time_us DESC LIMIT ?`; + const query = `SELECT ${select} FROM records r ${join} WHERE ${where} ${group} ${having} ORDER BY ${orderBy} LIMIT ?`; const result = await db .prepare(query) @@ -228,7 +290,7 @@ export async function queryRecords( const nextCursor = records.length === limit - ? String(records[records.length - 1].time_us) + ? records[records.length - 1].uri : undefined; return { records, cursor: nextCursor }; @@ -251,30 +313,3 @@ function parseCounts(raw?: string | null): Record | undefined { // --- Users --- -export interface UserRecord { - did: string; - record_count: number; -} - -export async function getUsersByCollection( - db: Database, - collection: string, - limit: number, - cursor?: number -): Promise<{ users: UserRecord[]; cursor?: string }> { - const clampedLimit = Math.min(Math.max(1, limit), 100); - const offset = cursor ?? 0; - - const result = await db - .prepare( - "SELECT did, COUNT(*) AS record_count FROM records WHERE collection = ? GROUP BY did ORDER BY record_count DESC LIMIT ? OFFSET ?" - ) - .bind(collection, clampedLimit, offset) - .all(); - - const users = result.results ?? []; - const nextCursor = - users.length === clampedLimit ? String(offset + clampedLimit) : undefined; - - return { users, cursor: nextCursor }; -} diff --git a/src/core/router/collection.ts b/src/core/router/collection.ts index 26d8473..5b28bfe 100644 --- a/src/core/router/collection.ts +++ b/src/core/router/collection.ts @@ -2,7 +2,8 @@ import type { Hono } from "hono"; import type { ContrailConfig, Database, RecordRow, QueryableField } from "../types"; import { getCollectionNames } from "../types"; import { resolvedQueryable, resolvedRelationsMap } from "../queryable.generated"; -import { queryRecords, getUsersByCollection } from "../db"; +import { queryRecords } from "../db"; +import type { SortOption } from "../db/records"; import { backfillUser } from "../backfill"; import { resolveHydrates, parseHydrateParams } from "./hydrate"; import { resolveProfiles, collectDids } from "./profiles"; @@ -21,10 +22,10 @@ export function registerCollectionRoutes( const queryableFields: Record = resolvedQueryable[collection] ?? colConfig.queryable ?? {}; - app.get(`/xrpc/${collection}.getRecords`, async (c) => { + app.get(`/xrpc/${collection}.listRecords`, async (c) => { const params = new URL(c.req.url).searchParams; const limit = parseIntParam(params.get("limit"), 50); - const cursor = parseIntParam(params.get("cursor")); + const cursor = params.get("cursor") || undefined; const actor = params.get("actor") || params.get("did") || undefined; const wantProfiles = params.get("profiles") === "true"; @@ -69,6 +70,45 @@ export function registerCollectionRoutes( } } + // Resolve sort option + let sort: SortOption | undefined; + const sortParam = params.get("sort"); + if (sortParam) { + const orderParam = params.get("order"); + + // Check if it's a queryable field (param name → json path) + const fieldEntry = Object.entries(queryableFields).find( + ([field]) => fieldToParam(field) === sortParam + ); + if (fieldEntry) { + // Default: desc for range fields (dates, numbers), asc for others + const defaultDir = fieldEntry[1].type === "range" ? "desc" : "asc"; + const direction = orderParam === "asc" ? "asc" as const : orderParam === "desc" ? "desc" as const : defaultDir as "asc" | "desc"; + sort = { recordField: fieldEntry[0], direction }; + } else { + // Count fields default to desc (you usually want "most X first") + const direction = orderParam === "asc" ? "asc" as const : "desc" as const; + // Check if it's a count field (e.g. rsvpsCount → collection NSID, rsvpsGoingCount → full token) + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + for (const [relName, rel] of Object.entries(relations)) { + if (sortParam === `${relName}Count`) { + sort = { countType: rel.collection, direction }; + break; + } + const mapping = relMap[relName]; + if (mapping) { + for (const [shortName, fullToken] of Object.entries(mapping.groups)) { + if (sortParam === `${relName}${capitalize(shortName)}Count`) { + sort = { countType: fullToken, direction }; + break; + } + } + if (sort) break; + } + } + } + } + const result = await queryRecords(db, config, { collection, did, @@ -77,6 +117,7 @@ export function registerCollectionRoutes( filters, rangeFilters, countFilters, + sort, }); const rows = result.records; @@ -165,32 +206,6 @@ export function registerCollectionRoutes( }); }); - app.get(`/xrpc/${collection}.getUsers`, async (c) => { - const limit = parseIntParam(c.req.query("limit"), 50) ?? 50; - const cursor = parseIntParam(c.req.query("cursor")); - return c.json(await getUsersByCollection(db, collection, limit, cursor)); - }); - - app.get(`/xrpc/${collection}.getStats`, async (c) => { - const row = await db - .prepare( - "SELECT COUNT(DISTINCT did) as unique_users, COUNT(*) as total_records, MAX(time_us) as last_record_time_us FROM records WHERE collection = ?" - ) - .bind(collection) - .first<{ - unique_users: number; - total_records: number; - last_record_time_us: number | null; - }>(); - - return c.json({ - collection, - unique_users: row?.unique_users ?? 0, - total_records: row?.total_records ?? 0, - last_record_time_us: row?.last_record_time_us ?? null, - }); - }); - for (const [queryName, handler] of Object.entries( colConfig.queries ?? {} )) { diff --git a/src/core/router/hydrate.ts b/src/core/router/hydrate.ts index b1d8a50..bf50b86 100644 --- a/src/core/router/hydrate.ts +++ b/src/core/router/hydrate.ts @@ -22,15 +22,19 @@ export function parseHydrateParams( return hydrates; } +// Per-relation hydrate result: array for ungrouped, Record for grouped +export type HydrateResult = Record>>; + export async function resolveHydrates( db: Database, relations: Record, requested: Record, records: RecordRow[] -): Promise>>> { +): Promise { if (Object.keys(requested).length === 0 || records.length === 0) return {}; - const result: Record>> = {}; + // Accumulate grouped results first, then flatten ungrouped ones + const grouped: Record>> = {}; for (const [relName, hydrateLimit] of Object.entries(requested)) { const rel = relations[relName]; @@ -43,7 +47,9 @@ export async function resolveHydrates( if (matchValues.length === 0) continue; - const maxRows = matchValues.length * hydrateLimit; + // Fetch more rows than needed since the limit applies per-group, not total + const groupCount = rel.groupBy ? 10 : 1; // estimate; overfetch is fine + const maxRows = matchValues.length * hydrateLimit * groupCount; const relatedRows = await batchedInQuery( db, `SELECT uri, did, collection, rkey, record, time_us FROM records @@ -65,16 +71,16 @@ export async function resolveHydrates( const groupValue = rel.groupBy ? String(getNestedValue(record, rel.groupBy) ?? "_other") - : "_all"; + : "_flat"; for (const parentUri of parentUris) { const targetUri = matchMode === "did" ? parentUri : matchedValue; - if (!result[targetUri]) result[targetUri] = {}; - if (!result[targetUri][relName]) result[targetUri][relName] = {}; - if (!result[targetUri][relName][groupValue]) result[targetUri][relName][groupValue] = []; + if (!grouped[targetUri]) grouped[targetUri] = {}; + if (!grouped[targetUri][relName]) grouped[targetUri][relName] = {}; + if (!grouped[targetUri][relName][groupValue]) grouped[targetUri][relName][groupValue] = []; - const group = result[targetUri][relName][groupValue]; + const group = grouped[targetUri][relName][groupValue]; if (group.length < hydrateLimit) { group.push(formatRecord(row)); } @@ -82,5 +88,18 @@ export async function resolveHydrates( } } + // Convert to final shape: ungrouped relations become flat arrays + const result: HydrateResult = {}; + for (const [uri, rels] of Object.entries(grouped)) { + result[uri] = {}; + for (const [relName, groups] of Object.entries(rels)) { + if (relations[relName].groupBy) { + result[uri][relName] = groups; + } else { + result[uri][relName] = groups["_flat"] ?? []; + } + } + } + return result; } diff --git a/src/core/router/profiles.ts b/src/core/router/profiles.ts index fed23c7..f926bb8 100644 --- a/src/core/router/profiles.ts +++ b/src/core/router/profiles.ts @@ -14,15 +14,16 @@ export interface ProfileEntry { export function collectDids( records: RecordRow[], - hydrates: Record>> + hydrates: Record>> ): string[] { const dids = new Set(records.map((r) => r.did)); - for (const groups of Object.values(hydrates)) { - for (const byGroup of Object.values(groups)) { - for (const items of Object.values(byGroup)) { - for (const item of items) { - if (item.did) dids.add(item.did); - } + for (const rels of Object.values(hydrates)) { + for (const value of Object.values(rels)) { + const items = Array.isArray(value) + ? value + : Object.values(value).flat(); + for (const item of items) { + if (item.did) dids.add(item.did); } } } diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts new file mode 100644 index 0000000..5f5f3fd --- /dev/null +++ b/tests/helpers.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { formatRecord, parseIntParam, fieldToParam } from "../src/core/router/helpers"; +import type { RecordRow } from "../src/core/types"; + +describe("formatRecord", () => { + it("parses JSON record string", () => { + const row: RecordRow = { + uri: "at://did:plc:test/test.col/abc", + did: "did:plc:test", + collection: "test.col", + rkey: "abc", + cid: "bafyabc", + record: '{"name":"hello"}', + time_us: 1000, + indexed_at: 2000, + }; + const formatted = formatRecord(row); + expect(formatted.record).toEqual({ name: "hello" }); + expect(formatted.uri).toBe("at://did:plc:test/test.col/abc"); + expect(formatted.did).toBe("did:plc:test"); + }); + + it("handles null record", () => { + const row: RecordRow = { + uri: "at://x/y/z", + did: "did:plc:x", + collection: "y", + rkey: "z", + cid: null, + record: null, + time_us: 0, + indexed_at: 0, + }; + expect(formatRecord(row).record).toBeNull(); + }); + + it("returns raw string for invalid JSON", () => { + const row: RecordRow = { + uri: "at://x/y/z", + did: "did:plc:x", + collection: "y", + rkey: "z", + cid: null, + record: "not-json", + time_us: 0, + indexed_at: 0, + }; + expect(formatRecord(row).record).toBe("not-json"); + }); +}); + +describe("parseIntParam", () => { + it("parses valid integers", () => { + expect(parseIntParam("42")).toBe(42); + expect(parseIntParam("0")).toBe(0); + expect(parseIntParam("-5")).toBe(-5); + }); + + it("returns default for null/undefined", () => { + expect(parseIntParam(null, 10)).toBe(10); + expect(parseIntParam(undefined, 10)).toBe(10); + expect(parseIntParam(null)).toBeUndefined(); + }); + + it("returns default for empty string", () => { + expect(parseIntParam("", 50)).toBe(50); + }); + + it("returns default for non-numeric strings", () => { + expect(parseIntParam("abc", 50)).toBe(50); + expect(parseIntParam("abc")).toBeUndefined(); + }); +}); + +describe("fieldToParam", () => { + it("converts dotted paths to camelCase", () => { + expect(fieldToParam("subject.uri")).toBe("subjectUri"); + expect(fieldToParam("a.b.c")).toBe("aBC"); + }); + + it("leaves flat names unchanged", () => { + expect(fieldToParam("name")).toBe("name"); + expect(fieldToParam("startsAt")).toBe("startsAt"); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..c702949 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,65 @@ +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import type { Database, ContrailConfig } from "../src/core/types"; +import { initSchema } from "../src/core/db/schema"; + +export function createTestDb(): Database { + return createSqliteDatabase(":memory:"); +} + +export const TEST_CONFIG: ContrailConfig = { + collections: { + "community.lexicon.calendar.event": { + queryable: { + mode: {}, + name: {}, + startsAt: { type: "range" }, + }, + relations: { + rsvps: { + collection: "community.lexicon.calendar.rsvp", + groupBy: "status", + }, + }, + }, + "community.lexicon.calendar.rsvp": { + relations: { + event: { + collection: "community.lexicon.calendar.event", + field: "subject.uri", + }, + }, + }, + }, +}; + +export async function createTestDbWithSchema(): Promise { + const db = createTestDb(); + await initSchema(db, TEST_CONFIG); + return db; +} + +export function makeEvent(overrides: Partial<{ + uri: string; + did: string; + collection: string; + rkey: string; + cid: string | null; + record: any; + time_us: number; + indexed_at: number; + operation: "create" | "update" | "delete"; +}> = {}) { + return { + uri: overrides.uri ?? "at://did:plc:test/community.lexicon.calendar.event/abc", + did: overrides.did ?? "did:plc:test", + collection: overrides.collection ?? "community.lexicon.calendar.event", + rkey: overrides.rkey ?? "abc", + cid: overrides.cid ?? "bafyabc", + record: overrides.record !== undefined + ? (typeof overrides.record === "string" ? overrides.record : JSON.stringify(overrides.record)) + : JSON.stringify({ name: "Test Event", startsAt: "2026-04-01T10:00:00Z", mode: "online" }), + time_us: overrides.time_us ?? 1000000, + indexed_at: overrides.indexed_at ?? Date.now(), + operation: overrides.operation ?? ("create" as const), + }; +} diff --git a/tests/hydrate.test.ts b/tests/hydrate.test.ts new file mode 100644 index 0000000..09dd83c --- /dev/null +++ b/tests/hydrate.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Database, RecordRow, RelationConfig } from "../src/core/types"; +import { parseHydrateParams, resolveHydrates } from "../src/core/router/hydrate"; +import { createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; +import { applyEvents } from "../src/core/db/records"; + +describe("parseHydrateParams", () => { + const relations: Record = { + rsvps: { collection: "community.lexicon.calendar.rsvp", groupBy: "status" }, + comments: { collection: "test.comment" }, + }; + + it("parses valid hydrate params", () => { + const params = new URLSearchParams({ hydrateRsvps: "5", hydrateComments: "3" }); + const result = parseHydrateParams(params, relations); + expect(result).toEqual({ rsvps: 5, comments: 3 }); + }); + + it("ignores unrelated params", () => { + const params = new URLSearchParams({ hydrateFoo: "5", limit: "10" }); + const result = parseHydrateParams(params, relations); + expect(result).toEqual({}); + }); + + it("ignores invalid numbers", () => { + const params = new URLSearchParams({ hydrateRsvps: "abc" }); + const result = parseHydrateParams(params, relations); + expect(result).toEqual({}); + }); + + it("ignores zero and negative", () => { + const params = new URLSearchParams({ hydrateRsvps: "0", hydrateComments: "-1" }); + const result = parseHydrateParams(params, relations); + expect(result).toEqual({}); + }); + + it("returns empty for no hydrate params", () => { + const params = new URLSearchParams({ limit: "50" }); + const result = parseHydrateParams(params, relations); + expect(result).toEqual({}); + }); +}); + +describe("resolveHydrates", () => { + let db: Database; + + beforeEach(async () => { + db = await createTestDbWithSchema(); + }); + + it("returns empty when no hydrations requested", async () => { + const result = await resolveHydrates(db, {}, {}, []); + expect(result).toEqual({}); + }); + + it("returns empty when no records", async () => { + const relations = TEST_CONFIG.collections["community.lexicon.calendar.event"].relations!; + const result = await resolveHydrates(db, relations, { rsvps: 5 }, []); + expect(result).toEqual({}); + }); + + it("hydrates related records", async () => { + const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + + // Insert event + await applyEvents(db, [makeEvent({ uri: eventUri, rkey: "evt1", time_us: 1000 })]); + + // Insert RSVPs + for (let i = 0; i < 3; i++) { + await applyEvents(db, [ + makeEvent({ + uri: `at://did:plc:user${i}/community.lexicon.calendar.rsvp/r${i}`, + did: `did:plc:user${i}`, + collection: "community.lexicon.calendar.rsvp", + rkey: `r${i}`, + record: { subject: { uri: eventUri }, status: "going" }, + time_us: 2000 + i, + }), + ]); + } + + const eventRow = await db + .prepare("SELECT * FROM records WHERE uri = ?") + .bind(eventUri) + .first(); + + const relations = TEST_CONFIG.collections["community.lexicon.calendar.event"].relations!; + const result = await resolveHydrates(db, relations, { rsvps: 10 }, [eventRow!]); + + expect(result[eventUri]).toBeDefined(); + expect(result[eventUri].rsvps).toBeDefined(); + expect(result[eventUri].rsvps["going"]).toHaveLength(3); + }); + + it("respects hydrate limit", async () => { + const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + await applyEvents(db, [makeEvent({ uri: eventUri, rkey: "evt1", time_us: 1000 })]); + + for (let i = 0; i < 5; i++) { + await applyEvents(db, [ + makeEvent({ + uri: `at://did:plc:user${i}/community.lexicon.calendar.rsvp/r${i}`, + did: `did:plc:user${i}`, + collection: "community.lexicon.calendar.rsvp", + rkey: `r${i}`, + record: { subject: { uri: eventUri }, status: "going" }, + time_us: 2000 + i, + }), + ]); + } + + const eventRow = await db + .prepare("SELECT * FROM records WHERE uri = ?") + .bind(eventUri) + .first(); + + const relations = TEST_CONFIG.collections["community.lexicon.calendar.event"].relations!; + const result = await resolveHydrates(db, relations, { rsvps: 2 }, [eventRow!]); + + expect(result[eventUri].rsvps["going"].length).toBeLessThanOrEqual(2); + }); + + it("hydrates with match: 'did'", async () => { + const did = "did:plc:shareduser"; + const eventUri = `at://${did}/community.lexicon.calendar.event/evt1`; + + // Insert event owned by this DID + await applyEvents(db, [makeEvent({ uri: eventUri, did, rkey: "evt1", time_us: 1000 })]); + + // Insert a related record whose "author" field contains the parent's DID + await applyEvents(db, [ + makeEvent({ + uri: `at://did:plc:other/community.lexicon.calendar.rsvp/r1`, + did: "did:plc:other", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: { author: did, status: "going" }, + time_us: 2000, + }), + ]); + + const eventRow = await db + .prepare("SELECT * FROM records WHERE uri = ?") + .bind(eventUri) + .first(); + + // match: "did" means matchValues are parent DIDs, and field points to where the DID is stored + const relations: Record = { + rsvps: { collection: "community.lexicon.calendar.rsvp", match: "did", field: "author", groupBy: "status" }, + }; + const result = await resolveHydrates(db, relations, { rsvps: 10 }, [eventRow!]); + + expect(result[eventUri]).toBeDefined(); + expect(result[eventUri].rsvps).toBeDefined(); + expect(result[eventUri].rsvps["going"]).toHaveLength(1); + }); + + it("groups into '_other' when groupBy value is null", async () => { + const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + + await applyEvents(db, [makeEvent({ uri: eventUri, rkey: "evt1", time_us: 1000 })]); + + // Insert RSVP without a status field — groupBy "status" should fall back to "_other" + await applyEvents(db, [ + makeEvent({ + uri: "at://did:plc:user1/community.lexicon.calendar.rsvp/r1", + did: "did:plc:user1", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: { subject: { uri: eventUri } }, + time_us: 2000, + }), + ]); + + const eventRow = await db + .prepare("SELECT * FROM records WHERE uri = ?") + .bind(eventUri) + .first(); + + const relations = TEST_CONFIG.collections["community.lexicon.calendar.event"].relations!; + const result = await resolveHydrates(db, relations, { rsvps: 10 }, [eventRow!]); + + expect(result[eventUri].rsvps).toBeDefined(); + expect(result[eventUri].rsvps["_other"]).toHaveLength(1); + }); +}); diff --git a/tests/records.test.ts b/tests/records.test.ts new file mode 100644 index 0000000..270e14a --- /dev/null +++ b/tests/records.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Database } from "../src/core/types"; +import { createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; +import { applyEvents, queryRecords, getLastCursor, saveCursor } from "../src/core/db/records"; + +let db: Database; + +beforeEach(async () => { + db = await createTestDbWithSchema(); +}); + +describe("cursor", () => { + it("returns null when no cursor exists", async () => { + expect(await getLastCursor(db)).toBeNull(); + }); + + it("saves and retrieves cursor", async () => { + await saveCursor(db, 12345); + expect(await getLastCursor(db)).toBe(12345); + }); + + it("updates existing cursor", async () => { + await saveCursor(db, 100); + await saveCursor(db, 200); + expect(await getLastCursor(db)).toBe(200); + }); +}); + +describe("applyEvents", () => { + it("does nothing for empty events", async () => { + await applyEvents(db, []); + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + expect(result.records).toHaveLength(0); + }); + + it("inserts create events", async () => { + await applyEvents(db, [makeEvent()]); + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + expect(result.records).toHaveLength(1); + expect(result.records[0].uri).toBe("at://did:plc:test/community.lexicon.calendar.event/abc"); + expect(result.records[0].did).toBe("did:plc:test"); + }); + + it("upserts on conflict", async () => { + await applyEvents(db, [ + makeEvent({ record: { name: "V1" }, time_us: 100 }), + ]); + await applyEvents(db, [ + makeEvent({ record: { name: "V2" }, time_us: 200, operation: "update" }), + ]); + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + expect(result.records).toHaveLength(1); + const record = JSON.parse(result.records[0].record!); + expect(record.name).toBe("V2"); + }); + + it("deletes events", async () => { + await applyEvents(db, [makeEvent()]); + await applyEvents(db, [makeEvent({ operation: "delete" })]); + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + expect(result.records).toHaveLength(0); + }); + + it("builds count statements for relations", async () => { + const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + + // Insert parent event + await applyEvents(db, [makeEvent({ uri: eventUri, rkey: "evt1" })]); + + // Insert RSVP pointing at the event + await applyEvents( + db, + [ + makeEvent({ + uri: "at://did:plc:user1/community.lexicon.calendar.rsvp/r1", + did: "did:plc:user1", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: { subject: { uri: eventUri }, status: "going" }, + time_us: 2000000, + }), + ], + TEST_CONFIG + ); + + // Query event with counts + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + expect(result.records).toHaveLength(1); + expect(result.records[0].counts).toBeDefined(); + expect(result.records[0].counts!["community.lexicon.calendar.rsvp"]).toBe(1); + }); + + it("decrements counts on delete", async () => { + const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + await applyEvents(db, [makeEvent({ uri: eventUri, rkey: "evt1" })]); + + const rsvpRecord = { subject: { uri: eventUri }, status: "going" }; + await applyEvents( + db, + [ + makeEvent({ + uri: "at://did:plc:user1/community.lexicon.calendar.rsvp/r1", + did: "did:plc:user1", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: rsvpRecord, + time_us: 2000000, + }), + ], + TEST_CONFIG + ); + + // Delete the RSVP + await applyEvents( + db, + [ + makeEvent({ + uri: "at://did:plc:user1/community.lexicon.calendar.rsvp/r1", + did: "did:plc:user1", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: rsvpRecord, + operation: "delete", + time_us: 3000000, + }), + ], + TEST_CONFIG + ); + + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + const counts = result.records[0].counts; + // Count should be 0 or undefined after delete + expect(counts?.["community.lexicon.calendar.rsvp"] ?? 0).toBe(0); + }); +}); + +describe("queryRecords", () => { + beforeEach(async () => { + await applyEvents(db, [ + makeEvent({ + uri: "at://did:plc:a/community.lexicon.calendar.event/1", + did: "did:plc:a", + rkey: "1", + record: { name: "Alpha", mode: "online", startsAt: "2026-01-01T00:00:00Z" }, + time_us: 3000, + }), + makeEvent({ + uri: "at://did:plc:b/community.lexicon.calendar.event/2", + did: "did:plc:b", + rkey: "2", + record: { name: "Beta", mode: "in-person", startsAt: "2026-02-01T00:00:00Z" }, + time_us: 2000, + }), + makeEvent({ + uri: "at://did:plc:a/community.lexicon.calendar.event/3", + did: "did:plc:a", + rkey: "3", + record: { name: "Gamma", mode: "online", startsAt: "2026-03-01T00:00:00Z" }, + time_us: 1000, + }), + ]); + }); + + it("returns records ordered by time_us desc", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + }); + expect(result.records).toHaveLength(3); + expect(result.records[0].time_us).toBe(3000); + expect(result.records[1].time_us).toBe(2000); + expect(result.records[2].time_us).toBe(1000); + }); + + it("filters by did", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + did: "did:plc:a", + }); + expect(result.records).toHaveLength(2); + expect(result.records.every((r) => r.did === "did:plc:a")).toBe(true); + }); + + it("respects limit", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 2, + }); + expect(result.records).toHaveLength(2); + expect(result.cursor).toBeDefined(); + }); + + it("clamps limit to [1, 100]", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 0, + }); + expect(result.records).toHaveLength(1); // clamped to 1 + + const result2 = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 999, + }); + expect(result2.records).toHaveLength(3); // clamped to 100, but only 3 exist + }); + + it("paginates with cursor", async () => { + const page1 = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 2, + }); + expect(page1.records).toHaveLength(2); + expect(page1.cursor).toBeDefined(); + + const page2 = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 2, + cursor: page1.cursor, + }); + expect(page2.records).toHaveLength(1); + expect(page2.cursor).toBeUndefined(); + }); + + it("filters by equality", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + filters: { mode: "online" }, + }); + expect(result.records).toHaveLength(2); + }); + + it("filters by range", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + rangeFilters: { + startsAt: { min: "2026-01-15T00:00:00Z" }, + }, + }); + expect(result.records).toHaveLength(2); + // Should include Beta (Feb) and Gamma (Mar) + }); + + it("sorts by record field asc", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + sort: { recordField: "name", direction: "asc" }, + }); + const names = result.records.map((r) => JSON.parse(r.record!).name); + expect(names).toEqual(["Alpha", "Beta", "Gamma"]); + }); + + it("sorts by record field desc", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + sort: { recordField: "name", direction: "desc" }, + }); + const names = result.records.map((r) => JSON.parse(r.record!).name); + expect(names).toEqual(["Gamma", "Beta", "Alpha"]); + }); + + it("returns no cursor when all results fit", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + limit: 50, + }); + expect(result.cursor).toBeUndefined(); + }); + + it("returns empty for unknown collection", async () => { + const result = await queryRecords(db, TEST_CONFIG, { + collection: "nonexistent.collection", + }); + expect(result.records).toHaveLength(0); + }); + + it("filter keys are interpolated into SQL — only trusted input should be passed", async () => { + // queryRecords interpolates filter field names directly into json_extract calls. + // Malicious field names can break the query. This test documents that filter keys + // must be validated before reaching queryRecords (e.g. via validateFieldName). + await expect( + queryRecords(db, TEST_CONFIG, { + collection: "community.lexicon.calendar.event", + filters: { "'); DROP TABLE records; --": "x" }, + }) + ).rejects.toThrow(); + }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..00fd6dc --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { initSchema } from "../src/core/db/schema"; +import { createTestDb, TEST_CONFIG } from "./helpers"; + +describe("initSchema", () => { + it("creates all required tables", async () => { + const db = createTestDb(); + await initSchema(db, TEST_CONFIG); + + const tables = await db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all<{ name: string }>(); + const names = tables.results.map((t) => t.name); + + expect(names).toContain("records"); + expect(names).toContain("counts"); + expect(names).toContain("backfills"); + expect(names).toContain("discovery"); + expect(names).toContain("cursor"); + expect(names).toContain("identities"); + }); + + it("creates dynamic indexes for queryable fields", async () => { + const db = createTestDb(); + await initSchema(db, TEST_CONFIG); + + const indexes = await db + .prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name") + .all<{ name: string }>(); + const names = indexes.results.map((i) => i.name); + + // Should have indexes for queryable fields + expect(names.some((n) => n.includes("mode"))).toBe(true); + expect(names.some((n) => n.includes("name"))).toBe(true); + expect(names.some((n) => n.includes("startsAt"))).toBe(true); + }); + + it("creates relation indexes", async () => { + const db = createTestDb(); + await initSchema(db, TEST_CONFIG); + + const indexes = await db + .prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name") + .all<{ name: string }>(); + const names = indexes.results.map((i) => i.name); + + // Should have index for subject.uri relation field + expect(names.some((n) => n.includes("subject"))).toBe(true); + }); + + it("is idempotent", async () => { + const db = createTestDb(); + await initSchema(db, TEST_CONFIG); + // Running again should not throw + await initSchema(db, TEST_CONFIG); + }); +}); diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 0000000..dfddf3f --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { + validateFieldName, + validateConfig, + getNestedValue, + getRelationField, + resolveConfig, + getCollectionNames, + getDiscoverableCollections, + getDependentCollections, +} from "../src/core/types"; + +describe("validateFieldName", () => { + it("accepts simple field names", () => { + expect(validateFieldName("name")).toBe("name"); + expect(validateFieldName("startsAt")).toBe("startsAt"); + }); + + it("accepts dotted paths", () => { + expect(validateFieldName("subject.uri")).toBe("subject.uri"); + expect(validateFieldName("a.b.c")).toBe("a.b.c"); + }); + + it("accepts underscores and numbers", () => { + expect(validateFieldName("field_1")).toBe("field_1"); + expect(validateFieldName("a2b")).toBe("a2b"); + }); + + it("rejects special characters", () => { + expect(() => validateFieldName("field; DROP TABLE")).toThrow("Invalid field name"); + expect(() => validateFieldName("field'")).toThrow("Invalid field name"); + expect(() => validateFieldName("a b")).toThrow("Invalid field name"); + expect(() => validateFieldName("$path")).toThrow("Invalid field name"); + expect(() => validateFieldName("")).toThrow("Invalid field name"); + }); +}); + +describe("validateConfig", () => { + it("passes for valid config", () => { + expect(() => + validateConfig({ + collections: { + "test.collection": { + queryable: { name: {}, startsAt: { type: "range" } }, + relations: { + items: { collection: "test.item", field: "subject.uri", groupBy: "status" }, + }, + }, + }, + }) + ).not.toThrow(); + }); + + it("rejects invalid queryable field names", () => { + expect(() => + validateConfig({ + collections: { + "test.col": { queryable: { "bad field": {} } }, + }, + }) + ).toThrow("Invalid field name"); + }); + + it("rejects invalid relation field", () => { + expect(() => + validateConfig({ + collections: { + "test.col": { + relations: { + r: { collection: "test.other", field: "bad field" }, + }, + }, + }, + }) + ).toThrow("Invalid field name"); + }); + + it("rejects invalid groupBy", () => { + expect(() => + validateConfig({ + collections: { + "test.col": { + relations: { + r: { collection: "test.other", groupBy: "bad;field" }, + }, + }, + }, + }) + ).toThrow("Invalid field name"); + }); +}); + +describe("getNestedValue", () => { + it("gets top-level values", () => { + expect(getNestedValue({ name: "hello" }, "name")).toBe("hello"); + }); + + it("gets nested values", () => { + expect(getNestedValue({ subject: { uri: "at://x" } }, "subject.uri")).toBe("at://x"); + }); + + it("returns undefined for missing paths", () => { + expect(getNestedValue({ a: 1 }, "b")).toBeUndefined(); + expect(getNestedValue({ a: { b: 1 } }, "a.c")).toBeUndefined(); + }); + + it("handles null in path", () => { + expect(getNestedValue({ a: null }, "a.b")).toBeUndefined(); + expect(getNestedValue(null, "a")).toBeUndefined(); + }); +}); + +describe("getRelationField", () => { + it("returns field when specified", () => { + expect(getRelationField({ collection: "x", field: "subject.uri" })).toBe("subject.uri"); + }); + + it("defaults to subject.uri", () => { + expect(getRelationField({ collection: "x" })).toBe("subject.uri"); + }); +}); + +describe("resolveConfig", () => { + it("adds default profile collection", () => { + const resolved = resolveConfig({ collections: { "test.col": {} } }); + expect(resolved.collections["app.bsky.actor.profile"]).toEqual({ discover: false }); + }); + + it("does not overwrite existing profile collection config", () => { + const resolved = resolveConfig({ + collections: { "app.bsky.actor.profile": { queryable: { displayName: {} } } }, + }); + expect(resolved.collections["app.bsky.actor.profile"].queryable).toEqual({ displayName: {} }); + }); + + it("uses custom profiles", () => { + const resolved = resolveConfig({ + collections: {}, + profiles: ["custom.profile"], + }); + expect(resolved.profiles).toEqual(["custom.profile"]); + expect(resolved.collections["custom.profile"]).toEqual({ discover: false }); + expect(resolved.collections["app.bsky.actor.profile"]).toBeUndefined(); + }); + + it("applies default jetstreams and relays", () => { + const resolved = resolveConfig({ collections: {} }); + expect(resolved.jetstreams).toHaveLength(4); + expect(resolved.relays).toHaveLength(1); + }); +}); + +describe("getCollectionNames / getDiscoverableCollections / getDependentCollections", () => { + const config = resolveConfig({ + collections: { + "test.main": {}, + "test.dep": { discover: false }, + }, + }); + + it("getCollectionNames returns all collections", () => { + const names = getCollectionNames(config); + expect(names).toContain("test.main"); + expect(names).toContain("test.dep"); + expect(names).toContain("app.bsky.actor.profile"); + }); + + it("getDiscoverableCollections excludes discover:false", () => { + const discoverable = getDiscoverableCollections(config); + expect(discoverable).toContain("test.main"); + expect(discoverable).not.toContain("test.dep"); + expect(discoverable).not.toContain("app.bsky.actor.profile"); + }); + + it("getDependentCollections returns discover:false", () => { + const dependent = getDependentCollections(config); + expect(dependent).toContain("test.dep"); + expect(dependent).toContain("app.bsky.actor.profile"); + expect(dependent).not.toContain("test.main"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..19384e8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); -- 2.51.2