inmake DOCS
Docs / Generative / Data collections

Data collections

The "data-collections" rule fragment — composed into the CRM & Studio agents' prompts and served as drop-core://rules/data-collections.

DATA COLLECTIONS: creating one takes TWO steps. Writing a document to data/{locale}/{collection}/{document} creates the collection in the database, but the admin UI's Data page lists setup/data.collections[] — a curated registry — and nothing else. A collection with documents but no registry row is INVISIBLE to the humans who own the store (it still renders on the published site, which resolves by path). So: after creating a collection that did not exist before, call registerCollection ONCE for it.

The registry row is NOT derivable from the documents — that is why no trigger can write it for you:

  • key — the {collection} path segment (lowercase, digits, underscores).
  • name — what the admin Data page shows a human (e.g. "Recipes").
  • template — the SHARED schemas/{schemaId} that defines the document fields (a template-typed schema; a data-typed one is the same shape for a collection that renders NO page — settings, lookups, feeds — and takes no web template); the same id each document carries in its schema field. Create the schema first; registerCollection refuses a template that does not exist. OMIT it when the collection's documents each own a per-document schema (below).
  • slugs — the collection's public URL segment per locale (e.g. {"cs_CZ": {"slug": "recepty"}}). Distinct from a document's own seo.slug. Resolved at request time (never stored in the slug registry): /public/slug + /public/content {slug} list the collection by it.
  • route — {enabled: true, index?: {collection, document}} turns the segment into a ROUTE: the collection's documents serve at /{segment}/{seo.slug} (canonical; the flat /{seo.slug} keeps working), and index names the listing page rendered at the bare /{segment} when no document owns that slug. Templates link with {{route "{key}" slug}} (readRule("handlebars-binding")). Off by default. Routed URLs resolve through the collection's own ROUTE registry (routes/{key}/slugs/{slug}, written by the platform from each document's seo.slug + seo.aliases), so the SAME slug may exist in several collections; a document's seo.scope decides where its slug lives — both (default: /{slug} AND /{segment}/{slug}), root (flat only) or collection (only under the route — pick this when the slug is taken at the root or the page must not have a flat URL). Never write registry entries yourself — write the document's seo and the platform syncs both registries. CATEGORIES (one level): route.categories: {"<key>": {name?, slugs?: {"<locale>": {slug, aliases?}}, index?: {collection, document}}} adds a /{segment}/{category}/{slug} tier to a routed collection (e.g. /recepty/polevky/gulasova). Keys and segments are lowercase letters, digits and dashes; a category's segment is its locale slug, else its key, and must be unique (aliases included) among the collection's categories per language; its aliases 301 to the primary segment, so renaming a segment keeps old links alive (keep the old one as an alias); index is the page served at the bare /{segment}/{category} (a document owning that slug in the collection wins). A document JOINS a category by its seo.category (ONE key, the category key — never a path or a segment) on each locale copy; it then serves at the category URL (canonical), its slug lives in the category's own registry (routes/{key}/routes/{category}/slugs/{slug} — server-written like the others; never write registry entries yourself), and an unknown key leaves it on the plain /{segment}/{slug}. categories in registerCollection replaces the whole map (send them all); routing off keeps them (they just stop serving). There is NO parent: nested categories are not supported yet. Do not rename or remove a category key that documents still carry — their URLs fall back to /{segment}/{slug} (and only on each document's next save); rename its segment (slugs) instead. Any removal (a key gone from the map, or a kept category whose stored segment is no longer its slug or an alias) is answered with needsConfirmation: true and the removed keys: relay the question to the user in your answer and END the turn — pass confirmRemove: true only in a LATER turn, after the user confirmed in chat, never on the first call and never in the same turn (an agent host ignores the flag until the user has answered, so a self-confirmed call writes nothing). One that documents still carry is refused with the per-locale counts unless force: true, which only the user's explicit request justifies — under the same rule. REDIRECTS: a renamed seo.slug / changed seo.category keeps the old URL as a 301 by itself — never add a redirect for that. Use setRedirect (a manual redirect on an address: target = every language, targets = chosen languages, clear) and retireFormerUrl (drop a renamed page's old URL) ONLY when the user asks; never redirect an address a page lives on unless they explicitly want that page hidden. Verify the result with resolveAddress (read-only; per language status 200 / 301 / 302 / 404). OLD ADDRESSES of another site (a migration — any URL shape, /cz/kampus/x/, /produkt.php): setRedirect({from, target}), one call per address; the target is the new address as the visitor lands on it (with its language prefix when it has one). Such a redirect answers only where the site would otherwise 404.
  • columns — the admin listing's columns, in render order (e.g. ["name", "status", "timestamp"]). Each entry is a built-in (id, schema, status, timestamp), a field key from the template, or a DOTTED path one level into an object field's subvalue (cover.src) — object fields have no one-line cell themselves, so pick the subfield worth showing instead (an image subfield renders as a thumbnail; the header shows just the last segment). Optional: omit for the default columns; prefer the fields a human scans a listing by (name/title, an image, status).
  • keys — the collection's EXTRA QUERY KEYS beyond the built-in ones (QUERY KEYS below): {"<name>": ["<field>", …]}, one per filtered + sorted listing no built-in key serves (e.g. {"status_prep_time": ["status", "prep_time"]}); {"<name>": null} disables a built-in. Omit to keep the current keys.

SHARED VS PER-DOCUMENT SCHEMAS — decide this BEFORE writing a schema. A schema is a shape AND a page design: its web templates live under it (schemas/{schemaId}/templates/{v}), so every document sharing a schema shares its template.

  • MANY documents, ONE repeating shape (articles, recipes, team members): one SHARED schema with a flat id (recipes), registered as the collection's template. Each document carries that id in its schema field — write it yourself when you create documents (the admin stamps it for documents created there).
  • ONE-OFF documents, each with its own content and layout (the home page, About, Contact, a landing page — typically the pages collection): give EACH document its OWN schema, id general/{collection}/{document} (e.g. general/pages/index). PREFER this whenever a shape describes exactly one document. Do NOT create a flat schema per page: flat ids are the reusable templates the admin lists on its Schemas page, and one-per-page turns that list into a pile of single-use entries. Per-document schemas do not appear there — they are edited inside the document itself, which is where their owner looks for them. Register such a collection WITHOUT a template.
  • ONE document of an otherwise uniform collection needs extra fields: give that one document a per-document schema with templateBase = the shared id. It INHERITS the base's fields (kept structurally locked), so fieldsJson lists ONLY the document's own extra fields — never copies of the base's.

writeSchema takes both id forms. Writing a per-document schema also stamps schema: "general/{collection}/{document}" onto that document in every locale (creating it when missing), so the admin editor resolves the right fields. Its templates live at schemas/general/{collection}/{document}/templates/{v} — pass that SAME id to writeWebTemplate as schemaId.

registerCollection is idempotent: call it again to change the name / template / slugs / columns (omitted fields keep their current value). It never creates, moves or modifies a document. products, templates and translations are reserved by the store and cannot be data collections. PRODUCTS in particular are NOT a data collection at all — a sellable product is a BASE commerce document (products/{id}: price, stock, status) PLUS per-locale content documents; readRule("commerce-products") BEFORE creating or restructuring products, or they end up invisible to the admin and unsellable.

ORDERING: the platform has BUILT-IN manual ordering — pass reorder: true to registerCollection and the collection's admin listing grows drag handles; the human's dragged order is saved on the registry row (an id array the admin owns — never write it yourself). While reorder is on, that dragged order is the collection's CANONICAL order: public listings (site render, /content, load-more paging) follow it by default, so the human's drag is all the sequencing a site needs. NEVER invent an ordering field (order, position, sort, weight, …) on a schema so a human can sequence documents, and never mint zero-padded ids as an ordering trick — both re-create by hand what reorder already does end-to-end. Enable reorder for small curated collections whose sequence a human curates (FAQs, testimonials, team, process steps). Set order_by on a listing ONLY when a real content field defines the order (e.g. a date for chronological articles) — an explicit order_by overrides the manual order. order_by may only name a field the schema declares AND every document populates: the platform sorts with Firestore's orderBy, where the sort key doubles as a filter — a document missing the field is silently EXCLUDED, and a field no document carries returns an empty listing with no error.

QUERY KEYS — a listing that FILTERS and SORTS (query equality on some fields plus order_by or a range on another: "articles of category X, newest first") runs on a QUERY KEY on the collection. Firestore indexes neither shape by itself and the platform ships NO composite index for a store's collections — never ask for one. BUILT-IN KEYS: the common shapes need NO declaration. Every registered collection gets keys derived from its template (schema order decides "first"; up to 5, on their own budget — the store's 8 explicit keys are untouched): status_timestamp ([status, timestamp], every collection, also one without a template); status_{d} ([status, d]) for the first two datetime fields d; for the first enum field e: {e}_{d1} and status_{e}_{d1}, where d1 is the first datetime field, else timestamp. Examples: recipes (enum category, no date field) → status_timestamp, category_timestamp, status_category_timestamp; articles with publish_date → status_timestamp, status_publish_date. Nothing for number, boolean or array fields. Products have their own fixed set plus platform composites for category/tag pages (readRule("commerce-products") LISTINGS). NEVER re-declare a built-in; a store disables one with keys: {"<name>": null} and overrides one by declaring the same name. Built-ins backfill lazily — the first listing after they appear schedules the build (building → ready; the query runs natively meanwhile). DECLARE a key only for a shape beyond the built-ins (a number sort like prep_time, a second enum, a boolean filter + sort …), on the registry row: keys: {"<name>": ["<field>", …]}, a NAMED, ORDERED field list ({"status_prep_time": ["status", "prep_time"]}). The platform stamps a computed _k.{name} on every document (never write _k yourself — an incoming _k is stripped and recomputed; it never appears on the public site) and rewrites a matching query onto it. Rule of thumb: EQUALITY on the leading fields, RANGE/SORT on the LAST one — a key [c1, …, cn] serves exactly == c1 … == c(n-1) plus a range and/or order_by (asc or desc) on cn; an extra equality, in, != or array-contains does not match it. Limits: ≤ 8 keys per collection, 1–4 fields each (dotted paths allowed, contact.email), names ^[a-z][a-z0-9_]{0,31}$, never id (the document id is native) and never _k. Store each component in ONE form across the collection (all timestamps, or all ISO strings of one format) — mixing forms splits the order. No key for: equality-only queries, a range/sort on one field with no equality, the reorder manual order. A query no key serves still runs while its equality part matches ≤ 500 documents (sorted in memory); above that the platform answers 400 query_key_required {collection, suggest} — with the built-ins in place this only happens for a shape outside them; declare exactly suggest as a key, do not shrink the listing. Full-text search, facets and tag + sort over large collections are a search index, not a key.

translations is special: it holds the store's localization strings for the {{t}} template helper — one flat document per group at data/{locale}/translations/{group} (public key group.leaf), edited by humans in the admin's dedicated Localizations view. Read/write its group documents directly like any data documents when a template needs a key that does not exist yet; it never gets a registry row or a schema. Two of its groups are SYSTEM groups: tag and category label the product taxonomy (readRule("commerce-products") TAXONOMY) and are edited on the Tags / Categories pages — write them only to label a key you are also putting on a product.

Order that works: create the schema (every reference field scoped with collection_ref — readRule("schema-fields")) → write the documents → registerCollection → lock the shared schema (writeSchema locked: true — readRule("schema-fields") STRUCTURE LOCK) → place it on the Data page: registerCollection groups puts it in a Data page GROUP (setup/data.groups; create the group itself with writeDataGroups — one group per business context, named the way the client talks: the site shell, Blog, Tým, lookups last) → pin what editors maintain daily in the console menu (readRule("console")). If you register first, the Data page shows an empty collection, which is harmless. A build is not finished while its collections sit ungrouped and unpinned, or their shared schemas unlocked.

TAXONOMY — a category, a tag, a type: which model? Decide BEFORE creating a collection for it:

  • a fixed set of values with no page of their own (a status, a type, product badges, blog tags without listing pages) → an ENUM schema + a kind:"enum" field (array of item_type:"enum" for several), labels through a translations group named after the enum (readRule("schema-fields") ENUMS ON THE SITE);
  • categories that need their OWN URL and listing page (/blog/recipes/…) → route.categories on the routed collection + each document's seo.category (one level);
  • product tags / categories → the product TAXONOMY (readRule("commerce-products")), never a data collection;
  • entries with real content of their own — a page, a body, a picture worth a detail, SEO (an author profile, a brand page) → a collection. A collection of a few name-only documents that other documents point at is a picklist in disguise; one document per photo is a gallery in disguise; never classify the same thing twice (an enum AND a reference). Colours and CSS classes in data (bg_color, card_class) → an enum of named variants the template maps to classes and design tokens.

SCHEMA STAMPS — the platform keeps two more server-owned keys on every data document: _dt (which values are dates, derived from the template's datetime fields, plus its reference fields) and _f (the resolved contract of every form placed in the document). The render server localizes dates and validates form submissions from them instead of reading the schema. Like _k: NEVER write _dt / _f (an incoming value is dropped and recomputed; updateStoreDocument drops them and tells you), and the read tools leave them out. A SCHEMA EDIT NEEDS NO FOLLOW-UP: turning a field into datetime, placing or reshaping a form, or changing a template_base / template_ref target re-stamps the affected documents automatically within moments — never re-save documents to "apply" a schema change.

FRESHNESS — every write that changes what a page shows (a data document, a template, an element, setup/web / setup/data / setup/design / setup/elements, the store's locales or name, a slug, a file) is pushed to the hosted site and shows within seconds. Never wait for a cache to expire and never tell the user a change "needs time" — if a hosted page still shows old content after a minute, the store's web-address card has a "Refresh site caches" action.