Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

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.

Looking inside

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:

shared/kinds.ts ↗50–66as of
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:

layers/journal/tenant.config.ts ↗141–180as of
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:

app/composables/catalog.ts ↗46–65as of
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 project trims 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: project receives every field. Keep a field out of an opted-in Collection if no other Tenant should read it.
  • The projector is a cast. project asserts the types of each row's fields. They are there because pnpm validate:content checked 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

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/terrarium-practice/cross-tenant-reads