House rulesLesson
Cross-tenant reads
One app that hosts several sites keeps each site's content apart, yet a few of its pages must read all of them: a search box over every site, a feed of what's new anywhere. Let those pages read any site directly and the separation is gone; copy each site's fields into them and the copies drift. The fix is to let each site offer named parts of its content, and give readers one way in that sees only what was offered.
Know this pattern already? Skip to In Terrarium.
Outside the box · General
The concept
Give each shape that readers in more than one place need a name and one home, as a contract: the fields every reader may rely on. A tenant offers a data set by naming the contract in its own config. The contract is a minimum, so the tenant keeps fields of its own on top, and its validation checks both.
At build time, collect every data set that named a contract into one list, grouped by contract. Readers go through one function that reads only that list and tags every row with the tenant it came from. A data set that names no contract is on no list, so no reader outside its tenant finds it. Separation stays the default, and each exception is written down in the config of the tenant that made it.
In Terrarium
Each keeps its content in tables of its own (Multi-tenant isolation). The shared shapes are the in shared/kinds.ts. The page kind adds optional fields to Nuxt Content's own page fields, and the session kind's contract is the whole session-log schema:
50export const KINDS = {
51 page: {
52 type: 'page',
53 contract: z.object({
54 /** Publish instant — what the Commons Timeline orders dated pages by. */
55 publishedAt: utcTimestamp.optional(),
56 /** One-line headline (e.g. a Journal digest's day-summary) an aggregator
57 * prefers over the title. */
58 summary: z.string().optional(),
59 }),
60 },
⋯ // …
65 session: { type: 'data', contract: sessionSchema },
66} satisfies Record<string, KindDef>
A opts in with kind: 'page' or kind: 'session' in its Tenant's (Content collections). The Journal's manifest, layers/journal/tenant.config.ts, names both kinds:
141 pages: {
142 type: 'page',
143 kind: 'page', // opt into the cross-Tenant #catalog (ADR-0025)
⋯ // …
180 sessions: { type: 'data', kind: 'session', source: '**/*.yml' },
The manifest check refuses an unknown kind, or a kind whose type doesn't match the Collection's. expand() in shared/expand.ts gives the Collection the contract's fields plus its own, so pnpm validate:content checks the contract in every Document that opts in. catalogFrom() in the same file lists every Collection that names a kind, grouped by kind, and modules/catalog.ts writes that list at build time as the #catalog module: the .
The read function is queryAcrossTenants in app/composables/catalog.ts. It runs Nuxt Content's queryCollection on each Collection the Catalog lists for a kind, one query per Collection, lets the caller's project pick the fields it needs, and tags each row with its Tenant, Space and Collection:
46export async function queryAcrossTenants<T>(
47 kind: KindName,
48 project: (item: CatalogItem) => T,
49): Promise<Array<T & CatalogProvenance>> {
50 const perCollection = await Promise.all(
51 catalogByKind(kind).map(async (entry) => {
⋯ // …
55 const items = await queryCollection(entry.key as keyof Collections).all()
56 return items.map((item) => ({
57 ...project(item as unknown as CatalogItem),
58 tenant: entry.tenant,
59 space: entry.space,
60 collection: entry.collection,
61 }))
62 }),
63 )
64 return perCollection.flat()
65}
queryPages() in the same file reads the page kind and adds each page's own URL. The Commons, the Tenant that holds the views, builds its Search and Timeline on these two functions, and a Blog post finds the Journal digest of the day it was published through queryPages(). The Commons's own pages name no kind in layers/commons/tenant.config.ts. One page reads other Tenants without the Catalog: the Platform home page, app/pages/index.vue, which features hand-picked Tenants.
tests/unit/expand.spec.ts checks that a Collection without a kind stays out of the Catalog and that an opted-in one gets the contract's fields; tests/unit/kinds.spec.ts checks the contracts.
Why it's built this way
- Opt-in, not open. With no supported way to read across Tenants, a fork built its view of them from a hard-coded list of Tenants and made-up data. Now a Collection is readable across only when its manifest names a kind; open by default was rejected, as it would make privacy the opt-out. The cost: a new view sees only Collections that opted in. ADR-0025 (docs/adr/0025-cross-tenant-catalog-and-collection-kinds.md).
- One home per shape. Copying each Tenant's schema into a reader drifts. The date that orders the Timeline was once each Tenant's own field, so renaming it in one manifest could silently drop that Tenant from the feed. Now the contract holds it once, as a minimum, and each Tenant keeps its private fields. The cost: readers get plain records and cast them. ADR-0025 (docs/adr/0025-cross-tenant-catalog-and-collection-kinds.md).
- Still one table each. Every Collection stays its own table, so a read across Tenants is one query per Collection, merged in code, never a query that spans tables. The cost: each query fetches whole Documents before
projecttrims them, which grows with the Catalog. ADR-0001 (docs/adr/0001-single-container-baked-multitenancy.md). - A person merges the read path. Renaming a kind's field can silently empty a view, and widening the read path can expose content; the sees neither. So shared/kinds.ts, modules/catalog.ts and app/composables/catalog.ts are (Protected paths), while a view that only calls the read functions merges like other code. The cost: even a small fix to the read path waits for a person. ADR-0025 (docs/adr/0025-cross-tenant-catalog-and-collection-kinds.md).
What to watch for
- No test stops a direct read. The tests check what the Catalog lists, not who calls
queryCollection, which reads any key it is given; the home page does so on purpose. Code in one Tenant that names another's key reads it, and only review catches that. - Opting in shows the reader the whole Document. The contract says which fields a reader can count on, not which it can see:
projectreceives every field. Keep a field out of an opted-in Collection if no other Tenant should read it. - The projector is a cast.
projectasserts the types of each row's fields. They are there becausepnpm validate:contentchecked the contract, not because TypeScript knows them.
Try it in your project
If your app keeps tenants apart and one view needs to read across them, name the shape that view needs and put it in one shared file. Let each tenant's config name the shapes it offers, build the list of offered data sets at build time, and route every cross-tenant read through one function that reads only that list.
Where next
- LessonMulti-tenant isolation
- LessonContent collections
- LessonProtected paths
- LessonContent validation
- JournalArchitecture & Deployment
Walkthroughs through this Lesson
- WalkthroughAdding a site
Last updated · Latest source on GitHub ↗