diff --git a/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json b/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json index b2fda74..8e79377 100644 --- a/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json +++ b/lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json @@ -21,10 +21,8 @@ "description": "Include profile + identity info keyed by DID" }, "hydrateEvent": { - "type": "integer", - "minimum": 1, - "maximum": 50, - "description": "Number of event records to embed" + "type": "boolean", + "description": "Embed the referenced event record" } } }, @@ -65,17 +63,6 @@ "time_us": { "type": "integer" }, - "eventCount": { - "type": "integer", - "description": "Total event count" - }, - "event": { - "type": "array", - "items": { - "type": "ref", - "ref": "#hydrateEventRecord" - } - }, "profiles": { "type": "array", "items": { @@ -87,43 +74,6 @@ } } }, - "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": [ diff --git a/lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json b/lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json index 464ed99..45379dc 100644 --- a/lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json +++ b/lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json @@ -34,22 +34,15 @@ "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" + "type": "boolean", + "description": "Embed the referenced event record" }, "sort": { "type": "string", "knownValues": [ "status", - "subjectUri", - "eventCount" + "subjectUri" ], "description": "Field to sort by (default: time_us)" }, @@ -124,54 +117,6 @@ "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" } diff --git a/scripts/generate-lexicons.ts b/scripts/generate-lexicons.ts index 6eb0b3d..ef84818 100644 --- a/scripts/generate-lexicons.ts +++ b/scripts/generate-lexicons.ts @@ -575,6 +575,15 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { }); } + // Add per-reference hydrate params (e.g. hydrateEvent=true) + for (const refName of Object.keys(colConfig.references ?? {})) { + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + listRecordsParamProps[`hydrate${capitalize(refName)}`] = { + type: "boolean", + description: `Embed the referenced ${refName} record`, + }; + } + // Build sortable field values: queryable fields + count fields const sortableValues: string[] = []; for (const field of Object.keys(merged)) { @@ -646,6 +655,15 @@ for (const [collection, colConfig] of Object.entries(config.collections)) { }; } + // Add per-reference hydrate params to getRecord too + for (const refName of Object.keys(colConfig.references ?? {})) { + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + getRecordParamProps[`hydrate${capitalize(refName)}`] = { + type: "boolean", + description: `Embed the referenced ${refName} record`, + }; + } + writeLexicon(`${collection}.getRecord`, { lexicon: 1, id: `${collection}.getRecord`, diff --git a/src/config.ts b/src/config.ts index 16d0a8a..92b63f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,7 +12,7 @@ export const config: ContrailConfig = { }, }, "community.lexicon.calendar.rsvp": { - relations: { + references: { event: { collection: "community.lexicon.calendar.event", field: "subject.uri", diff --git a/src/core/router/collection.ts b/src/core/router/collection.ts index 5b28bfe..5b6a091 100644 --- a/src/core/router/collection.ts +++ b/src/core/router/collection.ts @@ -5,7 +5,7 @@ import { resolvedQueryable, resolvedRelationsMap } from "../queryable.generated" import { queryRecords } from "../db"; import type { SortOption } from "../db/records"; import { backfillUser } from "../backfill"; -import { resolveHydrates, parseHydrateParams } from "./hydrate"; +import { resolveHydrates, resolveReferences, parseHydrateParams } from "./hydrate"; import { resolveProfiles, collectDids } from "./profiles"; import { resolveActor } from "../identity"; import type { FormattedRecord } from "./helpers"; @@ -19,6 +19,7 @@ export function registerCollectionRoutes( for (const collection of getCollectionNames(config)) { const colConfig = config.collections[collection]; const relations = colConfig.relations ?? {}; + const references = colConfig.references ?? {}; const queryableFields: Record = resolvedQueryable[collection] ?? colConfig.queryable ?? {}; @@ -121,11 +122,17 @@ export function registerCollectionRoutes( }); const rows = result.records; - const hydrateRequested = parseHydrateParams(params, relations); + const hydrateRequested = parseHydrateParams(params, relations, references); const hydrates = await resolveHydrates( db, relations, - hydrateRequested, + hydrateRequested.relations, + rows + ); + const refs = await resolveReferences( + db, + references, + hydrateRequested.references, rows ); @@ -138,6 +145,12 @@ export function registerCollectionRoutes( formatted[relName] = groups; } } + const r = refs[row.uri]; + if (r) { + for (const [refName, record] of Object.entries(r)) { + formatted[refName] = record; + } + } return formatted; }); @@ -181,11 +194,17 @@ export function registerCollectionRoutes( const params = new URL(c.req.url).searchParams; const wantProfilesSingle = params.get("profiles") === "true"; - const hydrateRequested = parseHydrateParams(params, relations); + const hydrateRequested = parseHydrateParams(params, relations, references); const hydrates = await resolveHydrates( db, relations, - hydrateRequested, + hydrateRequested.relations, + [row] + ); + const refs = await resolveReferences( + db, + references, + hydrateRequested.references, [row] ); const h = hydrates[row.uri]; @@ -194,6 +213,12 @@ export function registerCollectionRoutes( (formatted as any)[relName] = groups; } } + const r = refs[row.uri]; + if (r) { + for (const [refName, record] of Object.entries(r)) { + (formatted as any)[refName] = record; + } + } const allDids = collectDids([row], hydrates); const profileMap = wantProfilesSingle diff --git a/src/core/router/hydrate.ts b/src/core/router/hydrate.ts index ea2da3a..0cde478 100644 --- a/src/core/router/hydrate.ts +++ b/src/core/router/hydrate.ts @@ -1,25 +1,36 @@ -import type { RelationConfig, RecordRow, Database } from "../types"; +import type { RelationConfig, ReferenceConfig, RecordRow, Database } from "../types"; import { getNestedValue, getRelationField } from "../types"; import { batchedInQuery, formatRecord } from "./helpers"; -// --- Hydration: embed related records that point at the parent --- +// --- Hydration: embed related records --- export function parseHydrateParams( params: URLSearchParams, - relations: Record -): Record { - const hydrates: Record = {}; + relations: Record, + references: Record +): { relations: Record; references: Set } { + const relHydrates: Record = {}; + const refHydrates = new Set(); const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + for (const relName of Object.keys(relations)) { const val = params.get(`hydrate${capitalize(relName)}`); if (val) { const limit = parseInt(val, 10); if (!isNaN(limit) && limit > 0) { - hydrates[relName] = limit; + relHydrates[relName] = limit; } } } - return hydrates; + + for (const refName of Object.keys(references)) { + const val = params.get(`hydrate${capitalize(refName)}`); + if (val === "true" || val === "1") { + refHydrates.add(refName); + } + } + + return { relations: relHydrates, references: refHydrates }; } // Per-relation hydrate result: array for ungrouped, Record for grouped @@ -103,3 +114,54 @@ export async function resolveHydrates( return result; } + +// --- References: embed records that our records point at --- + +export type ReferenceResult = Record>; + +export async function resolveReferences( + db: Database, + references: Record, + requested: Set, + records: RecordRow[] +): Promise { + if (requested.size === 0 || records.length === 0) return {}; + + const result: ReferenceResult = {}; + + for (const refName of requested) { + const ref = references[refName]; + if (!ref) continue; + + // Extract target URIs from our records + const targetMap = new Map(); // targetUri → parentUris[] + for (const r of records) { + const parsed = r.record ? JSON.parse(r.record) : null; + const targetValue = parsed ? getNestedValue(parsed, ref.field) : null; + if (!targetValue) continue; + if (!targetMap.has(targetValue)) targetMap.set(targetValue, []); + targetMap.get(targetValue)!.push(r.uri); + } + + const targetUris = [...targetMap.keys()]; + if (targetUris.length === 0) continue; + + const rows = await batchedInQuery( + db, + `SELECT uri, did, collection, rkey, record, time_us FROM records + WHERE collection = ? AND uri IN (__IN__)`, + [ref.collection], + targetUris + ); + + for (const row of rows) { + const parentUris = targetMap.get(row.uri) ?? []; + for (const parentUri of parentUris) { + if (!result[parentUri]) result[parentUri] = {}; + result[parentUri][refName] = formatRecord(row); + } + } + } + + return result; +} diff --git a/src/core/types.ts b/src/core/types.ts index cec1721..f98f133 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -24,6 +24,13 @@ export interface RelationConfig { groupBy?: string; } +/** A forward reference: this collection's records point at another collection. */ +export interface ReferenceConfig { + collection: string; + /** Field on this collection's records containing the target URI. */ + field: string; +} + export type CustomQueryHandler = ( db: Database, params: URLSearchParams, @@ -34,6 +41,8 @@ export interface CollectionConfig { discover?: boolean; queryable?: Record; relations?: Record; + /** Forward references: fields on this collection's records that point at another collection. */ + references?: Record; queries?: Record; } diff --git a/tests/helpers.ts b/tests/helpers.ts index c702949..e53e936 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,6 +7,7 @@ export function createTestDb(): Database { } export const TEST_CONFIG: ContrailConfig = { + namespace: "com.example", collections: { "community.lexicon.calendar.event": { queryable: { @@ -22,7 +23,7 @@ export const TEST_CONFIG: ContrailConfig = { }, }, "community.lexicon.calendar.rsvp": { - relations: { + references: { event: { collection: "community.lexicon.calendar.event", field: "subject.uri", diff --git a/tests/hydrate.test.ts b/tests/hydrate.test.ts index 45bde36..b9ca899 100644 --- a/tests/hydrate.test.ts +++ b/tests/hydrate.test.ts @@ -1,6 +1,6 @@ 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 type { Database, RecordRow, RelationConfig, ReferenceConfig } from "../src/core/types"; +import { parseHydrateParams, resolveHydrates, resolveReferences } from "../src/core/router/hydrate"; import { createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; import { applyEvents } from "../src/core/db/records"; @@ -9,35 +9,54 @@ describe("parseHydrateParams", () => { rsvps: { collection: "community.lexicon.calendar.rsvp", groupBy: "status" }, comments: { collection: "test.comment" }, }; + const references: Record = { + event: { collection: "community.lexicon.calendar.event", field: "subject.uri" }, + }; - it("parses valid hydrate params", () => { + it("parses valid relation hydrate params", () => { const params = new URLSearchParams({ hydrateRsvps: "5", hydrateComments: "3" }); - const result = parseHydrateParams(params, relations); - expect(result).toEqual({ rsvps: 5, comments: 3 }); + const result = parseHydrateParams(params, relations, references); + expect(result.relations).toEqual({ rsvps: 5, comments: 3 }); + expect(result.references.size).toBe(0); + }); + + it("parses valid reference hydrate params", () => { + const params = new URLSearchParams({ hydrateEvent: "true" }); + const result = parseHydrateParams(params, relations, references); + expect(result.relations).toEqual({}); + expect(result.references.has("event")).toBe(true); + }); + + it("parses reference hydrate with 1", () => { + const params = new URLSearchParams({ hydrateEvent: "1" }); + const result = parseHydrateParams(params, relations, references); + expect(result.references.has("event")).toBe(true); }); it("ignores unrelated params", () => { const params = new URLSearchParams({ hydrateFoo: "5", limit: "10" }); - const result = parseHydrateParams(params, relations); - expect(result).toEqual({}); + const result = parseHydrateParams(params, relations, references); + expect(result.relations).toEqual({}); + expect(result.references.size).toBe(0); }); it("ignores invalid numbers", () => { const params = new URLSearchParams({ hydrateRsvps: "abc" }); - const result = parseHydrateParams(params, relations); - expect(result).toEqual({}); + const result = parseHydrateParams(params, relations, references); + expect(result.relations).toEqual({}); }); it("ignores zero and negative", () => { const params = new URLSearchParams({ hydrateRsvps: "0", hydrateComments: "-1" }); - const result = parseHydrateParams(params, relations); - expect(result).toEqual({}); + const result = parseHydrateParams(params, relations, references); + expect(result.relations).toEqual({}); }); it("returns empty for no hydrate params", () => { const params = new URLSearchParams({ limit: "50" }); - const result = parseHydrateParams(params, relations); - expect(result).toEqual({}); + const result = parseHydrateParams(params, relations, references); + expect(result.relations).toEqual({}); + expect(result.references.size).toBe(0); }); }); @@ -155,6 +174,88 @@ describe("resolveHydrates", () => { expect(result[eventUri].rsvps["going"]).toHaveLength(1); }); + it("resolves reference: event onto rsvp (child→parent via subject.uri)", async () => { + const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + + // Insert event + await applyEvents(db, [ + makeEvent({ uri: eventUri, rkey: "evt1", record: { name: "My Event" }, time_us: 1000 }), + ]); + + // Insert RSVP pointing at the event + const rsvpUri = "at://did:plc:user1/community.lexicon.calendar.rsvp/r1"; + await applyEvents(db, [ + makeEvent({ + uri: rsvpUri, + did: "did:plc:user1", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: { subject: { uri: eventUri }, status: "going" }, + time_us: 2000, + }), + ]); + + const rsvpRow = await db + .prepare("SELECT * FROM records WHERE uri = ?") + .bind(rsvpUri) + .first(); + + const references = TEST_CONFIG.collections["community.lexicon.calendar.rsvp"].references!; + const result = await resolveReferences(db, references, new Set(["event"]), [rsvpRow!]); + + expect(result[rsvpUri]).toBeDefined(); + expect(result[rsvpUri].event).toBeDefined(); + // reference returns a single object, not an array + expect(result[rsvpUri].event.uri).toBe(eventUri); + }); + + it("resolves references onto multiple rsvps", async () => { + const eventUri1 = "at://did:plc:test/community.lexicon.calendar.event/evt1"; + const eventUri2 = "at://did:plc:test/community.lexicon.calendar.event/evt2"; + + // Insert two events + await applyEvents(db, [ + makeEvent({ uri: eventUri1, rkey: "evt1", record: { name: "Event 1" }, time_us: 1000 }), + makeEvent({ uri: eventUri2, rkey: "evt2", record: { name: "Event 2" }, time_us: 1001 }), + ]); + + // Insert RSVPs pointing at different events + const rsvpUri1 = "at://did:plc:user1/community.lexicon.calendar.rsvp/r1"; + const rsvpUri2 = "at://did:plc:user2/community.lexicon.calendar.rsvp/r2"; + await applyEvents(db, [ + makeEvent({ + uri: rsvpUri1, + did: "did:plc:user1", + collection: "community.lexicon.calendar.rsvp", + rkey: "r1", + record: { subject: { uri: eventUri1 }, status: "going" }, + time_us: 2000, + }), + makeEvent({ + uri: rsvpUri2, + did: "did:plc:user2", + collection: "community.lexicon.calendar.rsvp", + rkey: "r2", + record: { subject: { uri: eventUri2 }, status: "interested" }, + time_us: 2001, + }), + ]); + + const rsvpRows = await db + .prepare("SELECT * FROM records WHERE collection = ? ORDER BY time_us DESC") + .bind("community.lexicon.calendar.rsvp") + .all(); + + const references = TEST_CONFIG.collections["community.lexicon.calendar.rsvp"].references!; + const result = await resolveReferences(db, references, new Set(["event"]), rsvpRows.results!); + + // Each RSVP should have its event resolved + expect(result[rsvpUri1]?.event).toBeDefined(); + expect(result[rsvpUri2]?.event).toBeDefined(); + expect(result[rsvpUri1].event.uri).toBe(eventUri1); + expect(result[rsvpUri2].event.uri).toBe(eventUri2); + }); + it("groups into 'other' when groupBy value is null", async () => { const eventUri = "at://did:plc:test/community.lexicon.calendar.event/evt1";