Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/reference-repeater-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@cloudhal/core-contracts": minor
"@cloudhal/core-ts": patch
"@cloudhal/mcp-server": patch
"@cloudhal/api": patch
---

The contract gains two field types for collections, `reference` and `repeater` (RFC-0045 §1, rollout step 1, #114). Every write path still refuses them, so existing sites behave exactly as before.

- **Contract.** `FieldDefinition` gains `ReferenceField` and `RepeaterField`. A new `SubFieldDefinition` union holds a repeater's sub-fields: scalar values only, so a repeater cannot contain a repeater. `FieldMigration.fromType` gains both types, and `FieldMigration` gains an optional `sub`. There is no `schemaVersion` bump, and existing documents stay valid.
- **The gate.** Until rollout step 7 enables them, a reference or repeater definition, or a migration marker that names one, is refused with `fields_unsupported`: API status 409, with an MCP hint. The refusal covers:
- site creates and updates, so Studio, MCP and the API;
- field operations;
- every bundle `importSite` takes: import, pull, push, `cloudhal import --replace` and `--revert`, snapshot restores and edition copies.

`checkBundle` gained `assertFieldTypes`. It checks the working site row, and `prepareImport` checks every snapshot site body the import writes once it has been verified (bodies the target already holds are its own), and refuses the types when no capability is given. A push answers `409 fields_unsupported` with its issues, not `400 bad_request`.
- **Pure core** (`@cloudhal/core-ts/fields`), with no effect on stored data yet:
- the definition rules: `reference_target_unknown`, `where`, `itemLabel`, and caps on sub-fields and leaves;
- value checks for both types, the per-item `requiredToPublish`, and `too_many_references`;
- `resolveFields` skips types it does not know;
- `fieldsJsonSchema(…, { strict: true })` and `strictSchemaCost`;
- `pickMember`, `referenceIssues`, and `FIELD_QUERY_OPS` and structured-data entries that make neither type listable or mappable.

`FIELD_TYPES` keeps the nine enabled types (`EnabledFieldType`). `ALL_FIELD_TYPES` lists all eleven.
- **MCP.** `update_collection`'s input now builds its `$defs` with `defsClosure` and offers only the enabled types. A test checks that every `$ref` in every tool's input and output schema resolves. Before this change, `pnpm gen` alone would have left the input pointing at `$defs` it did not carry.
- **Fix, existing sites:** block props and typed-field objects no longer accept keys named after `Object.prototype` members (`constructor`, `toString`, `__proto__`, …) as known props. The props interpreter (`validateProps`) now checks and reads own keys only. Such keys are refused as unknown, and a prop the value leaves out is absent, not the prototype's function. `toLocaleString`, `isPrototypeOf` and `propertyIsEnumerable` join the reserved field keys, beside `toString`, `valueOf` and `hasOwnProperty`.
6 changes: 5 additions & 1 deletion apps/studio/src/components/fields/FieldRow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FieldDefinition } from "@cloudhal/core-contracts";
import { isReferenceFieldType } from "@cloudhal/core-ts/fields";
import { Badge, Button, DropdownMenu, Text, Tooltip } from "@cloudhal/ui";
import {
ArrowDown,
Expand Down Expand Up @@ -59,7 +60,10 @@ export function FieldRow({
<Text as="span" variant="mono-secondary">
{def.key}
</Text>
<Badge variant="secondary">{t(`fields.types.${def.type}`)}</Badge>
{/* RFC-0045: reference and repeater get their words with their blocks (rollout step 6). */}
{!isReferenceFieldType(def.type) && (
<Badge variant="secondary">{t(`fields.types.${def.type}`)}</Badge>
)}
{def.localized && <Badge variant="info">{t("fields.builder.localized")}</Badge>}
{def.type !== "boolean" && def.requiredToPublish && (
<Badge variant="outline">{t("fields.editor.neededToPublish")}</Badge>
Expand Down
6 changes: 4 additions & 2 deletions apps/studio/src/components/listings/ListFieldsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CollectionSettings, FieldDefinition, Site } from "@cloudhal/core-contracts";
import { fieldLabel } from "@cloudhal/core-ts/fields";
import { fieldLabel, isReferenceFieldType } from "@cloudhal/core-ts/fields";
import { Badge, Button, Link, SectionEmpty, Select, Text } from "@cloudhal/ui";
import { ArrowDown, ArrowUp, Columns, Trash } from "@phosphor-icons/react";
import { type ReactNode, useEffect, useState } from "react";
Expand Down Expand Up @@ -181,7 +181,9 @@ function ListFieldRow({
<Text as="span" variant="mono-secondary">
{fieldKey}
</Text>
{def && <Badge variant="secondary">{t(`fields.types.${def.type}`)}</Badge>}
{def && !isReferenceFieldType(def.type) && (
<Badge variant="secondary">{t(`fields.types.${def.type}`)}</Badge>
)}
{def && sortable(def) && (
<Badge variant="outline">{t("listings.listFields.sortable")}</Badge>
)}
Expand Down
2 changes: 1 addition & 1 deletion crates/cloudhal-schema/src/generated/content_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ pub struct ContentEntry {
pub cover_image: ::std::option::Option<ImageRef>,
#[serde(rename = "createdAt")]
pub created_at: Timestamp,
#[doc = "Collection-specific values, keyed by field. A collection with typed fields (RFC-0041, Site.collections.<key>.fields) checks each value against its definition on write; otherwise any JSON. An absent key means no value."]
#[doc = "Collection-specific values, keyed by field. A collection with typed fields (RFC-0041, Site.collections.<key>.fields) checks each value against its definition on write; otherwise any JSON. An absent key means no value. A reference value is a translation group id or a list of them, a repeater value a list of flat objects (RFC-0045)."]
pub fields: ::serde_json::Map<::std::string::String, ::serde_json::Value>,
pub id: Id,
pub locale: Locale,
Expand Down
198 changes: 198 additions & 0 deletions crates/cloudhal-schema/src/generated/site.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2772,6 +2772,70 @@ pub enum FieldDefinition {
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "ReferenceField\n\nRFC-0045: entries of other (or this) collections of the same site, by translation group id (entry_…): the id of the group's original, which every language version and edition carries in translation.group."]
#[serde(rename = "reference")]
Reference {
#[doc = "The collections a target may belong to: declared collections of this site (checked in code)."]
collections: ::std::vec::Vec<Slug>,
key: FieldKey,
labels: FieldLabels,
#[doc = "true: each language version picks its own targets; false: only the translation group's original holds them, and every version resolves them in its own language."]
localized: bool,
#[doc = "With multiple: at most this many targets. Absent: 20."]
#[serde(
rename = "maxItems",
skip_serializing_if = "::std::option::Option::is_none"
)]
max_items: ::std::option::Option<::std::num::NonZeroU64>,
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
migration: ::std::option::Option<FieldMigration>,
#[doc = "The value is an ordered list of targets. Absent: false."]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
multiple: ::std::option::Option<bool>,
#[doc = "An entry in review or published needs at least one target. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
#[doc = "Only with one target collection: conditions on its list fields that a newly chosen target must meet (no `now`; checked in code). Narrows the picker; not re-checked when the target changes later."]
#[serde(
rename = "where",
default,
skip_serializing_if = "::std::vec::Vec::is_empty"
)]
where_: ::std::vec::Vec<FieldCondition>,
},
#[doc = "RepeaterField\n\nRFC-0045: an ordered list of items, each a flat object of sub-fields. A repeater cannot contain a repeater."]
#[serde(rename = "repeater")]
Repeater {
#[doc = "The sub-fields of every item, in form order. Unique keys (checked in code)."]
fields: ::std::vec::Vec<SubFieldDefinition>,
#[doc = "The text, select or reference sub-field that names an item in Studio and approval prompts. Absent: the first such sub-field."]
#[serde(
rename = "itemLabel",
skip_serializing_if = "::std::option::Option::is_none"
)]
item_label: ::std::option::Option<FieldKey>,
key: FieldKey,
labels: FieldLabels,
#[doc = "true: each language version has its own list (missing reads the original's); false: only the original holds the list."]
localized: bool,
#[doc = "At most this many items. Absent: 20."]
#[serde(
rename = "maxItems",
skip_serializing_if = "::std::option::Option::is_none"
)]
max_items: ::std::option::Option<::std::num::NonZeroU64>,
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
migration: ::std::option::Option<FieldMigration>,
#[doc = "An entry in review or published needs at least one item. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
}
#[doc = "The field's key in ContentEntry.fields: lower camelCase, a JavaScript identifier. Unique in its collection; built-in entry property names are reserved (checked in code)."]
#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
Expand Down Expand Up @@ -2979,6 +3043,9 @@ pub struct FieldMigration {
pub from_type: ::std::option::Option<FieldMigrationFromType>,
#[doc = "The operation's \"before\" snapshot."]
pub id: Id,
#[doc = "RFC-0045: the repeater's sub-field the operation rewrites (its key once the operation is done); fromKey and fromType then describe the sub-field."]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
pub sub: ::std::option::Option<FieldKey>,
}
#[doc = "`FieldMigrationFromType`"]
#[derive(
Expand Down Expand Up @@ -3012,6 +3079,10 @@ pub enum FieldMigrationFromType {
Image,
#[serde(rename = "select")]
Select,
#[serde(rename = "reference")]
Reference,
#[serde(rename = "repeater")]
Repeater,
}
impl ::std::fmt::Display for FieldMigrationFromType {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
Expand All @@ -3025,6 +3096,8 @@ impl ::std::fmt::Display for FieldMigrationFromType {
Self::Url => f.write_str("url"),
Self::Image => f.write_str("image"),
Self::Select => f.write_str("select"),
Self::Reference => f.write_str("reference"),
Self::Repeater => f.write_str("repeater"),
}
}
}
Expand All @@ -3041,6 +3114,8 @@ impl ::std::str::FromStr for FieldMigrationFromType {
"url" => Ok(Self::Url),
"image" => Ok(Self::Image),
"select" => Ok(Self::Select),
"reference" => Ok(Self::Reference),
"repeater" => Ok(Self::Repeater),
_ => Err("invalid value".into()),
}
}
Expand Down Expand Up @@ -5452,6 +5527,129 @@ impl<'de> ::serde::Deserialize<'de> for StructuredDataSourceValue {
})
}
}
#[doc = "RFC-0045: one sub-field of a repeater item, discriminated by `type`. Only types whose value is a scalar: no datetime, no multiple select or reference, no repeater. No localized and no migration: the repeater's apply."]
#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)]
#[serde(tag = "type", deny_unknown_fields)]
pub enum SubFieldDefinition {
#[doc = "TextSubField"]
#[serde(rename = "text")]
Text {
key: FieldKey,
labels: FieldLabels,
#[doc = "Absent: 5000."]
#[serde(
rename = "maxLength",
skip_serializing_if = "::std::option::Option::is_none"
)]
max_length: ::std::option::Option<::std::num::NonZeroU64>,
#[doc = "Line breaks allowed. Absent: false."]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
multiline: ::std::option::Option<bool>,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "RichTextSubField"]
#[serde(rename = "richText")]
RichText {
key: FieldKey,
labels: FieldLabels,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "NumberSubField"]
#[serde(rename = "number")]
Number {
#[doc = "Whole numbers only. Absent: false."]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
integer: ::std::option::Option<bool>,
key: FieldKey,
labels: FieldLabels,
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
maximum: ::std::option::Option<f64>,
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
minimum: ::std::option::Option<f64>,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "BooleanSubField"]
#[serde(rename = "boolean")]
Boolean { key: FieldKey, labels: FieldLabels },
#[doc = "DateSubField"]
#[serde(rename = "date")]
Date {
key: FieldKey,
labels: FieldLabels,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "UrlSubField"]
#[serde(rename = "url")]
Url {
key: FieldKey,
labels: FieldLabels,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "ImageSubField"]
#[serde(rename = "image")]
Image {
key: FieldKey,
labels: FieldLabels,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "SelectSubField"]
#[serde(rename = "select")]
Select {
key: FieldKey,
labels: FieldLabels,
options: ::std::vec::Vec<FieldOption>,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
#[doc = "ReferenceSubField"]
#[serde(rename = "reference")]
Reference {
#[doc = "The collections a target may belong to (checked in code)."]
collections: ::std::vec::Vec<Slug>,
key: FieldKey,
labels: FieldLabels,
#[doc = "Every item of an entry in review or published needs a value. Absent: false."]
#[serde(
rename = "requiredToPublish",
skip_serializing_if = "::std::option::Option::is_none"
)]
required_to_publish: ::std::option::Option<bool>,
},
}
#[doc = "One element of a generative UI surface (RFC-0020 §1), in a flat list: `component` names a catalog entry (a PascalCase built-in primitive such as `Stack`, or a registry block type such as `callout`), `children` refer to other ids of the same surface, and `props` follow the entry's schema, which the store checks. Field names follow A2UI v0.9."]
#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)]
#[serde(deny_unknown_fields)]
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export const INVARIANT_STATUS: Record<InvariantCode, 400 | 409 | 410 | 415 | 422
structured_data_invalid: 422,
invalid_expiry: 422,
list_field_in_use: 409,
// RFC-0045 (collections stage 4): a type this server does not take yet is 409, like
// gates_unsupported (a deploy, not the request, changes the answer); the rest 422.
fields_unsupported: 409,
reference_target_unknown: 422,
too_many_references: 422,
reserved_key: 400,
immutable_field: 400,
primary_not_assigned: 422,
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/field-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
runFieldOperation,
type SiteGraphStore,
} from "@cloudhal/core-ts";
import { FIELD_TYPES, type FieldType } from "@cloudhal/core-ts/fields";
import { type EnabledFieldType, FIELD_TYPES, type FieldType } from "@cloudhal/core-ts/fields";
import type { Context } from "hono";
import { Hono } from "hono";
import type { Env } from "./context.ts";
Expand Down Expand Up @@ -53,7 +53,7 @@ export function operationOf(collection: string, b: Body): FieldOperation {
return { op, collection, key: str(b, "key"), localized: b.localized };
}
const type = str(b, "type");
if (!FIELD_TYPES.includes(type as FieldType))
if (!FIELD_TYPES.includes(type as EnabledFieldType))
throw badRequest(`type must be one of ${FIELD_TYPES.join(", ")}`);
if (b.onInvalid !== undefined && b.onInvalid !== "clear")
throw badRequest('onInvalid is "clear" or absent');
Expand Down
17 changes: 11 additions & 6 deletions packages/api/src/routes/admin/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ import { OPERATOR_ACTOR } from "./shared.ts";
const MAX_MEDIA_PUT = 100 * 1024 * 1024;

/** Bundle export/import, primary domain, media blobs. */
/**
* Refusals a push answers with their own code and details instead of 400 bad_request (RFC-0008:
* 409 gates_unsupported, 400 invalid_availability; RFC-0045: 409 fields_unsupported with its issues).
*/
const KEPT_CODES: ReadonlySet<string> = new Set([
"gates_unsupported",
"invalid_availability",
"fields_unsupported",
]);

export function syncAdminRoutes(deps: AdminDeps): Hono<Env> {
const { storage } = deps;
return new Hono<Env>()
Expand Down Expand Up @@ -144,12 +154,7 @@ export function syncAdminRoutes(deps: AdminDeps): Hono<Env> {
{ error: "conflict", message: e.message, details: { differing: e.differing } },
409,
);
// RFC-0008: refused gates keep their code (409 gates_unsupported, 400 invalid_availability).
if (
e instanceof InvariantError &&
(e.code === "gates_unsupported" || e.code === "invalid_availability")
)
throw e;
if (e instanceof InvariantError && KEPT_CODES.has(e.code)) throw e;
if (e instanceof Error) throw badRequest(e.message);
throw e;
}
Expand Down
Loading
Loading