diff --git a/.changeset/rfc45-step7b-enable.md b/.changeset/rfc45-step7b-enable.md
new file mode 100644
index 00000000..7f791136
--- /dev/null
+++ b/.changeset/rfc45-step7b-enable.md
@@ -0,0 +1,15 @@
+---
+"@cloudhal/core-ts": patch
+"@cloudhal/storage": patch
+"@cloudhal/cloudflare": patch
+"@cloudhal/api": patch
+"@cloudhal/cli": patch
+"@cloudhal/mcp-server": patch
+---
+
+RFC-0045 rollout step 7b: **reference and repeater fields are now enabled.** Sites can define `reference` fields (entries of one to five collections of the same site, one or several, with optional conditions) and `repeater` fields (ordered lists of items with up to ten flat sub-fields) wherever the renderer serving them reads the two types. See [docs/guides/fields.md](https://github.com/cloudhal-dev/cloudhal/blob/main/docs/guides/fields.md).
+
+- **Where.** On Cloudflare, once the site Worker serving the site answers `GET /__cloudhal/features` with `fields: 2` (0.9.0 and later): the admin Worker asks the Worker each site is routed to (`SITE`, or an attached `SITE_n`), as it asks for time gates, and remembers a yes for that Worker only. Time gates are now confirmed per site Worker too, so a site on an older attached Worker no longer inherits the installation Worker's answer. Deploy the site Worker first; until it reports `fields: 2`, the types stay refused with `fields_unsupported` on every path. On Node (`cloudhal dev`, `studio`, self-host, Docker) and in the playground, always, since the renderer runs in the same process (`openLocalStore` passes `FIELDS_SUPPORTED`).
+- **Every write path follows the capability:** Studio, MCP, the API, field operations and their dry runs, and every bundle: `cloudhal push` (the admin route passes the store's capability), `pull`, `cloudhal import` (dry runs included), `--replace` and `--revert`. A bundle that defines either type is still refused whole where the capability does not hold, and a site's new snapshot cannot point at another site's held site body to bring the types in.
+- **MCP and Studio.** `update_collection` and `change_field_type` list both types, and `/api/instance` answers `features.referenceFields: true`, on servers that have the capability. The store still decides per site.
+- **A one-time `entry_refs` check per site** (`rebuildEntryRefs`, new core-ts export): the admin Worker's new `refs.rebuild` system job (every five minutes, offset from `lists.backfill`, until every site is done; then two reads a run), and `openLocalStore` at start-up. Rows are written per entry, anchored on the hash each entry was read with, so a concurrent edit keeps its own rows; each chunk is anchored on the site node's hash, so no statement grows with the site's settings; it leaves a site alone while a text ↔ reference type change runs there; progress is in the new `entry_refs_state` table (storage migration `0034_entry_refs_state`), so a run stopped anywhere resumes. It is a safety net and should find nothing to change.
diff --git a/apps/studio/src/components/fields/useReferenceTypes.ts b/apps/studio/src/components/fields/useReferenceTypes.ts
index fd16cf20..1ed10a10 100644
--- a/apps/studio/src/components/fields/useReferenceTypes.ts
+++ b/apps/studio/src/components/fields/useReferenceTypes.ts
@@ -3,8 +3,9 @@ import { api } from "../../lib/api.ts";
/**
* RFC-0045: whether the server takes reference and repeater fields (`/api/instance`,
- * `features.referenceFields`). Until rollout step 7 production answers no, and the builder offers
- * neither type, so it looks as before; an older server omits it, which reads as no.
+ * `features.referenceFields`): true where the store has the capability (rollout step 7: the admin
+ * Worker, Node and the playground). Without it the builder offers neither type, so it looks as
+ * before; an older server omits it, which reads as no. The store still decides per site.
*/
export function useReferenceTypes(): boolean {
const instance = useQuery({ queryKey: ["instance"], queryFn: api.instance, retry: false });
diff --git a/docs/adr/0046-references-and-repeaters.md b/docs/adr/0046-references-and-repeaters.md
index 14661b21..8489f6e3 100644
--- a/docs/adr/0046-references-and-repeaters.md
+++ b/docs/adr/0046-references-and-repeaters.md
@@ -8,7 +8,7 @@
Collections have typed fields (ADR-0043), but a venue, a speaker or a brand is still plain text retyped on every entry, and a recipe's ingredients are lines of one text field. Two field types fix that, and each needs integrity rules that hold under D1, which has only atomic batches (AGENTS.md rule 4).
## Decision
-1. **Two types.** `reference` (one to five target collections of the same site, one target or up to 50) and `repeater` (up to 50 items of up to ten flat scalar sub-fields; no nesting). Both are refused on every write path until rollout step 7, when the site Worker reports `fields: 2`: definitions with `fields_unsupported`, field operations that name them with `field_operation_unsupported` (until step 4).
+1. **Two types.** `reference` (one to five target collections of the same site, one target or up to 50) and `repeater` (up to 50 items of up to ten flat scalar sub-fields; no nesting). Both were refused on every write path until rollout step 7. Since then they are accepted where the renderer serving the site reads them: on Cloudflare once the site Worker reports `fields: 2`, on Node always (`FIELDS_SUPPORTED`). Elsewhere definitions are refused with `fields_unsupported`, bundles included; an absent capability refuses.
2. **A reference stores a translation group id**, `COALESCE(translation.group, id)`, the one identity shared by every language version in every site. It resolves only through members of the page's own site, in the page's locale, then `localization.fallback`. Membership is that expression everywhere (guards, renderer, purges).
3. **`entry_refs`** holds one row per (holder, field path, stored group id), whatever the target's state, written in the holder's own batch, anchored on its new hash, with a plain `INSERT` (never `OR IGNORE`) that reads `shared` from the site row at batch time. Every guard and lookup filters `site_id`.
4. **Strong where the live site depends.** Only what a published entry shows locks: a delete, a move out of every allowed collection, or a group change of a group's last usable member is refused while a published entry shows a reference to it (`entry_referenced`, `reference_target_moved`). Archiving and unpublishing are always allowed; an unresolved reference renders nothing. Publishing a *site* warns (`referenceIssues`) and never fails; publishing a *referrer* whose own value names a group with no usable member is refused (`unknown_entry`).
diff --git a/docs/guides/fields.md b/docs/guides/fields.md
index 0a724bbe..8113501f 100644
--- a/docs/guides/fields.md
+++ b/docs/guides/fields.md
@@ -2,7 +2,7 @@
A collection can declare **typed fields**: an event has a venue, a start time and a ticket link; a job has a deadline and a salary range. Agents fill them through a JSON Schema instead of guessing, people get a form in Studio, and themes can rely on every value being there in the right shape.
-The design and its reasons are in [RFC-0041](../rfcs/0041-typed-custom-fields.md) and [ADR-0043](../adr/0043-typed-custom-fields.md).
+The design and its reasons are in [RFC-0041](../rfcs/0041-typed-custom-fields.md) and [ADR-0043](../adr/0043-typed-custom-fields.md); references and repeaters in [RFC-0045](../rfcs/0045-collections-stage-4-references-and-repeaters.md) and [ADR-0046](../adr/0046-references-and-repeaters.md).
## What a field is
@@ -19,6 +19,10 @@ Each field has a **key** (lower camelCase, like `startsAt`), **labels** per lang
| `url` | `https:`, `http:`, `mailto:`, `tel:`, a site-relative path or a `#fragment` | a link |
| `image` | an image asset of the same site (`asset_…`) | an ` ` with the asset's alt text in the reader's language |
| `select` | one of the field's options, or a list of them with `multiple` | the options' labels in the reader's language |
+| `reference` | another entry of the same site, by its translation group id (`entry_…`), or a list of them with `multiple` | a link to the target in the reader's language, or nothing while no published version shows |
+| `repeater` | an ordered list of items, each a flat object of sub-fields | a nested list, one item each |
+
+References and repeaters have [their own section](#references-and-repeaters) below.
A field can be **required to publish** (`requiredToPublish`, every type but `boolean`): drafts save without it, but an entry cannot go to review or be published while the value is missing. Text and rich-text fields marked **searchable** add their words to site search from the next publish on (off by default, so internal notes never reach search snippets; see the [search guide](search.md)).
@@ -44,6 +48,74 @@ Adding fields, reordering, editing labels and help, adding options and toggling
Each runs a **dry run** first (`--dry-run`): how many entries change, and which values move, convert or would be cleared. A type change converts values with a fixed table (text to rich text, number, select or link; any of those back to text; date to datetime at midnight in the site's time zone and back); values that do not convert block it unless you opt in to clearing them (`--clear`, `onInvalid: "clear"`). Every operation takes a snapshot first and can be reverted (`cloudhal fields revert `). A long operation resumes where it stopped (`cloudhal fields continue`). Operations change the draft graph only; publish as usual.
+## References and repeaters
+
+### Reference fields
+
+A **reference** points to entries of the same site: an event's venue, a talk's speakers, a product's brand.
+
+- **Targets.** `collections` names one to five collections a target may belong to (declared collections of the site, or ones its entries already use). A target is never the entry itself or another language version of it.
+- **One or several.** Without `multiple` the value is one target. With `multiple: true` it is an ordered list without repeats, of at most `maxItems` targets (20 when absent, 50 at most).
+- **Conditions.** With exactly one target collection, `where` (up to four conditions on that collection's list fields, [RFC-0042](../rfcs/0042-collections-stage-3.md), no `now`) narrows what may be *newly* chosen, as Studio's picker does: "only places with `capacity` ≥ 500". A target that stops meeting it later stays.
+- **What is stored.** The target's **translation group id**: the id of its group's original, the same in every language version and in every language edition of the project. A page in Turkish shows the Turkish version of the venue, else the default language's under `localization.fallback: default-with-notice`, else nothing. When the target has no published version the page can show (a draft, archived, embargoed or hidden entry, or one deleted elsewhere), the field shows nothing: no id, no title, no empty link.
+- **Publishing warns, never fails.** Studio's site publish dialog lists published entries whose references will not resolve, and `cloudhal import --dry-run` reports them for an archive.
+
+### Repeater fields
+
+A **repeater** is an ordered list of items with the same sub-fields: an agenda's sessions, a recipe's ingredients, a job's benefits.
+
+- **Sub-fields.** `fields` holds one to ten sub-fields, each with a key, labels and a type: `text`, `richText`, `number`, `boolean`, `date`, `url`, `image`, a single `select` or a single `reference`. No `datetime`, no multiple select or reference, and no repeater inside a repeater, so every schema agents see has a fixed depth.
+- **Items.** At most `maxItems` items (20 when absent, 50 at most). An item holds only sub-field keys, and an empty item is refused. `requiredToPublish` on a sub-field means every item of an entry in review or published needs it; on the repeater, at least one item.
+- **Item labels.** `itemLabel` names the text, select or reference sub-field that names an item in Studio's legends ("Item 2: Ada Lovelace") and in approval prompts. Absent: the first such sub-field.
+- **Languages.** `localized` is the repeater's and applies to the whole list: each language version has its own list, or only the original holds one. Sub-fields have no `localized` of their own.
+- **Images and rich text** inside items are checked like top-level ones (an image sub-field takes image assets only) and count as uses of the asset.
+
+### Limits
+
+- **100 leaves per collection:** fields plus repeater sub-fields. Top-level fields stay at 30 per collection.
+- **200 per site**, sub-fields included.
+- **100 references per entry**, across fields and repeater items (`too_many_references`).
+
+### The delete lock and "Used by"
+
+A reference holds its target in place while a **published** entry shows it, its own value or one a published translation inherits:
+
+- **Deleting** the target's last version in the site, or **relinking** it into another translation group, is refused with `409 entry_referenced`; **moving** it to a collection the field does not allow, with `409 reference_target_moved`. Both name up to 20 published referrers and the total. Studio's delete dialog lists them before you confirm; drafts that would lose the value are listed too, without blocking. Deleting one language version while another remains is allowed: that language's pages then fall back or show nothing, and the dialog says which.
+- **Archiving or unpublishing** the target is always allowed. It renders nothing from then on, but it does **not** release the lock: the referrers still hold it, so the delete stays refused until they change or are unpublished. Unpublishing a referrer releases its hold at once.
+- Drafts, entries in review and archived entries lock nothing on their own. A contributor, who cannot publish, therefore never pins anyone's entry.
+- **"Used by"** on an entry's page in Studio lists the entries that reference it, published or not, with the field. Agents use `list_references` (`direction: "to"` or `"from"`).
+
+### Field operations on references and repeaters
+
+The four operations above work on both types, and take `sub` to work on one sub-field of a repeater (`cloudhal fields rename events speakers who --sub person` renames the sub-field `person` of `speakers` to `who`; in Studio, pick the sub-field in the operation's dialog):
+
+- **Rename and remove** a sub-field. Removing a repeater's only sub-field is refused: remove the repeater instead. `set_field_localized` takes no `sub`, since sub-fields share their repeater's setting.
+- **Change type** has these conversions besides RFC-0041's table:
+ - text ↔ reference: a text value becomes the target whose title it matches in the field's collections (exactly one, not the entry itself); a reference becomes its target's title;
+ - reference single ↔ multiple, and reference (multiple) ↔ repeater with one reference sub-field;
+ - a repeater with exactly one text, rich-text or reference sub-field → text (one line per item) or reference (multiple);
+ - a sub-field's type (`--sub`), with `--collections` for a new reference sub-field.
+
+ A conversion that carries ids over may only widen the target collections. The CLI takes `--collections a,b`, `--max-items n` and `--sub-fields file.json` (a JSON list of sub-field definitions).
+- **While a conversion into references runs**, deletes, moves and relinks of entries in its target collections wait (`409 field_operation_running`), so no value it converts can lose its target.
+- **"Clear and finish."** A value can stop converting while an operation runs (its target was archived, or a second entry took the same title). The operation then stops with those entries listed as unconvertible and keeps its marker. Studio's dialog offers **Clear and finish** behind a confirmation; the CLI's `continue` and `revert` take `--clear`, the API and MCP `onInvalid: "clear"`. It drops only what no longer converts.
+
+### Agents (MCP)
+
+- **Schemas.** `list_collections` returns `fieldsSchema` (references as `entry_…` strings, repeaters as arrays of flat objects; no `$ref`, depth at most 4) and `fieldsSchemaStrict` for providers' strict modes, with `strictCost` (`properties`, `depth`, `enumValues`, `unions`) so an agent can tell when to send a subset. Each property of the strict schema stands alone, so splitting it is safe.
+- **Writing.** `create_entry` and `update_entry` take `fields` (a merge patch: `null` removes a key) or **`fieldsStrict`** (strict output: `null` means "leave as it is", and inside an item "unset"), with **`clearFields`** for removals. A given reference or repeater value replaces the whole value. Items left empty by `null`s are dropped and reported (`droppedItems`). The prechecks run the store's own checks, and approval prompts name targets readably (`venue → "Main hall" (entry_…, Places)`).
+- **Translations.** Item paths are `key/index/sub` (`speakers/0/talk`). `get_translation_source` returns `sourceRev`, which `save_translation` needs with item paths. Once a translation holds its own list for a repeater, item paths into it are refused (`translation_shape_mismatch`): `get_translation_source` then returns the source's whole list under the repeater's key in `fields` and names it in **`wholeLists`**. Translate its text and rich-text sub-values and send the whole list back, in the source's order. A whole list sent alone is the translation's own and is stored as sent. References and other sub-values are not translated.
+- **`update_collection`** and **`change_field_type`** list both types where the server can render them; the store still decides per site (below).
+
+### Where they are available
+
+Both types are accepted on every write path (Studio, MCP, the API, `cloudhal fields`, imports, `push`, `pull`, `cloudhal import --replace` and `--revert`) wherever the renderer serving the site reads them:
+
+- **Cloudflare:** once the site Worker serving that site answers `GET /__cloudhal/features` with `fields: 2` (0.9.0 and later). With several site Workers (an attached theme, [multi-site](multi-site.md)), each is asked for the sites it serves, so a site on an older attached Worker keeps refusing while the others accept. Deploy the site Worker first; until it reports it, the admin Worker refuses both types for its sites with `fields_unsupported`, and a push or archive that defines them is refused whole.
+- **Node** (`cloudhal dev`, `studio`, self-host, Docker): always, since the renderer runs in the same process.
+
+Upgrading runs a one-time check of every site's reference index (`refs.rebuild`, every five minutes on the admin Worker until each site is done; at start-up on Node). It is a safety net and should find nothing to change. It leaves a site alone while a text ↔ reference type change runs there, and checks it once the change is done.
+
## Languages
Each field decides once whether it is **localized**:
@@ -80,6 +152,17 @@ It draws a plain definition list of the fields that have a value, in definition
It has no styles of its own. Style `.ch-fields`, `.ch-field[data-type="…"]` or one field with `[data-field=""]`. Multi-line text fields carry `data-multiline` (give them `white-space: pre-line`), and values read from the original carry `data-from-original`.
+A **reference** is a link to each target the page shows, by its graph path (localized like every entry link, with `lang` on a card from the fallback language), several in a `ul.ch-references`. A **repeater** is an `ol.ch-items` with one nested `dl` per item that has something to show:
+
+```html
+
+
+```
+
+Themes from v0.5.0 on draw both with no change of their own.
+
### Your own markup: `entryFields`
`source.entryFields(entry, options)` (or `entryFields(source, entry, options)` from `@cloudhal/astro-renderer/fields`, for any source) returns every defined field by key, in definition order, ready to draw:
@@ -94,8 +177,12 @@ It has no styles of its own. Style `.ch-fields`, `.ch-field[data-type="…"]` or
| `image` | `image` only: ` ` attributes (`src`, `srcset`, `sizes`, `width`, `height`, `alt`, …) and `assetId` |
| `datetime` | `date` and `datetime`: the machine-readable value for `` |
| `value`, `def`, `fromOriginal` | the resolved value, its definition, and whether it came from the original |
+| `references` | `reference` only: the targets the page shows, in stored order, as cards (`title`, `path`, `url`, `summary`, `image`, `collection`, `locale`, `fromFallbackLocale`) |
+| `items` | `repeater` only: each item's sub-fields, rendered like fields |
+
+Options: `locale` (default: the entry's), `media` (the page's media context: assets, image transforms, placeholders), `timeZone` (show every `datetime` in one zone instead of its own), `words` (`{ yes, no }`), `sizes` (for image fields), `depth` (`1`, the default: reference cards; `2`: each card's own `fields` too, with *their* references as cards without fields).
-Options: `locale` (default: the entry's), `media` (the page's media context: assets, image transforms, placeholders), `timeZone` (show every `datetime` in one zone instead of its own), `words` (`{ yes, no }`), `sizes` (for image fields).
+A page resolves at most 200 targets (`placeholderBudget().references`); pass one `placeholderBudget()` per request in `media.budget`, as the starter does. For "events at this venue", `source.referencedBy?.(groupId, { collection, field, limit })` lists the entries the page's view shows whose values name the group, newest first; its page is purged when such an entry appears, goes or changes. Drawing *those* entries' own fields is outside that guarantee.
```astro
---
@@ -121,4 +208,5 @@ From `@cloudhal/astro-renderer` (re-exported from `@cloudhal/core-ts/fields`):
- **Links are checked twice:** on write (`https:`, `http:`, `mailto:`, `tel:`, relative, fragment) and again on render, so `javascript:` and `data:` links never reach a page; an unsafe stored value is dropped.
- **Images** must be image assets of the same site, so a value can never point at another site's media or an outside URL, and a used asset cannot be deleted.
- **Agents** see field values as data. Definitions cannot carry regular expressions, and every definition change and field operation waits for a person.
-- **Limits:** 30 fields per collection, 200 per site, 256 KB of values per entry, 50 options per select.
+- **References stay inside the site.** A value names a translation group, and resolves and locks only through members of the same site, so no value can reach or pin another tenant's entry.
+- **Limits:** 30 fields per collection (100 with repeater sub-fields), 200 per site, 256 KB of values per entry, 50 options per select, 100 references per entry.
diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md
index 2287660f..92b396ea 100644
--- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md
+++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md
@@ -1454,3 +1454,23 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r
- **Held rows follow their holder's status** (PR #221 review). A row a person's write holds kept the `source_status` of its last insert, since the writer leaves it out of its `DELETE` and `INSERT`, while the delete guard reads that column. The write now sets the held pairs' `source_status` to the entry's new status in the same batch, anchored on its new hash (`references/held-status.ts`), for reshapes and text ↔ reference conversions alike; the rebuild writes the same value.
- **`sourceRev` with whole lists.** A given `sourceRev` is checked for a whole repeater list too (`conflict` when the source moved on), so a list read before the source changed is not linked to the new revision. It stays optional there.
- **The length rule, qualified.** Amended in 5 said a whole list of another length than the source's is refused. That holds only when item paths come in the same call, where the paths index into the given list (`baseList`). A whole list sent alone is the translation's own, like any other localized value, and is stored as sent. §3's "only into a list of the original's length" describes item paths.
+
+### Amended in 7b (2026-11-13, rollout step 7, second PR: enable)
+
+- **Where the capability comes from, per production caller** (§1):
+ - the admin Worker's store: the `fields: 2` of the site Worker serving that site, through `siteSupport` (`cloudflare/src/features.ts`). It builds both `GateSupport` and `FieldTypeSupport`, asks the binding that serves the site (`siteWorkerFor`: the site's `CLOUDHAL_SITE_ROUTES` binding, else `SITE`, RFC-0034 §4), and remembers each answer per binding as a plain value (`SiteConfirmations`). A site Worker older than step 3 answers no `fields`, so the types stay refused for the sites it serves, bundles included; a failed probe is refused and not remembered. *Review round 1:* the first version asked `SITE` alone and kept one isolate-wide yes, so a site on an older attached Worker accepted the types; time gates had the same shape and are fixed the same way;
+ - the admin push route (`PUT /api/admin/sites/:id/bundle`): the store's own capability (`fieldTypes: store.fieldTypes`), so a push follows the same answer as Studio and MCP;
+ - Node: `openLocalStore` passes `FIELDS_SUPPORTED`, so `cloudhal dev`, `studio`, `mcp`, self-host and Docker, and every CLI command that opens the local store, accept them. `cloudhal pull`, `cloudhal import` (and its dry run, also against the in-memory store a new project plans with) and `--revert` pass the local store's capability to `importSite`/`planImport`;
+ - the playground: `FIELDS_SUPPORTED`, because the session's site is rendered by the same Worker's renderer (`options.render`);
+ - no capability, deliberately: the scheduler's stores (release runs and approval checks never write site definitions), the site Worker's own read store, and the release purge planner (read-only). The types stay refused there if a path ever writes definitions through them.
+- **`FIELD_TYPES` stays the nine types** of RFC-0041. Studio and MCP already offer `REFERENCE_FIELD_TYPES` beside them where the capability is present (6b, step 5); no list grows.
+- **The one-time job** (§2.4) is `rebuildEntryRefs` (`core-ts/src/references/rebuild-job.ts`):
+ - **Leaves a site alone while a text ↔ reference conversion runs** (`operation_running`, cursor unmoved; *review round 1*). There the rows decide which values phase B or a person's write accepted (Amended in 4, round 3), which no rebuild from bodies can know: the import rules would delete an accepted row (text → reference) or bring one back for a value already text (reference → text). Renames and reshapes read the same through `rebuildRows` as the writer, so they do not wait.
+ - **Per node, in a fixed number of statements a chunk.** Instead of a `DELETE` and an `INSERT` per node, a chunk of up to 100 entries writes one `DELETE … WHERE source_id IN (SELECT n.id FROM json_each(:pins) JOIN nodes n ON n.id = $.n AND n.hash = $.x …)` and `INSERT … SELECT … FROM json_each(:rows) JOIN nodes n ON n.id = $.n AND n.hash = $.x`, each JSON item carrying its node's read hash. That is the same anchoring as one pair per node (a node whose hash moved matches nothing in either statement), but a chunk costs the site row, the entries, the `DELETE`, its `INSERT`s below 90 KB and the advance, so it fits the free tick (40 statements).
+ - **The site anchor, not the collections text** (*review round 1*). Every statement of a chunk, the advance included, carries `EXISTS (SELECT 1 FROM nodes WHERE id = ? AND kind = 'site' AND hash = ?)` with the site node's hash read with the chunk. §2.4 asked for the collections anchor; binding `$.collections` (up to 256 KB) to every statement broke the 90 KB-per-statement rule and, on Workers Free, kept a site with a large `collections` from ever finishing. The hash is 64 characters, any definitions or marker change moves it (so does any other site edit, which costs a retry), and one entry's rows (at most 100 references, about 25 KB) always fit one `INSERT`, so every run on Free makes progress.
+ - **Progress** lives in a new table, `entry_refs_state (site_id, cursor, done, updated_at)` (storage migration `0034_entry_refs_state`, in `SITE_TABLES`). The cursor is the entry's `nodes.key` (the `nodes_site_kind_key` index). The advance is an upsert anchored like the rest of the chunk and guarded on the cursor as read, so a second runner never moves it back; when the anchor fails, every statement of the chunk is a no-op and the chunk is read again.
+ - **The last chunk** removes orphans (`source_id NOT IN (SELECT id FROM nodes WHERE site_id = ?)`) and marks the site done; when that does not fit the statement budget, the chunk advances without it and an empty chunk closes the site. A site marked done is never read again; a site created later gets its own run.
+ - **Wiring**: the admin Worker's `refs.rebuild` system job (every five minutes at :02:30 and so on, between `lists.backfill`'s marks; 12 statements a run on Workers Free, 150 on Paid), and `openLocalStore` at start-up, without a budget, after `rebuildListValues`. `lists.backfill` has no CLI command and no admin route, so neither has this job, and no API route was added (nothing to classify in `playground.ts`).
+ - **Accepted:** on Workers Free, while a site still has work, the job's statements can move another due job of the same tick (`comments.hourly`) to the next minute's tick; once every site is done it costs its two discovery reads.
+- **The step-1 watch note** (snapshot site bodies are content-addressed and shared): already closed by `heldSiteBodies` (`sync-plan.ts`), which reads the held site bodies a bundle's *new* snapshots name and runs the gate over them for the importing site's capability. A test now covers it: a site without the capability cannot point a new snapshot at a capable site's held body, whether the importer has no capability or one for the other site only (`testing-gates-fields.ts`).
+- **MCP tool docs** are regenerated from the full tool list (`defineTools({ referenceTypes: true })`); the README table, which shows first sentences only, does not change.
diff --git a/packages/api/src/field-operations.ts b/packages/api/src/field-operations.ts
index aea263a7..781f1310 100644
--- a/packages/api/src/field-operations.ts
+++ b/packages/api/src/field-operations.ts
@@ -71,7 +71,7 @@ function stage4Of(b: Body) {
/**
* The request body as an operation on `collection`; 400 for a malformed one. Reference and
- * repeater targets parse; the store refuses them (`fields_unsupported`) until RFC-0045 step 7.
+ * repeater targets parse; the store refuses them (`fields_unsupported`) without its capability (RFC-0045 §1).
*/
export function operationOf(collection: string, b: Body): FieldOperation {
const op = b.op;
diff --git a/packages/api/src/routes/admin/sync.ts b/packages/api/src/routes/admin/sync.ts
index e6afcc01..709e4d9a 100644
--- a/packages/api/src/routes/admin/sync.ts
+++ b/packages/api/src/routes/admin/sync.ts
@@ -142,6 +142,8 @@ export function syncAdminRoutes(deps: AdminDeps): Hono {
// Publishing is Studio's (an approved publish or rollback); a push never moves it back.
published: "target",
gates: { enforced: (siteId) => store.gatesEnforced(siteId) },
+ // RFC-0045 §1: the types only where the store's renderer reads them (`fields: 2`).
+ fieldTypes: store.fieldTypes,
});
const after = await store.getSite(bundle.siteId);
// A replaced site may point production elsewhere: purge the edge cache like a publish.
diff --git a/packages/api/test/admin.test.ts b/packages/api/test/admin.test.ts
index 9cbedb2a..f4a86a45 100644
--- a/packages/api/test/admin.test.ts
+++ b/packages/api/test/admin.test.ts
@@ -7,6 +7,7 @@ import {
SiteGraphStore,
seedStarterSite,
} from "@cloudhal/core-ts";
+import type { FieldTypeSupport } from "@cloudhal/core-ts/fields";
import { makePng } from "@cloudhal/core-ts/media/fixtures";
import { MemoryBlobStore } from "@cloudhal/storage";
import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite";
@@ -16,10 +17,10 @@ import { createApi } from "../src/app.ts";
const TOKEN = "deploy-token-for-tests-0123456789abcdef";
const human = { kind: "human" as const, id: "user_admin000001", name: "Halil" };
-async function backend() {
+async function backend(fieldTypes?: FieldTypeSupport) {
const sql = await BetterSqlite3Driver.open(":memory:");
const blobs = new MemoryBlobStore();
- const store = new SiteGraphStore({ sql, blobs });
+ const store = new SiteGraphStore({ sql, blobs, ...(fieldTypes ? { fieldTypes } : {}) });
await store.init();
return { sql, blobs, store };
}
@@ -277,7 +278,7 @@ describe("admin bundle endpoints", () => {
expect((await put(bundle, "?mode=yolo")).status).toBe(400);
});
- it("refuses a pushed reference or repeater definition with 409 fields_unsupported (RFC-0045)", async () => {
+ it("refuses a pushed reference or repeater definition with 409 fields_unsupported without the capability (RFC-0045)", async () => {
const a = await backend();
const b = await backend();
const site = await seedStarterSite(a.store, { name: "Pushed", slug: "pushed" });
@@ -322,6 +323,26 @@ describe("admin bundle endpoints", () => {
]);
}
expect(await b.store.getSite(site.id)).toBeUndefined();
+
+ // rollout step 7: a store whose renderer reads them (`fields: 2`) takes the same push,
+ // per site: another site's capability does not count
+ for (const [support, status] of [
+ [{ supported: (id: string) => id !== site.id }, 409],
+ [{ supported: (id: string) => id === site.id }, 200],
+ ] as const) {
+ const c = await backend(support);
+ const capable = createApi({ store: c.store, admin: { deployToken: TOKEN, storage: c } });
+ const res = await capable.request(`/api/admin/sites/${site.id}/bundle`, {
+ method: "PUT",
+ headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
+ body: JSON.stringify(bundle),
+ });
+ expect(res.status, await res.clone().text()).toBe(status);
+ if (status === 200)
+ expect((await c.store.getSite(site.id))?.collections?.events?.fields?.[0]?.type).toBe(
+ "reference",
+ );
+ }
});
});
diff --git a/packages/api/test/studio-endpoints.test.ts b/packages/api/test/studio-endpoints.test.ts
index 0cd97829..f8ca939d 100644
--- a/packages/api/test/studio-endpoints.test.ts
+++ b/packages/api/test/studio-endpoints.test.ts
@@ -560,7 +560,7 @@ describe("instance and skills", () => {
publishNow: false,
limits: { maxItems: 100, horizonDays: 366, requestHorizonDays: 30, lateMinutes: 60 },
},
- // RFC-0045: a store without the field-type capability (production until step 7)
+ // RFC-0045: a store without the field-type capability
referenceFields: false,
// RFC-0006 §8: a server without analytics says so
analytics: {
diff --git a/packages/astro-renderer/README.md b/packages/astro-renderer/README.md
index bad3d1a0..c970a507 100644
--- a/packages/astro-renderer/README.md
+++ b/packages/astro-renderer/README.md
@@ -70,7 +70,7 @@ Guide: [`docs/guides/structured-data.md`](../../docs/guides/structured-data.md).
Derived gates: `unlist` keeps an entry's page (200), comments and menu links but leaves `entries()`, `allEntries()`, `entriesByFilter`/`entriesByTerm` (archives, related), feeds, the sitemap and search; `hide` is a 404 like an expired `availability`. Both sources apply them, the gate index caps the edge cache at every derived instant and at every value instant of date/datetime list fields (for `now` conditions), and previews ignore them with an "Ended on …" notice. The site Worker's `GET /__cloudhal/features` answers `{ "gates": 2 }`; writers refuse `expiry` until it does.
### References and repeaters (RFC-0045, rollout step 3)
-Additive, and dormant until rollout step 7 lets sites define the two types. `entryFields` (and `source.entryFields`) resolve a `reference` to cards of the targets the page's *view* shows (gates applied at its instant; draft, archived, embargoed and hidden targets resolve to nothing and leave no id, title or empty item) and a `repeater` to its items. `EntryFields.astro` draws both, so old theme copies show them with no change of their own (`theme-v0.8.0` in the compat test).
+Additive. Sites define the two types since rollout step 7, wherever the site Worker reports `fields: 2` (Node always). `entryFields` (and `source.entryFields`) resolve a `reference` to cards of the targets the page's *view* shows (gates applied at its instant; draft, archived, embargoed and hidden targets resolve to nothing and leave no id, title or empty item) and a `repeater` to its items. `EntryFields.astro` draws both, so old theme copies show them with no change of their own (`theme-v0.8.0` in the compat test).
| Export | Signature |
|---|---|
diff --git a/packages/astro-renderer/test/references-fixture.ts b/packages/astro-renderer/test/references-fixture.ts
index db56d6ef..4b45927e 100644
--- a/packages/astro-renderer/test/references-fixture.ts
+++ b/packages/astro-renderer/test/references-fixture.ts
@@ -1,6 +1,6 @@
/**
* RFC-0045 step 3 renderer fixture: a site with references and repeaters, enabled on a store of
- * its own (`SiteGraphStoreOptions.fieldTypes`, as the shared suites do; production refuses them).
+ * its own (`SiteGraphStoreOptions.fieldTypes`, as the shared suites do).
* The store's clock is `T − 24h`; views are taken at `T`.
*
* - `places` (no fields): `hall` (en, with a published Turkish member `salon`), `stage` (en
diff --git a/packages/cli/src/commands/fields.ts b/packages/cli/src/commands/fields.ts
index e017c2aa..74d9148a 100644
--- a/packages/cli/src/commands/fields.ts
+++ b/packages/cli/src/commands/fields.ts
@@ -135,7 +135,7 @@ export function parseFieldsArgs(
};
case "retype": {
need(a, b);
- // The store refuses reference and repeater targets until RFC-0045 rollout step 7.
+ // The store refuses reference and repeater targets without its capability (RFC-0045 §1).
if (!ALL_FIELD_TYPES.includes(b as FieldType))
throw new Error(`the type is one of ${ALL_FIELD_TYPES.join(", ")}`);
const op: FieldOperation = {
diff --git a/packages/cli/src/commands/import-apply.ts b/packages/cli/src/commands/import-apply.ts
index cc0fb565..df85b749 100644
--- a/packages/cli/src/commands/import-apply.ts
+++ b/packages/cli/src/commands/import-apply.ts
@@ -129,7 +129,11 @@ export async function applyImport(
? await take(local, siteId, options.bracket.before)
: found.snapshot;
if (before) options.onBefore?.(before);
- const result = await importSite(local, bundle, { mode: options.mode });
+ const result = await importSite(local, bundle, {
+ mode: options.mode,
+ // RFC-0045 §1: the local renderer reads reference and repeater fields (`openLocalStore`).
+ fieldTypes: local.store.fieldTypes,
+ });
const after =
before && options.bracket
? await take(local, siteId, options.bracket.after(before.id))
diff --git a/packages/cli/src/commands/import-revert.ts b/packages/cli/src/commands/import-revert.ts
index 07cd844a..a29a9273 100644
--- a/packages/cli/src/commands/import-revert.ts
+++ b/packages/cli/src/commands/import-revert.ts
@@ -105,7 +105,13 @@ async function planRevert(
bundle: SiteBundle,
): Promise<{ plan: ImportPlan } | { refusal: string }> {
try {
- return { plan: await planImport(local, bundle, { mode: "replace", report: true }) };
+ return {
+ plan: await planImport(local, bundle, {
+ mode: "replace",
+ report: true,
+ fieldTypes: local.store.fieldTypes,
+ }),
+ };
} catch (e) {
const refusal = hostRefusal(e, "the snapshot's");
if (refusal === undefined) throw e;
diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts
index 5cd7220c..7d734e25 100644
--- a/packages/cli/src/commands/import.ts
+++ b/packages/cli/src/commands/import.ts
@@ -57,6 +57,7 @@ import {
planImport,
type SiteBundle,
} from "@cloudhal/core-ts";
+import { FIELDS_SUPPORTED } from "@cloudhal/core-ts/fields";
import { type LocalStore, openLocalStore } from "@cloudhal/core-ts/local";
import { migrate, migrations } from "@cloudhal/storage";
import { FsBlobStore } from "@cloudhal/storage/fs";
@@ -200,13 +201,24 @@ async function planLocally(
try {
if (run.storeExists) {
run.state.local = await openLocalStore(run.dataDir);
- return { plan: await planImport(run.state.local, bundle, options) };
+ return {
+ plan: await planImport(run.state.local, bundle, {
+ ...options,
+ fieldTypes: run.state.local.store.fieldTypes,
+ }),
+ };
}
const sql = await BetterSqlite3Driver.open(":memory:");
try {
await migrate(sql, migrations);
const blobs = new FsBlobStore(path.join(run.dataDir, "blobs"));
- return { plan: await planImport({ sql, blobs }, bundle, options) };
+ // The store this import would create reads reference and repeater fields (RFC-0045 §1).
+ return {
+ plan: await planImport({ sql, blobs }, bundle, {
+ ...options,
+ fieldTypes: FIELDS_SUPPORTED,
+ }),
+ };
} finally {
await sql.close();
}
diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts
index 464cc458..560dd5e7 100644
--- a/packages/cli/src/commands/sync.ts
+++ b/packages/cli/src/commands/sync.ts
@@ -201,6 +201,8 @@ export async function pullSite(options: SyncOptions): Promise<{ message: string
const result = await importSite(local, bundle, {
mode: options.replace ? "replace" : "bootstrap",
log,
+ // RFC-0045 §1: the local renderer reads reference and repeater fields (`openLocalStore`).
+ fieldTypes: local.store.fieldTypes,
});
const message = JSON.stringify(result);
log(`pulled: ${message}`);
diff --git a/packages/cli/test/cloudflare-gates.test.ts b/packages/cli/test/cloudflare-gates.test.ts
index d7ce1c39..284fbf20 100644
--- a/packages/cli/test/cloudflare-gates.test.ts
+++ b/packages/cli/test/cloudflare-gates.test.ts
@@ -19,6 +19,9 @@
* REST once it has asked.
* - **Writer guard (S15).** Against an "old" site Worker (no features endpoint), the admin Worker
* refuses `availability` in REST writes and bundle pushes with `409 gates_unsupported`.
+ * - **Field types (RFC-0045 §1, rollout step 7).** The admin Worker takes a pushed collection with
+ * a reference field once the site Worker reports `fields: 2`, and refuses it against the old one
+ * with `409 fields_unsupported`.
*
* Comments and analytics endpoints are off in these Workers (no key, no dataset); their gate
* checks run in the renderer sweep (`entryById`, `publishedNode`).
@@ -29,10 +32,13 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Site } from "@cloudhal/core-contracts";
import {
+ canonicalJson,
copySite,
exportSite,
GATES_ENFORCED,
mintPreviewToken,
+ nodeHash,
+ type SiteBundle,
SiteGraphStore,
} from "@cloudhal/core-ts";
import { openLocalStore } from "@cloudhal/core-ts/local";
@@ -130,10 +136,33 @@ const pushBundle = (
body: JSON.stringify(bundle),
});
+/** `bundle` with two RFC-0045 collections added to its site: places, and events naming them. */
+async function withReferences(bundle: SiteBundle): Promise {
+ const copy = structuredClone(bundle);
+ const row = copy.nodes.find((n) => n.kind === "site");
+ if (!row) throw new Error("no site row");
+ const site = JSON.parse(row.body) as Site;
+ const venue = {
+ key: "venue",
+ type: "reference",
+ labels: { en: { label: "Venue" } },
+ localized: false,
+ collections: ["venues"],
+ };
+ const body = {
+ ...site,
+ collections: { ...site.collections, venues: {}, gigs: { fields: [venue] } },
+ } as Site;
+ row.body = canonicalJson(body);
+ row.hash = await nodeHash(body);
+ return copy;
+}
+
describe("time gates on workerd: the starter's routes, the cache cap, the handshake", () => {
let p: Awaited>;
let harness: Harness;
let remote: SiteGraphStore;
+ let target: { sql: D1Driver; blobs: R2BlobStore };
let fx: GatesFixture;
let dx: DerivedGatesFixture;
let snapshotId: string;
@@ -167,7 +196,7 @@ describe("time gates on workerd: the starter's routes, the cache cap, the handsh
});
await harness.listen();
const env = (await harness.getWorker(p.W.site).getEnv()) as { DB: D1Database; BLOBS: R2Bucket };
- const target = { sql: new D1Driver(env.DB), blobs: new R2BlobStore(env.BLOBS) };
+ target = { sql: new D1Driver(env.DB), blobs: new R2BlobStore(env.BLOBS) };
await migrate(target.sql, migrations);
const local = await openLocalStore(path.join(p.projectDir, p.cfg.dataDir));
await copySite(local, target, p.siteId);
@@ -422,13 +451,22 @@ describe("time gates on workerd: the starter's routes, the cache cap, the handsh
expect(bad.status).toBe(400);
expect(((await bad.json()) as { error: string }).error).toBe("invalid_availability");
});
+
+ it("takes reference fields in a push once the site Worker reports fields 2 (RFC-0045)", async () => {
+ const bundle = await withReferences(await exportSite(target, p.siteId));
+ const pushed = await pushBundle(adminFetch, p.siteId, bundle, "replace");
+ expect(pushed.status, await pushed.clone().text()).toBe(200);
+ expect((await remote.getSite(p.siteId))?.collections?.gigs?.fields?.[0]?.type).toBe(
+ "reference",
+ );
+ });
});
describe("time gates on workerd: an old site Worker (S15)", () => {
let p: Awaited>;
let harness: Harness;
let gatedBundle: unknown;
- let plainBundle: unknown;
+ let plainBundle: SiteBundle;
const adminFetch = (route: string, init?: RequestInit) =>
harness
@@ -507,4 +545,10 @@ describe("time gates on workerd: an old site Worker (S15)", () => {
});
expect(retitle.status, await retitle.clone().text()).toBe(200);
});
+
+ it("refuses reference fields in pushes with 409 fields_unsupported (RFC-0045 §1)", async () => {
+ const refused = await pushBundle(adminFetch, p.siteId, await withReferences(plainBundle));
+ expect(refused.status, await refused.clone().text()).toBe(409);
+ expect(((await refused.json()) as { error: string }).error).toBe("fields_unsupported");
+ });
});
diff --git a/packages/cli/test/fields-cli.test.ts b/packages/cli/test/fields-cli.test.ts
index f5520c95..f82e7e3f 100644
--- a/packages/cli/test/fields-cli.test.ts
+++ b/packages/cli/test/fields-cli.test.ts
@@ -137,14 +137,15 @@ describe("cloudhal fields (RFC-0041 §6)", () => {
});
});
- it("reads --sub-fields from a file; the local store still refuses the types (step 7)", async () => {
+ it("reads --sub-fields from a file; the local store takes the types (RFC-0045 step 7)", async () => {
await writeFile(
path.join(projectDir, "subs.json"),
JSON.stringify([{ key: "line", type: "text", labels: { en: { label: "Line" } } }]),
);
const cwd = vi.spyOn(process, "cwd").mockReturnValue(projectDir);
- await expect(
- main([
+ const out = log();
+ expect(
+ await main([
"fields",
"retype",
"events",
@@ -154,7 +155,9 @@ describe("cloudhal fields (RFC-0041 §6)", () => {
"subs.json",
"--dry-run",
]),
- ).rejects.toThrow(/not enabled on this server/);
+ ).toBe(0);
+ expect(lines(out)[0]).toContain("change_field_type events.venue: would touch 1 of 1 entries");
+ out.mockRestore();
await writeFile(path.join(projectDir, "bad.json"), JSON.stringify([1, "x"]));
await expect(
main(["fields", "retype", "events", "venue", "repeater", "--sub-fields", "bad.json"]),
diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts
index 48559a52..c52bd003 100644
--- a/packages/cloudflare/src/admin-worker.ts
+++ b/packages/cloudflare/src/admin-worker.ts
@@ -66,7 +66,7 @@ import { R2BlobStore } from "@cloudhal/storage/r2";
import { type BrowserBinding, browserRenderingRenderer } from "./browser-render.ts";
import { type BuildRoomsEnv, buildRoomClass, createDurableBuildHub } from "./build-rooms.ts";
import { adminComments, type CommentsEnv, commentsOn, flushCommentPurges } from "./comments.ts";
-import { siteGateSupport } from "./features.ts";
+import { featureRoute, type SiteConfirmations, siteSupport } from "./features.ts";
import { adminMail, type MailEnv } from "./mail.ts";
import { purgeSiteCache } from "./purge.ts";
import {
@@ -343,8 +343,8 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) {
let migrated = false;
/** The request backstop's isolate state (RFC-0008 §8.6); plain numbers. */
const backstop: BackstopState = { lastCheck: 0, lastRun: 0 };
- /** The gates version the site Worker confirmed (RFC-0008 §7.7, RFC-0042 §3.6); 0: none yet. */
- let gatesConfirmed = 0;
+ /** What the site Worker confirmed: gates (RFC-0008 §7.7, RFC-0042 §3.6), fields (RFC-0045). */
+ const confirmed: SiteConfirmations = { gates: {}, fields: {} };
const ensureMigrated = async (sql: SqlDriver) => {
if (migrated) return;
try {
@@ -439,18 +439,9 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) {
blobs,
blocks,
...storeExtras(options, env, sql),
- // S15: `availability` is refused until the site Worker says it enforces gates.
- gates: siteGateSupport({
- fetch: siteBinding
- ? (u, init) => siteBinding.fetch(u, init as never) as unknown as Promise
- : undefined,
- token: purgeToken,
- confirmed: () => gatesConfirmed,
- remember: (version) => {
- gatesConfirmed = version;
- },
- log,
- }),
+ // S15 and RFC-0045 §1: `availability`, and reference and repeater fields, are refused
+ // until the site Worker serving the site says it enforces gates and reads those fields.
+ ...siteSupport({ route: (id) => featureRoute(env, id), token: purgeToken, confirmed, log }),
onPublished: async (site, change) => {
const binding = routedSite(env);
if (!binding || !env.CLOUDHAL_PURGE_TOKEN) return;
diff --git a/packages/cloudflare/src/entry-refs-job.ts b/packages/cloudflare/src/entry-refs-job.ts
new file mode 100644
index 00000000..f4d48e5f
--- /dev/null
+++ b/packages/cloudflare/src/entry-refs-job.ts
@@ -0,0 +1,46 @@
+/**
+ * RFC-0045 §2.4, rollout step 7: the admin Worker's `refs.rebuild` system job. It runs the
+ * one-time per-site `entry_refs` rebuild (`rebuildEntryRefs`: per node, anchored on each node's
+ * read hash, resumable by cursor, never a site-wide `DELETE`) every five minutes, offset from
+ * `lists.backfill`, until every site is done; from then on a run costs its two discovery reads.
+ * It is a safety net: the gate (§1) means no reference value existed before the types were
+ * enabled, so it should find nothing to change.
+ *
+ * The declared `cost` is the run's statement budget, which `rebuildEntryRefs` enforces on every
+ * statement it issues, so the tick's meter never sees more.
+ */
+import { rebuildEntryRefs } from "@cloudhal/core-ts";
+import type { SystemJobDefinition } from "@cloudhal/schedules";
+import type { SqlDriver } from "@cloudhal/storage";
+
+const EVERY_MS = 5 * 60_000;
+/** Half-way between `lists.backfill`'s marks, so the two rarely share a tick's budget. */
+const OFFSET_MS = 150_000;
+
+/** The next five-minute mark plus 2:30 strictly after `after` (UTC). */
+export const everyFiveMinutesOffset = (after: Date): Date =>
+ new Date((Math.floor((after.getTime() - OFFSET_MS) / EVERY_MS) + 1) * EVERY_MS + OFFSET_MS);
+
+export function entryRefsJob(
+ sql: SqlDriver,
+ o: { paid: boolean; log: (line: string) => void },
+): SystemJobDefinition {
+ // Discovery is two statements, a chunk of 100 entries about five; the free tick allows 40 in all.
+ const budget = o.paid
+ ? { entries: 2000, ms: 20_000, statements: 150 }
+ : { entries: 200, ms: 5_000, statements: 12 };
+ return {
+ name: "refs.rebuild",
+ cost: budget.statements,
+ next: everyFiveMinutesOffset,
+ async run() {
+ const r = await rebuildEntryRefs(sql, { budget });
+ const written = r.sites.reduce((n, s) => n + s.written, 0);
+ const waiting = r.sites.filter((s) => !s.done).length;
+ if (r.sites.length > 0)
+ o.log(
+ `refs rebuild: ${written} entr${written === 1 ? "y" : "ies"} checked, ${r.sites.length - waiting} site(s) done, ${waiting} not yet${r.exhausted ? " (more next run)" : ""}`,
+ );
+ },
+ };
+}
diff --git a/packages/cloudflare/src/features.ts b/packages/cloudflare/src/features.ts
index 4f467427..3cf9555a 100644
--- a/packages/cloudflare/src/features.ts
+++ b/packages/cloudflare/src/features.ts
@@ -8,6 +8,7 @@
import { AVAILABILITY_GATES_VERSION, type GateSupport } from "@cloudhal/core-ts/availability";
import { FIELDS_FEATURE_VERSION, type FieldTypeSupport } from "@cloudhal/core-ts/fields";
import type { SiteFetch } from "./purge.ts";
+import { type SiteRoutesEnv, siteWorkerFor } from "./site-routes.ts";
const FEATURES_URL = "https://site.internal/__cloudhal/features";
@@ -78,8 +79,9 @@ export function siteGateSupport(options: {
/**
* RFC-0045 §1: the store's reference and repeater capability for one admin Worker isolate, the
* twin of `siteGateSupport`: yes once the site Worker answers `{ "fields": 2 }` or more (kept as
- * a plain value); a refusal or an error is asked again. Built in rollout step 2; nothing passes it
- * to a store until step 7 enables the types.
+ * a plain value); a refusal or an error is asked again. Built in rollout step 2; the admin Worker's
+ * store takes it from rollout step 7 (`siteSupport`), so a site Worker older than step 3 keeps the
+ * types refused.
*/
export function siteFieldTypeSupport(options: {
readonly fetch: SiteFetch | undefined;
@@ -107,3 +109,77 @@ export function siteFieldTypeSupport(options: {
},
};
}
+
+/**
+ * What one admin Worker isolate has confirmed so far, per site Worker binding (`SITE`, `SITE_1`,
+ * …; RFC-0034 §4): each attached Worker may run another renderer version, so a confirmation from
+ * one never stands for another. Plain values, nothing awaited across requests (ADR-0012).
+ */
+export interface SiteConfirmations {
+ /** The gates version each site Worker reported; absent: none yet. */
+ gates: Record;
+ /** The site Workers that reported `fields: 2` or more (RFC-0045). */
+ fields: Record;
+}
+
+/** The site Worker serving one site: its binding's name and a fetch to it. */
+export interface SiteWorkerRoute {
+ readonly key: string;
+ readonly fetch: SiteFetch;
+}
+
+/**
+ * The store's capabilities, each asked of the site Worker that serves the site in question
+ * (`route`) and remembered for that Worker in `confirmed`: time gates (S15) and reference and
+ * repeater fields (RFC-0045 §1). A site with no site Worker is refused both, never asked; a
+ * refusal or a failed probe is not remembered, so it is asked again.
+ */
+export function siteSupport(options: {
+ readonly route: (siteId: string) => SiteWorkerRoute | undefined;
+ readonly token: string | undefined;
+ readonly confirmed: SiteConfirmations;
+ readonly log?: (line: string) => void;
+}): { gates: GateSupport; fieldTypes: FieldTypeSupport } {
+ const { route, token, confirmed, log } = options;
+ const common = (siteId: string) => {
+ const r = route(siteId);
+ return { r, base: { fetch: r?.fetch, token, ...(log ? { log } : {}) } };
+ };
+ return {
+ gates: {
+ enforced: (siteId, version) => {
+ const { r, base } = common(siteId);
+ return siteGateSupport({
+ ...base,
+ confirmed: () => (r ? (confirmed.gates[r.key] ?? 0) : 0),
+ remember: (v) => {
+ if (r) confirmed.gates[r.key] = v;
+ },
+ }).enforced(siteId, version);
+ },
+ },
+ fieldTypes: {
+ supported: (siteId) => {
+ const { r, base } = common(siteId);
+ return siteFieldTypeSupport({
+ ...base,
+ confirmed: () => (r ? confirmed.fields[r.key] === true : false),
+ remember: () => {
+ if (r) confirmed.fields[r.key] = true;
+ },
+ }).supported(siteId);
+ },
+ },
+ };
+}
+
+/** `siteSupport`'s route on the admin Worker: the binding that serves the site (RFC-0034 §4). */
+export function featureRoute(env: SiteRoutesEnv, siteId: string): SiteWorkerRoute | undefined {
+ const w = siteWorkerFor(env, siteId);
+ if (!w) return undefined;
+ const fetcher = w.fetcher;
+ return {
+ key: w.key,
+ fetch: (u, init) => fetcher.fetch(u, init as never) as unknown as Promise,
+ };
+}
diff --git a/packages/cloudflare/src/playground-site.ts b/packages/cloudflare/src/playground-site.ts
index 34c6e172..8fc6ae88 100644
--- a/packages/cloudflare/src/playground-site.ts
+++ b/packages/cloudflare/src/playground-site.ts
@@ -7,6 +7,7 @@
*/
import { createApi } from "@cloudhal/api";
import { SiteGraphStore } from "@cloudhal/core-ts";
+import { FIELDS_SUPPORTED } from "@cloudhal/core-ts/fields";
import {
type BlobStore,
FetcherBlobStore,
@@ -135,7 +136,9 @@ export function playgroundSiteClass(options: PlaygroundOptions): PlaygroundSiteC
own,
)
: own;
- const store = new SiteGraphStore({ sql, blobs });
+ // The session's site is rendered by this Worker's own renderer (`options.render`), which
+ // reads reference and repeater fields (RFC-0045 §1, rollout step 7).
+ const store = new SiteGraphStore({ sql, blobs, fieldTypes: FIELDS_SUPPORTED });
await store.init();
let siteId = await this.state.storage.get("siteId");
if (!siteId) {
diff --git a/packages/cloudflare/src/scheduler.ts b/packages/cloudflare/src/scheduler.ts
index 4f50e119..5c04c4fd 100644
--- a/packages/cloudflare/src/scheduler.ts
+++ b/packages/cloudflare/src/scheduler.ts
@@ -64,6 +64,7 @@ import type { SqlDriver } from "@cloudhal/storage";
import { D1Driver } from "@cloudhal/storage/d1";
import { R2BlobStore } from "@cloudhal/storage/r2";
import { flushCommentPurges, runCommentsHourly } from "./comments.ts";
+import { entryRefsJob } from "./entry-refs-job.ts";
import { listValuesJob } from "./list-values-job.ts";
import { PURGE_MAX_REQUESTS, PURGE_TAGS_PER_REQUEST } from "./purge.ts";
import { attachedSite, bindingFor, routedSite } from "./site-routes.ts";
@@ -273,6 +274,7 @@ export function adminSystemJobs(
},
},
listValuesJob(sql, { paid: env.CLOUDHAL_TICK_BUDGET === "paid", log }),
+ entryRefsJob(sql, { paid: env.CLOUDHAL_TICK_BUDGET === "paid", log }),
oauthClientsPruneJob(sql, log),
];
if (o.commentsOn)
diff --git a/packages/cloudflare/src/site-routes.ts b/packages/cloudflare/src/site-routes.ts
index a7fc9237..59e4a24d 100644
--- a/packages/cloudflare/src/site-routes.ts
+++ b/packages/cloudflare/src/site-routes.ts
@@ -33,6 +33,21 @@ export function bindingFor(env: SiteRoutesEnv, siteId: string | undefined): Fetc
return isFetcher(bound) ? bound : env.SITE;
}
+/**
+ * The Worker that serves `siteId` and its binding's name (`SITE`, `SITE_1`, …): what the admin
+ * Worker asks about features (RFC-0008 §7.7, RFC-0045 §1), and keys its confirmations by, since
+ * each attached Worker may run another renderer version.
+ */
+export function siteWorkerFor(
+ env: SiteRoutesEnv,
+ siteId: string,
+): { key: string; fetcher: Fetcher } | undefined {
+ const name = routesOf(env)[siteId];
+ const bound = name ? (env as unknown as Record)[name] : undefined;
+ if (name && isFetcher(bound)) return { key: name, fetcher: bound };
+ return env.SITE ? { key: "SITE", fetcher: env.SITE } : undefined;
+}
+
/** True when `siteId` is served by an attached Worker (not the installation's own). */
export function attachedSite(env: SiteRoutesEnv, siteId: string): boolean {
return routesOf(env)[siteId] !== undefined;
diff --git a/packages/cloudflare/test/comments.test.ts b/packages/cloudflare/test/comments.test.ts
index 02d83b5e..f2193a4d 100644
--- a/packages/cloudflare/test/comments.test.ts
+++ b/packages/cloudflare/test/comments.test.ts
@@ -25,6 +25,7 @@ import {
flushCommentPurges,
runCommentsHourly,
} from "../src/comments.ts";
+import { everyFiveMinutesOffset } from "../src/entry-refs-job.ts";
import { everyFiveMinutes } from "../src/list-values-job.ts";
import { purgeSiteCache } from "../src/purge.ts";
@@ -324,6 +325,12 @@ describe("availability and the Worker", () => {
});
const tick = (scheduledTime: number) =>
worker.scheduled({ scheduledTime, cron: "* * * * *" } as ScheduledController, e, ctx);
+ // RFC-0045's one-time `refs.rebuild` is done here already (its steady state: two reads a
+ // run); while a site still has work, it can move a free tick's other jobs to the next minute.
+ await sql.run(
+ "INSERT INTO entry_refs_state (site_id, cursor, done, updated_at) VALUES (?, NULL, 1, ?)",
+ [siteId, new Date().toISOString()],
+ );
// The first tick registers the hourly jobs' watermarks (RFC-0008 §8.2): nothing runs yet.
const first = Date.now();
await tick(first);
@@ -345,8 +352,12 @@ describe("availability and the Worker", () => {
true,
);
expect(lines.some((l) => /^analytics rollup \(none\)/.test(l))).toBe(true);
- // `lists.backfill` (every five minutes, #110) is due too unless the first tick came after :15.
- const ran = everyFiveMinutes(new Date(first)).getTime() <= Date.parse(at17) ? 3 : 2;
+ // `lists.backfill` (every five minutes, #110) is due too unless the first tick came after :15,
+ // and `refs.rebuild` (RFC-0045, every five minutes from :02:30) unless it came after :12:30.
+ const ran =
+ 2 +
+ (everyFiveMinutes(new Date(first)).getTime() <= Date.parse(at17) ? 1 : 0) +
+ (everyFiveMinutesOffset(new Date(first)).getTime() <= Date.parse(at17) + 4_100 ? 1 : 0);
expect(lines.some((l) => l.startsWith(`schedules tick (cron): ${ran} ran`))).toBe(true);
// The watermark moved to the next hour: the next minute does not run it again.
const n = lines.length;
diff --git a/packages/cloudflare/test/entry-refs-job.test.ts b/packages/cloudflare/test/entry-refs-job.test.ts
new file mode 100644
index 00000000..f5c8a234
--- /dev/null
+++ b/packages/cloudflare/test/entry-refs-job.test.ts
@@ -0,0 +1,136 @@
+/** RFC-0045 §2.4, rollout step 7: the admin Worker's `refs.rebuild` system job on D1. */
+import { SiteGraphStore } from "@cloudhal/core-ts";
+import { MemoryBlobStore, type SqlDriver } from "@cloudhal/storage";
+import { D1Driver } from "@cloudhal/storage/d1";
+import { createMiniflareD1 } from "@cloudhal/storage/testing";
+import { afterAll, describe, expect, it } from "vitest";
+import { entryRefsJob, everyFiveMinutesOffset } from "../src/entry-refs-job.ts";
+
+let d1: Awaited> | undefined;
+afterAll(async () => {
+ await d1?.dispose();
+});
+
+const author = { kind: "human" as const, id: "user_01HZX8K3Q9CD", name: "H" };
+const L = (label: string) => ({ en: { label } });
+const VENUE = {
+ key: "venue",
+ type: "reference",
+ labels: L("Venue"),
+ localized: false,
+ collections: ["places"],
+};
+
+/** `sql`, counting statements as the tick's meter does (a batch counts each of its statements). */
+function counting(sql: SqlDriver) {
+ const c = { n: 0, sql: undefined as unknown as SqlDriver };
+ c.sql = {
+ ...sql,
+ name: `${sql.name}+count`,
+ get: async (q, p) => {
+ c.n++;
+ return sql.get(q, p);
+ },
+ all: async (q, p) => {
+ c.n++;
+ return sql.all(q, p);
+ },
+ run: async (q, p) => {
+ c.n++;
+ return sql.run(q, p);
+ },
+ batch: async (st) => {
+ c.n += st.length;
+ return sql.batch(st);
+ },
+ };
+ return c;
+}
+
+describe("refs.rebuild", () => {
+ it("runs on five-minute marks, half-way between lists.backfill's", () => {
+ expect(everyFiveMinutesOffset(new Date("2026-11-13T10:01:10Z")).toISOString()).toBe(
+ "2026-11-13T10:02:30.000Z",
+ );
+ expect(everyFiveMinutesOffset(new Date("2026-11-13T10:02:30Z")).toISOString()).toBe(
+ "2026-11-13T10:07:30.000Z",
+ );
+ expect(everyFiveMinutesOffset(new Date("2026-11-13T10:04:59Z")).toISOString()).toBe(
+ "2026-11-13T10:07:30.000Z",
+ );
+ });
+
+ it("rebuilds every site once within its declared cost, logs while it works, then stays quiet", async () => {
+ d1 = await createMiniflareD1();
+ const sql = new D1Driver(d1.db);
+ const store = new SiteGraphStore({
+ sql,
+ blobs: new MemoryBlobStore(),
+ fieldTypes: { supported: () => true },
+ });
+ await store.init();
+ const expected: { site: string; rows: number }[] = [];
+ for (let i = 0; i < 3; i++) {
+ const project = await store.createProject({
+ name: `R${i}`,
+ slug: `r${i}`,
+ ownerId: author.id,
+ siteIds: [],
+ });
+ const site = await store.createSite({
+ projectId: project.id,
+ name: `R${i}`,
+ slug: `r${i}`,
+ defaultLocale: "en",
+ locales: ["en"],
+ domains: [],
+ pageIds: [],
+ theme: { name: "blog" },
+ collections: { places: {}, events: { fields: [VENUE] } } as never,
+ });
+ const entry = (collection: string, slug: string, fields = {}) =>
+ store.createContentEntry({
+ siteId: site.id,
+ collection,
+ slug,
+ locale: "en",
+ status: "published",
+ title: slug,
+ body: { format: "markdown", value: slug },
+ fields,
+ tags: [],
+ author,
+ });
+ const place = await entry("places", "hall");
+ for (let j = 0; j < 5; j++) await entry("events", `e${j}`, { venue: place.id });
+ expected.push({ site: site.id, rows: 5 });
+ }
+ // An index the writers never filled (what the job is the safety net for).
+ await sql.run("DELETE FROM entry_refs");
+ const lines: string[] = [];
+ const counted = counting(sql);
+ const job = entryRefsJob(counted.sql, { paid: false, log: (l) => lines.push(l) });
+ expect(job.name).toBe("refs.rebuild");
+ for (let run = 0; run < 20; run++) {
+ counted.n = 0;
+ await job.run({ now: new Date(), trigger: "cron" });
+ expect(counted.n).toBeLessThanOrEqual(job.cost);
+ const states = await sql.all<{ done: number }>("SELECT done FROM entry_refs_state");
+ if (states.length === 3 && states.every((s) => Number(s.done) === 1)) break;
+ }
+ for (const { site, rows } of expected)
+ expect(
+ await sql.get<{ c: number }>("SELECT count(*) AS c FROM entry_refs WHERE site_id = ?", [
+ site,
+ ]),
+ ).toEqual({ c: rows });
+ expect(lines.length).toBeGreaterThan(0);
+ expect(lines[0]).toMatch(/^refs rebuild: \d+ entr(y|ies) checked/);
+ // done everywhere: two discovery reads a run, and nothing logged
+ const n = lines.length;
+ counted.n = 0;
+ await job.run({ now: new Date(), trigger: "cron" });
+ expect(counted.n).toBe(2);
+ expect(lines).toHaveLength(n);
+ });
+});
diff --git a/packages/cloudflare/test/features.test.ts b/packages/cloudflare/test/features.test.ts
index feee1186..dd55d4bb 100644
--- a/packages/cloudflare/test/features.test.ts
+++ b/packages/cloudflare/test/features.test.ts
@@ -6,7 +6,14 @@
* packages/cli/test/cloudflare-gates.test.ts.
*/
import { describe, expect, it } from "vitest";
-import { siteFeatures, siteFieldTypeSupport, siteGateSupport } from "../src/features.ts";
+import {
+ featureRoute,
+ type SiteConfirmations,
+ siteFeatures,
+ siteFieldTypeSupport,
+ siteGateSupport,
+ siteSupport,
+} from "../src/features.ts";
function site(answer: () => Response) {
const calls: { url: string; auth: string | null; method: string | undefined }[] = [];
@@ -132,3 +139,55 @@ describe("fields: 2 (RFC-0045 §1, admin side)", () => {
expect(await none.supported("site_01HZX8K3Q9AB")).toBe(false);
});
});
+
+describe("siteSupport: both capabilities, per site Worker (RFC-0045 step 7, RFC-0034 §4)", () => {
+ const A = "site_01HZX8K3Q9AAAAAAAAAAAAAA";
+ const B = "site_01HZX8K3Q9BBBBBBBBBBBBBB";
+ /** An admin env with the installation's Worker (`SITE`) and an attached one serving B. */
+ function installation(main: Record, attached: Record) {
+ const s = site(() => Response.json(main));
+ const a = site(() => Response.json(attached));
+ const env = {
+ SITE: { fetch: s.fetch } as unknown as Fetcher,
+ SITE_1: { fetch: a.fetch } as unknown as Fetcher,
+ CLOUDHAL_SITE_ROUTES: JSON.stringify({ [B]: "SITE_1" }),
+ };
+ return { env, s, a };
+ }
+
+ it("asks the Worker that serves each site and remembers each answer for that Worker only", async () => {
+ // the installation's Worker reads the types; the attached one runs an older renderer
+ const { env, s, a } = installation({ gates: 2, fields: 2 }, { gates: 2 });
+ const confirmed: SiteConfirmations = { gates: {}, fields: {} };
+ const support = () =>
+ siteSupport({ route: (id) => featureRoute(env, id), token: "tok", confirmed });
+ expect(await support().fieldTypes.supported(A)).toBe(true);
+ expect(await support().fieldTypes.supported(B)).toBe(false);
+ expect(confirmed).toEqual({ gates: {}, fields: { SITE: true } });
+ expect(s.calls).toHaveLength(1);
+ expect(a.calls).toHaveLength(1);
+ // A's yes never stands for B: B is asked again, and stays refused
+ expect(await support().fieldTypes.supported(B)).toBe(false);
+ expect(a.calls).toHaveLength(2);
+ // remembered for SITE: no more asking for A
+ expect(await support().fieldTypes.supported(A)).toBe(true);
+ expect(s.calls).toHaveLength(1);
+ // gates are per Worker too
+ expect(await support().gates.enforced(B, 2)).toBe(true);
+ expect(confirmed.gates).toEqual({ SITE_1: 2 });
+ });
+
+ it("a Worker that fails or has no binding is refused and never remembered", async () => {
+ const failing = site(() => new Response("boom", { status: 500 }));
+ const env = { SITE: { fetch: failing.fetch } as unknown as Fetcher };
+ const confirmed: SiteConfirmations = { gates: {}, fields: {} };
+ const support = siteSupport({ route: (id) => featureRoute(env, id), token: "tok", confirmed });
+ expect(await support.fieldTypes.supported(A)).toBe(false);
+ expect(await support.gates.enforced(A)).toBe(false);
+ expect(confirmed).toEqual({ gates: {}, fields: {} });
+ // no site Worker at all: refused, never asked
+ const none = siteSupport({ route: (id) => featureRoute({}, id), token: "tok", confirmed });
+ expect(await none.fieldTypes.supported(A)).toBe(false);
+ expect(await none.gates.enforced(A)).toBe(false);
+ });
+});
diff --git a/packages/core-ts/src/errors.ts b/packages/core-ts/src/errors.ts
index fe112f4f..dcd7e4b7 100644
--- a/packages/core-ts/src/errors.ts
+++ b/packages/core-ts/src/errors.ts
@@ -225,7 +225,7 @@ export type InvariantCode =
/** §3: `expiry.field` is not a date or datetime field of the collection. */
| "invalid_expiry"
// ── RFC-0045 (collections stage 4) ──
- /** §1: a reference or repeater definition; refused on every write path until rollout step 7. */
+ /** §1: a reference or repeater definition where the site's renderer cannot read them (no capability). */
| "fields_unsupported"
/** §1: a reference's `collections` names a collection the site neither declares nor uses. */
| "reference_target_unknown"
diff --git a/packages/core-ts/src/fields-operations/plan.ts b/packages/core-ts/src/fields-operations/plan.ts
index 41bf9f83..2c9b8895 100644
--- a/packages/core-ts/src/fields-operations/plan.ts
+++ b/packages/core-ts/src/fields-operations/plan.ts
@@ -77,8 +77,8 @@ export function targetDefinition(def: FieldDefinition, op: FieldOperation): Fiel
/** Checks the request against the collection; throws when it can never run as written. */
export function checkOperation(site: Site, op: FieldOperation): void {
const { defs, def } = currentDefinition(site, op);
- // RFC-0045 §7: reference and repeater fields take every operation; the types themselves stay
- // refused by `checkFieldDefinitions`' gate until rollout step 7.
+ // RFC-0045 §7: reference and repeater fields take every operation; the types themselves pass
+ // `checkFieldDefinitions`' gate only with the store's capability.
if (op.sub !== undefined) checkSub(def, { ...op, sub: op.sub });
else if (op.op === "rename_field") {
if (!KEY.test(op.to))
diff --git a/packages/core-ts/src/fields-operations/run.ts b/packages/core-ts/src/fields-operations/run.ts
index a4f67c87..97b4003f 100644
--- a/packages/core-ts/src/fields-operations/run.ts
+++ b/packages/core-ts/src/fields-operations/run.ts
@@ -127,8 +127,8 @@ async function dryRun(
/**
* RFC-0045 §7: the definitions the operation ends with pass the contract and
- * `checkFieldDefinitions` (with the store's capability: the types stay refused until rollout step
- * 7), so the dry run refuses what phase A or C could not write.
+ * `checkFieldDefinitions` (with the store's capability: without it the types are refused), so the
+ * dry run refuses what phase A or C could not write.
*/
async function checkEndState(ctx: OperationContext, site: Site, op: FieldOperation) {
const end = phaseCSite(phaseASite(site, op, DRY_RUN_ID), op.collection, DRY_RUN_ID) ?? site;
diff --git a/packages/core-ts/src/fields/index.ts b/packages/core-ts/src/fields/index.ts
index c81a548b..4befa2c5 100644
--- a/packages/core-ts/src/fields/index.ts
+++ b/packages/core-ts/src/fields/index.ts
@@ -7,7 +7,7 @@
*
* RFC-0045 (`../references/`): the `reference` and `repeater` types' gate and definition rules,
* values, strict JSON Schema profile, `pickMember` and `referenceIssues`. Both types are refused on
- * every write path (`fields_unsupported`) until rollout step 7.
+ * every write path (`fields_unsupported`) without the store's capability (`FieldTypeSupport`).
*/
export {
diff --git a/packages/core-ts/src/fields/types.ts b/packages/core-ts/src/fields/types.ts
index 15c12c6e..de824f01 100644
--- a/packages/core-ts/src/fields/types.ts
+++ b/packages/core-ts/src/fields/types.ts
@@ -9,7 +9,7 @@ export type SubFieldType = SubFieldDefinition["type"];
export type SelectFieldDefinition = Extract;
export type ReferenceFieldDefinition = Extract;
export type RepeaterFieldDefinition = Extract;
-/** RFC-0045: the two stage-4 types, refused on every write path until rollout step 7. */
+/** RFC-0045: the two stage-4 types, accepted only with the store's capability (`fields_unsupported`). */
export type ReferenceFieldType = "reference" | "repeater";
/** The types every writer accepts today (`FIELD_TYPES`). */
export type EnabledFieldType = Exclude;
@@ -23,8 +23,9 @@ export interface SiteFields {
}
/**
- * The nine types every writer accepts, in the contract's order: what Studio, MCP, the API and the
- * CLI offer. The stage-4 types (`REFERENCE_FIELD_TYPES`) are not among them until rollout step 7.
+ * The nine types every writer accepts, in the contract's order. The stage-4 types
+ * (`REFERENCE_FIELD_TYPES`) are offered beside them only where the store has the capability
+ * (Studio's `features.referenceFields`, MCP's tool inputs): rollout step 7 enabled them there.
*/
export const FIELD_TYPES: readonly EnabledFieldType[] = [
"text",
@@ -38,7 +39,7 @@ export const FIELD_TYPES: readonly EnabledFieldType[] = [
"select",
];
-/** RFC-0045: `reference` and `repeater`, gated (`fields_unsupported`) until rollout step 7. */
+/** RFC-0045: `reference` and `repeater`, gated by the store's capability (`fields_unsupported`). */
export const REFERENCE_FIELD_TYPES: readonly ReferenceFieldType[] = ["reference", "repeater"];
/** Every type of the contract, in its order (`FIELD_TYPES` then `REFERENCE_FIELD_TYPES`). */
diff --git a/packages/core-ts/src/index.ts b/packages/core-ts/src/index.ts
index 8586ea54..1a197400 100644
--- a/packages/core-ts/src/index.ts
+++ b/packages/core-ts/src/index.ts
@@ -84,6 +84,12 @@ export {
type ReferrerCandidates,
referrerCandidates,
} from "./references/reads.ts";
+export {
+ type RebuildEntryRefsOptions,
+ type RebuildEntryRefsResult,
+ type RebuiltEntryRefs,
+ rebuildEntryRefs,
+} from "./references/rebuild-job.ts";
export {
listReferenceTargets,
listReferrers,
diff --git a/packages/core-ts/src/local.ts b/packages/core-ts/src/local.ts
index 7b5a770a..34a964b5 100644
--- a/packages/core-ts/src/local.ts
+++ b/packages/core-ts/src/local.ts
@@ -6,6 +6,8 @@ import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite";
import { GATES_ENFORCED } from "./availability.ts";
import type { BlockRegistry, PropsSchema } from "./blocks.ts";
import { rebuildListValues } from "./list-values/backfill.ts";
+import { FIELDS_SUPPORTED } from "./references/definitions.ts";
+import { rebuildEntryRefs } from "./references/rebuild-job.ts";
import { SiteGraphStore, type SiteGraphStoreOptions } from "./store.ts";
export interface LocalStore {
@@ -45,12 +47,16 @@ export async function openLocalStore(
await migrate(sql, migrations);
// RFC-0042 §4: list values for collections listed before migration 0029 or left unfinished.
await rebuildListValues(sql);
+ // RFC-0045 §2.4, rollout step 7: `entry_refs` checked once per site (a safety net; resumes).
+ await rebuildEntryRefs(sql);
const blobs = new FsBlobStore(path.join(dir, "blobs"));
- // The renderer that serves this store runs in the same process and enforces gates (S15).
+ // The renderer that serves this store runs in the same process: it enforces gates (S15) and
+ // reads reference and repeater fields (RFC-0045 §1, rollout step 7).
const store = new SiteGraphStore({
sql,
blobs,
gates: GATES_ENFORCED,
+ fieldTypes: FIELDS_SUPPORTED,
...(options.blocks ? { blocks: options.blocks } : {}),
...(options.themeSettings ? { themeSettings: options.themeSettings } : {}),
...(options.themeDesign ? { themeDesign: options.themeDesign } : {}),
diff --git a/packages/core-ts/src/references/definitions.ts b/packages/core-ts/src/references/definitions.ts
index d53bbb8a..54d14a23 100644
--- a/packages/core-ts/src/references/definitions.ts
+++ b/packages/core-ts/src/references/definitions.ts
@@ -1,6 +1,6 @@
/**
* RFC-0045 §1: the gate that refuses `reference` and `repeater` definitions (`fields_unsupported`)
- * on every write path until rollout step 7, and the definition rules for both types that JSON
+ * on every write path without the writer's capability, and the definition rules for both types that JSON
* Schema cannot express. Pure and Worker-safe; `checkFieldDefinitions` runs both.
*/
import type { FieldDefinition, Site, SubFieldDefinition } from "@cloudhal/core-contracts";
@@ -28,13 +28,13 @@ export interface FieldTypeSupport {
/**
* The `fields` value of `GET /__cloudhal/features` from which a site Worker reads reference and
- * repeater fields (rollout step 3 reports it; step 7 starts asking it).
+ * repeater fields (rollout step 3 reports it; the admin Worker asks it from step 7).
*/
export const FIELDS_FEATURE_VERSION = 2;
/**
* The renderer in this process reads both types (Node: `cloudhal dev`, `studio`, self-host), the
- * twin of `GATES_ENFORCED`. Not passed by any production caller before rollout step 7.
+ * twin of `GATES_ENFORCED`: `openLocalStore` and the playground pass it (rollout step 7).
*/
export const FIELDS_SUPPORTED: FieldTypeSupport = { supported: () => true };
diff --git a/packages/core-ts/src/references/rebuild-job.ts b/packages/core-ts/src/references/rebuild-job.ts
new file mode 100644
index 00000000..8d70a966
--- /dev/null
+++ b/packages/core-ts/src/references/rebuild-job.ts
@@ -0,0 +1,301 @@
+/**
+ * RFC-0045 §2.4, rollout step 7: the one-time per-site rebuild of `entry_refs`, a safety net once
+ * the types are enabled (the gate of §1 means it should find nothing to change). The admin Worker
+ * runs it as the `refs.rebuild` system job, `openLocalStore` on Node; both resume by cursor.
+ *
+ * Per site, entries are read in key order, 100 at a time, with the site row read first:
+ * - **Rows are the import rebuild's** (`entryRefItemsOf`, so `rebuildRows`: keys through
+ * `migration.fromKey`, a running conversion's path through its earlier type, old-shape values
+ * through `oldShapePairs`), with `shared` computed in JS from the site row read with the chunk
+ * and `source_status` the holder's own.
+ * - **Each node is anchored on the hash it was read with.** The chunk's `DELETE` removes only the
+ * rows of nodes whose hash is still the one read, and its `INSERT`s write only those nodes' rows
+ * (`json_each` pairs joined to `nodes` on id and hash): the same as one anchored `DELETE` and
+ * `INSERT` per node, in a fixed number of statements a chunk, so the tick budget holds. A node a
+ * writer changed since the read is skipped, since its writer wrote fresh rows (R17). Never a
+ * site-wide `DELETE`.
+ * - **The site anchor** is on every statement of the chunk, the advance included: the site node's
+ * `hash` as read with the chunk. A definitions or marker change between the read and the batch
+ * (any site edit, in fact) makes the whole chunk a no-op, and it is read again. The hash is 64
+ * characters, so no statement grows with the site's `collections` (up to 256 KB), and one
+ * entry's rows (at most 100 references, about 25 KB) always fit one `INSERT` below 90 KB.
+ * - **No site with a running text ↔ reference conversion** is touched (`operation_running`). Its
+ * rows say which values phase B or a person's write accepted (`fields/text-reference.ts`), which
+ * no rebuild from bodies can know, so the job waits, cursor unmoved, until phase C drops the
+ * marker. Other markers (renames, reshapes) read the same through `rebuildRows` as the writer.
+ * - **The cursor** (`entry_refs_state`) moves in the same batch, guarded on the cursor as read, so
+ * a second runner never moves it back. The last chunk removes the rows of nodes that no longer
+ * exist (`source_id NOT IN (SELECT id FROM nodes WHERE site_id = ?)`) and marks the site done.
+ *
+ * No transactions (rule 4): progress lives in the state row, so a run stopped anywhere resumes.
+ * Inserts stay below 90 KB a statement. Worker-safe: it takes a raw `SqlDriver`.
+ */
+import type { ContentEntry, Site } from "@cloudhal/core-contracts";
+import type { SqlDriver, SqlStatement } from "@cloudhal/storage";
+import { type ListValuesBudget, ListValuesMeter } from "../list-values/meter.ts";
+import { chunks, REFS_STATEMENT_BYTES } from "../media/refs-rebuild.ts";
+import { convertingPath } from "./conversion-rows.ts";
+import { entryRefItemsOf } from "./rebuild.ts";
+
+export interface RebuildEntryRefsOptions {
+ /** One site (absent: every site not done yet). */
+ readonly siteId?: string | undefined;
+ /** Absent: until every site is done. */
+ readonly budget?: ListValuesBudget | undefined;
+}
+
+export interface RebuiltEntryRefs {
+ readonly siteId: string;
+ readonly done: boolean;
+ /** Entries read by the chunks this call committed (a node a writer moved meanwhile is skipped, but counted). */
+ readonly written: number;
+ /**
+ * Why it is not done: the budget ran out, writers kept the cursor or the site busy, or a text ↔
+ * reference conversion runs (the job waits for it to end).
+ */
+ readonly reason?: "budget" | "busy" | "operation_running";
+}
+
+export interface RebuildEntryRefsResult {
+ readonly sites: RebuiltEntryRefs[];
+ /** The budget ran out before every site was done. */
+ readonly exhausted: boolean;
+}
+
+/** Chunk batches in a row that changed nothing before a site is left for the next run. */
+const MISSES = 20;
+/**
+ * Statements a chunk needs at least: the site row, the entries, a `DELETE`, one `INSERT` (one
+ * entry's rows stay far below 90 KB: at most 100 references) and the advance.
+ */
+const CHUNK_COST = 5;
+
+const SITE_ANCHOR = "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND kind = 'site' AND hash = ?)";
+
+const DELETE_SQL = `DELETE FROM entry_refs WHERE source_id IN (
+ SELECT n.id FROM json_each(?) j JOIN nodes n
+ ON n.id = json_extract(j.value, '$.n') AND n.hash = json_extract(j.value, '$.x')
+ WHERE n.site_id = ? AND n.kind = 'contentEntry')
+ AND ${SITE_ANCHOR}`;
+
+const INSERT_SQL = `INSERT INTO entry_refs (site_id, target_id, source_id, source_collection, field, root_key, shared, source_status)
+ SELECT ?, json_extract(r.value, '$.t'), n.id, json_extract(r.value, '$.c'),
+ json_extract(r.value, '$.f'), json_extract(r.value, '$.k'), json_extract(r.value, '$.h'), json_extract(r.value, '$.s')
+ FROM json_each(?) r JOIN nodes n
+ ON n.id = json_extract(r.value, '$.n') AND n.hash = json_extract(r.value, '$.x')
+ WHERE n.site_id = ? AND n.kind = 'contentEntry' AND ${SITE_ANCHOR}`;
+
+const ORPHANS_SQL =
+ "DELETE FROM entry_refs WHERE site_id = ? AND source_id NOT IN (SELECT id FROM nodes WHERE site_id = ?)";
+
+const ADVANCE_SQL = `INSERT INTO entry_refs_state (site_id, cursor, done, updated_at)
+ SELECT ?, ?, ?, ? WHERE ${SITE_ANCHOR}
+ ON CONFLICT (site_id) DO UPDATE SET cursor = excluded.cursor, done = excluded.done, updated_at = excluded.updated_at
+ WHERE entry_refs_state.done = 0 AND entry_refs_state.cursor IS ?`;
+
+/** Entries of a site after a key, in key order (`nodes_site_kind_key`). */
+const ENTRIES_SQL = `SELECT id, key, hash, body FROM nodes WHERE site_id = ? AND kind = 'contentEntry'
+ AND key > ? ORDER BY key LIMIT ?`;
+
+const SITE_SQL = "SELECT id, hash, body FROM nodes WHERE id = ? AND kind = 'site'";
+
+interface State {
+ readonly cursor: string | null;
+ readonly done: boolean;
+}
+
+/** A chunk's entries with the rows each would get. */
+interface Read {
+ readonly key: string;
+ /** JSON items for `INSERT_SQL`, each with the node's read hash (`x`). */
+ readonly items: string[];
+ /** `{n, x}` for `DELETE_SQL`. */
+ readonly pin: string;
+}
+
+const encoder = new TextEncoder();
+const stamp = () => new Date().toISOString();
+
+async function readState(sql: SqlDriver, siteId: string): Promise {
+ const row = await sql.get<{ cursor: string | null; done: number }>(
+ "SELECT cursor, done FROM entry_refs_state WHERE site_id = ?",
+ [siteId],
+ );
+ return row ? { cursor: row.cursor, done: Number(row.done) === 1 } : undefined;
+}
+
+/** The sites to rebuild: one, or every site, and the states of those already started. */
+async function discover(sql: SqlDriver, siteId: string | undefined) {
+ const sites = siteId
+ ? await sql.all<{ id: string }>("SELECT id FROM nodes WHERE id = ? AND kind = 'site'", [siteId])
+ : await sql.all<{ id: string }>(
+ "SELECT id FROM nodes WHERE kind = 'site' ORDER BY created_at, id",
+ );
+ const rows = siteId
+ ? await sql.all<{ site_id: string; cursor: string | null; done: number }>(
+ "SELECT site_id, cursor, done FROM entry_refs_state WHERE site_id = ?",
+ [siteId],
+ )
+ : await sql.all<{ site_id: string; cursor: string | null; done: number }>(
+ "SELECT site_id, cursor, done FROM entry_refs_state",
+ );
+ const states = new Map(
+ rows.map((r) => [r.site_id, { cursor: r.cursor, done: Number(r.done) === 1 }]),
+ );
+ return { ids: sites.map((s) => s.id), states };
+}
+
+/** The chunk after `cursor`, each entry's rows under `site` (`entryRefItemsOf`). */
+async function readChunk(
+ sql: SqlDriver,
+ site: Pick,
+ cursor: string | null,
+ limit: number,
+): Promise {
+ const rows = await sql.all<{ id: string; key: string; hash: string; body: string }>(ENTRIES_SQL, [
+ site.id,
+ cursor ?? "",
+ limit,
+ ]);
+ return rows.map((r) => {
+ const entry = JSON.parse(r.body) as ContentEntry;
+ return {
+ key: r.key,
+ items: entryRefItemsOf(site, r.id, entry).map((item) =>
+ JSON.stringify({ ...item, x: r.hash }),
+ ),
+ pin: JSON.stringify({ n: r.id, x: r.hash }),
+ };
+ });
+}
+
+interface ChunkPlan {
+ readonly siteId: string;
+ /** The site node's hash as read with the chunk. */
+ readonly siteHash: string;
+ readonly before: string | null;
+ readonly cursor: string | null;
+ readonly last: boolean;
+}
+
+/** The chunk's batch: the anchored `DELETE`, the `INSERT`s, the orphans when last, the advance. */
+function chunkStatements(reads: readonly Read[], plan: ChunkPlan): SqlStatement[] {
+ const { siteId, siteHash } = plan;
+ const anchor = [siteId, siteHash];
+ const fixed = encoder.encode(INSERT_SQL + siteId + siteId + siteId + siteHash).length;
+ const out: SqlStatement[] = [];
+ if (reads.length > 0) {
+ out.push({
+ sql: DELETE_SQL,
+ params: [`[${reads.map((r) => r.pin).join(",")}]`, siteId, ...anchor],
+ });
+ for (const items of chunks(
+ reads.flatMap((r) => r.items),
+ REFS_STATEMENT_BYTES - fixed,
+ ))
+ out.push({ sql: INSERT_SQL, params: [siteId, items, siteId, ...anchor] });
+ }
+ if (plan.last) out.push({ sql: ORPHANS_SQL, params: [siteId, siteId] });
+ out.push({
+ sql: ADVANCE_SQL,
+ params: [siteId, plan.cursor, plan.last ? 1 : 0, stamp(), ...anchor, plan.before],
+ });
+ return out;
+}
+
+/** How many of `reads` (a prefix) one batch of at most `room` statements holds. */
+function fitting(reads: readonly Read[], room: number, plan: (k: number) => ChunkPlan): number {
+ const fits = (k: number) => chunkStatements(reads.slice(0, k), plan(k)).length <= room;
+ if (fits(reads.length)) return reads.length;
+ let lo = 0;
+ let hi = reads.length;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ if (fits(mid)) lo = mid;
+ else hi = mid - 1;
+ }
+ return lo;
+}
+
+/** A text ↔ reference conversion runs on some field or sub-field of the site (`convertingPath`). */
+function convertsTextReferences(site: Pick): boolean {
+ return Object.values(site.collections ?? {}).some((c) =>
+ (c?.fields ?? []).some((def) => convertingPath(def) !== undefined),
+ );
+}
+
+async function rebuildSite(
+ meter: ListValuesMeter,
+ siteId: string,
+ found: State | undefined,
+): Promise {
+ const { sql } = meter;
+ const result = (done: boolean, written: number, reason?: RebuiltEntryRefs["reason"]) => ({
+ siteId,
+ done,
+ written,
+ ...(reason ? { reason } : {}),
+ });
+ let state: State = found ?? { cursor: null, done: false };
+ let written = 0;
+ let misses = 0;
+ while (!state.done) {
+ if (meter.out(CHUNK_COST)) return result(false, written, "budget");
+ const row = await sql.get<{ id: string; hash: string; body: string }>(SITE_SQL, [siteId]);
+ if (!row) return result(true, written); // the site went: nothing of it is left to index
+ const site = JSON.parse(row.body) as Site;
+ if (convertsTextReferences(site)) return result(false, written, "operation_running");
+ const limit = meter.room();
+ const reads = await readChunk(sql, site, state.cursor, limit);
+ const plan = (k: number, last = false): ChunkPlan => ({
+ siteId,
+ siteHash: row.hash,
+ before: state.cursor,
+ cursor: reads[k - 1]?.key ?? state.cursor,
+ last,
+ });
+ const room = meter.left();
+ const k = fitting(reads, room, (i) => plan(i));
+ if (k === 0 && reads.length > 0) return result(false, written, "budget");
+ // The end of the site, with the orphans' `DELETE`, when it fits; else the next chunk is empty.
+ const end = k === reads.length && reads.length < limit;
+ const closing = plan(k, true);
+ const p = end && chunkStatements(reads.slice(0, k), closing).length <= room ? closing : plan(k);
+ const results = await sql.batch(chunkStatements(reads.slice(0, k), p));
+ meter.take(Math.max(1, k));
+ if (results.at(-1)?.changes === 1) {
+ written += k;
+ misses = 0;
+ state = { cursor: p.cursor, done: p.last };
+ continue;
+ }
+ // The definitions changed since the read, or another runner moved the cursor.
+ if (meter.left() < 1) return result(false, written, "budget");
+ const now = await readState(sql, siteId);
+ if (now?.done) return result(true, written);
+ if (++misses > MISSES) return result(false, written, "busy");
+ state = now ?? { cursor: null, done: false };
+ }
+ return result(true, written);
+}
+
+/**
+ * Makes `entry_refs` match the stored entries once per site (of one site, or every site not done
+ * yet), within `budget`. Safe to run concurrently with writers and with itself; a site marked
+ * done is never read again.
+ */
+export async function rebuildEntryRefs(
+ sql: SqlDriver,
+ options: RebuildEntryRefsOptions = {},
+): Promise {
+ const meter = new ListValuesMeter(sql, options.budget);
+ if (meter.left() < 2) return { sites: [], exhausted: true };
+ const { ids, states } = await discover(meter.sql, options.siteId);
+ const sites: RebuiltEntryRefs[] = [];
+ for (const id of ids) {
+ const state = states.get(id);
+ if (state?.done) continue;
+ sites.push(await rebuildSite(meter, id, state));
+ }
+ return { sites, exhausted: sites.some((s) => s.reason === "budget") };
+}
diff --git a/packages/core-ts/src/references/rebuild.ts b/packages/core-ts/src/references/rebuild.ts
index d732d045..2d424dcf 100644
--- a/packages/core-ts/src/references/rebuild.ts
+++ b/packages/core-ts/src/references/rebuild.ts
@@ -93,6 +93,44 @@ const INSERT_SQL = `INSERT INTO entry_refs (site_id, target_id, source_id, sourc
const encoder = new TextEncoder();
+/** One `entry_refs` row as the rebuild `INSERT`s read it from `json_each`. */
+export interface EntryRefItem {
+ /** Target group. */
+ t: string;
+ /** The holding node. */
+ n: string;
+ /** Its collection. */
+ c: string;
+ /** Field path. */
+ f: string;
+ /** Top-level key as stored. */
+ k: string;
+ /** `shared`, from the definition. */
+ h: 1 | 2;
+ /** The holder's own status. */
+ s: string;
+}
+
+/**
+ * One entry's rows under `site`'s definitions (`rebuildRows`): what an import rebuild writes for
+ * it, and what the step-7 job (`rebuild-job.ts`) writes, so the two cannot drift.
+ */
+export function entryRefItemsOf(
+ site: Pick,
+ id: string,
+ entry: ContentEntry,
+): EntryRefItem[] {
+ return rebuildRows(site, entry).map((row) => ({
+ t: row.t,
+ n: id,
+ c: entry.collection,
+ f: row.f,
+ k: row.k,
+ h: row.shared,
+ s: entry.status,
+ }));
+}
+
/** The rows of a site's entry bodies, JSON-encoded for `json_each`. */
export function entryRefItems(
siteId: string,
@@ -102,23 +140,10 @@ export function entryRefItems(
if (!siteRow) return [];
const site = JSON.parse(siteRow.body) as Pick;
const out: string[] = [];
- const entries = rows.flatMap((r) =>
- r.kind === "contentEntry" ? [{ id: r.id, entry: JSON.parse(r.body) as ContentEntry }] : [],
- );
- for (const { id, entry } of entries) {
- for (const row of rebuildRows(site, entry))
- out.push(
- JSON.stringify({
- t: row.t,
- n: id,
- c: entry.collection,
- f: row.f,
- k: row.k,
- h: row.shared,
- s: entry.status,
- }),
- );
- }
+ for (const r of rows)
+ if (r.kind === "contentEntry")
+ for (const item of entryRefItemsOf(site, r.id, JSON.parse(r.body) as ContentEntry))
+ out.push(JSON.stringify(item));
return out;
}
diff --git a/packages/core-ts/src/site-delete.ts b/packages/core-ts/src/site-delete.ts
index 3e0cce08..a206ca12 100644
--- a/packages/core-ts/src/site-delete.ts
+++ b/packages/core-ts/src/site-delete.ts
@@ -46,6 +46,7 @@ export const SITE_TABLES = [
"media_uploads",
"asset_refs",
"entry_refs",
+ "entry_refs_state",
"snapshot_blobs",
"media_blobs",
"approvals",
diff --git a/packages/core-ts/src/store/types.ts b/packages/core-ts/src/store/types.ts
index 7c5fe933..f67336be 100644
--- a/packages/core-ts/src/store/types.ts
+++ b/packages/core-ts/src/store/types.ts
@@ -53,8 +53,9 @@ export interface SiteGraphStoreOptions {
readonly themeDesign?: string;
/**
* RFC-0045 §1: whether the renderer serving a site reads `reference` and `repeater` fields.
- * Without it both types are refused on every write path (`fields_unsupported`); the shared
- * suites pass one, production stores pass none until rollout step 7.
+ * Without it both types are refused on every write path (`fields_unsupported`). Since rollout
+ * step 7 the admin Worker passes the site Worker's `fields: 2` (`siteSupport`), and Node stores
+ * (`openLocalStore`) and the playground `FIELDS_SUPPORTED`.
*/
readonly fieldTypes?: FieldTypeSupport;
}
diff --git a/packages/core-ts/src/sync-bundle.ts b/packages/core-ts/src/sync-bundle.ts
index b7f3b547..b661a257 100644
--- a/packages/core-ts/src/sync-bundle.ts
+++ b/packages/core-ts/src/sync-bundle.ts
@@ -38,7 +38,7 @@ export interface CopySiteOptions {
* RFC-0045 §1: whether the target's renderer reads reference and repeater fields. Without it a
* bundle defining them is refused (`fields_unsupported`), unlike `gates`.
*/
- fieldTypes?: FieldTypeSupport;
+ fieldTypes?: FieldTypeSupport | undefined;
}
export interface CopySiteResult {
diff --git a/packages/core-ts/src/testing-entry-refs-job.ts b/packages/core-ts/src/testing-entry-refs-job.ts
new file mode 100644
index 00000000..09f3b635
--- /dev/null
+++ b/packages/core-ts/src/testing-entry-refs-job.ts
@@ -0,0 +1,504 @@
+/**
+ * RFC-0045 §2.4, rollout step 7: the one-time per-site `entry_refs` rebuild (`rebuildEntryRefs`).
+ * Its rows equal the writer's and the import rebuild's (a running rename and a held old-shape value
+ * included), it resumes by cursor, removes orphans of its own site only, skips a node a writer
+ * moved meanwhile (R17), and a definitions change between its read and its batch fails the chunk.
+ * It waits while a text ↔ reference conversion runs (rows decide what phase B accepted), and no
+ * statement grows with the site's `collections`, so a large site still finishes on Workers Free.
+ * Runs on every backend (`describeSiteGraphStore`); the types are enabled with
+ * `SiteGraphStoreOptions.fieldTypes`.
+ */
+import type { Actor, ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
+import type { SqlDriver, SqlStatement } from "@cloudhal/storage";
+import { describe, expect, it } from "vitest";
+import { GATES_ENFORCED } from "./availability.ts";
+import {
+ continueFieldOperation,
+ type FieldOperation,
+ runFieldOperation,
+} from "./fields-operations/index.ts";
+import { rebuildEntryRefs } from "./references/rebuild-job.ts";
+import { SiteGraphStore } from "./store.ts";
+import { rebuildingImport } from "./testing-reimport.ts";
+import { racingDriver, type SuiteContext } from "./testing-taxonomy.ts";
+
+const person: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" };
+const L = (label: string) => ({ en: { label } });
+const ENABLED = { supported: () => true };
+
+const VENUE = {
+ key: "venue",
+ type: "reference",
+ labels: L("Venue"),
+ localized: false,
+ collections: ["places"],
+} as FieldDefinition;
+const SPEAKERS = {
+ key: "speakers",
+ type: "repeater",
+ labels: L("Speakers"),
+ localized: true,
+ fields: [
+ { key: "person", type: "reference", labels: L("Person"), collections: ["places"] },
+ { key: "name", type: "text", labels: L("Name") },
+ ],
+} as FieldDefinition;
+const HOSTS = {
+ key: "hosts",
+ type: "reference",
+ labels: L("Hosts"),
+ localized: false,
+ collections: ["places"],
+ multiple: true,
+} as FieldDefinition;
+
+type Row = { n: string; t: string; f: string; k: string; s: number; st: string };
+
+export function entryRefsJobSuite(ctx: SuiteContext): void {
+ describe("RFC-0045 §2.4 step 7: the one-time entry_refs rebuild job", () => {
+ let n = 0;
+ let tick = 0;
+ const now = () => new Date(Date.UTC(2026, 10, 13, 12, 0, tick++));
+ const store = () =>
+ new SiteGraphStore({
+ sql: ctx.sql(),
+ blobs: ctx.blobs(),
+ now,
+ gates: GATES_ENFORCED,
+ fieldTypes: ENABLED,
+ });
+
+ async function world(collections: Site["collections"]): Promise {
+ n++;
+ const project = await store().createProject({
+ name: "Refs job",
+ slug: `refs-job-${n}`,
+ ownerId: person.id,
+ siteIds: [],
+ });
+ return store().createSite({
+ projectId: project.id,
+ name: "Refs job",
+ slug: `refs-job-site-${n}`,
+ defaultLocale: "en",
+ locales: ["en"],
+ domains: [],
+ pageIds: [],
+ theme: { name: "blog" },
+ ...(collections ? { collections } : {}),
+ });
+ }
+ const events = () =>
+ world({ events: { fields: [VENUE, SPEAKERS] }, places: {}, talks: { fields: [HOSTS] } });
+ const entry = (site: Site, collection: string, over: Partial = {}) =>
+ store().createContentEntry({
+ siteId: site.id,
+ collection,
+ slug: `${collection}-${Math.random().toString(36).slice(2, 8)}`,
+ locale: "en",
+ status: "draft",
+ title: `A ${collection}`,
+ body: { format: "markdown", value: "Body" },
+ fields: {},
+ tags: [],
+ author: person,
+ ...over,
+ } as never);
+ const fresh = async (e: ContentEntry) => (await store().getContentEntry(e.id)) as ContentEntry;
+ /** Every row of the site, `source_status` included. */
+ const rows = async (siteId: string): Promise =>
+ (
+ await ctx
+ .sql()
+ .all(
+ "SELECT source_id AS n, target_id AS t, field AS f, root_key AS k, shared AS s, source_status AS st FROM entry_refs WHERE site_id = ? ORDER BY source_id, field, target_id",
+ [siteId],
+ )
+ ).map((r) => ({ ...r, s: Number(r.s) }));
+ /** The site's rows gone and its job state with them: what the job finds on a stale index. */
+ const forget = async (siteId: string) => {
+ await ctx.sql().run("DELETE FROM entry_refs WHERE site_id = ?", [siteId]);
+ await ctx.sql().run("DELETE FROM entry_refs_state WHERE site_id = ?", [siteId]);
+ };
+ const state = (siteId: string) =>
+ ctx
+ .sql()
+ .get<{ cursor: string | null; done: number }>(
+ "SELECT cursor, done FROM entry_refs_state WHERE site_id = ?",
+ [siteId],
+ );
+
+ /** A site with three places and four events referencing them, published and draft. */
+ async function populated() {
+ const site = await events();
+ const [p, q, r] = [
+ await entry(site, "places", { title: "P", slug: "p" }),
+ await entry(site, "places", { title: "Q", slug: "q" }),
+ await entry(site, "places", { title: "R", slug: "r" }),
+ ];
+ const evs: ContentEntry[] = [];
+ for (const [i, [venue, people]] of (
+ [
+ [p, [q, r]],
+ [q, [p]],
+ [r, []],
+ [p, [r, q]],
+ ] as const
+ ).entries())
+ evs.push(
+ await entry(site, "events", {
+ slug: `event-${i}`,
+ status: i % 2 === 0 ? "published" : "draft",
+ fields: {
+ venue: venue.id,
+ ...(people.length
+ ? { speakers: people.map((x) => ({ person: x.id, name: `N ${x.title}` })) }
+ : {}),
+ },
+ }),
+ );
+ await entry(site, "talks", { slug: "talk", fields: { hosts: [q.id, r.id] } });
+ return { site, places: [p, q, r] as const, events: evs };
+ }
+
+ it("writes the writer's rows, resumes by cursor, and is done once per site", async () => {
+ const { site } = await populated();
+ const written = await rows(site.id);
+ expect(written.length).toBeGreaterThan(5);
+ await forget(site.id);
+
+ // two entries a run: the cursor moves and the next run goes on from it
+ const budget = { entries: 2, ms: 60_000 };
+ const first = await rebuildEntryRefs(ctx.sql(), { siteId: site.id, budget });
+ expect(first.sites).toEqual([{ siteId: site.id, done: false, written: 2, reason: "budget" }]);
+ expect(first.exhausted).toBe(true);
+ const after = await state(site.id);
+ expect(after?.cursor).toMatch(/^events:en:event-1$/);
+ expect(Number(after?.done)).toBe(0);
+ const partial = await rows(site.id);
+ expect(partial.length).toBeGreaterThan(0);
+ expect(partial.length).toBeLessThan(written.length);
+
+ let runs = 1;
+ for (;;) {
+ const r = await rebuildEntryRefs(ctx.sql(), { siteId: site.id, budget });
+ runs++;
+ if (r.sites[0]?.done) break;
+ expect(runs).toBeLessThan(10);
+ }
+ expect(runs).toBeGreaterThan(2);
+ expect(await rows(site.id)).toEqual(written);
+ expect(Number((await state(site.id))?.done)).toBe(1);
+
+ // done: never read again, even when its rows go missing later
+ await ctx.sql().run("DELETE FROM entry_refs WHERE site_id = ?", [site.id]);
+ expect((await rebuildEntryRefs(ctx.sql(), { siteId: site.id })).sites).toEqual([]);
+ expect(await rows(site.id)).toEqual([]);
+ });
+
+ it("statement budgets cut a chunk short and count every statement", async () => {
+ const { site } = await populated();
+ const written = await rows(site.id);
+ await forget(site.id);
+ let issued = 0;
+ const counted: SqlDriver = {
+ ...ctx.sql(),
+ name: "counted",
+ get: (q, p) => {
+ issued++;
+ return ctx.sql().get(q, p);
+ },
+ all: (q, p) => {
+ issued++;
+ return ctx.sql().all(q, p);
+ },
+ run: (q, p) => {
+ issued++;
+ return ctx.sql().run(q, p);
+ },
+ batch: (s) => {
+ issued += s.length;
+ return ctx.sql().batch(s);
+ },
+ };
+ for (let i = 0; i < 20; i++) {
+ issued = 0;
+ const r = await rebuildEntryRefs(counted, {
+ siteId: site.id,
+ budget: { entries: 100, ms: 60_000, statements: 7 },
+ });
+ expect(issued).toBeLessThanOrEqual(7);
+ if (r.sites[0]?.done) break;
+ }
+ expect(await rows(site.id)).toEqual(written);
+ });
+
+ it("removes the rows of nodes that no longer exist, of its own site only", async () => {
+ const { site, places } = await populated();
+ const other = await events();
+ const written = await rows(site.id);
+ const ghost = (siteId: string, source: string) =>
+ ctx
+ .sql()
+ .run(
+ "INSERT INTO entry_refs (site_id, target_id, source_id, source_collection, field, root_key, shared, source_status) VALUES (?, ?, ?, 'events', 'venue', 'venue', 1, 'published')",
+ [siteId, places[0].id, source],
+ );
+ await ghost(site.id, "entry_01HZX8K3Q9GHOST000000000A");
+ await ghost(other.id, "entry_01HZX8K3Q9GHOST000000000B");
+ await ctx.sql().run("DELETE FROM entry_refs_state WHERE site_id = ?", [site.id]);
+
+ expect((await rebuildEntryRefs(ctx.sql(), { siteId: site.id })).sites[0]?.done).toBe(true);
+ expect(await rows(site.id)).toEqual(written);
+ // the other site's orphan waits for that site's own run
+ expect((await rows(other.id)).map((r) => r.n)).toEqual(["entry_01HZX8K3Q9GHOST000000000B"]);
+ await rebuildEntryRefs(ctx.sql(), { siteId: other.id });
+ expect(await rows(other.id)).toEqual([]);
+ });
+
+ it("R17: a referrer written between the job's read and its batch keeps its fresh rows; the moved node is skipped", async () => {
+ const { site, places, events: evs } = await populated();
+ const target = evs[1] as ContentEntry;
+ await forget(site.id);
+ const raced = racingDriver(
+ ctx.sql(),
+ async () => {
+ // the writer moves the node after the job read it: new venue, new rows
+ await store().updateContentEntry({
+ ...(await fresh(target)),
+ status: "published",
+ fields: { venue: places[2].id },
+ });
+ },
+ (q) => q.includes("INSERT INTO entry_refs_state"),
+ );
+ const r = await rebuildEntryRefs(raced, { siteId: site.id });
+ expect(r.sites[0]?.done).toBe(true);
+ // the writer's rows stand, not the job's stale ones (venue Q, a draft)
+ expect((await rows(site.id)).filter((x) => x.n === target.id)).toEqual([
+ { n: target.id, t: places[2].id, f: "venue", k: "venue", s: 1, st: "published" },
+ ]);
+ // every other node got its rows, and they are what a writer writes
+ const now = await rows(site.id);
+ await forget(site.id);
+ for (const e of evs) await store().updateContentEntry({ ...(await fresh(e)), title: "Re" });
+ for (const e of await store().listContentEntries(site.id))
+ if (e.collection === "talks")
+ await store().updateContentEntry({ ...(await fresh(e)), title: "Re" });
+ expect(await rows(site.id)).toEqual(now);
+ });
+
+ it("a definitions change between the read and the batch fails the chunk, which is read again", async () => {
+ const { site } = await populated();
+ const written = await rows(site.id);
+ await forget(site.id);
+ const after: { changes: number | undefined; rows: number }[] = [];
+ const raced = racingDriver(
+ ctx.sql(),
+ async () => {
+ const s = (await store().getSite(site.id)) as Site;
+ const fields: [FieldDefinition, ...FieldDefinition[]] = [
+ ...(s.collections?.events?.fields ?? []),
+ { key: "room", type: "text", labels: L("Room"), localized: true },
+ ];
+ await store().updateSite({
+ ...s,
+ collections: { ...s.collections, events: { ...s.collections?.events, fields } },
+ });
+ },
+ (q) => q.includes("INSERT INTO entry_refs_state"),
+ );
+ const observed: SqlDriver = {
+ ...raced,
+ batch: async (s: readonly SqlStatement[]) => {
+ const results = await raced.batch(s);
+ const count = await ctx
+ .sql()
+ .get<{ c: number }>("SELECT count(*) AS c FROM entry_refs WHERE site_id = ?", [
+ site.id,
+ ]);
+ after.push({ changes: results.at(-1)?.changes, rows: Number(count?.c ?? 0) });
+ return results;
+ },
+ };
+ const r = await rebuildEntryRefs(observed, { siteId: site.id });
+ expect(r.sites[0]?.done).toBe(true);
+ // the first chunk's batch wrote nothing, not even the cursor
+ expect(after[0]).toEqual({ changes: 0, rows: 0 });
+ expect(after.length).toBeGreaterThan(1);
+ expect(await rows(site.id)).toEqual(written);
+ });
+
+ it("rows equal the writer's and the import rebuild's: a running rename and a held old-shape value", async () => {
+ const { site, places, events: evs } = await populated();
+ const [, q] = places;
+ const talk = (await store().listContentEntries(site.id)).find(
+ (e) => e.collection === "talks",
+ ) as ContentEntry;
+ const start = (op: FieldOperation) =>
+ runFieldOperation(store(), site.id, op, { actor: person, budget: { entries: 0 } });
+ // a rename runs on events (no entry rewritten), a reshape on talks
+ await start({ op: "rename_field", collection: "events", from: "venue", to: "place" });
+ await start({
+ op: "change_field_type",
+ collection: "talks",
+ key: "hosts",
+ type: "repeater",
+ subFields: [
+ { key: "person", type: "reference", labels: L("Person"), collections: ["places"] },
+ ] as never,
+ });
+ // one event written under the marker (its new key), the talk holding an old-shape value
+ // (its stored id keeps its row through the write, `oldShapePairs`)
+ await store().updateContentEntry({ ...(await fresh(evs[0] as ContentEntry)), title: "W" });
+ await ctx
+ .sql()
+ .run("UPDATE nodes SET body = json_set(body, '$.fields.hosts', json(?)) WHERE id = ?", [
+ JSON.stringify([q.id, 5]),
+ talk.id,
+ ]);
+ await store().updateContentEntry({
+ ...(await fresh(talk)),
+ title: "Held",
+ status: "published",
+ });
+ const writer = await rows(site.id);
+ expect(writer.some((r) => r.n === talk.id && r.t === q.id && r.st === "published")).toBe(
+ true,
+ );
+ expect(writer.some((r) => r.k === "venue")).toBe(true);
+ expect(writer.some((r) => r.k === "place")).toBe(true);
+
+ await forget(site.id);
+ expect((await rebuildEntryRefs(ctx.sql(), { siteId: site.id })).sites[0]?.done).toBe(true);
+ expect(await rows(site.id)).toEqual(writer);
+
+ await rebuildingImport(
+ { sql: ctx.sql(), blobs: ctx.blobs() },
+ { siteId: site.id, fieldTypes: ENABLED, addNode: () => entry(site, "places") },
+ );
+ expect(await rows(site.id)).toEqual(writer);
+ });
+ it("waits while a text → reference conversion runs: rows phase B accepted stay, then it finishes", async () => {
+ const site = await world({
+ events: {
+ fields: [{ key: "venue", type: "text", labels: L("Venue"), localized: false }],
+ },
+ places: {},
+ });
+ const hall = await entry(site, "places", { title: "Main Hall", slug: "main-hall" });
+ const evs = [
+ await entry(site, "events", { slug: "a", fields: { venue: "Main Hall" } }),
+ await entry(site, "events", { slug: "b", fields: { venue: "Main Hall" } }),
+ ];
+ const started = await runFieldOperation(
+ store(),
+ site.id,
+ {
+ op: "change_field_type",
+ collection: "events",
+ key: "venue",
+ type: "reference",
+ collections: ["places"],
+ },
+ { actor: person, budget: { entries: 1 } },
+ );
+ expect(started.done).toBe(false);
+ // phase B accepted one entry's value: its row is what tells phase C it is converted
+ const accepted = await rows(site.id);
+ expect(accepted).toEqual([
+ expect.objectContaining({ t: hall.id, f: "venue", k: "venue", s: 1 }),
+ ]);
+ expect(evs.map((e) => e.id)).toContain(accepted[0]?.n);
+
+ const waiting = await rebuildEntryRefs(ctx.sql(), { siteId: site.id });
+ expect(waiting.sites).toEqual([
+ { siteId: site.id, done: false, written: 0, reason: "operation_running" },
+ ]);
+ expect(await rows(site.id)).toEqual(accepted);
+ expect(await state(site.id)).toBeUndefined();
+
+ const done = await continueFieldOperation(store(), site.id, started.operationId, {
+ actor: person,
+ });
+ expect(done.done).toBe(true);
+ const converted = await rows(site.id);
+ expect(converted).toHaveLength(2);
+ expect((await rebuildEntryRefs(ctx.sql(), { siteId: site.id })).sites[0]?.done).toBe(true);
+ expect(await rows(site.id)).toEqual(converted);
+ });
+
+ it("a collections body over 90 KB and an entry with 20 references finish on the Free budget", async () => {
+ // 20 select fields of 50 options with 100-character labels: about 130 KB of collections
+ const options = Array.from({ length: 50 }, (_, i) => ({
+ value: `o${i}`,
+ labels: { en: `${"x".repeat(96)}${String(i).padStart(4, "0")}` },
+ }));
+ const selects = Array.from({ length: 20 }, (_, i) => ({
+ key: `s${i}`,
+ type: "select",
+ labels: { en: { label: `Select ${i}`, help: "h".repeat(300) } },
+ localized: false,
+ options,
+ })) as unknown as FieldDefinition[];
+ const many = { ...HOSTS, maxItems: 50 } as FieldDefinition;
+ const site = await world({
+ places: {},
+ talks: { fields: [many] },
+ notes: { fields: selects as [FieldDefinition, ...FieldDefinition[]] },
+ });
+ expect(new TextEncoder().encode(JSON.stringify(site.collections)).length).toBeGreaterThan(
+ 90_000,
+ );
+ const places: ContentEntry[] = [];
+ for (let i = 0; i < 20; i++)
+ places.push(await entry(site, "places", { title: `Place ${i}`, slug: `place-${i}` }));
+ const talk = await entry(site, "talks", {
+ slug: "big",
+ status: "published",
+ fields: { hosts: places.map((p) => p.id) },
+ });
+ const written = await rows(site.id);
+ expect(written.filter((r) => r.n === talk.id)).toHaveLength(20);
+ await forget(site.id);
+
+ let issued = 0;
+ let largest = 0;
+ const size = (q: string, p: readonly unknown[] = []) =>
+ new TextEncoder().encode(q + p.map((x) => String(x ?? "")).join("")).length;
+ const counted: SqlDriver = {
+ ...ctx.sql(),
+ name: "free",
+ get: (q, p) => {
+ issued++;
+ return ctx.sql().get(q, p);
+ },
+ all: (q, p) => {
+ issued++;
+ return ctx.sql().all(q, p);
+ },
+ run: (q, p) => {
+ issued++;
+ largest = Math.max(largest, size(q, p));
+ return ctx.sql().run(q, p);
+ },
+ batch: (st) => {
+ issued += st.length;
+ for (const x of st) largest = Math.max(largest, size(x.sql, x.params));
+ return ctx.sql().batch(st);
+ },
+ };
+ const free = { entries: 200, ms: 60_000, statements: 12 };
+ let runs = 0;
+ for (; runs < 5; runs++) {
+ issued = 0;
+ const r = await rebuildEntryRefs(counted, { siteId: site.id, budget: free });
+ expect(issued).toBeLessThanOrEqual(12);
+ if (r.sites[0]?.done) break;
+ }
+ expect(runs).toBeLessThan(5);
+ expect(largest).toBeLessThan(90_000);
+ expect(await rows(site.id)).toEqual(written);
+ });
+ });
+}
diff --git a/packages/core-ts/src/testing-gates-fields.ts b/packages/core-ts/src/testing-gates-fields.ts
new file mode 100644
index 00000000..d0157d93
--- /dev/null
+++ b/packages/core-ts/src/testing-gates-fields.ts
@@ -0,0 +1,185 @@
+/**
+ * RFC-0045 §1, rollout step 7, beside the bundle-gate cases of `testing-gates.ts`: a bundle that
+ * defines reference or repeater fields is refused by import, push, pull and restore without the
+ * capability (`CopySiteOptions.fieldTypes` absent, saying no, or saying yes for another site) and
+ * accepted with it, its index rebuilt in the node batch. And the step-1 watch note: node bodies
+ * are content-addressed and shared by every site, so a capable site's held site body must not be
+ * re-pointed by another site's new snapshot to bring the types into a site without the capability.
+ */
+import type { Actor, ContentEntry, FieldDefinition, NodeRef, Site } from "@cloudhal/core-contracts";
+import type { BlobStore, SqlDriver } from "@cloudhal/storage";
+import { describe, expect, it } from "vitest";
+import { GATES_ENFORCED } from "./availability.ts";
+import { InvariantError } from "./errors.ts";
+import { canonicalJson } from "./hash.ts";
+import type { FieldTypeSupport } from "./references/definitions.ts";
+import { SiteGraphStore } from "./store.ts";
+import { type CopySiteOptions, exportSite, importSite, restoreBundle } from "./sync.ts";
+
+const human: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" };
+const L = (label: string) => ({ en: { label } });
+const VENUE = {
+ key: "venue",
+ type: "reference",
+ labels: L("Venue"),
+ localized: false,
+ collections: ["places"],
+} as FieldDefinition;
+const SPEAKERS = {
+ key: "speakers",
+ type: "repeater",
+ labels: L("Speakers"),
+ localized: false,
+ fields: [
+ { key: "person", type: "reference", labels: L("Person"), collections: ["places"] },
+ { key: "name", type: "text", labels: L("Name") },
+ ],
+} as FieldDefinition;
+
+const YES: FieldTypeSupport = { supported: () => true };
+const NO: FieldTypeSupport = { supported: () => false };
+const only = (siteId: string): FieldTypeSupport => ({ supported: (id) => id === siteId });
+
+async function code(p: Promise): Promise {
+ try {
+ await p;
+ } catch (e) {
+ if (e instanceof InvariantError) return e.code;
+ throw e;
+ }
+ return "ok";
+}
+
+export function fieldTypeGateCases(storage: () => { sql: SqlDriver; blobs: BlobStore }): void {
+ describe("RFC-0045 step 7: reference and repeater bundles follow the capability", () => {
+ let n = 0;
+ const open = () => new SiteGraphStore({ ...storage(), gates: GATES_ENFORCED, fieldTypes: YES });
+
+ async function site(collections: Site["collections"]): Promise {
+ n++;
+ const store = open();
+ const project = await store.createProject({
+ name: "Bundles",
+ slug: `ref-bundles-${n}`,
+ ownerId: human.id,
+ siteIds: [],
+ });
+ return store.createSite({
+ projectId: project.id,
+ name: "Bundles",
+ slug: `ref-bundles-site-${n}`,
+ defaultLocale: "en",
+ locales: ["en"],
+ domains: [],
+ pageIds: [],
+ theme: { name: "blog" },
+ ...(collections ? { collections } : {}),
+ });
+ }
+ const entry = (s: Site, collection: string, over: Partial = {}) =>
+ open().createContentEntry({
+ siteId: s.id,
+ collection,
+ slug: `${collection}-${n}-${Math.random().toString(36).slice(2, 8)}`,
+ locale: "en",
+ status: "published",
+ title: `A ${collection}`,
+ body: { format: "markdown", value: "Body" },
+ fields: {},
+ tags: [],
+ author: human,
+ ...over,
+ } as never);
+ const rows = async (sourceId: string) =>
+ storage().sql.all<{ t: string; f: string }>(
+ "SELECT target_id AS t, field AS f FROM entry_refs WHERE source_id = ? ORDER BY field",
+ [sourceId],
+ );
+ /** An edit after the export, so a replace import writes the node set (and rebuilds). */
+ const edit = async (e: ContentEntry) => {
+ const now = (await open().getContentEntry(e.id)) as ContentEntry;
+ await open().updateContentEntry({ ...now, title: `${now.title}!` });
+ await storage().sql.run("DELETE FROM entry_refs WHERE source_id = ?", [e.id]);
+ };
+
+ it("import, push, pull and restore refuse them without the capability and accept them with it", async () => {
+ const s = await site({ events: { fields: [VENUE, SPEAKERS] }, places: {} });
+ const place = await entry(s, "places");
+ const event = await entry(s, "events", {
+ fields: { venue: place.id, speakers: [{ person: place.id, name: "Ada" }] },
+ });
+ const expected = [
+ { t: place.id, f: "speakers.person" },
+ { t: place.id, f: "venue" },
+ ];
+ const snap = await open().createSnapshot(s.id, { actor: human, reason: "manual" });
+ const bundle = await exportSite(storage(), s.id);
+ const paths: [string, CopySiteOptions][] = [
+ ["import", { mode: "replace" }],
+ ["pull", { mode: "replace", published: "source" }],
+ ["push", { mode: "replace", published: "target" }],
+ ];
+ for (const [what, options] of paths) {
+ await edit(event);
+ for (const fieldTypes of [undefined, NO, only("site_01HZX8K3Q9OTHER0000000A")])
+ expect(
+ await code(importSite(storage(), bundle, { ...options, fieldTypes })),
+ `${what} without`,
+ ).toBe("fields_unsupported");
+ expect(await rows(event.id), `${what}: nothing written`).toEqual([]);
+ const result = await importSite(storage(), bundle, { ...options, fieldTypes: only(s.id) });
+ expect(result.nodes.unchanged, what).toBe(false);
+ expect(await rows(event.id), `${what}: rebuilt`).toEqual(expected);
+ }
+ // restore (`cloudhal import --revert`): the snapshot's bundle, replace mode
+ await edit(event);
+ const restored = await restoreBundle(storage(), snap);
+ for (const fieldTypes of [undefined, NO])
+ expect(
+ await code(importSite(storage(), restored, { mode: "replace", fieldTypes })),
+ "restore without",
+ ).toBe("fields_unsupported");
+ const result = await importSite(storage(), restored, {
+ mode: "replace",
+ fieldTypes: only(s.id),
+ });
+ expect(result.nodes.unchanged).toBe(false);
+ expect(await rows(event.id)).toEqual(expected);
+ });
+
+ it("a site without the capability cannot point a new snapshot at a capable site's held site body", async () => {
+ const capable = await site({ events: { fields: [VENUE] }, places: {} });
+ const held = await open().createSnapshot(capable.id, { actor: human, reason: "manual" });
+ const heldRef = held.nodes.find((r) => r.kind === "site") as NodeRef;
+ expect(await storage().blobs.head(`nodes/${heldRef.hash}.json`)).not.toBeNull();
+
+ const plain = await site({ places: {} });
+ const snap = await open().createSnapshot(plain.id, { actor: human, reason: "manual" });
+ const bundle = await exportSite(storage(), plain.id);
+ // the snapshot is new to the target, and names the capable site's body, which the bundle
+ // does not carry (the target holds it)
+ await storage().sql.run("DELETE FROM snapshots WHERE id = ?", [snap.id]);
+ const repointed = structuredClone(bundle);
+ const row = repointed.snapshots.find((r) => r.id === snap.id);
+ if (!row) throw new Error("no snapshot row");
+ const body = JSON.parse(row.body) as { nodes: NodeRef[] };
+ const ref = body.nodes.find((r) => r.kind === "site") as NodeRef;
+ ref.hash = heldRef.hash;
+ row.body = canonicalJson(body);
+ delete repointed.blobs[heldRef.hash];
+
+ for (const fieldTypes of [undefined, only(capable.id)])
+ for (const mode of ["bootstrap", "replace"] as const)
+ expect(
+ await code(importSite(storage(), repointed, { mode, fieldTypes })),
+ `${mode}, ${fieldTypes ? "capable site only" : "no capability"}`,
+ ).toBe("fields_unsupported");
+ // the snapshot with its own clean site body passes the gate
+ expect(
+ await code(
+ importSite(storage(), bundle, { mode: "replace", fieldTypes: only(capable.id) }),
+ ),
+ ).not.toBe("fields_unsupported");
+ });
+ });
+}
diff --git a/packages/core-ts/src/testing-gates.ts b/packages/core-ts/src/testing-gates.ts
index fa912282..5eef6e26 100644
--- a/packages/core-ts/src/testing-gates.ts
+++ b/packages/core-ts/src/testing-gates.ts
@@ -12,6 +12,7 @@ import { canonicalJson, nodeHash } from "./hash.ts";
import { seedStarterSite } from "./seed.ts";
import { SiteGraphStore } from "./store.ts";
import { exportSite, importSite } from "./sync.ts";
+import { fieldTypeGateCases } from "./testing-gates-fields.ts";
const human: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" };
/** The store's clock: 2026-10-12T12:00:00Z plus whatever the test moves it by. */
@@ -207,5 +208,8 @@ export function gatesSuite(
importSite({ sql, blobs }, broken, { mode: "replace", gates: GATES_ENFORCED }),
).rejects.toMatchObject({ code: "invalid_availability" });
});
+
+ // RFC-0045 §1: the field-type gate beside the time-gate bundle cases (rollout step 7).
+ fieldTypeGateCases(() => ({ sql, blobs }));
});
}
diff --git a/packages/core-ts/src/testing-reference-gate.ts b/packages/core-ts/src/testing-reference-gate.ts
index f976ba0c..6e12ad65 100644
--- a/packages/core-ts/src/testing-reference-gate.ts
+++ b/packages/core-ts/src/testing-reference-gate.ts
@@ -1,6 +1,7 @@
/**
- * RFC-0045 rollout step 1: the gate. Until rollout step 7 no write path stores a `reference` or
- * `repeater` definition (`fields_unsupported`): site creates and updates (Studio, MCP and the API
+ * RFC-0045 §1: the gate. Without the store's capability (`SiteGraphStoreOptions.fieldTypes`; the
+ * shared suite's store has none) no write path stores a `reference` or `repeater` definition
+ * (`fields_unsupported`): site creates and updates (Studio, MCP and the API
* all write through them), field operations, and every bundle `importSite` takes (import, pull,
* push, `cloudhal import --replace` and `--revert`, snapshot restores and edition copies all go
* through its `checkBundle`). Called by `describeSiteGraphStore`, so it runs on every backend.
@@ -84,7 +85,7 @@ async function withSnapshotSite(
}
export function referenceGateSuite(ctx: SuiteContext): void {
- describe("RFC-0045 step 1: reference and repeater types are refused on every write path", () => {
+ describe("RFC-0045 §1: without the capability, reference and repeater types are refused on every write path", () => {
let n = 0;
const store = (): SiteGraphStore => ctx.store();
const storage = () => ({ sql: ctx.sql(), blobs: ctx.blobs() });
diff --git a/packages/core-ts/src/testing.ts b/packages/core-ts/src/testing.ts
index 1a8a005c..aefaaece 100644
--- a/packages/core-ts/src/testing.ts
+++ b/packages/core-ts/src/testing.ts
@@ -12,6 +12,7 @@ import { assetRefsSuite } from "./testing-asset-refs.ts";
import { assetsSuite } from "./testing-assets.ts";
import { collectionsSuite } from "./testing-collections.ts";
import { entryQuerySuite } from "./testing-entry-query.ts";
+import { entryRefsJobSuite } from "./testing-entry-refs-job.ts";
import { fieldOperationsSuite } from "./testing-field-operations.ts";
import { fieldOperationsOldShapeSuite } from "./testing-field-operations-old-shape.ts";
import { fieldOperationsReferencesSuite } from "./testing-field-operations-references.ts";
@@ -282,6 +283,7 @@ export function describeSiteGraphStore(label: string, factory: StoreFactory): vo
fieldOperationsSuite(suite);
fieldOperationsReferencesSuite(suite);
fieldOperationsOldShapeSuite(suite);
+ entryRefsJobSuite(suite);
referenceGateSuite(suite);
referencesSuite(suite);
referenceReadsSuite(suite);
diff --git a/packages/mcp-server/src/collection-fields.ts b/packages/mcp-server/src/collection-fields.ts
index a70bc9ec..2613c6fa 100644
--- a/packages/mcp-server/src/collection-fields.ts
+++ b/packages/mcp-server/src/collection-fields.ts
@@ -33,8 +33,8 @@ function withoutMarker(variant: JsonSchema): JsonSchema {
export interface FieldTypeOptions {
/**
* The store accepts RFC-0045's `reference` and `repeater` (`SiteGraphStoreOptions.fieldTypes`
- * is set). Absent or false: the inputs list the enabled types only, as production does until
- * rollout step 7. The store still decides per site.
+ * is set: the admin Worker, Node stores, the playground; rollout step 7). Absent or false: the
+ * inputs list the other types only. The store still decides per site (`fields: 2`).
*/
referenceTypes?: boolean | undefined;
}
diff --git a/packages/storage/migrations/0034_entry_refs_state.ts b/packages/storage/migrations/0034_entry_refs_state.ts
new file mode 100644
index 00000000..6c4fda11
--- /dev/null
+++ b/packages/storage/migrations/0034_entry_refs_state.ts
@@ -0,0 +1,25 @@
+import type { Migration } from "../src/migrate.ts";
+
+/**
+ * RFC-0045 §2.4, rollout step 7: progress of the one-time per-site `entry_refs` rebuild
+ * (`rebuildEntryRefs` in core-ts; the admin Worker's `refs.rebuild` job, and `openLocalStore` on
+ * Node). One row per site once the job has started there:
+ *
+ * - `cursor`: the key (`nodes.key`, `collection:locale:slug`) of the last entry whose rows a chunk
+ * wrote; `NULL` before the first chunk;
+ * - `done`: 1 once every entry was read and the orphans removed; the site is then never read again.
+ *
+ * The rows themselves are written per node, anchored on each node's read hash, so the job needs no
+ * more state than where to go on.
+ */
+export const migration0034EntryRefsState: Migration = {
+ name: "0034_entry_refs_state",
+ sql: `
+CREATE TABLE entry_refs_state (
+ site_id TEXT PRIMARY KEY,
+ cursor TEXT,
+ done INTEGER NOT NULL,
+ updated_at TEXT NOT NULL
+);
+`,
+};
diff --git a/packages/storage/migrations/index.ts b/packages/storage/migrations/index.ts
index 4ce3127b..ec8d5fd1 100644
--- a/packages/storage/migrations/index.ts
+++ b/packages/storage/migrations/index.ts
@@ -32,6 +32,7 @@ import { migration0030RateLimits } from "./0030_rate_limits.ts";
import { migration0031Oauth } from "./0031_oauth.ts";
import { migration0032OauthClientKindIndex } from "./0032_oauth_client_kind_index.ts";
import { migration0033EntryRefs } from "./0033_entry_refs.ts";
+import { migration0034EntryRefsState } from "./0034_entry_refs_state.ts";
/** All Cloudhal storage migrations, in order. */
export const migrations: readonly Migration[] = [
@@ -68,4 +69,5 @@ export const migrations: readonly Migration[] = [
migration0031Oauth,
migration0032OauthClientKindIndex,
migration0033EntryRefs,
+ migration0034EntryRefsState,
];
diff --git a/packages/storage/test/migrate.test.ts b/packages/storage/test/migrate.test.ts
index 8afc66b1..b27095df 100644
--- a/packages/storage/test/migrate.test.ts
+++ b/packages/storage/test/migrate.test.ts
@@ -51,6 +51,7 @@ describe("migrate", () => {
"0031_oauth",
"0032_oauth_client_kind_index",
"0033_entry_refs",
+ "0034_entry_refs_state",
]);
expect((await migrate(driver, migrations)).applied).toEqual([]);
expect(await appliedMigrations(driver)).toEqual([
@@ -87,6 +88,7 @@ describe("migrate", () => {
"0031_oauth",
"0032_oauth_client_kind_index",
"0033_entry_refs",
+ "0034_entry_refs_state",
]);
const tables = await driver.all<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
@@ -124,6 +126,7 @@ describe("migrate", () => {
"entry_list_state",
"entry_list_values",
"entry_refs",
+ "entry_refs_state",
"invitation",
"jwks",
"media_blobs",
@@ -213,6 +216,7 @@ describe("migrate", () => {
"0031_oauth",
"0032_oauth_client_kind_index",
"0033_entry_refs",
+ "0034_entry_refs_state",
]);
expect(await appliedMigrations(driver)).toEqual([
"0001_init",
@@ -248,6 +252,7 @@ describe("migrate", () => {
"0031_oauth",
"0032_oauth_client_kind_index",
"0033_entry_refs",
+ "0034_entry_refs_state",
]);
await h.dispose();
});
@@ -290,6 +295,7 @@ describe("migrate", () => {
"0031_oauth",
"0032_oauth_client_kind_index",
"0033_entry_refs",
+ "0034_entry_refs_state",
]);
const ok = await driver.get("SELECT name FROM sqlite_master WHERE name = 'ok'");
expect(ok).toBeUndefined();
diff --git a/scripts/mcp-tools-doc.ts b/scripts/mcp-tools-doc.ts
index c48b34d7..204426bd 100644
--- a/scripts/mcp-tools-doc.ts
+++ b/scripts/mcp-tools-doc.ts
@@ -75,7 +75,11 @@ async function render(): Promise {
const a = readme.indexOf(START);
const b = readme.indexOf(END);
if (a < 0 || b < a) throw new Error(`${path.relative(ROOT, README)}: tools markers not found`);
- return readme.slice(0, a) + toolsTable(defineTools()) + readme.slice(b + END.length);
+ return (
+ readme.slice(0, a) +
+ toolsTable(defineTools({ referenceTypes: true })) +
+ readme.slice(b + END.length)
+ );
}
if (import.meta.url === `file://${process.argv[1]}`) {
diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json
index fd24e680..f24115a3 100644
--- a/scripts/size-baseline.json
+++ b/scripts/size-baseline.json
@@ -369,10 +369,10 @@
"params": 5
},
"packages/cloudflare/src/admin-worker.ts::createAdminWorker": {
- "lines": 261
+ "lines": 252
},
"packages/cloudflare/src/admin-worker.ts::createAdminWorker>fetch": {
- "lines": 199
+ "lines": 190
},
"packages/comments/src/client/comments.browser.js::enhance": {
"lines": 205