From 303007b2b548b79101e78995d48117641fda1611 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sun, 14 Jun 2026 18:49:28 -0400 Subject: [PATCH 1/2] fix(search): populate FTS for NSID-keyed collections shortNameForNsid returns undefined when a collection is keyed directly by its NSID, so the FTS-sync and existing-record lookup paths skipped those collections, leaving full-text search empty and replay/update detection broken. Add a resolveCollectionKey helper that returns the storage key (alias or NSID) and use it at all three record paths. --- .changeset/fts-nsid-keyed-collections.md | 13 ++++++ .../contrail-appview/src/core/db/records.ts | 13 +++--- packages/contrail-base/src/types.ts | 16 +++++++ .../tests/resolve-collection-key.test.ts | 44 +++++++++++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 .changeset/fts-nsid-keyed-collections.md create mode 100644 packages/contrail/tests/resolve-collection-key.test.ts diff --git a/.changeset/fts-nsid-keyed-collections.md b/.changeset/fts-nsid-keyed-collections.md new file mode 100644 index 0000000..5fa5a55 --- /dev/null +++ b/.changeset/fts-nsid-keyed-collections.md @@ -0,0 +1,13 @@ +--- +"@atmo-dev/contrail-base": patch +"@atmo-dev/contrail-appview": patch +--- + +Populate FTS and detect existing records for NSID-keyed collections. + +When a collection is keyed directly by its NSID (no short alias / `collection` +field), `shortNameForNsid` returns undefined, so the FTS-sync and +existing-record-lookup paths silently skipped it. Only the records insert had +the NSID fallback, leaving full-text search empty and replay/update detection +broken for those collections. Added a `resolveCollectionKey` helper that returns +the storage key (alias or NSID) and used it at all three sites. diff --git a/packages/contrail-appview/src/core/db/records.ts b/packages/contrail-appview/src/core/db/records.ts index 42ac3b3..f042180 100644 --- a/packages/contrail-appview/src/core/db/records.ts +++ b/packages/contrail-appview/src/core/db/records.ts @@ -18,6 +18,7 @@ import { recordsTableName, spacesRecordsTableName, shortNameForNsid, + resolveCollectionKey, nsidForShortName, normalizeFeedTarget, feedTargetMaxItems, @@ -166,7 +167,7 @@ function buildFtsStatements( // PostgreSQL: tsvector generated column is auto-maintained, no manual FTS sync if (getDialect(db).ftsStrategy === "generated-column") return []; - const short = shortNameForNsid(config, event.collection); + const short = resolveCollectionKey(config, event.collection); if (!short) return []; const colConfig = config.collections[short]; if (!colConfig) return []; @@ -513,7 +514,7 @@ export async function lookupExistingRecords( // Group by short name (config lookup); skip events for collections not in our config. const byShort = new Map(); for (const e of events) { - const short = config ? shortNameForNsid(config, e.collection) : e.collection; + const short = config ? resolveCollectionKey(config, e.collection) : e.collection; if (!short) continue; const uris = byShort.get(short) ?? []; uris.push(e.uri); @@ -599,11 +600,9 @@ export async function applyEvents( const countTargets = new Map(); for (const e of events) { - // Event's collection is an NSID. Look up the short name from config. - // If no config or not found, treat collection string as-is (for tests that pre-populate tables). - const short = config - ? shortNameForNsid(config, e.collection) ?? (config.collections[e.collection] ? e.collection : null) - : e.collection; + // Event's collection is an NSID. Resolve its storage key from config. + // If no config, treat collection string as-is (for tests that pre-populate tables). + const short = config ? resolveCollectionKey(config, e.collection) : e.collection; if (!short) { (config?.logger ?? console).warn( `[ingest] drop (unknown collection in applyEvents): ${e.operation} ${e.uri} collection=${e.collection}` diff --git a/packages/contrail-base/src/types.ts b/packages/contrail-base/src/types.ts index 1e9761b..b8ed54c 100644 --- a/packages/contrail-base/src/types.ts +++ b/packages/contrail-base/src/types.ts @@ -747,6 +747,22 @@ export function shortNameForNsid( return undefined; } +/** The config key a collection's rows are stored under: its short alias when + * one exists, otherwise the NSID itself when the config is keyed directly by + * NSID. Returns null when the collection is unknown. Use this wherever you need + * the storage key (records insert, FTS, existing-record lookup). Unlike + * {@link shortNameForNsid}, which only reports an alias and so returns + * undefined for NSID-keyed configs. */ +export function resolveCollectionKey( + config: ContrailConfig, + nsid: string +): string | null { + return ( + shortNameForNsid(config, nsid) ?? + (config.collections[nsid] ? nsid : null) + ); +} + /** Full NSID for a collection short name. */ export function nsidForShortName( config: ContrailConfig, diff --git a/packages/contrail/tests/resolve-collection-key.test.ts b/packages/contrail/tests/resolve-collection-key.test.ts new file mode 100644 index 0000000..a29795a --- /dev/null +++ b/packages/contrail/tests/resolve-collection-key.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { resolveConfig, resolveCollectionKey } from "../src/core/types"; + +// resolveCollectionKey returns the key a collection's rows are stored under: +// its short alias when one exists, otherwise the NSID itself when the config is +// keyed directly by NSID. This is the resolution the records insert, FTS, and +// lookup paths all need (storage key), distinct from shortNameForNsid which +// only reports an alias and returns undefined for NSID-keyed configs. +describe("resolveCollectionKey", () => { + it("returns the short alias for an alias-keyed collection", () => { + const config = resolveConfig({ + namespace: "com.example", + collections: { + events: { + collection: "community.lexicon.calendar.event", + queryable: { name: {} }, + }, + }, + }); + expect( + resolveCollectionKey(config, "community.lexicon.calendar.event"), + ).toBe("events"); + }); + + it("falls back to the NSID when the collection is keyed directly by its NSID", () => { + const config = resolveConfig({ + namespace: "com.example", + collections: { + "community.lexicon.calendar.event": { queryable: { name: {} } }, + }, + }); + expect( + resolveCollectionKey(config, "community.lexicon.calendar.event"), + ).toBe("community.lexicon.calendar.event"); + }); + + it("returns null for a collection the config does not know", () => { + const config = resolveConfig({ + namespace: "com.example", + collections: { "test.known": { queryable: { name: {} } } }, + }); + expect(resolveCollectionKey(config, "test.unknown")).toBeNull(); + }); +}); From deac05cc20f430a66ccfa9c7469e06391ffa9ba7 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Mon, 15 Jun 2026 07:54:36 -0400 Subject: [PATCH 2/2] fix(contrail): ingest NSID-keyed collections through normal entry points A collection keyed directly by its NSID (no short alias, omitted `collection` field) was only handled at the records/FTS layer via `resolveCollectionKey`. The real ingestion entry points still skipped it: `getCollectionNsids`/`getDiscoverableNsids`/`getDependentNsids` produced undefined NSIDs (Jetstream never subscribed, backfill never ran), `shortNameForNsid` returned undefined (notify rejected the URI as "collection not tracked"), and `validateConfig` rejected the config. Make `CollectionConfig.collection` optional, normalize an omitted value to the map key in `resolveConfig`, accept NSID-keyed entries in `validateConfig`, and resolve the NSID as `collection ?? key` at every collection-list and lookup site so behavior is correct on both raw and resolved configs. Add config-layer regression tests and an end-to-end notify->ingest test for an NSID-keyed collection. --- .changeset/fts-nsid-keyed-collections.md | 25 +++++--- packages/contrail-appview/src/core/refresh.ts | 2 +- .../src/core/router/collection.ts | 4 +- .../contrail-appview/src/core/router/feed.ts | 2 +- .../src/core/spaces/adapter.ts | 2 +- packages/contrail-base/src/types.ts | 60 +++++++++++-------- packages/contrail-record-host/src/adapter.ts | 2 +- packages/contrail-record-host/src/sync.ts | 2 +- packages/contrail/tests/notify.test.ts | 51 +++++++++++++++- packages/contrail/tests/types.test.ts | 46 ++++++++++++++ packages/lexicons/src/generate.ts | 10 ++-- 11 files changed, 160 insertions(+), 46 deletions(-) diff --git a/.changeset/fts-nsid-keyed-collections.md b/.changeset/fts-nsid-keyed-collections.md index 5fa5a55..283c751 100644 --- a/.changeset/fts-nsid-keyed-collections.md +++ b/.changeset/fts-nsid-keyed-collections.md @@ -1,13 +1,24 @@ --- "@atmo-dev/contrail-base": patch "@atmo-dev/contrail-appview": patch +"@atmo-dev/contrail-record-host": patch +"@atmo-dev/contrail-lexicons": patch --- -Populate FTS and detect existing records for NSID-keyed collections. +Make NSID-keyed collections work through normal ingestion, not just FTS. -When a collection is keyed directly by its NSID (no short alias / `collection` -field), `shortNameForNsid` returns undefined, so the FTS-sync and -existing-record-lookup paths silently skipped it. Only the records insert had -the NSID fallback, leaving full-text search empty and replay/update detection -broken for those collections. Added a `resolveCollectionKey` helper that returns -the storage key (alias or NSID) and used it at all three sites. +When a collection is keyed directly by its NSID (no short alias, `collection` +field omitted), the value defaulted to `undefined` everywhere it was read. The +records insert and FTS sync were patched via `resolveCollectionKey`, but the +real ingestion entry points still skipped these collections: `getCollectionNsids` +/ `getDiscoverableNsids` / `getDependentNsids` produced `undefined` NSIDs (so +Jetstream never subscribed and backfill never ran), `shortNameForNsid` returned +undefined (so `notify` rejected the URI as "collection not tracked"), and +`validateConfig` rejected the config outright (missing `collection`, dotted key +failing short-name validation). + +`CollectionConfig.collection` is now optional. `resolveConfig` normalizes an +omitted `collection` to the map key, `validateConfig` accepts NSID-keyed entries, +and every collection-list / lookup helper resolves the NSID as `collection ?? key` +so the behavior is correct on both raw and resolved configs. + diff --git a/packages/contrail-appview/src/core/refresh.ts b/packages/contrail-appview/src/core/refresh.ts index acbae5c..2c8a343 100644 --- a/packages/contrail-appview/src/core/refresh.ts +++ b/packages/contrail-appview/src/core/refresh.ts @@ -106,7 +106,7 @@ export async function refresh( // included because `resolveConfig` adds them to `config.collections`. const nsids = options?.nsids ?? - Object.values(config.collections).map((c) => c.collection); + Object.entries(config.collections).map(([short, c]) => c.collection ?? short); const byCollection: Record = {}; for (const nsid of nsids) byCollection[nsid] = emptyStats(); diff --git a/packages/contrail-appview/src/core/router/collection.ts b/packages/contrail-appview/src/core/router/collection.ts index db793a7..2ec0462 100644 --- a/packages/contrail-appview/src/core/router/collection.ts +++ b/packages/contrail-appview/src/core/router/collection.ts @@ -663,7 +663,7 @@ export function registerCollectionRoutes( // Streaming variant — same query shape, SSE'd forever. Opted in via the // presence of the realtime module; no explicit method config needed. if (pubsub && spacesCtx) { - const colNsid = colConfig.collection; + const colNsid = colConfig.collection ?? collection; const relations = colConfig.relations ?? {}; const references = colConfig.references ?? {}; // Map child-NSID → { relName, matchField } so we can route child @@ -1068,7 +1068,7 @@ export function registerCollectionRoutes( const gated = await gateSpaceAccess(c, spaceUri, "read"); if (gated instanceof Response) return gated; - const nsid = colConfig.collection; + const nsid = colConfig.collection ?? collection; const record = await spacesCtx!.adapter.getRecord(spaceUri, nsid, did, rkey); if (!record) return c.json({ error: "NotFound" }, 404); return c.json({ record }); diff --git a/packages/contrail-appview/src/core/router/feed.ts b/packages/contrail-appview/src/core/router/feed.ts index e817e40..8ef149e 100644 --- a/packages/contrail-appview/src/core/router/feed.ts +++ b/packages/contrail-appview/src/core/router/feed.ts @@ -118,7 +118,7 @@ async function runFeedBackfill( const inserted = await backfillUser( db, actor, - followCfg.collection, + followCfg.collection ?? followShort, Date.now() + BACKFILL_TIMEOUT_MS, config, { diff --git a/packages/contrail-appview/src/core/spaces/adapter.ts b/packages/contrail-appview/src/core/spaces/adapter.ts index 50fb33d..dab781e 100644 --- a/packages/contrail-appview/src/core/spaces/adapter.ts +++ b/packages/contrail-appview/src/core/spaces/adapter.ts @@ -383,7 +383,7 @@ export class HostedAdapter extends HostedAuthorityAdapter implements StorageAdap .bind(...params) .first<{ count: number }>(); const count = Number(row?.count ?? 0); - if (count > 0) results.push({ collection: colConfig.collection, count }); + if (count > 0) results.push({ collection: colConfig.collection ?? short, count }); } catch { // table doesn't exist (collection added after init, or allowInSpaces toggled) — skip } diff --git a/packages/contrail-base/src/types.ts b/packages/contrail-base/src/types.ts index b8ed54c..af37b17 100644 --- a/packages/contrail-base/src/types.ts +++ b/packages/contrail-base/src/types.ts @@ -111,8 +111,9 @@ export function buildFeedTargetCaps( const colCfg = config.collections[target.collection]; if (!colCfg) continue; const cap = feedTargetMaxItems(feed, target); - const existing = caps.get(colCfg.collection) ?? 0; - if (cap > existing) caps.set(colCfg.collection, cap); + const nsid = colCfg.collection ?? target.collection; + const existing = caps.get(nsid) ?? 0; + if (cap > existing) caps.set(nsid, cap); } } return caps; @@ -125,8 +126,10 @@ export const DEFAULT_COLLECTION_METHODS: CollectionMethod[] = [ ]; export interface CollectionConfig { - /** Full NSID of the record type this collection indexes. */ - collection: string; + /** Full NSID of the record type this collection indexes. May be omitted when + * the collection's map key is itself the full NSID (an "NSID-keyed" config); + * `resolveConfig` normalizes the omitted value to that key. */ + collection?: string; /** Include this collection in Jetstream ingest / discovery (default true). * Set false for dependent collections (auto-fetched on demand). */ discover?: boolean; @@ -412,14 +415,15 @@ export function resolveConfig(config: ContrailConfig): ResolvedContrailConfig { ); const collections: Record = {}; - // Default `discover: false` for any collection whose NSID lives under the + // Normalize an omitted `collection` (NSID-keyed config) to the map key, then + // default `discover: false` for any collection whose NSID lives under the // `app.bsky.*` namespace, since these are external/network-wide records that // would otherwise blow up storage if left discoverable. - for (const [short, c] of Object.entries(config.collections)) { + for (const [short, rawC] of Object.entries(config.collections)) { + const c = + rawC.collection === undefined ? { ...rawC, collection: short } : rawC; collections[short] = - c.discover === undefined && - typeof c.collection === "string" && - c.collection.startsWith("app.bsky.") + c.discover === undefined && c.collection!.startsWith("app.bsky.") ? { ...c, discover: false } : c; } @@ -473,7 +477,7 @@ function _resolveQueryableMaps(config: ContrailConfig): ResolvedMaps { const nsidToShort: Record = {}; for (const [short, colConfig] of Object.entries(config.collections)) { - nsidToShort[colConfig.collection] = short; + nsidToShort[colConfig.collection ?? short] = short; if (colConfig.queryable) { queryable[short] = colConfig.queryable; @@ -583,16 +587,16 @@ function validateShortName(short: string): void { export function validateConfig(config: ContrailConfig): void { const shortNames = new Set(); for (const [short, colConfig] of Object.entries(config.collections)) { - validateShortName(short); + // NSID-keyed collections use the map key as the NSID (no short alias), so + // the key legitimately contains dots and must skip short-name validation. + const nsidKeyed = + colConfig.collection === undefined || colConfig.collection === short; + if (!nsidKeyed) validateShortName(short); if (shortNames.has(short)) { throw new Error(`Duplicate collection short name: ${short}`); } shortNames.add(short); - if (!colConfig.collection) { - throw new Error(`Collection "${short}" is missing required 'collection' field (NSID)`); - } - for (const field of Object.keys(colConfig.queryable ?? {})) { validateFieldName(field); } @@ -700,9 +704,10 @@ export function getCollectionShortNames(config: ContrailConfig): string[] { /** Alias: collection short names (same as getCollectionShortNames). */ export const getCollectionNames = getCollectionShortNames; -/** All indexed record NSIDs (what Jetstream filters on). */ +/** All indexed record NSIDs (what Jetstream filters on). For NSID-keyed + * collections (omitted `collection`), the map key is the NSID. */ export function getCollectionNsids(config: ContrailConfig): string[] { - return Object.values(config.collections).map((c) => c.collection); + return Object.entries(config.collections).map(([short, c]) => c.collection ?? short); } export function getDependentShortNames(config: ContrailConfig): string[] { @@ -723,15 +728,15 @@ export const getDiscoverableCollections = getDiscoverableShortNames; /** Short names of collections the user declared with `discover !== false`, mapped to NSIDs. */ export function getDiscoverableNsids(config: ContrailConfig): string[] { - return Object.values(config.collections) - .filter((c) => c.discover !== false) - .map((c) => c.collection); + return Object.entries(config.collections) + .filter(([, c]) => c.discover !== false) + .map(([short, c]) => c.collection ?? short); } export function getDependentNsids(config: ContrailConfig): string[] { - return Object.values(config.collections) - .filter((c) => c.discover === false) - .map((c) => c.collection); + return Object.entries(config.collections) + .filter(([, c]) => c.discover === false) + .map(([short, c]) => c.collection ?? short); } /** Short name for a record NSID, if known. */ @@ -742,7 +747,7 @@ export function shortNameForNsid( const resolved = (config as ResolvedContrailConfig)._resolved; if (resolved?.nsidToShort) return resolved.nsidToShort[nsid]; for (const [short, c] of Object.entries(config.collections)) { - if (c.collection === nsid) return short; + if ((c.collection ?? short) === nsid) return short; } return undefined; } @@ -763,12 +768,15 @@ export function resolveCollectionKey( ); } -/** Full NSID for a collection short name. */ +/** Full NSID for a collection short name. For NSID-keyed collections (omitted + * `collection`), the short name is itself the NSID. */ export function nsidForShortName( config: ContrailConfig, short: string ): string | undefined { - return config.collections[short]?.collection; + const c = config.collections[short]; + if (!c) return undefined; + return c.collection ?? short; } /** The methods a collection should expose via XRPC. */ diff --git a/packages/contrail-record-host/src/adapter.ts b/packages/contrail-record-host/src/adapter.ts index 4bfb321..8f9ed33 100644 --- a/packages/contrail-record-host/src/adapter.ts +++ b/packages/contrail-record-host/src/adapter.ts @@ -408,7 +408,7 @@ export class HostedRecordHostAdapter implements RecordHost { .bind(...params) .first<{ count: number }>(); const count = Number(row?.count ?? 0); - if (count > 0) results.push({ collection: colConfig.collection, count }); + if (count > 0) results.push({ collection: colConfig.collection ?? short, count }); } catch { // table doesn't exist (collection added after init, or allowInSpaces toggled) — skip } diff --git a/packages/contrail-record-host/src/sync.ts b/packages/contrail-record-host/src/sync.ts index 00af1e1..6926824 100644 --- a/packages/contrail-record-host/src/sync.ts +++ b/packages/contrail-record-host/src/sync.ts @@ -242,7 +242,7 @@ async function streamCatchup(args: { if (colCfg.allowInSpaces === false) continue; if (isClosed()) return highest; - const collectionNsid = colCfg.collection; + const collectionNsid = colCfg.collection ?? _short; const short = shortNameForNsid(config, collectionNsid); if (!short) continue; const table = spacesRecordsTableName(short); diff --git a/packages/contrail/tests/notify.test.ts b/packages/contrail/tests/notify.test.ts index 2477dee..89faf69 100644 --- a/packages/contrail/tests/notify.test.ts +++ b/packages/contrail/tests/notify.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import type { Database } from "../src/core/types"; -import { applyEvents, createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; +import { resolveConfig } from "../src/core/types"; +import { applyEvents, createTestDb, createTestDbWithSchema, makeEvent, TEST_CONFIG } from "./helpers"; +import { initSchema } from "../src/core/db/schema"; import { parseAtUri } from "../src/core/router/notify"; import { createApp } from "../src/core/router/index"; import { queryRecords } from "../src/core/db/records"; @@ -421,6 +423,53 @@ describe("POST notifyOfUpdate", () => { expect(body.deleted).toBe(0); }); + it("ingests an NSID-keyed collection end-to-end (no short alias)", async () => { + // Config keyed directly by NSID, with `collection` omitted — the shape + // PR #59 advertised but that normal ingestion entry points used to skip. + const nsidConfig = resolveConfig({ + namespace: "com.example", + notify: true, + collections: { + "com.example.thing": { queryable: { name: {} } }, + }, + }); + const nsidDb = createTestDb(); + await initSchema(nsidDb, nsidConfig); + const nsidApp = createApp(nsidDb, nsidConfig); + + const did = "did:plc:nsidtest"; + const uri = `at://${did}/com.example.thing/t1`; + const record = { name: "Findable Thing" }; + + await nsidDb + .prepare( + "INSERT OR REPLACE INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?)" + ) + .bind(did, "test.handle", "https://pds.example.com", Date.now()) + .run(); + mockFetch({ [uri]: { value: record, cid: "bafynsid" } }); + + const res = await nsidApp.request("/xrpc/com.example.notifyOfUpdate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uri }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + // The collection must NOT be rejected as "not tracked", and must ingest. + expect(body.errors).toBeUndefined(); + expect(body.indexed).toBe(1); + + // And the record is retrievable under the NSID-keyed collection. + const result = await queryRecords(nsidDb, nsidConfig, { + collection: "com.example.thing", + }); + expect(result.records).toHaveLength(1); + expect(result.records[0].uri).toBe(uri); + expect(JSON.parse(result.records[0].record!)).toEqual(record); + }); + it("decrements counts when deleting a relation record", async () => { const did = "did:plc:test"; const eventUri = `at://${did}/community.lexicon.calendar.event/evt1`; diff --git a/packages/contrail/tests/types.test.ts b/packages/contrail/tests/types.test.ts index bec1de5..7c7fc26 100644 --- a/packages/contrail/tests/types.test.ts +++ b/packages/contrail/tests/types.test.ts @@ -9,6 +9,9 @@ import { getDiscoverableShortNames, getDependentShortNames, getCollectionNsids, + getDiscoverableNsids, + getDependentNsids, + shortNameForNsid, } from "../src/core/types"; describe("validateFieldName", () => { @@ -200,6 +203,49 @@ describe("resolveConfig", () => { }); }); +describe("NSID-keyed collections (no short alias / omitted `collection`)", () => { + const config = resolveConfig({ + namespace: "com.example", + collections: { + "com.example.event": { searchable: ["name"] }, + "com.example.follow": { discover: false }, + }, + }); + + it("validateConfig accepts an NSID-keyed config", () => { + expect(() => + validateConfig({ + namespace: "com.example", + collections: { + "com.example.event": { searchable: ["name"] }, + }, + }) + ).not.toThrow(); + }); + + it("getCollectionNsids returns the config key as the NSID", () => { + const nsids = getCollectionNsids(config); + expect(nsids).toContain("com.example.event"); + expect(nsids).toContain("com.example.follow"); + expect(nsids).not.toContain(undefined); + }); + + it("getDiscoverableNsids / getDependentNsids split NSID-keyed entries", () => { + expect(getDiscoverableNsids(config)).toContain("com.example.event"); + expect(getDependentNsids(config)).toContain("com.example.follow"); + expect(getDiscoverableNsids(config)).not.toContain("com.example.follow"); + }); + + it("shortNameForNsid resolves an NSID-keyed collection to its key", () => { + expect(shortNameForNsid(config, "com.example.event")).toBe("com.example.event"); + }); + + it("builds nsidToShort for NSID-keyed entries", () => { + expect(config._resolved.nsidToShort["com.example.event"]).toBe("com.example.event"); + expect(config._resolved.nsidToShort["com.example.follow"]).toBe("com.example.follow"); + }); +}); + describe("collection lookup helpers", () => { const config = resolveConfig({ namespace: "test", diff --git a/packages/lexicons/src/generate.ts b/packages/lexicons/src/generate.ts index 8b39b6d..d6069a0 100644 --- a/packages/lexicons/src/generate.ts +++ b/packages/lexicons/src/generate.ts @@ -432,7 +432,7 @@ export function generateLexicons(options: GenerateOptions): Record }>> = {}; for (const [shortName, colConfig] of Object.entries(config.collections)) { - const collection = colConfig.collection; // full NSID for lexicon refs + const collection = colConfig.collection ?? shortName; // full NSID for lexicon refs const collectionRef = getCollectionLexiconRef(collection); const merged = colConfig.queryable ?? {}; @@ -969,8 +969,8 @@ export function generateLexicons(options: GenerateOptions): Record c.collection) + const autoCollections = Object.entries(config.collections) + .map(([short, c]) => c.collection ?? short) .filter((nsid) => nsid === ns || nsid.startsWith(nsPrefix)) .sort();