Skip to content

feat(core-ts): reference consumers, gated off (RFC-0045 step 2b, #114) - #206

Merged
Madraka merged 5 commits into
mainfrom
claude/rfc45-step2b
Nov 11, 2026
Merged

Madraka merged 5 commits into
mainfrom
claude/rfc45-step2b

Conversation

@Madraka

@Madraka Madraka commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

RFC-0045 rollout step 2b, the second half of "Store and integrity", for #114. It builds on 2a (PR #205, merged) and ports the seven "Moves to 2b" items onto 2a's amended design:

  • the text anchors;
  • the definitions anchor on every write that touches references;
  • usableMembers as one parameter;
  • unknown_entry for referrer writes.

Production still refuses reference and repeater on every write path, and nothing passes a FieldTypeSupport before step 7. Each consumer here is therefore reachable only in the shared suites, which enable the types through SiteGraphStoreOptions.fieldTypes.

What 2b contains, per "Moves to 2b" item

  1. referenceIssues in cloudhal import --dry-run. In cli/src/commands/import-references.ts, a dry run prints warnings for:

    • published references that will show nothing;
    • OQ 14's originals that were relinked into another group, whose translations now read none of their non-localized values.

    These are warnings, never refusals.

  2. Assets inside repeater items (media/field-refs.ts).

    • fieldAssetRefs walks items, records fields.<key>.<sub> (one row per asset and field), and returns each use's item path.
    • fieldAssetIssues resolves sub-field types, so a non-image in an image sub-field is refused. It reports at every item that uses the file: fieldAssetRefs keeps each use's path in paths, while asset_refs stays one row per asset and field.
    • The skip set and the filter match in either direction by prefix, which keeps top-level behaviour unchanged. Both the store (store/entry-fields.ts) and the MCP precheck (mcp-server/src/entry-fields.ts) pass full issue paths.
  3. Rows and the target guard in field-operation rewrites (fields-operations/rewrite.ts).

    • A rewrite that changes values writes its entry_refs rows with entryRefStatements, next to its asset rows.
    • When it changes reference values, it ANDs rewriteTargetGuard into its CAS.
    • Adapted to 2a: each pass in a site with reference fields reads one definitions anchor (the text anchor) and ANDs it into every rewrite. If the site changed since the pass read it, the pass's entries are skipped, and the next pass, which re-reads the site, retries them.
    • Operations that name the new types are still refused (2a).
  4. Kind-aware release depsOf (core-ts/src/release-deps.ts).

    • Reference values, top level and inside items, read through a running rename's fromKey, are stripped by the pinned site's definitions before referencedNodeIds.
    • releases.ts only imports it and stays at 902 lines.
  5. Edition id mapping (references/editions.ts, forEdition). It keeps exactly what the edition's own write accepts:

    • a group with a member in a collection the edition's field allows, not archived, that meets its where;
    • one id for a single-target field, at most maxItems for a multiple one;
    • at most 100 references per entry;
    • at most maxItems repeater items.

    Emptied items are dropped. Values are read through a running rename's fromKey and written back under the same key. Every drop is reported with its path and reason through the new optional onDropped.

    • The 100-reference cap is spent first on every unchanged key, then on the changed keys. It counts every reference value, repeated targets included.
    • A re-save of an existing edition translation passes its stored entry. A key whose value is unchanged is kept as stored. Groups the stored entry already holds under the same key are not fresh, so the where and archived checks are skipped for them, exactly as the edition's own write does.
    • Shared rule. The edition mapping and the writer's plan-time check share references/targets.ts: readMembers reads members of any status, and targetProblem applies the target rule. An archived same-site original therefore still lends its where field to a translation member.
  6. The fields: 2 capability.

    • cloudflare/src/features.ts: siteFeatures reads fields, and siteFieldTypeSupport is the twin of siteGateSupport (yes from 2, remembered, a no asked again).
    • @cloudhal/core-ts/fields exports FIELDS_FEATURE_VERSION and the Node constant FIELDS_SUPPORTED.
    • Nothing passes either to a store yet.
  7. API. In api/src/routes/entry-references.ts, both routes are content:read and declared in scopes.ts:

    • GET /sites/:siteId/entries/pick returns at most 20 cards from the allowed collections, never archived ones. q is at most 100 characters and refused before it is used. A per-member rate_limits window applies. The search is in api/src/entry-pick.ts:
      • Where it reads. A content entry's node key is <collection>:<locale>:<slug>, and nodes_site_kind_key (site_id, kind, key) is a unique index. Each allowed collection, or collection and locale, is one index range, read in key order with a keyset cursor. The ranges are visited in the order of their key prefixes, not their names, because "post-x:" sorts before "post:". The WHERE names only indexed columns, so every row read is a row returned. An EXPLAIN QUERY PLAN test checks the index search.
      • The budget. At most 1,000 rows per request, across collections. When it runs out before limit matches, the answer carries truncated: true and a cursor. Asking again with cursor continues where the last request stopped, so any matching entry can be found.
      • The match. q and each row's title and slug are folded in JS with the same foldName (case, diacritics, Turkish İ and ı) and matched as a substring, with no pattern (rule 13). A q with nothing left after folding, such as only punctuation, matches nothing.
      • Why this design. Requirements a to d are met with no migration and no new write-time state. A folded search key stored at write time would need maintaining on every write, import and rebuild. A title expression index would give title order but needs a migration, and a single IN-list query over several collections cannot walk it in order.
    • GET /sites/:siteId/entries/:entryId/references returns the group's rows, published first, each with the same-site members that show its value, paged. A member is left out of a row's shownBy only when it is that row's holder, so the result does not depend on where the pages are cut.

R9 and where it is tested

R9 is "field-operation phase B against deleting a target". It cannot happen through an operation before step 4, because operations that convert into references are refused. The rewrite path those operations will use is tested directly:

  • "R9: a rewrite changing a reference value skips the entry when its target goes first" (testing-references.ts, every backend) calls rewritePass with a decision that changes venue, while a racing delete removes the new target.
    • The entry is skipped, and its value and rows are unchanged.
    • Without the race, the rewrite lands, rows included.
  • Removing rewriteTargetGuard from the CAS makes the test fail; I checked by doing that.
  • "a text field's rename rewrites entries holding references and keeps their rows" covers a real operation on a site with references.

Other new suite cases:

  • an item image recorded in asset_refs, kept through a rebuild, and blocking its asset's delete;
  • a non-image refused at fields/speakers/1/photo;
  • editions: ids carried and dropped, emptied items, the cross-site group guarded in the edition;
  • editions keeping only what the edition's field accepts (where, single target, maxItems) and reading through fromKey;
  • a re-save keeping a value whose target has since closed and been archived;
  • an archived same-site original lending its where field to a translation member;
  • unchanged repeaters with a repeated target filling the cap before a changed key;
  • a bad asset reported at every item that uses it;
  • references-store.test.ts: release depsOf drops reference values and keeps every other dependency.

api/test/entry-pick.test.ts runs on better-sqlite3, D1 and DO. It covers:

  • following the cursor past the row budget to a non-ASCII title;
  • limit with a cursor across collections;
  • post/post-x and news/news2 ranges, in one request and across one-row continuations, with and without a locale;
  • punctuation-only queries;
  • the query plan (sqlite).

The API suite (api/src/testing-references.ts, sqlite and D1) covers:

  • the picker, with ASCII and non-ASCII uppercase queries ("ÇİÇEK", "İSTANBUL");
  • referrers, including a translation shown under the original's non-localized row while it holds its own row (paged at 20 and at 1);
  • the status codes.

Two more tests cover the review fixes. mcp-server/test/entry-fields-assets.test.ts checks that the precheck reports an item-level issue and another item's non-image together. references-store.test.ts checks every use's path and per-use skips. cli/test/import-references.test.ts covers the dry-run warnings, and cloudflare/test/features.test.ts covers fields: 2.

Deviations

  1. Rewrites carry the definitions anchor in sites with reference fields. RFC §2.6 asks only for the target guard. I added the anchor to follow 2a's "definitions anchor on every write that touches references": the rows and pairs come from the pass's definitions, and the marker guard alone does not pin the other fields of the same collection.
  2. Picker results come in key order (collection, locale, slug), not title order: that is the order of the index the bounded keyset walks. Each request reads at most 1,000 rows and answers truncated with a cursor when that runs out first.
  3. forEdition reports drops through an onDropped callback. The MCP translation tool will show them in step 5. OQ 14's relinked originals are reported in import --dry-run only; Studio's collection screen is step 6.
  4. referrers lists holders with the members that show their value (shownBy). It does not list the inheriting translations as their own rows.

Tests run (locally, one file at a time)

These ran on 9d02bf0.

The new SQL ran on all three drivers:

  • test/store.test.ts -t "<backend>.*RFC-0045 step 2": 49/49 on each of better-sqlite3, D1 (Miniflare) and Durable Object (Miniflare);
  • api/test/entry-pick.test.ts: 10/10, three cases on each driver plus the query plan;
  • api/test/references-api.test.ts: better-sqlite3 and D1, 8/8.

Other runs:

  • core-ts: store.test.ts -t better-sqlite3 214 passed; references-store and editions 27;
  • mcp-server: translation-tools 8;
  • typecheck: core-ts, api and mcp-server;
  • pnpm size:check passes, and biome reports no errors.

I checked that each of these fails without its fix:

  • R9, without the rewrite's target guard;
  • the MCP precheck, with slice(0, 2);
  • shownBy, with the old holder filter;
  • the archived original, with an archived filter in readMembers;
  • the range order, sorted by name;
  • the edition cap, with the previous editions.ts.

Not run locally: the D1 and DO variants of the rest of store.test.ts, and the full package suites (left to CI).

🤖 Generated with Claude Code

Madraka and others added 5 commits November 11, 2026 12:32
Ports the step-2 consumers onto the amended 2a design:
- fieldAssetRefs walks repeater items (fields.<key>.<sub>, item paths);
  fieldAssetIssues resolves sub-field types; skips match by prefix;
- field-operation rewrites write entry_refs rows, AND the target guard when
  reference values change, and carry the definitions anchor (text anchor,
  read per pass) in sites with reference fields; a stale site skips the
  pass's entries for the next pass;
- release-deps.ts: reference values are not release dependencies;
- forEdition keeps only references the edition resolves (onDropped);
- cloudhal import --dry-run warns about unresolved references and
  relinked originals;
- siteFieldTypeSupport and FIELDS_FEATURE_VERSION / FIELDS_SUPPORTED
  (not passed anywhere before step 7);
- API: entries/pick and entries/:id/references (content:read).
Suite: repeater assets, a text-field rename keeping rows, R9 through
rewritePass with a racing delete, editions; release depsOf unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…es, shownBy, picker folding)

- #0: the MCP precheck builds its asset skip set from full issue paths, as
  the store does, so an item-level issue no longer hides another item's
  non-image (precheck test).
- #1: fieldAssetRefs keeps every use's path (`paths`); fieldAssetIssues
  reports each use not under a skip path, the asset_refs rows still one per
  asset and field.
- #2, #3: editionReferences applies the edition field's write rules (where,
  a single target, maxItems, the 100-reference cap, repeater maxItems),
  reads values through a running rename's fromKey (storedKey) and writes
  them back under that key; every drop carries its reason.
- #7: shownBy leaves out only the row's own holder, so it no longer depends
  on pagination (suite case paging at 20 and 1).
- #8: the picker folds q and the candidates' titles and slugs with foldName
  in JS (case, diacritics, Turkish İ/ı) over at most 5,000 candidates read
  as small columns; tests with ASCII and non-ASCII uppercase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…red target rule (PR #206 verify)

- Picker (verify 1, 2): entry-pick.ts reads each allowed collection as a
  key range of nodes_site_kind_key (`<collection>:<locale>:<slug>`), at
  most 1,000 rows per request, folds q and every row's title and slug with
  foldName, and answers truncated: true with a cursor when the budget ends
  first, so any match is found by asking again. A q that folds to nothing
  matches nothing. Tests on better-sqlite3, D1 and DO, and an EXPLAIN QUERY
  PLAN check; no migration.
- Editions (verify 3): forEdition passes the edition's stored entry; a key
  whose value is unchanged is kept as stored, and groups it already holds
  under that key are not fresh (no where or archived check), as the write.
- Shared target rule (verify 4): references/targets.ts holds readMembers
  (any status) and targetProblem; the writer's useIssue and the edition
  mapping both use them, so an archived same-site original still lends its
  where field to a translation member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…st (PR #206 verify 2)

- Picker: collection ranges are visited in the order of their key
  prefixes, not their names ("post-x:" and "news2:" sort before "post:" and
  "news:"), so the cursor never skips a range. Test with post/post-x and
  news/news2 in one request and across one-row continuations, with and
  without a locale, on all three drivers.
- Editions: the per-entry cap is spent on every unchanged key first (pass
  one), then the changed keys are carried (pass two), and it counts every
  reference value, repeated targets included; the Set of held groups is
  only the freshness test. Suite case: two unchanged repeaters with one
  target 50 times each fill the cap; the changed key is dropped (cap) and
  the edition write accepts the result.
- RFC-0045 §5: the picker line gives cursor, {entries, cursor?, truncated?},
  the 1,000-row budget and key order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…#206 CI)

GET …/entries/pick and GET …/entries/:entryId/references are read-only
content:read routes; they join PLAYGROUND_ALLOW beside the other entry
reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit bab357b into main Nov 11, 2026
8 checks passed
@Madraka
Madraka deleted the claude/rfc45-step2b branch November 11, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant