diff --git a/.github/scripts/sync-from-marfa.test.sh b/.github/scripts/sync-from-marfa.test.sh index fbae8ce2..13c3afcf 100644 --- a/.github/scripts/sync-from-marfa.test.sh +++ b/.github/scripts/sync-from-marfa.test.sh @@ -363,7 +363,8 @@ catalog_json="$(cat <<'EOF' }, { "id": "parent-of", - "label": "Parent of", + "label": "Children", + "reverse_label": "Parent", "description": "Source is the parent of the target — once.", "cardinality": "one-to-many", "source_type_constraints": ["*"], @@ -377,6 +378,7 @@ catalog_json="$(cat <<'EOF' { "id": "in-folder", "label": "In folder", + "reverse_label": "Folder contents", "description": "Placement of an item in a folder.", "cardinality": "many-to-many", "source_type_constraints": ["*"], @@ -438,11 +440,11 @@ folder_expected="$(cat <<'EOF' EOF )" edge_table_expected="$(cat <<'EOF' -| Edge type | Reverse name | Cardinality | On delete | Target types | -| --------------- | ------------ | -------------- | --------- | ---------------------------------- | -| `in-collection` | None | `many-to-many` | `orphan` | Any type with the role `container` | -| `parent-of` | `child-of` | `one-to-many` | `cascade` | Any type | -| `in-folder` | None | `many-to-many` | `orphan` | `system.folder` | +| Edge type | Label | Reverse label | Reverse name | Cardinality | On delete | Target types | +| --------------- | ------------- | --------------- | ------------ | -------------- | --------- | ---------------------------------- | +| `in-collection` | In collection | In collection | None | `many-to-many` | `orphan` | Any type with the role `container` | +| `parent-of` | Children | Parent | `child-of` | `one-to-many` | `cascade` | Any type | +| `in-folder` | In folder | Folder contents | None | `many-to-many` | `orphan` | `system.folder` | EOF )" catalog_edges_expected="$(cat <<'EOF' @@ -797,7 +799,7 @@ check "a core type's file has its fields and settings, a subtype's section only "${note_expected}" "$(cat "${catalog_dir}/core.note.md")" check "a system type's file has its fields, as a core type's does" \ "${folder_expected}" "$(cat "${catalog_dir}/system.folder.md")" -check "the edge types are a table, with a Source types column only when an edge type constrains its source, a reverse name or None, and the contract's wording for a target" \ +check "the edge types are a table, with a Source types column only when an edge type constrains its source, a label, a reverse label that is the label where none is named, a reverse name or None, and the contract's wording for a target" \ "${edge_table_expected}" "$(cat "${catalog_dir}/edge-types.md")" check "each edge type is a section with its properties, and where it is written only when that is its target" \ "${catalog_edges_expected}" "$(cat "${catalog_dir}/edges.md")" @@ -1125,6 +1127,8 @@ refuse_catalog "a merge strategy the sync does not know" "${cat_file} has a merg refuse_catalog "display hints the sync does not write" "${cat_file} has display hints for core.note that the sync does not write. Nothing has changed." "$(catalog_with 'c["types"][0]["display_hints"]["icon_field"] = "title"')" refuse_catalog "an edge type with no description" "${cat_file} has an edge type with no id or description. Nothing has changed." "$(catalog_with 'del c["edge_types"][0]["description"]')" refuse_catalog "an edge type with a key the sync does not write" "${cat_file} has a key 'weight' on the edge type in-collection that the sync does not write. Nothing has changed." "$(catalog_with 'c["edge_types"][0]["weight"] = 1')" +refuse_catalog "an edge type with no label" "${cat_file} has the edge type in-collection with no label. Nothing has changed." "$(catalog_with 'del c["edge_types"][0]["label"]')" +refuse_catalog "an edge type with an empty reverse label" "${cat_file} has the edge type in-collection with a reverse_label that is not a label. Nothing has changed." "$(catalog_with 'c["edge_types"][0]["reverse_label"] = ""')" refuse_catalog "an edge type written nowhere the sync knows" "${cat_file} has the edge type in-collection with a written_at the sync does not know. Nothing has changed." "$(catalog_with 'c["edge_types"][0]["written_at"] = "both"')" refuse_catalog "an edge type written at its target with no reverse name" "${cat_file} has the edge type parent-of written at its target with no reverse_name. Nothing has changed." "$(catalog_with 'del c["edge_types"][1]["reverse_name"]')" refuse_catalog "an edge type with a cardinality the sync does not know" "${cat_file} has the edge type in-collection with a cardinality the sync does not know. Nothing has changed." "$(catalog_with 'c["edge_types"][0]["cardinality"] = "some-to-some"')" @@ -1147,7 +1151,7 @@ check "a default of both copies and a subtype with no field to add: the script s check "a default of both copies names the fields that do not keep both, as values" 1 \ "$(grep -cF '| [`merge_policy`](/catalog/your-own-types#merge-policy) | `last_writer_wins` for `title` and `language`; `keep_both_copies` for every other field |' "${work}/shapes/docs/src/generated/catalog/core.note.md")" check "an edge type that constrains its source adds a Source types column, and several types are joined with or" \ - "$(printf '%s\n%s\n%s\n%s\n%s' '| Edge type | Reverse name | Cardinality | On delete | Source types | Target types |' '| --------------- | ------------ | -------------- | --------- | ------------------------------- | ---------------------------------- |' '| `in-collection` | None | `many-to-many` | `orphan` | Any type | Any type with the role `container` |' '| `parent-of` | `child-of` | `one-to-many` | `cascade` | Any type | Any type |' '| `in-folder` | None | `many-to-many` | `orphan` | `core.note` or `core.note.memo` | `system.folder` |')" \ + "$(printf '%s\n%s\n%s\n%s\n%s' '| Edge type | Label | Reverse label | Reverse name | Cardinality | On delete | Source types | Target types |' '| --------------- | ------------- | --------------- | ------------ | -------------- | --------- | ------------------------------- | ---------------------------------- |' '| `in-collection` | In collection | In collection | None | `many-to-many` | `orphan` | Any type | Any type with the role `container` |' '| `parent-of` | Children | Parent | `child-of` | `one-to-many` | `cascade` | Any type | Any type |' '| `in-folder` | In folder | Folder contents | None | `many-to-many` | `orphan` | `core.note` or `core.note.memo` | `system.folder` |')" \ "$(cat "${work}/shapes/docs/src/generated/catalog/edge-types.md")" check "a subtype with nothing to add is a heading and its description" \ "$(printf '### core.note.bare [#core-note-bare]\n\nA note.')" "$(sed -n '/^### core.note.bare \[#core-note-bare\]$/,$p' "${work}/shapes/docs/src/generated/catalog/core.note.md")" diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index 37d3702e..504d21d9 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -11,7 +11,7 @@ An app built on the [Swift SDK](/swift-sdk) is a device, and so is the [CLI](/cl A device chooses the **slice** of the instance it keeps: a list of types, a tier, and the edge types it holds. For example, every `core.note` and `core.task` in the library. It fills its copy once by reading every item in the slice, then keeps it current from the instance's [event stream](/events). To hold every type except `system.*`, declare `*` alone: the device reads the whole slice in one pass, with no need to read the catalog and name its types first. `*` beside `system.*` types adds them, and `*` beside any other type is refused `invalid` before anything is read. -Reads come from the copy, with no connection needed. A file's bytes are there once the device has fetched them. The core also [reads saved views](/guides/read-a-view), including layout order, groups, counts and sums. **A view's item or group read needs the whole query held** and is refused with a reason if the copy cannot answer it completely. +Reads come from the copy, with no connection needed. A file's bytes are there once the device has fetched them. The core also [reads saved views](/guides/read-a-view), including sort order, groups and counts. **A view's item or group read needs the whole query held** and is refused with a reason if the copy cannot answer it completely. **While a device fills its copy, it answers direct item reads from what has arrived.** A list or search without a saved view returns the items the copy holds so far and marks the answer `partial`; once the copy is whole, the same read is marked `complete`. A read by ID answers for an item that has arrived, and is refused `hydration_incomplete` for one that hasn't. Until the copy is whole, edge reads are refused `hydration_incomplete`, because an edge's two ends can arrive on different pages. A write to an item that has arrived, or a new item, is queued against the version the copy holds and sent once the copy is whole. This includes a file added on its own or attached to an item that has arrived: the device keeps its bytes and uploads them with the rest of the queue. A write to an item that hasn't arrived, or a file attached to one, is refused `hydration_incomplete`, because there is no version to base it on, and a drain is refused `hydration_incomplete` until the copy is whole. **Edge writes are refused `hydration_incomplete` until the copy is whole**, because a page still to arrive can bring the instance's edge in place of the one a write was made against. An edit that changes an item's body is still queued, and the device reads the body's links and embeds and makes their edges once the server answers the edit. The device's status reports how many items the fill has brought so far and, where the instance counted them, how many the slice held when the fill began, so an app can show how far the fill has come. Compare those two figures, not the count of items the copy holds, which also includes pinned items and creates that are still queued. The Swift SDK returns these answers as `Rows` with a `completeness`. diff --git a/content/docs/(overview)/glossary.mdx b/content/docs/(overview)/glossary.mdx index 48e55002..df84134f 100644 --- a/content/docs/(overview)/glossary.mdx +++ b/content/docs/(overview)/glossary.mdx @@ -18,6 +18,8 @@ Marfa uses each word below in one sense only, in its code, its API and these doc | [**edge**](/properties-metadata-and-edges#edges) | A typed link from one item to another. | | [**edge type**](/properties-metadata-and-edges#edges) | What an edge is, such as `about`, and the rules its edges follow. | | [**reverse name**](/properties-metadata-and-edges#edges) | An edge's name read from its target: `child-of` for `parent-of`. | +| [**label**](/properties-metadata-and-edges#edges) | The heading a type or an edge type shows for itself. An edge type's label heads the source's end of an edge, such as `Children` for `parent-of`. | +| [**reverse label**](/properties-metadata-and-edges#edges) | The heading an edge type shows for the target's end, such as `Parent`. Where an edge type names none, its label names both ends. | | [**file**](/files) | An item that names its bytes. | | [**blob**](/files) | A file's bytes, stored once under a hash of their content. | | [**state**](/items-and-types#an-items-life) | `active`, `archived` or `trashed`; `active` or `revoked` for a `system` item. | diff --git a/content/docs/(overview)/properties-metadata-and-edges.mdx b/content/docs/(overview)/properties-metadata-and-edges.mdx index 41af0768..d164c320 100644 --- a/content/docs/(overview)/properties-metadata-and-edges.mdx +++ b/content/docs/(overview)/properties-metadata-and-edges.mdx @@ -72,7 +72,7 @@ Content-Type: application/json { "source_id": "", "target_id": "", "edge_type": "about" } ``` -The **edge type** sets the rules: the properties its edges hold, how many edges each item can hold (a task has at most one parent), which types each end can be, and what moving an item to the bin does across the edge. Read from the other end, an edge has a reverse name: `parent-of` reads as `child-of` from the child. You can [register edge types](/api-reference/edge-types/registerEdgeType) of your own. +The **edge type** sets the rules: the properties its edges hold, how many edges each item can hold (a task has at most one parent), which types each end can be, and what moving an item to the bin does across the edge. Each edge type has a label for the source's end, such as `Children`, and a reverse label for the target's end, such as `Parent`; an edge type with no reverse label uses its label for both ends. Read from the other end, an edge also has a reverse name: `parent-of` reads as `child-of` from the child. You can [register edge types](/api-reference/edge-types/registerEdgeType) of your own. Each of Marfa's edge types either **cascades** or **orphans** when an item goes to the bin: @@ -83,6 +83,6 @@ The figure shows a note with a child note, linked by `parent-of`, and a book the -The Catalog's [Edges](/catalog/edges) page lists every edge type Marfa ships, with its reverse name, cardinality and what it does on delete. +The Catalog's [Edges](/catalog/edges) page lists every edge type Marfa ships, with its label, reverse label, reverse name, cardinality and what it does on delete. The rules behind this page are in the [items](https://github.com/withmarfa/marfa/blob/main/conformance/spec/items.md) and [edges](https://github.com/withmarfa/marfa/blob/main/conformance/spec/edges.md) chapters of the contract. diff --git a/content/docs/(reference)/api-reference/index.mdx b/content/docs/(reference)/api-reference/index.mdx index 6b92d167..75321ea1 100644 --- a/content/docs/(reference)/api-reference/index.mdx +++ b/content/docs/(reference)/api-reference/index.mdx @@ -58,12 +58,12 @@ The reference groups operations by what they act on. The groups and the operatio | Edges | Typed, directed relationships between items | [Create an edge](/api-reference/edges/createEdge) | | Search | Full-text search across items | [Search items](/api-reference/search/searchItems) | | Types | The types items can have: those Marfa ships and those registered on this instance | [Register a type](/api-reference/types/registerType) | -| Edge types | The types edges can have, each with its cardinality and cascade rule | [Register an edge type](/api-reference/edge-types/registerEdgeType) | +| Edge types | The types edges can have, each with its labels, cardinality and cascade rule | [Register an edge type](/api-reference/edge-types/registerEdgeType) | | Blobs | Bytes stored by content hash, and the stores that hold them | [Upload a blob](/api-reference/blobs/uploadBlob) | | Event stream | Changes to items and edges, streamed as they happen | [Stream change events](/api-reference/event-stream/streamEvents) | | Webhooks | Changes sent to a URL you choose, and each delivery's record | [Create a webhook](/api-reference/webhooks/createWebhook) | | Folders | A folder's settings, held as a `system.folder` item | [Create a folder](/api-reference/folders/createFolder) | -| Views | Saved lists of items and how each app shows them, held as `system.view` items | [Create a view](/api-reference/views/createView) | +| Views | Saved lists of items with their sort and grouping, held as `system.view` items | [Create a view](/api-reference/views/createView) | | Connectors | Processes outside the server that read and write on your behalf, with their heartbeats and runs | [Register a connector](/api-reference/connectors/registerConnector) | | Connector state | What the instance keeps for a connector's source: its hold, its state and its agreements | [Take a connector hold](/api-reference/connector-state/takeConnectorHold) | | Inbound webhooks | Addresses where a service sends requests to a connector, and the deliveries Marfa stores for it | [Create an inbound endpoint](/api-reference/inbound-webhooks/createInboundEndpoint) | diff --git a/content/docs/(reference)/cli.mdx b/content/docs/(reference)/cli.mdx index 091e2393..60fc908b 100644 --- a/content/docs/(reference)/cli.mdx +++ b/content/docs/(reference)/cli.mdx @@ -232,14 +232,14 @@ That's how the owner makes the first key in the [Quickstart](/quickstart). These ## Read a view from a working copy -The `marfa device views` commands read saved views from the database named by `--db`. **They need no server connection.** [Read a view offline](/guides/read-a-view) shows how to fill the copy and read a layout. +The `marfa device views` commands read saved views from the database named by `--db`. **They need no server connection.** [Read a view offline](/guides/read-a-view) shows how to fill the copy and read its items and groups. | Command | Answer | | --- | --- | | `marfa device views list` | Views held in the copy. | | `marfa device views get ` | One view's settings and version. | -| `marfa device views groups --layout ` | A layout's groups, counts and sums; `--time-zone` selects the time zone for date buckets. | -| `marfa device items list --view --layout ` | The view's items in layout order; `--limit` and `--offset` select a page. | +| `marfa device views groups ` | The view's groups and counts; `--time-zone` selects the time zone for date buckets. | +| `marfa device items list --view ` | The view's items in its sort order; `--limit` and `--offset` select a page. | | `marfa device search --view ` | Search matches within the view's query. | Each command takes `--json`. A view whose whole query or grouping is not held is refused with a reason. diff --git a/content/docs/catalog/(core)/file.mdx b/content/docs/catalog/(core)/file.mdx index 203b516e..4f6f8986 100644 --- a/content/docs/catalog/(core)/file.mdx +++ b/content/docs/catalog/(core)/file.mdx @@ -1,6 +1,6 @@ --- title: File -description: "A file or binary reference: the generic fallback for non-media files." +description: "A file or binary reference: the generic fallback for any file that has no more specific type." identifier: core.file --- diff --git a/content/docs/catalog/(system)/view.mdx b/content/docs/catalog/(system)/view.mdx index 78e176c7..8b94d633 100644 --- a/content/docs/catalog/(system)/view.mdx +++ b/content/docs/catalog/(system)/view.mdx @@ -1,6 +1,6 @@ --- title: View -description: "A saved list of items, held here and shared by every app: which items, and how each layout shows them. Written only through /views: created, changed at a version, and deleted, which removes it at once with no trash. Lifecycle bounded to active | revoked. Has no tier." +description: "A saved list of items, held here and shared by every app: which items, in what order and how they group. How an app shows them is the app's own. Written only through /views: created, changed at a version, and deleted, which removes it at once with no trash. Lifecycle bounded to active | revoked. Has no tier." identifier: system.view --- diff --git a/content/docs/catalog/edges.mdx b/content/docs/catalog/edges.mdx index 7e8f83f1..00162ede 100644 --- a/content/docs/catalog/edges.mdx +++ b/content/docs/catalog/edges.mdx @@ -7,7 +7,7 @@ An edge is a typed, directed link from one item, its source, to another, its tar src/generated/catalog/edge-types.md -The reverse name is the edge's name read from its target. In a [synced folder](/guides/sync-a-folder), the source item's file writes the edge, unless its section below gives `written_at`: `target` puts it in the target item's file under its reverse name, and `none` in no file. +Each edge type has two headings. The label names the source's end of an edge and the reverse label the target's end: `parent-of` lists a note's `Children` and, read from the child, its `Parent`. A label is optional when you register an edge type: without one, Marfa makes it from the last segment of the ID, such as `List Member` for `acme.list-member`. A reverse label is optional too. When you change a registered type, a label you leave out stays as it is, and so does the reverse label. To make a type two-way, send a reverse label equal to the label. An edge type with no reverse label names both ends alike, such as `Related to`, and an app shows one list for both directions. Both headings are for people. The reverse name is different: it is the edge's name read from its target, used as a frontmatter key. In a [synced folder](/guides/sync-a-folder), the source item's file writes the edge, unless its section below gives `written_at`: `target` puts it in the target item's file under its reverse name, and `none` in no file. ## Fields every edge has diff --git a/content/docs/guides/list-filter-search.mdx b/content/docs/guides/list-filter-search.mdx index e4de6fbd..cb6a5d3d 100644 --- a/content/docs/guides/list-filter-search.mdx +++ b/content/docs/guides/list-filter-search.mdx @@ -231,12 +231,13 @@ The whole window comes in one page, in order of `starts_at`. A window spans at m ## Save a list as a view -A [view](/catalog/view) saves a listing so every app shows the same one: its `query` says which items it lists, and its `layouts` say how each one shows them. [Create a view](/api-reference/views/createView) with a `title`, a `query` of `types`, `tier`, `state`, `filter` and `beneath` that says which items it lists, and at least one layout, each with an `id` and a `kind` (`list`, `table`, `board`, `gallery`, `calendar`, `timeline`, `chart` or `map`): +A [view](/catalog/view) saves a listing so every app shows the same one: its `query` says which items it lists, and its `sort` and `group` say in what order and how they group. **How an app shows the items is the app's own**, so a view holds no layout. [Create a view](/api-reference/views/createView) with a `title`, and optionally a `query` of `types`, `tier`, `state`, `filter` and `beneath`, a `sort` of `by` and `direction` keys, and a `group` with a `by` field: ```bash tab="CLI" tab-group="client" marfa views create --title "Open tasks" \ --query '{"types":["core.task"],"filter":"properties.status neq \"completed\""}' \ - --layouts '[{"id":"main","kind":"board","group":{"by":"properties.priority"}}]' + --sort '[{"by":"updated_at","direction":"desc"}]' \ + --group '{"by":"properties.priority"}' ``` ```http tab="HTTP" @@ -247,9 +248,8 @@ Content-Type: application/json { "title": "Open tasks", "query": { "types": ["core.task"], "filter": "properties.status neq \"completed\"" }, - "layouts": [ - { "id": "main", "kind": "board", "group": { "by": "properties.priority" } } - ] + "sort": [{ "by": "updated_at", "direction": "desc" }], + "group": { "by": "properties.priority" } } ``` @@ -258,20 +258,21 @@ const { data } = await marfa.POST("/views", { body: { title: "Open tasks", query: { types: ["core.task"], filter: 'properties.status neq "completed"' }, - layouts: [ - { id: "main", kind: "board", group: { by: "properties.priority" } }, - ], + sort: [{ by: "updated_at", direction: "desc" }], + group: { by: "properties.priority" }, }, }); ``` -The answer is the view as a `system.view` item at `version` 1. The server returns its settings, not the items it lists. **The device core can read its items and layout groups offline**, as [Read a view offline](/guides/read-a-view) shows. A layout only arranges, so it takes no filter of its own. +The answer is the view as a `system.view` item at `version` 1. The server returns its settings, not the items it lists. **The device core can read its items and groups offline**, as [Read a view offline](/guides/read-a-view) shows. -- **To change a view,** [update it](/api-reference/views/updateView) with the `version` you read and the settings you change. Each one you name is replaced whole, `layouts` included, and a change based on an older version still applies when nobody changed the same setting since. +A view's `filter` takes any expression this guide describes, so a view can list one folder's items with `edge[in-folder] eq ""`, or one source's with `source eq ""`. + +- **To change a view,** [update it](/api-reference/views/updateView) with the `version` you read and the settings you change. Each one you name is replaced whole, and a change based on an older version still applies when nobody changed the same setting since. To stop a view grouping its items, send `group: null`, or run `marfa views change --version --group null`; the change is to the `group` setting alone, so it follows the same rule. - **To delete a view,** [delete it](/api-reference/views/deleteView). It's gone at once, with no trash. - **To read a view,** [get it](/api-reference/items/getItem) by its `id`, or list the type `system.view`, like any other item. -Writing a view needs write on `system.view`, and reading one needs read; a `content:*` scope reaches neither. Marfa refuses a malformed setting before it writes anything, and names it in `details.errors[0].path`, such as `layouts.0.group.bucket`. The rules are in the [views](https://github.com/withmarfa/marfa/blob/main/conformance/spec/views.md) chapter of the contract. +Writing a view needs write on `system.view`, and reading one needs read; a `content:*` scope reaches neither. Marfa refuses a malformed setting before it writes anything, and names it in `details.errors[0].path`, such as `group.bucket`. The rules are in the [views](https://github.com/withmarfa/marfa/blob/main/conformance/spec/views.md) chapter of the contract. ## When a query is refused diff --git a/content/docs/guides/read-a-view.mdx b/content/docs/guides/read-a-view.mdx index 03825425..94c1ef59 100644 --- a/content/docs/guides/read-a-view.mdx +++ b/content/docs/guides/read-a-view.mdx @@ -1,11 +1,11 @@ --- title: Read a view offline -description: Read a saved view's items, groups, counts and sums from a device's working copy. +description: Read a saved view's items and groups from a device's working copy. --- -A [view](/catalog/view) saves which items to show and how to arrange them. **The device core answers its layouts offline** from a [working copy](/devices), so an app uses the same sorting and grouping as the CLI. +A [view](/catalog/view) saves which items to list, in what order and how they group. **The device core answers a view offline** from a [working copy](/devices), so an app uses the same sorting and grouping as the CLI. How the app shows the items is its own. -Start with a view [saved on the instance](/guides/list-filter-search#save-a-list-as-a-view) and a credential that can read `system.view` and every content type its query names. The examples use a view of `core.task` items with a layout whose `id` is `main`. +Start with a view [saved on the instance](/guides/list-filter-search#save-a-list-as-a-view) and a credential that can read `system.view` and every content type its query names. The examples use a view of `core.task` items. ## Fill the working copy @@ -15,11 +15,11 @@ While connected, hydrate the view type and the types its query needs: marfa device --db ./copy.sqlite hydrate --types system.view,core.task --tier all ``` -**Hold the whole query**, including its tier. A query over both tiers needs `--tier all`. A group that names `edge[]` or `backref[]` needs that edge type held whole: add `--edge-type ` to the hydration. A query with `beneath` needs `parent-of` held whole too. +**Hold the whole query**, including its tier. A query over both tiers needs `--tier all`. A group that names `edge[]` or `backref[]`, and a `filter` with a `backref[]` condition, need that edge type held whole: add `--edge-type ` to the hydration. A query with `beneath` needs `parent-of` held whole too. Hydration needs the server and a credential, as [Connect to a server](/cli#connect-to-a-server) describes. The reads below need neither a connection nor a new hydration while the copy remains complete. -## Choose the view and layout +## Choose the view List the views held in the copy, then read the selected view's settings: @@ -28,10 +28,10 @@ marfa device --db ./copy.sqlite views list --json marfa device --db ./copy.sqlite views get --json ``` -Use a layout's `id` from `settings.layouts` to read its items in order: +Read the view's items in its sort order: ```bash -marfa device --db ./copy.sqlite items list --view --layout main --limit 50 --offset 0 --json +marfa device --db ./copy.sqlite items list --view --limit 50 --offset 0 --json ``` **Sort keys apply in their declared order.** Property values use the server's ordering, missing and null values come last in either direction, and item IDs break ties. A property that no item holds leaves every item tied on that key. @@ -42,56 +42,56 @@ To search within the view's query, pass the view ID to search. Search orders mat marfa device --db ./copy.sqlite search "meeting" --view --json ``` -## Read groups and totals +## Read groups -A layout with `group` returns one count per group. A chart whose `y` is `{"sum":"properties."}` also sums the numeric values of that property; missing and non-number values add nothing. +A view with a `group` returns one count per group. A view with no `group` returns one group, with the count of every item it lists. ```bash -marfa device --db ./copy.sqlite views groups --layout main --json +marfa device --db ./copy.sqlite views groups --json ``` -Each entry in `groups` has `key`, `count` and `sum`. **Membership counts once per group**: an item with two tags belongs to both tag groups, so adding their counts can exceed the number of items. Missing values belong to the `null` group. Edge groups use the ID at the other end of each edge. +Each entry in `groups` has `key` and `count`. **Membership counts once per group**: an item with two tags belongs to both tag groups, so adding their counts can exceed the number of items. Missing values belong to the `null` group. Edge groups use the ID at the other end of each edge. -The layout's `group.order` puts named groups first, and `group.hidden` leaves named groups out. Other groups follow value order, with `null` last. +The view's `group.order` puts named groups first, and `group.hidden` leaves named groups out. Other groups follow value order, with `null` last. -A date group uses its layout's `bucket`: `day`, `week`, `month` or `year`. Its key is the bucket's start date as `YYYY-MM-DD`, and weeks start on Monday. Buckets follow calendar boundaries in the device's time zone, including daylight saving changes. To select a time zone explicitly: +A date group uses the view's `group.bucket`: `day`, `week`, `month` or `year`. Its key is the bucket's start date as `YYYY-MM-DD`, and weeks start on Monday. Buckets follow calendar boundaries in the device's time zone, including daylight saving changes. To select a time zone explicitly: ```bash -marfa device --db ./copy.sqlite views groups --layout main --time-zone Europe/London --json +marfa device --db ./copy.sqlite views groups --time-zone Europe/London --json ``` ## Read a view in an app Use the core's view reads on the same handle as other working-copy reads. Rust exposes `list_views`, `view`, `list_in_view`, `search_in_view` and `view_groups`; the core's Node binding exposes the corresponding camel-case methods, and the [Swift SDK](/swift-sdk#saved-views) wraps them in its own types. -For example, with an open Rust `Core` handle, read a page and the layout's totals: +For example, with an open Rust `Core` handle, read a page and the view's groups: ```rust -let page = core.list_in_view(&view_id, Some("main"), Some(50), None)?; -let groups = core.view_groups(&view_id, "main", None)?; +let page = core.list_in_view(&view_id, Some(50), None)?; +let groups = core.view_groups(&view_id, None)?; ``` With a Swift `WorkingCopy`, the same two reads are: ```swift -let page = try await copy.items.list(inView: viewID, layout: "main", limit: 50) -let groups = try await copy.items.groups(inView: viewID, layout: "main") +let page = try await copy.items.list(inView: viewID, limit: 50) +let groups = try await copy.items.groups(inView: viewID) ``` | Core read | Swift SDK | Answer | | --- | --- | --- | | `list_views` | `items.views()` | The views held in the copy. | | `view` | `items.view(_:)` | One view's settings and version. | -| `list_in_view` | `items.list(inView:layout:limit:offset:)` | A page of matching items, ordered by the selected layout. | +| `list_in_view` | `items.list(inView:limit:offset:)` | A page of matching items, in the view's sort order. | | `search_in_view` | `search(_:inView:limit:)` | Search matches within the view's query. | -| `view_groups` | `items.groups(inView:layout:timeZone:)` | The selected layout's group keys, counts and sums. | +| `view_groups` | `items.groups(inView:timeZone:)` | The view's group keys and counts. | -Read the selected layout to choose the presentation, and pass the device's time zone when asking for date groups. The Swift SDK also writes views and follows a layout with a live query: [Saved views](/swift-sdk#saved-views) shows both. +Pass the device's time zone when asking for date groups. The Swift SDK also writes views and follows one with a live query: [Saved views](/swift-sdk#saved-views) shows both. ## Handle a refused view -**A view's items and groups are never answered in part.** If the working copy cannot answer the whole query or grouping, the read is refused with a reason. This includes an unfinished hydration, a query outside the held types or tiers, and an edge group whose edge type is not held whole. Listing the view settings during a fill can return `partial`; that does not make their item queries answerable. +**A view's items and groups are never answered in part.** If the working copy cannot answer the whole query or grouping, the read is refused with a reason. This includes an unfinished hydration, a query outside the held types or tiers, and an edge group or a `backref` condition whose edge type is not held whole. Listing the view settings during a fill can return `partial`; that does not make their item queries answerable. -Show the reason and, when connected, hydrate the slice and edge types the view needs before reading again. A field that no matching item holds is not a refusal: it sorts as missing, groups under `null`, and contributes nothing to a sum. +Show the reason and, when connected, hydrate the slice and edge types the view needs before reading again. A field that no matching item holds is not a refusal: it sorts as missing and groups under `null`. The rules are in the contract's [views read from a copy](https://github.com/withmarfa/marfa/blob/main/conformance/spec/device.md#views-read-from-a-copy) section and [views](https://github.com/withmarfa/marfa/blob/main/conformance/spec/views.md) chapter. diff --git a/openapi.json b/openapi.json index 9c8a3262..5c1d52c9 100644 --- a/openapi.json +++ b/openapi.json @@ -30,7 +30,11 @@ }, "label": { "type": "string", - "description": "A name for people to read." + "description": "The name an app shows on the source end of an edge of this type, such as `Children` on a parent. Not the `reverse_name`, which is the code name a file writes." + }, + "reverse_label": { + "type": "string", + "description": "The name an app shows on the target end, such as `Parent` on a child. It is the same text as `label` when the relation is two-way, and an app then shows both directions as one list." }, "description": { "type": "string", @@ -96,6 +100,8 @@ }, "required": [ "id", + "label", + "reverse_label", "cardinality", "source_type_constraints", "target_type_constraints", @@ -1039,6 +1045,25 @@ "blob_hashes_referenced": { "type": "integer", "description": "On a purge job, how many distinct blobs the purged items referenced, whether or not anything still references them. Absent otherwise." + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeSetting" + }, + "description": "On a type move, every folder, view and webhook setting that named the old type when the job finished. Absent otherwise." + }, + "remaining": { + "type": "integer", + "description": "On a type move, how many items you can see are still of the old type when the job finished, in any state: those it left unchanged and any written since the move began. Absent otherwise." + }, + "remaining_hidden": { + "type": "boolean", + "description": "On a type move, `true` if items your source filter hides are still of the old type. A move takes only items you can see. Absent otherwise." + }, + "settings_rewritten": { + "type": "boolean", + "description": "On a type move, `true` if Marfa rewrote `settings` to name the new type. `false` if an item of the old type remains, counted in `remaining` or `remaining_hidden`, or a setting has a `reason`: Marfa then rewrote none of them. Absent otherwise." } }, "required": [ @@ -1078,6 +1103,47 @@ ], "description": "An item a bulk action left unchanged, and why." }, + "TypeSetting": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "folder", + "view", + "webhook" + ], + "description": "What holds the setting." + }, + "id": { + "type": "string", + "description": "The ID of the folder, view or webhook. Absent when you can't read it." + }, + "field": { + "type": "string", + "description": "The setting that names the type: `query.types`, `query.filter`, `defaults.type`, `first_placement` or `type_filter`." + }, + "reason": { + "type": "string", + "enum": [ + "not_permitted", + "filter_term", + "placement_conflict", + "subtypes" + ], + "description": "Why a move can't rewrite the setting: `not_permitted`, you can't change it; `filter_term`, its filter can't be rewritten safely; `placement_conflict`, it places the types apart; `subtypes`, a webhook can't name both." + }, + "message": { + "type": "string", + "description": "What to change before moving, for a person to read. Present with `reason`." + } + }, + "required": [ + "kind", + "field" + ], + "description": "A folder, view or webhook setting that names a type." + }, "ViewField": { "type": "string", "pattern": "^(?:title|created_at|updated_at|occurred_at|type|state|tier|tags|properties\\.[A-Za-z_][A-Za-z0-9_]*)$", @@ -1093,20 +1159,6 @@ ], "description": "A tier: `library`, `feed`, or `all` for both." }, - "ViewLayoutKind": { - "type": "string", - "enum": [ - "list", - "table", - "board", - "gallery", - "calendar", - "timeline", - "chart", - "map" - ], - "description": "How a layout shows items. `list`: rows. `table`: rows with columns. `board`: columns by `group`. `gallery`: cards. `calendar`: on dates. `timeline`: as spans. `chart`: as a chart. `map`: on a map." - }, "ViewSort": { "type": "object", "properties": { @@ -1134,7 +1186,7 @@ "direction" ], "additionalProperties": false, - "description": "One key a layout sorts by." + "description": "One key a view sorts by." }, "ViewGroup": { "type": "object", @@ -1171,7 +1223,7 @@ "type": "null" } ], - "description": "A value of the field the layout groups by." + "description": "A value of the field the view groups by." }, "maxItems": 1000, "description": "The values whose groups come first, in this order. Other groups follow." @@ -1193,17 +1245,17 @@ "type": "null" } ], - "description": "A value of the field the layout groups by." + "description": "A value of the field the view groups by." }, "maxItems": 1000, - "description": "The values whose groups the layout leaves out." + "description": "The values whose groups the view leaves out." } }, "required": [ "by" ], "additionalProperties": false, - "description": "How a layout groups its items." + "description": "How a view groups its items." }, "InstanceDescription": { "type": "object", @@ -3587,7 +3639,12 @@ }, "label": { "type": "string", - "description": "A name for people to read." + "description": "The name an app shows on the source end, such as `Children` on a parent. Leave it out and the last segment of the `id` makes it, such as `List Member`. On a change, leaving it out keeps the stored one." + }, + "reverse_label": { + "type": "string", + "minLength": 1, + "description": "The name an app shows on the target end, such as `Parent` on a child. Leave it out and `label` names both ends. On a change, leaving it out keeps the stored one; send the `label` to make a type two-way." }, "description": { "type": "string", @@ -3650,7 +3707,7 @@ }, "evolve_constraints": { "type": "boolean", - "description": "`true` widens a registered type's endpoints in place. Retain every existing constraint token, or allow all types with `[]` or `*`; other members must stay unchanged. Omit or set `false` to register a new type. Not stored in the definition." + "description": "`true` updates a registered type in place: widen its endpoints (keep every constraint token, or allow all types with `[]` or `*`) and change its labels. Other members stay unchanged. Omit or `false` to register a new type. Not stored." } }, "required": [ @@ -4526,269 +4583,88 @@ ], "description": "An error response." }, - "SearchResultPage": { + "TypeMoveDryRun": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchResult" - }, - "description": "The results, best match first." - }, - "next_cursor": { - "type": [ - "string", - "null" + "dry_run": { + "type": "boolean", + "enum": [ + true ], - "description": "Pass as `cursor` for the next page; `null` on the last. A page can be short, or empty, with a cursor still to follow, so a walk stops on `null` and never on a short page." - } - }, - "required": [ - "data", - "next_cursor" - ], - "description": "One page of search results." - }, - "SearchResult": { - "type": "object", - "properties": { - "item": { - "allOf": [ - { - "$ref": "#/components/schemas/Item" - }, - { - "description": "The matching item." - } - ] - }, - "metadata": { - "allOf": [ - { - "$ref": "#/components/schemas/Metadata" - }, - { - "description": "The item's tags and the extensions you can read." - } - ] + "description": "Always `true`." }, - "relevance_score": { - "type": "number", - "description": "How well the item matches `q`: the absolute value of its BM25 score, so higher is better. Scores depend on the rows indexed, so compare them only within one search." + "from": { + "type": "string", + "description": "The type the items are in now." }, - "snippet_html": { + "to": { "type": "string", - "description": "An excerpt of at most 32 words from the text that matches best, as escaped HTML with each matched word in `` tags and `...` where the text is cut. Absent if there is none." - } - }, - "required": [ - "item", - "metadata", - "relevance_score" - ], - "description": "A search result is an item that matches the query, with its metadata and how well it matches." - }, - "OccurrencePage": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Occurrence" - }, - "description": "The occurrences that overlap the window, by start time." + "description": "The type the items would move to." }, - "next_cursor": { - "type": [ - "string", - "null" - ], - "description": "Always `null`: Marfa returns every occurrence in the window in one page." + "matched": { + "type": "integer", + "description": "How many items of `from` the move would take, in any state. Items your source filter hides aren't counted or moved." }, - "window": { - "type": "object", - "properties": { - "from": { - "type": "string", - "description": "The start of the window, in UTC." - }, - "to": { - "type": "string", - "description": "The end of the window, in UTC." - } + "ids": { + "type": "array", + "items": { + "type": "string" }, - "required": [ - "from", - "to" - ], - "description": "The window you asked for, in UTC." + "description": "The IDs of those items." }, - "scan": { - "type": "object", - "properties": { - "events_read": { - "type": "integer", - "description": "How many event items this request read. It grows with the size of the calendar more than with the window." - }, - "occurrences": { - "type": "integer", - "description": "How many occurrences `data` holds." - }, - "max_occurrences": { - "type": "integer", - "description": "The most occurrences a window may hold; a window with more is refused. Compare it with `occurrences` to see a calendar approaching it." - }, - "series_errors": { - "type": "integer", - "description": "How many failures Marfa found in the recurrence rules this request read, as entries of `series_errors`, including any the list leaves out. It covers only the event types read, so `0` says nothing of the rest." - }, - "max_series_errors": { - "type": "integer", - "description": "The most entries `series_errors` holds. Past it, the list stops and `series_errors_truncated` is `true`; the read still succeeds." - }, - "unproductive_iterations": { - "type": "integer", - "description": "How many rule iterations this request spent on expansions that returned no occurrence, such as a rule that ended before the window. A rule that fails before it iterates adds nothing here." - }, - "max_unproductive_iterations": { - "type": "integer", - "description": "The most `unproductive_iterations` a request spends before it stops expanding. Iterations that return occurrences don't count, so a busy calendar doesn't reach it." - }, - "series_unexpanded": { - "type": "integer", - "description": "How many series didn't finish expanding: stopped by their own bound, and listed in `series_errors`, or never reached once `max_unproductive_iterations` was spent. Above `0`, `expansion_incomplete` is `true`." - } + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeSetting" }, - "required": [ - "events_read", - "occurrences", - "max_occurrences", - "series_errors", - "max_series_errors", - "unproductive_iterations", - "max_unproductive_iterations", - "series_unexpanded" - ], - "description": "What this read cost, and the limits that would stop it." + "description": "Every folder, view and webhook setting that names `from`, which the move rewrites to name `to` once no item of `from` remains." }, - "series_errors": { + "grants": { "type": "array", "items": { - "type": "object", - "properties": { - "item_id": { - "type": "string", - "description": "The ID of the recurring event." - }, - "message": { - "type": "string", - "description": "What is wrong with its rule, for a person to read." - } - }, - "required": [ - "item_id", - "message" - ] + "$ref": "#/components/schemas/TypeGrant" }, - "description": "One entry per failure found in a recurrence rule, such as a line Marfa can't read or a timezone that doesn't resolve. One event can have several entries and still appear in `data`. Absent when there were none." - }, - "series_errors_truncated": { - "type": "boolean", - "description": "`true` when `series_errors` stops at `scan.max_series_errors` entries and leaves failures out. `scan.series_errors` has the total. Absent otherwise." - }, - "expansion_incomplete": { - "type": "boolean", - "description": "`true` when a series didn't finish expanding, so `data` may be missing occurrences; `scan.series_unexpanded` counts them. If a series has too many in the window, narrow the window; else narrow by `type` or fix the rules. Absent otherwise." + "description": "The keys and apps whose type map names `from`, which the move doesn't change. Keys are listed if you have `keys.manage`, and apps if you have `grants.manage`." } }, "required": [ - "data", - "next_cursor", - "window", - "scan" + "dry_run", + "from", + "to", + "matched", + "ids", + "settings", + "grants" ], - "description": "The occurrences in a time window, with what reading them cost and any rules Marfa couldn't apply." + "description": "What a type move would do, written nowhere." }, - "Occurrence": { + "TypeGrant": { "type": "object", "properties": { - "starts_at": { - "type": "string", - "description": "When the occurrence starts, in UTC." - }, - "ends_at": { - "type": "string", - "description": "When the occurrence ends, in UTC. A whole-day event ends at midnight in its `timezone`, or in UTC if it has none. One computed from a rule lasts as long as its series' first; any other ends at its item's `ends_at`. Absent when it has no end." - }, - "item": { - "allOf": [ - { - "$ref": "#/components/schemas/Item" - }, - { - "description": "The event: the series for an occurrence computed from its rule, otherwise the occurrence's own item." - } - ] - }, - "series_id": { + "kind": { "type": "string", - "description": "The ID of the recurring event the occurrence belongs to. Absent on an event that doesn't recur." + "enum": [ + "key", + "app" + ], + "description": "`key` for an API key, `app` for an app someone signed in to." }, - "replaces": { + "id": { "type": "string", - "description": "The start, in UTC, of the computed occurrence this stored exception takes the place of. Present only on an exception." - } - }, - "required": [ - "starts_at", - "item" - ], - "description": "One time an event happens." - }, - "TagCountPage": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagCount" - }, - "description": "Every tag." + "description": "The ID of the key, or the app's client ID." }, - "next_cursor": { - "type": [ - "string", - "null" - ], - "description": "Always `null`: Marfa returns every tag in one page." - } - }, - "required": [ - "data", - "next_cursor" - ], - "description": "A page holding every tag." - }, - "TagCount": { - "type": "object", - "properties": { - "tag": { + "label": { "type": "string", - "description": "The tag." - }, - "count": { - "type": "integer", - "description": "How many active items you can read carry the tag." + "description": "The key's label. Absent for an app." } }, "required": [ - "tag", - "count" + "kind", + "id" ], - "description": "A tag, and how many items carry it." + "description": "A key or app whose type map names a type outright." }, - "RequestTimeoutRefusal": { + "BulkCapExceededOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal": { "type": "object", "properties": { "error": { @@ -4797,7 +4673,378 @@ "code": { "type": "string", "enum": [ - "request_timeout" + "bulk_cap_exceeded", + "missing_required_field", + "unknown_type", + "validation_error" + ], + "description": "A machine-readable code for the error. Use it in your logic." + }, + "message": { + "type": "string", + "description": "A description of the error for a person to read. It can change, so don't match on it." + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "More about the error, such as the field it concerns. Each code defines its own details." + } + }, + "required": [ + "code", + "message" + ], + "description": "What went wrong." + } + }, + "required": [ + "error" + ], + "description": "An error response." + }, + "CoreTypeImmutableOrTypeNotPermittedRefusal": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "core_type_immutable", + "type_not_permitted" + ], + "description": "A machine-readable code for the error. Use it in your logic." + }, + "message": { + "type": "string", + "description": "A description of the error for a person to read. It can change, so don't match on it." + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "More about the error, such as the field it concerns. Each code defines its own details." + } + }, + "required": [ + "code", + "message" + ], + "description": "What went wrong." + } + }, + "required": [ + "error" + ], + "description": "An error response." + }, + "TypeMoveBlockedRefusal": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "type_move_blocked" + ], + "description": "A machine-readable code for the error. Use it in your logic." + }, + "message": { + "type": "string", + "description": "A description of the error for a person to read. It can change, so don't match on it." + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "More about the error, such as the field it concerns. Each code defines its own details." + } + }, + "required": [ + "code", + "message" + ], + "description": "What went wrong." + } + }, + "required": [ + "error" + ], + "description": "An error response." + }, + "SearchResultPage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchResult" + }, + "description": "The results, best match first." + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass as `cursor` for the next page; `null` on the last. A page can be short, or empty, with a cursor still to follow, so a walk stops on `null` and never on a short page." + } + }, + "required": [ + "data", + "next_cursor" + ], + "description": "One page of search results." + }, + "SearchResult": { + "type": "object", + "properties": { + "item": { + "allOf": [ + { + "$ref": "#/components/schemas/Item" + }, + { + "description": "The matching item." + } + ] + }, + "metadata": { + "allOf": [ + { + "$ref": "#/components/schemas/Metadata" + }, + { + "description": "The item's tags and the extensions you can read." + } + ] + }, + "relevance_score": { + "type": "number", + "description": "How well the item matches `q`: the absolute value of its BM25 score, so higher is better. Scores depend on the rows indexed, so compare them only within one search." + }, + "snippet_html": { + "type": "string", + "description": "An excerpt of at most 32 words from the text that matches best, as escaped HTML with each matched word in `` tags and `...` where the text is cut. Absent if there is none." + } + }, + "required": [ + "item", + "metadata", + "relevance_score" + ], + "description": "A search result is an item that matches the query, with its metadata and how well it matches." + }, + "OccurrencePage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Occurrence" + }, + "description": "The occurrences that overlap the window, by start time." + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Always `null`: Marfa returns every occurrence in the window in one page." + }, + "window": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The start of the window, in UTC." + }, + "to": { + "type": "string", + "description": "The end of the window, in UTC." + } + }, + "required": [ + "from", + "to" + ], + "description": "The window you asked for, in UTC." + }, + "scan": { + "type": "object", + "properties": { + "events_read": { + "type": "integer", + "description": "How many event items this request read. It grows with the size of the calendar more than with the window." + }, + "occurrences": { + "type": "integer", + "description": "How many occurrences `data` holds." + }, + "max_occurrences": { + "type": "integer", + "description": "The most occurrences a window may hold; a window with more is refused. Compare it with `occurrences` to see a calendar approaching it." + }, + "series_errors": { + "type": "integer", + "description": "How many failures Marfa found in the recurrence rules this request read, as entries of `series_errors`, including any the list leaves out. It covers only the event types read, so `0` says nothing of the rest." + }, + "max_series_errors": { + "type": "integer", + "description": "The most entries `series_errors` holds. Past it, the list stops and `series_errors_truncated` is `true`; the read still succeeds." + }, + "unproductive_iterations": { + "type": "integer", + "description": "How many rule iterations this request spent on expansions that returned no occurrence, such as a rule that ended before the window. A rule that fails before it iterates adds nothing here." + }, + "max_unproductive_iterations": { + "type": "integer", + "description": "The most `unproductive_iterations` a request spends before it stops expanding. Iterations that return occurrences don't count, so a busy calendar doesn't reach it." + }, + "series_unexpanded": { + "type": "integer", + "description": "How many series didn't finish expanding: stopped by their own bound, and listed in `series_errors`, or never reached once `max_unproductive_iterations` was spent. Above `0`, `expansion_incomplete` is `true`." + } + }, + "required": [ + "events_read", + "occurrences", + "max_occurrences", + "series_errors", + "max_series_errors", + "unproductive_iterations", + "max_unproductive_iterations", + "series_unexpanded" + ], + "description": "What this read cost, and the limits that would stop it." + }, + "series_errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item_id": { + "type": "string", + "description": "The ID of the recurring event." + }, + "message": { + "type": "string", + "description": "What is wrong with its rule, for a person to read." + } + }, + "required": [ + "item_id", + "message" + ] + }, + "description": "One entry per failure found in a recurrence rule, such as a line Marfa can't read or a timezone that doesn't resolve. One event can have several entries and still appear in `data`. Absent when there were none." + }, + "series_errors_truncated": { + "type": "boolean", + "description": "`true` when `series_errors` stops at `scan.max_series_errors` entries and leaves failures out. `scan.series_errors` has the total. Absent otherwise." + }, + "expansion_incomplete": { + "type": "boolean", + "description": "`true` when a series didn't finish expanding, so `data` may be missing occurrences; `scan.series_unexpanded` counts them. If a series has too many in the window, narrow the window; else narrow by `type` or fix the rules. Absent otherwise." + } + }, + "required": [ + "data", + "next_cursor", + "window", + "scan" + ], + "description": "The occurrences in a time window, with what reading them cost and any rules Marfa couldn't apply." + }, + "Occurrence": { + "type": "object", + "properties": { + "starts_at": { + "type": "string", + "description": "When the occurrence starts, in UTC." + }, + "ends_at": { + "type": "string", + "description": "When the occurrence ends, in UTC. A whole-day event ends at midnight in its `timezone`, or in UTC if it has none. One computed from a rule lasts as long as its series' first; any other ends at its item's `ends_at`. Absent when it has no end." + }, + "item": { + "allOf": [ + { + "$ref": "#/components/schemas/Item" + }, + { + "description": "The event: the series for an occurrence computed from its rule, otherwise the occurrence's own item." + } + ] + }, + "series_id": { + "type": "string", + "description": "The ID of the recurring event the occurrence belongs to. Absent on an event that doesn't recur." + }, + "replaces": { + "type": "string", + "description": "The start, in UTC, of the computed occurrence this stored exception takes the place of. Present only on an exception." + } + }, + "required": [ + "starts_at", + "item" + ], + "description": "One time an event happens." + }, + "TagCountPage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagCount" + }, + "description": "Every tag." + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Always `null`: Marfa returns every tag in one page." + } + }, + "required": [ + "data", + "next_cursor" + ], + "description": "A page holding every tag." + }, + "TagCount": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "The tag." + }, + "count": { + "type": "integer", + "description": "How many active items you can read carry the tag." + } + }, + "required": [ + "tag", + "count" + ], + "description": "A tag, and how many items carry it." + }, + "RequestTimeoutRefusal": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "request_timeout" ], "description": "A machine-readable code for the error. Use it in your logic." }, @@ -6799,125 +7046,6 @@ "additionalProperties": false, "description": "Which items the view lists. Absent lists every item the reading key can read, except `system.*`." }, - "ViewLayout": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "The layout's ID, unique in the view, which an app keeps to reopen it." - }, - "kind": { - "allOf": [ - { - "$ref": "#/components/schemas/ViewLayoutKind" - }, - { - "description": "How the layout shows items." - } - ] - }, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "The layout's name. Absent, an app names it by its kind." - }, - "sort": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewSort" - }, - "maxItems": 10, - "description": "The keys the layout sorts by, first key first." - }, - "group": { - "allOf": [ - { - "$ref": "#/components/schemas/ViewGroup" - }, - { - "description": "How the layout groups its items. A `board` requires it." - } - ] - }, - "shown": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ViewField" - }, - "maxItems": 200, - "description": "The fields the layout shows, in order." - }, - "image": { - "type": "string", - "pattern": "^(?:properties\\.[A-Za-z_][A-Za-z0-9_]*)$", - "description": "For a `gallery`, the property that holds each card's image." - }, - "date": { - "type": "string", - "pattern": "^(?:created_at|updated_at|occurred_at|properties\\.[A-Za-z_][A-Za-z0-9_]*)$", - "description": "For a `calendar`, the date field that places each item." - }, - "start": { - "type": "string", - "pattern": "^(?:created_at|updated_at|occurred_at|properties\\.[A-Za-z_][A-Za-z0-9_]*)$", - "description": "For a `timeline`, the date field each span starts at. Required for a `timeline`." - }, - "end": { - "type": "string", - "pattern": "^(?:created_at|updated_at|occurred_at|properties\\.[A-Za-z_][A-Za-z0-9_]*)$", - "description": "For a `calendar` or `timeline`, the date field each span ends at. Required for a `timeline`." - }, - "x": { - "allOf": [ - { - "$ref": "#/components/schemas/ViewField" - }, - { - "description": "For a `chart`, the field along its horizontal axis." - } - ] - }, - "y": { - "anyOf": [ - { - "type": "string", - "enum": [ - "count" - ] - }, - { - "type": "object", - "properties": { - "sum": { - "type": "string", - "pattern": "^(?:properties\\.[A-Za-z_][A-Za-z0-9_]*)$", - "description": "The number property whose values are added up." - } - }, - "required": [ - "sum" - ], - "additionalProperties": false - } - ], - "description": "What a chart measures: `count` of items, or `{ \"sum\": \"properties.\" }`." - }, - "location": { - "type": "string", - "pattern": "^(?:properties\\.[A-Za-z_][A-Za-z0-9_]*)$", - "description": "For a `map`, the property that holds each item's place." - } - }, - "required": [ - "id", - "kind" - ], - "additionalProperties": false, - "description": "One way a view shows its items. A layout takes only the options its `kind` takes, and never narrows which items the view lists." - }, "InvalidIdOrInvalidTransitionOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal": { "type": "object", "properties": { @@ -25209,12 +25337,368 @@ } } }, - "404": { - "description": "`bulk_atomic_rollback`: with `atomic` true, an entry names an item that doesn't exist, or whose type you can't read. `details.code` is `item_not_found`.", + "404": { + "description": "`bulk_atomic_rollback`: with `atomic` true, an entry names an item that doesn't exist, or whose type you can't read. `details.code` is `item_not_found`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAtomicRollbackRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "409": { + "description": "`bulk_atomic_rollback`: with `atomic` true, an entry's edge has moved or its ID is taken. `details.code` is `version_conflict` or `id_reused`.", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/BulkAtomicRollbackRefusal" + }, + { + "$ref": "#/components/schemas/InstanceChangedRefusal" + } + ] + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "413": { + "description": "`request_too_large`: the request body is larger than this instance accepts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestTooLargeRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "429": { + "description": "`rate_limited`: you sent too many requests. Wait for the number of seconds in `Retry-After`, then try again.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitedRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + }, + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + } + }, + "500": { + "description": "`internal_error`: Marfa failed in a way it didn't expect, and the request may not have completed. Read what you changed before you repeat a write.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "503": { + "description": "`write_contention`: the database was busy, and Marfa couldn't complete the request in time. Nothing changed. Try the request again.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteContentionRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "507": { + "description": "`insufficient_storage`: the disk that holds the instance's data has no room for the request, or the request would leave less free than the instance keeps in reserve. Nothing changed, unless `details.write_outcome` is `unknown`, which means the write may have landed: read what you changed before you repeat it. Free space on the disk, then try the request again.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InsufficientStorageRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + } + }, + "parameters": [ + { + "name": "X-Marfa-Instance-Id", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." + } + ] + } + }, + "/items/{id}/edges": { + "get": { + "operationId": "listItemEdges", + "tags": [ + "Edges" + ], + "summary": "List outbound edges", + "description": "Returns the edges that start at an item. An item in the trash still lists its edges. If `source_filter` leaves the item out, none of its edges appear here. `GET /items/{id}/backrefs` lists the edges that point at it.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The ID of the item." + }, + "required": true, + "description": "The ID of the item.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Only return edges of these edge types, comma-separated, up to 10." + }, + "required": false, + "description": "Only return edges of these edge types, comma-separated, up to 10.", + "name": "edge_type", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "description": "The maximum number of results to return." + }, + "required": false, + "description": "The maximum number of results to return.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The `next_cursor` from the previous page. Leave it out to get the first page." + }, + "required": false, + "description": "The `next_cursor` from the previous page. Leave it out to get the first page.", + "name": "cursor", + "in": "query" + }, + { + "name": "X-Marfa-Reach", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "description": "The reach from a copy stream, for a working copy. Marfa reads the current data and checks the reach in one snapshot, and returns `409 reach_changed` if it has changed. Leave it out for an ordinary read." + } + ], + "responses": { + "200": { + "description": "Returns a page of the item's outbound edges.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EdgePage" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Marfa-Reach": { + "$ref": "#/components/headers/X-Marfa-Reach" + } + } + }, + "400": { + "description": "- `invalid_id`: the ID is not a valid item ID.\n- `validation_error`: a query parameter is unknown or invalid, or `edge_type` names more than 10 edge types.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidIdOrValidationErrorRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "401": { + "description": "`unauthorized`: the request has no credential, or its credential is not valid.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkAtomicRollbackRefusal" + "$ref": "#/components/schemas/UnauthorizedRefusal" } } }, @@ -25239,19 +25723,12 @@ } } }, - "409": { - "description": "`bulk_atomic_rollback`: with `atomic` true, an entry's edge has moved or its ID is taken. `details.code` is `version_conflict` or `id_reused`.", + "403": { + "description": "- `type_not_permitted`: your credential reaches no type.", "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/BulkAtomicRollbackRefusal" - }, - { - "$ref": "#/components/schemas/InstanceChangedRefusal" - } - ] + "$ref": "#/components/schemas/TypeNotPermittedRefusal" } } }, @@ -25273,15 +25750,18 @@ }, "X-Error-Code": { "$ref": "#/components/headers/X-Error-Code" + }, + "X-Marfa-Reach": { + "$ref": "#/components/headers/X-Marfa-Reach" } } }, - "413": { - "description": "`request_too_large`: the request body is larger than this instance accepts.", + "404": { + "description": "- `item_not_found`: no item has this ID, or its type is one you can't read.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestTooLargeRefusal" + "$ref": "#/components/schemas/ItemNotFoundRefusal" } } }, @@ -25292,6 +25772,48 @@ "X-Request-ID": { "$ref": "#/components/headers/X-Request-ID" }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + }, + "X-Marfa-Reach": { + "$ref": "#/components/headers/X-Marfa-Reach" + } + } + }, + "409": { + "description": "`reach_changed`: the reach in `X-Marfa-Reach` has changed. Rebuild the working copy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReachChangedRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, "X-Error-Code": { "$ref": "#/components/headers/X-Error-Code" } @@ -25420,29 +25942,17 @@ } } } - }, - "parameters": [ - { - "name": "X-Marfa-Instance-Id", - "in": "header", - "required": false, - "schema": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." - } - ] + } } }, - "/items/{id}/edges": { + "/items/{id}/backrefs": { "get": { - "operationId": "listItemEdges", + "operationId": "listItemBackrefs", "tags": [ "Edges" ], - "summary": "List outbound edges", - "description": "Returns the edges that start at an item. An item in the trash still lists its edges. If `source_filter` leaves the item out, none of its edges appear here. `GET /items/{id}/backrefs` lists the edges that point at it.", + "summary": "List inbound edges", + "description": "Returns the edges that point at an item. An item in the trash still lists its edges. An edge whose source item `source_filter` leaves out doesn't appear here, whatever the source of this item. `GET /items/{id}/edges` lists the edges that start at it.", "security": [ { "bearerAuth": [] @@ -25490,23 +26000,11 @@ "description": "The `next_cursor` from the previous page. Leave it out to get the first page.", "name": "cursor", "in": "query" - }, - { - "name": "X-Marfa-Reach", - "in": "header", - "required": false, - "schema": { - "type": "string", - "pattern": "^[0-9a-f]{64}$", - "minLength": 64, - "maxLength": 64 - }, - "description": "The reach from a copy stream, for a working copy. Marfa reads the current data and checks the reach in one snapshot, and returns `409 reach_changed` if it has changed. Leave it out for an ordinary read." } ], "responses": { "200": { - "description": "Returns a page of the item's outbound edges.", + "description": "Returns a page of the item's inbound edges.", "content": { "application/json": { "schema": { @@ -25529,9 +26027,6 @@ }, "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" - }, - "X-Marfa-Reach": { - "$ref": "#/components/headers/X-Marfa-Reach" } } }, @@ -25622,9 +26117,6 @@ }, "X-Error-Code": { "$ref": "#/components/headers/X-Error-Code" - }, - "X-Marfa-Reach": { - "$ref": "#/components/headers/X-Marfa-Reach" } } }, @@ -25637,39 +26129,6 @@ } } }, - "headers": { - "X-Marfa-Contract": { - "$ref": "#/components/headers/X-Marfa-Contract" - }, - "X-Request-ID": { - "$ref": "#/components/headers/X-Request-ID" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - }, - "X-Error-Code": { - "$ref": "#/components/headers/X-Error-Code" - }, - "X-Marfa-Reach": { - "$ref": "#/components/headers/X-Marfa-Reach" - } - } - }, - "409": { - "description": "`reach_changed`: the reach in `X-Marfa-Reach` has changed. Rebuild the working copy.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReachChangedRefusal" - } - } - }, "headers": { "X-Marfa-Contract": { "$ref": "#/components/headers/X-Marfa-Contract" @@ -25817,14 +26276,14 @@ } } }, - "/items/{id}/backrefs": { + "/search": { "get": { - "operationId": "listItemBackrefs", + "operationId": "searchItems", "tags": [ - "Edges" + "Search" ], - "summary": "List inbound edges", - "description": "Returns the edges that point at an item. An item in the trash still lists its edges. An edge whose source item `source_filter` leaves out doesn't appear here, whatever the source of this item. `GET /items/{id}/edges` lists the edges that start at it.", + "summary": "Search items", + "description": "Returns a page of the items you can read whose text properties or tags match `q`, ranked by BM25 relevance, with ties ordered by item ID.", "security": [ { "bearerAuth": [] @@ -25834,28 +26293,75 @@ { "schema": { "type": "string", - "description": "The ID of the item." + "minLength": 1, + "description": "The text to search for. Marfa matches every word by its stem, in any order, and the last word as the start of a word. Wrap the query in double quotes to match a phrase." }, "required": true, - "description": "The ID of the item.", - "name": "id", - "in": "path" + "description": "The text to search for. Marfa matches every word by its stem, in any order, and the last word as the start of a word. Wrap the query in double quotes to match a phrase.", + "name": "q", + "in": "query" }, { "schema": { "type": "string", - "description": "Only return edges of these edge types, comma-separated, up to 10." + "description": "Only return items of this type or a subtype. A wildcard such as `core.*` matches every type under that prefix." }, "required": false, - "description": "Only return edges of these edge types, comma-separated, up to 10.", - "name": "edge_type", + "description": "Only return items of this type or a subtype. A wildcard such as `core.*` matches every type under that prefix.", + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Only return items in this lifecycle state. Without it, you get `active` items. `any` searches every state, but trashed items are never searchable." + }, + "required": false, + "description": "Only return items in this lifecycle state. Without it, you get `active` items. `any` searches every state, but trashed items are never searchable.", + "name": "state", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "library", + "feed", + "all" + ], + "description": "Only return items in this tier. Omit it or send `all` for both tiers." + }, + "required": false, + "description": "Only return items in this tier. Omit it or send `all` for both tiers.", + "name": "tier", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Comma-separated extras. `system` also returns `system.*` items, which are left out by default. A `system.` type filter does the same." + }, + "required": false, + "description": "Comma-separated extras. `system` also returns `system.*` items, which are left out by default. A `system.` type filter does the same.", + "name": "include", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Comma-separated tags. Only return items that carry all of them." + }, + "required": false, + "description": "Comma-separated tags. Only return items that carry all of them.", + "name": "tags", "in": "query" }, { "schema": { "type": "integer", "minimum": 1, - "maximum": 500, + "maximum": 100, + "default": 20, "description": "The maximum number of results to return." }, "required": false, @@ -25872,15 +26378,47 @@ "description": "The `next_cursor` from the previous page. Leave it out to get the first page.", "name": "cursor", "in": "query" + }, + { + "schema": { + "type": "string", + "description": "A filter expression, as on `GET /items`. A term naming an edge type, `edge[]` or `backref[]`, matches by relationship and needs read on that edge type." + }, + "required": false, + "description": "A filter expression, as on `GET /items`. A term naming an edge type, `edge[]` or `backref[]`, matches by relationship and needs read on that edge type.", + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Only return items whose own time (`occurred_at`, else `created_at`) is after this RFC 3339 time." + }, + "required": false, + "description": "Only return items whose own time (`occurred_at`, else `created_at`) is after this RFC 3339 time.", + "name": "occurred_after", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Only return items whose own time (`occurred_at`, else `created_at`) is before this RFC 3339 time." + }, + "required": false, + "description": "Only return items whose own time (`occurred_at`, else `created_at`) is before this RFC 3339 time.", + "name": "occurred_before", + "in": "query" } ], "responses": { "200": { - "description": "Returns a page of the item's inbound edges.", + "description": "Returns a page of results. Each page runs the search again, so a change between two pages can repeat or skip a result. Marfa reads at most 10,000 results deep: the page that reaches that depth has `next_cursor: null`.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EdgePage" + "$ref": "#/components/schemas/SearchResultPage" } } }, @@ -25903,11 +26441,11 @@ } }, "400": { - "description": "- `invalid_id`: the ID is not a valid item ID.\n- `validation_error`: a query parameter is unknown or invalid, or `edge_type` names more than 10 edge types.", + "description": "- `missing_required_field`: `q` is missing.\n- `validation_error`: a parameter is unknown or invalid, such as a `state` that isn't a lifecycle state or `any`, a time that isn't an instant, or a `cursor` from another search.\n- `unknown_type`: `type` is a concrete type that nothing registers.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidIdOrValidationErrorRefusal" + "$ref": "#/components/schemas/MissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" } } }, @@ -25963,41 +26501,11 @@ } }, "403": { - "description": "- `type_not_permitted`: your credential reaches no type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypeNotPermittedRefusal" - } - } - }, - "headers": { - "X-Marfa-Contract": { - "$ref": "#/components/headers/X-Marfa-Contract" - }, - "X-Request-ID": { - "$ref": "#/components/headers/X-Request-ID" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - }, - "X-Error-Code": { - "$ref": "#/components/headers/X-Error-Code" - } - } - }, - "404": { - "description": "- `item_not_found`: no item has this ID, or its type is one you can't read.", + "description": "- `type_not_permitted`: your credential reaches no type, or `type` names a type you can't read with none readable under it.\n- `edge_permission_denied`: the filter has an `edge` or `backref` term for an edge type you can't read.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ItemNotFoundRefusal" + "$ref": "#/components/schemas/EdgePermissionDeniedOrTypeNotPermittedRefusal" } } }, @@ -26148,149 +26656,26 @@ } } }, - "/search": { + "/types": { "get": { - "operationId": "searchItems", + "operationId": "listTypes", "tags": [ - "Search" + "Types" ], - "summary": "Search items", - "description": "Returns a page of the items you can read whose text properties or tags match `q`, ranked by BM25 relevance, with ties ordered by item ID.", + "summary": "List types", + "description": "Returns every type on this instance: those Marfa ships and those registered through `POST /types`. Every credential reads the whole list, whatever its type map reaches.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "description": "The text to search for. Marfa matches every word by its stem, in any order, and the last word as the start of a word. Wrap the query in double quotes to match a phrase." - }, - "required": true, - "description": "The text to search for. Marfa matches every word by its stem, in any order, and the last word as the start of a word. Wrap the query in double quotes to match a phrase.", - "name": "q", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Only return items of this type or a subtype. A wildcard such as `core.*` matches every type under that prefix." - }, - "required": false, - "description": "Only return items of this type or a subtype. A wildcard such as `core.*` matches every type under that prefix.", - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Only return items in this lifecycle state. Without it, you get `active` items. `any` searches every state, but trashed items are never searchable." - }, - "required": false, - "description": "Only return items in this lifecycle state. Without it, you get `active` items. `any` searches every state, but trashed items are never searchable.", - "name": "state", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "library", - "feed", - "all" - ], - "description": "Only return items in this tier. Omit it or send `all` for both tiers." - }, - "required": false, - "description": "Only return items in this tier. Omit it or send `all` for both tiers.", - "name": "tier", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Comma-separated extras. `system` also returns `system.*` items, which are left out by default. A `system.` type filter does the same." - }, - "required": false, - "description": "Comma-separated extras. `system` also returns `system.*` items, which are left out by default. A `system.` type filter does the same.", - "name": "include", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Comma-separated tags. Only return items that carry all of them." - }, - "required": false, - "description": "Comma-separated tags. Only return items that carry all of them.", - "name": "tags", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20, - "description": "The maximum number of results to return." - }, - "required": false, - "description": "The maximum number of results to return.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "The `next_cursor` from the previous page. Leave it out to get the first page." - }, - "required": false, - "description": "The `next_cursor` from the previous page. Leave it out to get the first page.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "A filter expression, as on `GET /items`. A term naming an edge type, `edge[]` or `backref[]`, matches by relationship and needs read on that edge type." - }, - "required": false, - "description": "A filter expression, as on `GET /items`. A term naming an edge type, `edge[]` or `backref[]`, matches by relationship and needs read on that edge type.", - "name": "filter", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Only return items whose own time (`occurred_at`, else `created_at`) is after this RFC 3339 time." - }, - "required": false, - "description": "Only return items whose own time (`occurred_at`, else `created_at`) is after this RFC 3339 time.", - "name": "occurred_after", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Only return items whose own time (`occurred_at`, else `created_at`) is before this RFC 3339 time." - }, - "required": false, - "description": "Only return items whose own time (`occurred_at`, else `created_at`) is before this RFC 3339 time.", - "name": "occurred_before", - "in": "query" - } - ], "responses": { "200": { - "description": "Returns a page of results. Each page runs the search again, so a change between two pages can repeat or skip a result. Marfa reads at most 10,000 results deep: the page that reaches that depth has `next_cursor: null`.", + "description": "Returns every type. A type lists only its own fields, so resolve the rest through `parent`.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SearchResultPage" + "$ref": "#/components/schemas/TypeDefinitionPage" } } }, @@ -26309,15 +26694,18 @@ }, "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Marfa-Reach": { + "$ref": "#/components/headers/X-Marfa-Reach" } } }, "400": { - "description": "- `missing_required_field`: `q` is missing.\n- `validation_error`: a parameter is unknown or invalid, such as a `state` that isn't a lifecycle state or `any`, a time that isn't an instant, or a `cursor` from another search.\n- `unknown_type`: `type` is a concrete type that nothing registers.", + "description": "- `validation_error`: the query has a parameter this endpoint doesn't take.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" + "$ref": "#/components/schemas/ValidationErrorRefusal" } } }, @@ -26372,12 +26760,12 @@ } } }, - "403": { - "description": "- `type_not_permitted`: your credential reaches no type, or `type` names a type you can't read with none readable under it.\n- `edge_permission_denied`: the filter has an `edge` or `backref` term for an edge type you can't read.", + "409": { + "description": "`reach_changed`: the reach in `X-Marfa-Reach` has changed. Rebuild the working copy.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EdgePermissionDeniedOrTypeNotPermittedRefusal" + "$ref": "#/components/schemas/ReachChangedRefusal" } } }, @@ -26525,29 +26913,51 @@ } } } - } - } - }, - "/types": { - "get": { - "operationId": "listTypes", + }, + "parameters": [ + { + "name": "X-Marfa-Reach", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "description": "The reach from a copy stream, for a working copy. Marfa reads the current data and checks the reach in one snapshot, and returns `409 reach_changed` if it has changed. Leave it out for an ordinary read." + } + ] + }, + "post": { + "operationId": "registerType", "tags": [ "Types" ], - "summary": "List types", - "description": "Returns every type on this instance: those Marfa ships and those registered through `POST /types`. Every credential reads the whole list, whatever its type map reaches.", + "summary": "Register a type", + "description": "Registers a type under the `app.*`, `user.*` or `.*` namespace and returns it. Name a `parent` to inherit its fields.", "security": [ { "bearerAuth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeDefinitionInput" + } + } + } + }, "responses": { - "200": { - "description": "Returns every type. A type lists only its own fields, so resolve the rest through `parent`.", + "201": { + "description": "Returns the new type.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypeDefinitionPage" + "$ref": "#/components/schemas/TypeResponse" } } }, @@ -26566,18 +26976,45 @@ }, "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" - }, - "X-Marfa-Reach": { - "$ref": "#/components/headers/X-Marfa-Reach" } } }, "400": { - "description": "- `validation_error`: the query has a parameter this endpoint doesn't take.", + "description": "- `missing_required_field`: `fields` is missing.\n- `validation_error`: `id` is malformed, or `parent` isn't registered or makes too deep a chain.\n- `invalid_schema`: the schema is invalid or has a key it doesn't define (`details.errors` names each path).\n- `property_shadows_field`: a field is named like one every item has.\n- `inheritance_violation`: the type reshapes an inherited field.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorRefusal" + "$ref": "#/components/schemas/InheritanceViolationOrInvalidSchemaOrMissingRequiredFieldOrPropertyShadowsFieldOrValidationErrorRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "401": { + "description": "`unauthorized`: the request has no credential, or its credential is not valid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedRefusal" } } }, @@ -26602,12 +27039,12 @@ } } }, - "401": { - "description": "`unauthorized`: the request has no credential, or its credential is not valid.", + "403": { + "description": "- `forbidden`: you don't have `metadata.types:write`, or the identifier is under `core.*`, `system.*` or `marfa.*`, which no credential can register.\n- `type_not_permitted`: your type map lacks write on the identifier or a non-platform `parent` or `compatible_with` target (`details.grant` names it).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnauthorizedRefusal" + "$ref": "#/components/schemas/ForbiddenOrTypeNotPermittedRefusal" } } }, @@ -26633,11 +27070,69 @@ } }, "409": { - "description": "`reach_changed`: the reach in `X-Marfa-Reach` has changed. Rebuild the working copy.", + "description": "- `type_already_exists`: a type has this identifier.\n- `link_taken`: two items that a forced delete left under this identifier hold the same value in the `link_field`.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReachChangedRefusal" + "anyOf": [ + { + "$ref": "#/components/schemas/LinkTakenOrTypeAlreadyExistsRefusal" + }, + { + "$ref": "#/components/schemas/InstanceChangedRefusal" + } + ] + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "413": { + "description": "`request_too_large`: the request body is larger than this instance accepts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestTooLargeRefusal" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" + } + } + }, + "422": { + "description": "`compatible_with_violation`: `compatible_with` names a type this instance doesn't hold, or one the type doesn't satisfy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompatibleWithViolationRefusal" } } }, @@ -26788,48 +27283,50 @@ }, "parameters": [ { - "name": "X-Marfa-Reach", + "name": "X-Marfa-Instance-Id", "in": "header", "required": false, "schema": { "type": "string", - "pattern": "^[0-9a-f]{64}$", - "minLength": 64, - "maxLength": 64 + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }, - "description": "The reach from a copy stream, for a working copy. Marfa reads the current data and checks the reach in one snapshot, and returns `409 reach_changed` if it has changed. Leave it out for an ordinary read." + "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." } ] - }, - "post": { - "operationId": "registerType", + } + }, + "/types/{id}": { + "get": { + "operationId": "getType", "tags": [ "Types" ], - "summary": "Register a type", - "description": "Registers a type under the `app.*`, `user.*` or `.*` namespace and returns it. Name a `parent` to inherit its fields.", + "summary": "Get a type", + "description": "Returns a type's schema, with the fields and policies it inherits from its ancestors. Every credential reads any type, whatever its type map reaches.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypeDefinitionInput" - } - } + "parameters": [ + { + "schema": { + "type": "string", + "description": "The identifier of the type, such as `core.note`." + }, + "required": true, + "description": "The identifier of the type, such as `core.note`.", + "name": "id", + "in": "path" } - }, + ], "responses": { - "201": { - "description": "Returns the new type.", + "200": { + "description": "Returns the type.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypeResponse" + "$ref": "#/components/schemas/RegisteredType" } } }, @@ -26852,11 +27349,11 @@ } }, "400": { - "description": "- `missing_required_field`: `fields` is missing.\n- `validation_error`: `id` is malformed, or `parent` isn't registered or makes too deep a chain.\n- `invalid_schema`: the schema is invalid or has a key it doesn't define (`details.errors` names each path).\n- `property_shadows_field`: a field is named like one every item has.\n- `inheritance_violation`: the type reshapes an inherited field.", + "description": "- `validation_error`: the query has a parameter this endpoint doesn't take.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InheritanceViolationOrInvalidSchemaOrMissingRequiredFieldOrPropertyShadowsFieldOrValidationErrorRefusal" + "$ref": "#/components/schemas/ValidationErrorRefusal" } } }, @@ -26911,12 +27408,12 @@ } } }, - "403": { - "description": "- `forbidden`: you don't have `metadata.types:write`, or the identifier is under `core.*`, `system.*` or `marfa.*`, which no credential can register.\n- `type_not_permitted`: your type map lacks write on the identifier or a non-platform `parent` or `compatible_with` target (`details.grant` names it).", + "404": { + "description": "`type_not_found`: no type has this identifier.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForbiddenOrTypeNotPermittedRefusal" + "$ref": "#/components/schemas/TypeNotFoundRefusal" } } }, @@ -26942,18 +27439,11 @@ } }, "409": { - "description": "- `type_already_exists`: a type has this identifier.\n- `link_taken`: two items that a forced delete left under this identifier hold the same value in the `link_field`.", + "description": "`type_chain_unresolvable`: the type's parent chain is circular or too deep to resolve. `PUT /types/{id}` can correct it.", "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/LinkTakenOrTypeAlreadyExistsRefusal" - }, - { - "$ref": "#/components/schemas/InstanceChangedRefusal" - } - ] + "$ref": "#/components/schemas/TypeChainUnresolvableRefusal" } } }, @@ -26978,12 +27468,12 @@ } } }, - "413": { - "description": "`request_too_large`: the request body is larger than this instance accepts.", + "429": { + "description": "`rate_limited`: you sent too many requests. Wait for the number of seconds in `Retry-After`, then try again.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestTooLargeRefusal" + "$ref": "#/components/schemas/RateLimitedRefusal" } } }, @@ -26994,17 +27484,29 @@ "X-Request-ID": { "$ref": "#/components/headers/X-Request-ID" }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, "X-Error-Code": { "$ref": "#/components/headers/X-Error-Code" + }, + "Retry-After": { + "$ref": "#/components/headers/Retry-After" } } }, - "422": { - "description": "`compatible_with_violation`: `compatible_with` names a type this instance doesn't hold, or one the type doesn't satisfy.", + "500": { + "description": "`internal_error`: Marfa failed in a way it didn't expect, and the request may not have completed. Read what you changed before you repeat a write.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompatibleWithViolationRefusal" + "$ref": "#/components/schemas/InternalErrorRefusal" } } }, @@ -27029,12 +27531,12 @@ } } }, - "429": { - "description": "`rate_limited`: you sent too many requests. Wait for the number of seconds in `Retry-After`, then try again.", + "503": { + "description": "`write_contention`: the database was busy, and Marfa couldn't complete the request in time. Nothing changed. Try the request again.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RateLimitedRefusal" + "$ref": "#/components/schemas/WriteContentionRefusal" } } }, @@ -27056,18 +27558,15 @@ }, "X-Error-Code": { "$ref": "#/components/headers/X-Error-Code" - }, - "Retry-After": { - "$ref": "#/components/headers/Retry-After" } } }, - "500": { - "description": "`internal_error`: Marfa failed in a way it didn't expect, and the request may not have completed. Read what you changed before you repeat a write.", + "507": { + "description": "`insufficient_storage`: the disk that holds the instance's data has no room for the request, or the request would leave less free than the instance keeps in reserve. Nothing changed, unless `details.write_outcome` is `unknown`, which means the write may have landed: read what you changed before you repeat it. Free space on the disk, then try the request again.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InternalErrorRefusal" + "$ref": "#/components/schemas/InsufficientStorageRefusal" } } }, @@ -27091,13 +27590,87 @@ "$ref": "#/components/headers/X-Error-Code" } } + } + } + }, + "put": { + "operationId": "replaceType", + "tags": [ + "Types" + ], + "summary": "Replace a type", + "description": "Replaces a registered type's whole schema and returns it. A field you leave out is removed, but items keep their values for it. `metadata.types:write` alone can add optional fields and change `label`, `description`, `display_hints` or `version`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "The identifier of the type, such as `acme.deal`." + }, + "required": true, + "description": "The identifier of the type, such as `acme.deal`.", + "name": "id", + "in": "path" + }, + { + "name": "X-Marfa-Instance-Id", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeDefinitionUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Returns the replaced type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TypeResponse" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } }, - "503": { - "description": "`write_contention`: the database was busy, and Marfa couldn't complete the request in time. Nothing changed. Try the request again.", + "400": { + "description": "- `missing_required_field`: `fields` is missing.\n- `validation_error`: `id` is malformed, or `parent` isn't registered or makes a bad chain.\n- `invalid_schema`: the schema is invalid or has a key it doesn't define (`details.errors` names each path).\n- `property_shadows_field`: a field has the name of one every item has.\n- `inheritance_violation`: a field's shape differs in a parent or subtype.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WriteContentionRefusal" + "$ref": "#/components/schemas/InheritanceViolationOrInvalidSchemaOrMissingRequiredFieldOrPropertyShadowsFieldOrValidationErrorRefusal" } } }, @@ -27122,12 +27695,12 @@ } } }, - "507": { - "description": "`insufficient_storage`: the disk that holds the instance's data has no room for the request, or the request would leave less free than the instance keeps in reserve. Nothing changed, unless `details.write_outcome` is `unknown`, which means the write may have landed: read what you changed before you repeat it. Free space on the disk, then try the request again.", + "401": { + "description": "`unauthorized`: the request has no credential, or its credential is not valid.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InsufficientStorageRefusal" + "$ref": "#/components/schemas/UnauthorizedRefusal" } } }, @@ -27151,54 +27724,13 @@ "$ref": "#/components/headers/X-Error-Code" } } - } - }, - "parameters": [ - { - "name": "X-Marfa-Instance-Id", - "in": "header", - "required": false, - "schema": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." - } - ] - } - }, - "/types/{id}": { - "get": { - "operationId": "getType", - "tags": [ - "Types" - ], - "summary": "Get a type", - "description": "Returns a type's schema, with the fields and policies it inherits from its ancestors. Every credential reads any type, whatever its type map reaches.", - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "schema": { - "type": "string", - "description": "The identifier of the type, such as `core.note`." - }, - "required": true, - "description": "The identifier of the type, such as `core.note`.", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Returns the type.", + }, + "403": { + "description": "- `forbidden`: you lack `schema.write` and `metadata.types:write`, or `details.changes` names changes needing `schema.write`.\n- `core_type_immutable`: Marfa ships this type.\n- `type_not_permitted`: your type map lacks write on the type or a new non-platform `parent` or `compatible_with` target (`details.grant` names it).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisteredType" + "$ref": "#/components/schemas/CoreTypeImmutableOrForbiddenOrTypeNotPermittedRefusal" } } }, @@ -27217,15 +27749,18 @@ }, "X-RateLimit-Reset": { "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "X-Error-Code": { + "$ref": "#/components/headers/X-Error-Code" } } }, - "400": { - "description": "- `validation_error`: the query has a parameter this endpoint doesn't take.", + "404": { + "description": "`type_not_found`: no type has this identifier.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorRefusal" + "$ref": "#/components/schemas/TypeNotFoundRefusal" } } }, @@ -27250,12 +27785,19 @@ } } }, - "401": { - "description": "`unauthorized`: the request has no credential, or its credential is not valid.", + "409": { + "description": "`link_taken`: the replacement names a `link_field` in which two items of the type, in any state, hold the same value.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnauthorizedRefusal" + "anyOf": [ + { + "$ref": "#/components/schemas/LinkTakenRefusal" + }, + { + "$ref": "#/components/schemas/InstanceChangedRefusal" + } + ] } } }, @@ -27280,12 +27822,12 @@ } } }, - "404": { - "description": "`type_not_found`: no type has this identifier.", + "413": { + "description": "`request_too_large`: the request body is larger than this instance accepts.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypeNotFoundRefusal" + "$ref": "#/components/schemas/RequestTooLargeRefusal" } } }, @@ -27296,26 +27838,17 @@ "X-Request-ID": { "$ref": "#/components/headers/X-Request-ID" }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - }, "X-Error-Code": { "$ref": "#/components/headers/X-Error-Code" } } }, - "409": { - "description": "`type_chain_unresolvable`: the type's parent chain is circular or too deep to resolve. `PUT /types/{id}` can correct it.", + "422": { + "description": "`compatible_with_violation`: `compatible_with` names a type this instance doesn't hold, or one the type doesn't satisfy, or the change breaks another type's claim (`details.dependent_type_ids`).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypeChainUnresolvableRefusal" + "$ref": "#/components/schemas/CompatibleWithViolationRefusal" } } }, @@ -27465,16 +27998,19 @@ } } }, - "put": { - "operationId": "replaceType", + "delete": { + "operationId": "deleteType", "tags": [ "Types" ], - "summary": "Replace a type", - "description": "Replaces a registered type's whole schema and returns it. A field you leave out is removed, but items keep their values for it. `metadata.types:write` alone can add optional fields and change `label`, `description`, `display_hints` or `version`.", + "summary": "Delete a type", + "description": "Deletes a type no item holds and no folder, view or webhook names. With `force=true`, a key keeps the items without their type and leaves the settings as they are; new writes then fail until it's registered again.", "security": [ { "bearerAuth": [] + }, + { + "ownerSession": [] } ], "parameters": [ @@ -27488,6 +28024,20 @@ "name": "id", "in": "path" }, + { + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "Delete the type even if items of it exist, in any state, or a folder, view or webhook names it. It doesn't cover subtypes or compatibility dependents." + }, + "required": false, + "description": "Delete the type even if items of it exist, in any state, or a folder, view or webhook names it. It doesn't cover subtypes or compatibility dependents.", + "name": "force", + "in": "query" + }, { "name": "X-Marfa-Instance-Id", "in": "header", @@ -27499,23 +28049,13 @@ "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TypeDefinitionUpdate" - } - } - } - }, "responses": { "200": { - "description": "Returns the replaced type.", + "description": "Returns `ok: true`.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TypeResponse" + "$ref": "#/components/schemas/Ok" } } }, @@ -27538,11 +28078,11 @@ } }, "400": { - "description": "- `missing_required_field`: `fields` is missing.\n- `validation_error`: `id` is malformed, or `parent` isn't registered or makes a bad chain.\n- `invalid_schema`: the schema is invalid or has a key it doesn't define (`details.errors` names each path).\n- `property_shadows_field`: a field has the name of one every item has.\n- `inheritance_violation`: a field's shape differs in a parent or subtype.", + "description": "- `validation_error`: the query has a parameter this endpoint doesn't take.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InheritanceViolationOrInvalidSchemaOrMissingRequiredFieldOrPropertyShadowsFieldOrValidationErrorRefusal" + "$ref": "#/components/schemas/ValidationErrorRefusal" } } }, @@ -27598,7 +28138,7 @@ } }, "403": { - "description": "- `forbidden`: you lack `schema.write` and `metadata.types:write`, or `details.changes` names changes needing `schema.write`.\n- `core_type_immutable`: Marfa ships this type.\n- `type_not_permitted`: your type map lacks write on the type or a new non-platform `parent` or `compatible_with` target (`details.grant` names it).", + "description": "- `forbidden`: you lack `schema.write`, your owner sign-in is too old, or direct authority requested `force=true`.\n- `core_type_immutable`: Marfa ships this type, and no credential can delete it.\n- `type_not_permitted`: your type map doesn't grant write on the type.", "content": { "application/json": { "schema": { @@ -27658,13 +28198,13 @@ } }, "409": { - "description": "`link_taken`: the replacement names a `link_field` in which two items of the type, in any state, hold the same value.", + "description": "- `type_has_subtypes`: other types name this one as their `parent` (`details.subtype_ids` lists them). Delete them, or give them another parent with `PUT /types/{id}`.\n- `type_in_use`: an item of the type exists, in any state, or a folder, view or webhook names it, and `force` isn't `true`. `details` counts the items and lists the settings. Move them with `POST /types/{id}/move`.", "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/LinkTakenRefusal" + "$ref": "#/components/schemas/TypeHasSubtypesOrTypeInUseRefusal" }, { "$ref": "#/components/schemas/InstanceChangedRefusal" @@ -27716,7 +28256,7 @@ } }, "422": { - "description": "`compatible_with_violation`: `compatible_with` names a type this instance doesn't hold, or one the type doesn't satisfy, or the change breaks another type's claim (`details.dependent_type_ids`).", + "description": "`compatible_with_violation`: other types name this type in `compatible_with`. `details.dependent_type_ids` lists them. Remove their claims before deleting the target; `force=true` does not bypass this check.", "content": { "application/json": { "schema": { @@ -27869,47 +28409,32 @@ } } } - }, - "delete": { - "operationId": "deleteType", + } + }, + "/types/{id}/move": { + "post": { + "operationId": "moveType", "tags": [ "Types" ], - "summary": "Delete a type", - "description": "Deletes an unused type. A key can use `force=true` to keep existing items without their type; new writes then fail until it is registered again. A recent owner session or local command can remove only unused types.", + "summary": "Move items to another type", + "description": "Moves every item of the type you can see, in any state, to another type as a bulk-action job. Once none remains, it rewrites the folders, views and webhooks that name the type. `dry_run: true` returns what it would do.", "security": [ { "bearerAuth": [] - }, - { - "ownerSession": [] } ], "parameters": [ { "schema": { "type": "string", - "description": "The identifier of the type, such as `acme.deal`." + "description": "The identifier of the type to move items out of, such as `acme.deal`." }, "required": true, - "description": "The identifier of the type, such as `acme.deal`.", + "description": "The identifier of the type to move items out of, such as `acme.deal`.", "name": "id", "in": "path" }, - { - "schema": { - "type": "string", - "enum": [ - "true", - "false" - ], - "description": "Delete the type even if items of it exist, in any state. It doesn't cover subtypes or compatibility dependents." - }, - "required": false, - "description": "Delete the type even if items of it exist, in any state. It doesn't cover subtypes or compatibility dependents.", - "name": "force", - "in": "query" - }, { "name": "X-Marfa-Instance-Id", "in": "header", @@ -27921,13 +28446,69 @@ "description": "The ID of the instance you expect, as `GET /` returns it. If another instance answers at this address, Marfa returns `409 instance_changed` and doesn't write. Leave it out to send the request to whichever instance answers." } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "The identifier of the type to move items to, such as `acme.contract`. It must be registered." + }, + "properties": { + "type": "object", + "additionalProperties": {}, + "description": "Properties to merge into every item as it moves, such as a field the new type requires. A `null` value removes a property." + }, + "dry_run": { + "type": "boolean", + "description": "`true` returns the items and settings the move would change and writes nothing. Defaults to `false`." + } + }, + "required": [ + "to" + ], + "additionalProperties": false + } + } + } + }, "responses": { "200": { - "description": "Returns `ok: true`.", + "description": "With `dry_run: true`, returns what the move would do.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "$ref": "#/components/schemas/TypeMoveDryRun" + } + } + }, + "headers": { + "X-Marfa-Contract": { + "$ref": "#/components/headers/X-Marfa-Contract" + }, + "X-Request-ID": { + "$ref": "#/components/headers/X-Request-ID" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "202": { + "description": "Returns the queued job. Poll it with `GET /items/bulk-actions/jobs/{id}`. Its `result` lists the first 100 items it couldn't move in `errors`, counts the items of the type still left in `remaining` and `remaining_hidden`, and says in `settings_rewritten` whether it rewrote `settings`. Run the move again to move what remains.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkActionJob" } } }, @@ -27950,11 +28531,11 @@ } }, "400": { - "description": "- `validation_error`: the query has a parameter this endpoint doesn't take.", + "description": "- `missing_required_field`: `to` is missing.\n- `validation_error`: `to` is malformed or names the type itself.\n- `unknown_type`: no type has the identifier `to`.\n- `bulk_cap_exceeded`: the type holds over 50,000 items.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorRefusal" + "$ref": "#/components/schemas/BulkCapExceededOrMissingRequiredFieldOrUnknownTypeOrValidationErrorRefusal" } } }, @@ -28010,11 +28591,11 @@ } }, "403": { - "description": "- `forbidden`: you lack `schema.write`, your owner sign-in is too old, or direct authority requested `force=true`.\n- `core_type_immutable`: Marfa ships this type, and no credential can delete it.\n- `type_not_permitted`: your type map doesn't grant write on the type.", + "description": "- `core_type_immutable`: Marfa ships the type, and its items stay in it.\n- `type_not_permitted`: your type map doesn't grant write on both types.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CoreTypeImmutableOrForbiddenOrTypeNotPermittedRefusal" + "$ref": "#/components/schemas/CoreTypeImmutableOrTypeNotPermittedRefusal" } } }, @@ -28070,13 +28651,13 @@ } }, "409": { - "description": "- `type_has_subtypes`: other types name this one as their `parent` (`details.subtype_ids` lists them). Delete them, or give them another parent with `PUT /types/{id}`.\n- `type_in_use`: an item of the type exists, in any state, and `force` isn't `true`.", + "description": "`type_move_blocked`: a folder, view or webhook names the type in a way the move can't rewrite. `details.settings` lists each, with its `reason`. Change them, then move.", "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/TypeHasSubtypesOrTypeInUseRefusal" + "$ref": "#/components/schemas/TypeMoveBlockedRefusal" }, { "$ref": "#/components/schemas/InstanceChangedRefusal" @@ -28127,36 +28708,6 @@ } } }, - "422": { - "description": "`compatible_with_violation`: other types name this type in `compatible_with`. `details.dependent_type_ids` lists them. Remove their claims before deleting the target; `force=true` does not bypass this check.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompatibleWithViolationRefusal" - } - } - }, - "headers": { - "X-Marfa-Contract": { - "$ref": "#/components/headers/X-Marfa-Contract" - }, - "X-Request-ID": { - "$ref": "#/components/headers/X-Request-ID" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - }, - "X-Error-Code": { - "$ref": "#/components/headers/X-Error-Code" - } - } - }, "429": { "description": "`rate_limited`: you sent too many requests. Wait for the number of seconds in `Retry-After`, then try again.", "content": { @@ -28627,7 +29178,7 @@ "Edge types" ], "summary": "Register an edge type", - "description": "Registers an edge type and returns it. With `evolve_constraints: true`, widens a registered type's endpoint constraints without changing its edges. An edge type can't extend another.", + "description": "Registers an edge type and returns it. With `evolve_constraints: true`, widens a registered type's endpoint constraints and may rename its labels, without changing its edges. An edge type can't extend another.", "security": [ { "bearerAuth": [] @@ -28819,7 +29370,7 @@ } }, "409": { - "description": "`conflict`: `id` is the name of an edge type Marfa ships, `id` or `reverse_name` is already the ID or reverse name of an edge type, or `reverse_name` equals `id`, or constraint evolution would remove a constraint token without allowing every type or change another definition member.", + "description": "`conflict`: `id` is the name of an edge type Marfa ships, `id` or `reverse_name` is already the ID or reverse name of an edge type, or `reverse_name` equals `id`, or constraint evolution would remove a constraint token without allowing every type or change a definition member other than the labels.", "content": { "application/json": { "schema": { @@ -37063,7 +37614,7 @@ "Views" ], "summary": "Create a view", - "description": "Creates a view: a `system.view` item whose properties say which items it lists and how its layouts show them. The item endpoints can't write one.", + "description": "Creates a view: a `system.view` item whose properties say which items it lists, in what order and how they group. The item endpoints can't write one.", "security": [ { "bearerAuth": [] @@ -37085,19 +37636,27 @@ "query": { "$ref": "#/components/schemas/ViewQuery" }, - "layouts": { + "sort": { "type": "array", "items": { - "$ref": "#/components/schemas/ViewLayout" + "$ref": "#/components/schemas/ViewSort" }, - "minItems": 1, - "maxItems": 50, - "description": "How the view shows its items, in order. At least one." + "maxItems": 10, + "description": "The keys the view sorts by, first key first." + }, + "group": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewGroup" + }, + { + "description": "How the view groups the items it lists." + } + ] } }, "required": [ - "title", - "layouts" + "title" ], "additionalProperties": false } @@ -37136,7 +37695,7 @@ } }, "400": { - "description": "- `validation_error`: a setting is malformed or undeclared, such as a repeated layout `id`. `details.errors[0].path` or `details.unknown_body_fields` names it.\n- `missing_required_field`: `title`, `layouts` or a part a layout requires is missing. `details.field` names it.\n- `unknown_type`: `query.types` names a type that isn't registered.", + "description": "- `validation_error`: a setting is malformed or undeclared, `details.errors[0].path` or `details.unknown_body_fields` names it.\n- `missing_required_field`: `title` is missing. `details.field` names it.\n- `unknown_type`: `query.types` names a type that isn't registered.", "content": { "application/json": { "schema": { @@ -37489,7 +38048,7 @@ "Views" ], "summary": "Update a view", - "description": "Updates a view's title, query or layouts. Each one you send replaces the current value whole, and those you leave out stay as they are. Send the `version` you read.", + "description": "Updates a view's title, query, sort or group. Each one you send replaces the current value whole, and those you leave out stay as they are. Send `group: null` to remove the grouping. Send the `version` you read.", "security": [ { "bearerAuth": [] @@ -37555,14 +38114,24 @@ } ] }, - "layouts": { + "sort": { "type": "array", "items": { - "$ref": "#/components/schemas/ViewLayout" + "$ref": "#/components/schemas/ViewSort" }, - "minItems": 1, - "maxItems": 50, - "description": "New layouts, which replace the current ones whole. At least one." + "maxItems": 10, + "description": "A new sort, which replaces the current one whole." + }, + "group": { + "anyOf": [ + { + "$ref": "#/components/schemas/ViewGroup" + }, + { + "type": "null" + } + ], + "description": "A new grouping, which replaces the current one whole. `null` removes the grouping." } }, "required": [ @@ -37605,7 +38174,7 @@ } }, "400": { - "description": "- `validation_error`: no setting, or a malformed or undeclared setting; `details` names it.\n- `missing_required_field`: `version` or a required layout part is missing; `details.field` names it.\n- `unknown_type`: `query.types` names a type that isn't registered.\n- `invalid_id`: the ID is invalid.\n- `invalid_transition`: the view is revoked.", + "description": "- `validation_error`: no setting, or a malformed or undeclared setting; `details` names it.\n- `missing_required_field`: `version` is missing; `details.field` names it.\n- `unknown_type`: `query.types` names a type that isn't registered.\n- `invalid_id`: the ID is invalid.\n- `invalid_transition`: the view is revoked.", "content": { "application/json": { "schema": { @@ -57920,6 +58489,7 @@ "listTypes", "replaceType", "deleteType", + "moveType", "normalizeType" ] }, @@ -57980,7 +58550,7 @@ }, { "name": "Views", - "description": "Saved lists of items and how to show them, held as `system.view` items.", + "description": "Saved lists of items with their sort and grouping, held as `system.view` items.", "x-operation-order": [ "createView", "updateView", diff --git a/scripts/sync-from-marfa.sh b/scripts/sync-from-marfa.sh index a41bcf4f..30dd564b 100755 --- a/scripts/sync-from-marfa.sh +++ b/scripts/sync-from-marfa.sh @@ -523,7 +523,7 @@ def write(name, lines): FIELD_KEYS = {"type", "description", "required", "format", "enum_values", "items_type"} TYPE_KEYS = {"id", "parent", "label", "description", "version", "fields", "display_hints", "merge_policy", "roles"} EDGE_KEYS = { - "id", "label", "description", "cardinality", "source_type_constraints", "target_type_constraints", + "id", "label", "reverse_label", "description", "cardinality", "source_type_constraints", "target_type_constraints", "cascade_on_delete", "reverse_name", "written_at", "property_schema", "shipped", } STRATEGIES = {"last_writer_wins", "keep_both_copies"} @@ -723,6 +723,7 @@ def constraint(edge, key, type_ids): def edge_table(edges, type_ids): """One row for each edge type. The Source types column appears only when some edge type constrains its source. + The reverse label is the label where an edge type names none, as the API answers it. Cardinality and On delete are values, so they are code: a code span keeps `many-to-many` on one line.""" for e in edges: if e.get("cardinality") not in CARDINALITIES: @@ -731,12 +732,16 @@ def edge_table(edges, type_ids): refuse(catalog_path, f"has the edge type {e['id']} with a cascade_on_delete the sync does not know") if "reverse_name" in e and not (isinstance(e["reverse_name"], str) and e["reverse_name"]): refuse(catalog_path, f"has the edge type {e['id']} with a reverse_name that is not a name") + if not (isinstance(e.get("label"), str) and e["label"]): + refuse(catalog_path, f"has the edge type {e['id']} with no label") + if "reverse_label" in e and not (isinstance(e["reverse_label"], str) and e["reverse_label"]): + refuse(catalog_path, f"has the edge type {e['id']} with a reverse_label that is not a label") sources = [constraint(e, "source_type_constraints", type_ids) for e in edges] targets = [constraint(e, "target_type_constraints", type_ids) for e in edges] constrained = any(source != "Any type" for source in sources) - header = ["Edge type", "Reverse name", "Cardinality", "On delete", *(["Source types"] if constrained else []), "Target types"] + header = ["Edge type", "Label", "Reverse label", "Reverse name", "Cardinality", "On delete", *(["Source types"] if constrained else []), "Target types"] rows = [ - [code(e["id"]), code(e["reverse_name"]) if e.get("reverse_name") else "None", code(e["cardinality"]), code(e["cascade_on_delete"]), + [code(e["id"]), e["label"], e.get("reverse_label", e["label"]), code(e["reverse_name"]) if e.get("reverse_name") else "None", code(e["cardinality"]), code(e["cascade_on_delete"]), *([source] if constrained else []), target] for e, source, target in zip(edges, sources, targets) ] diff --git a/src/generated/GLOSSARY.md b/src/generated/GLOSSARY.md index a16e3df1..7c676fa0 100644 --- a/src/generated/GLOSSARY.md +++ b/src/generated/GLOSSARY.md @@ -20,17 +20,19 @@ The words are grouped by topic. | **tier** | Which layer an item sits in. `feed` holds what arrives in volume and as it came, from connectors and capture, under the source's own type, unsorted until a person or an app moves it to the library or archives it. `library` holds what a person chose to keep, modeled in a core type or a child of one. An item **moves to the library** or **moves to the feed** by a change of its `tier` (`PATCH /items/{id}`): it is the same item, not a copy, and a connector's type must be readable as a core type, by `parent` or `compatible_with`, for the moved item to be read as one. Not a permission and not a lifecycle. The **namespace tier** (`core`, `system`, `app`, `user`, `marfa`, publisher) is a different axis and is not this word; say "namespace" for it. | | **link** | The value a row holds in the property its type names as `link_field`: the row's own id at the vendor that writes the type, by which a connector finds the row. One row's within the type, in every state, and kept as a tombstone when the row is purged. A type's own, never a parent's. The word keeps its ordinary sense of a URL, a blob's link or a `[[name]]` in a Markdown body, where the sentence says which. | | **tombstone** | What a purge leaves of a row under its type: its link and its natural key `(source, source_id)`, each with `purged_at` and a `settled_at`, the purge time or the later time of the vendor's own change a connector made in carrying the purge out, moved only later. An entry from the vendor naming the key is a new row only if the vendor changed it after `settled_at`. Kept, never swept, until a row holds the key again or the type goes: one small row per purged key. A link change drops the old link's; registering a type clears its identifier's. A trashed row leaves none. Not a trashed row, nor a revoked grant row. | -| **view** | A saved list of items, held as a `system.view` and shared by every app: its query says which items it lists, and its layouts say how each one shows them, as a list, a table, a board, a gallery, a calendar, a timeline, a chart or a map. Written only through `/views`. Not a folder, which holds items as files on a machine. | +| **view** | A saved list of items, held as a `system.view` and shared by every app: its query says which items it lists, and its sort and group say in what order and how they group. How an app shows them is the app's own. Written only through `/views`. Not a folder, which holds items as files on a machine. | ### Properties, metadata and edges -| Word | What it means | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **property** | A named value inside an item, governed by its type's schema. Never "field" for the same thing on the wire. | -| **tag** | A free-form label on an item. Flat, unversioned, and searched alongside the item's text. A release is named by a git tag, `v0.0.1`; call that a **release tag**. | -| **extension** | A namespaced object of metadata attached to an item by something other than the key that wrote the item. Read and written per namespace, and not to be confused with a blob: an extension is JSON. | -| **edge** | A directed relationship between two items, named by an **edge type**. Edges carry properties and a version of their own. | -| **reverse name** | The name an edge goes by read from its target, which an edge type may declare: `child-of` for `parent-of`. An edge type also says where it is **written at**, the end whose file writes it, or `none` where no file does. Never "inverse". | +| Word | What it means | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **property** | A named value inside an item, governed by its type's schema. Never "field" for the same thing on the wire. | +| **tag** | A free-form label on an item. Flat, unversioned, and searched alongside the item's text. A release is named by a git tag, `v0.0.1`; call that a **release tag**. | +| **extension** | A namespaced object of metadata attached to an item by something other than the key that wrote the item. Read and written per namespace, and not to be confused with a blob: an extension is JSON. | +| **edge** | A directed relationship between two items, named by an **edge type**. Edges carry properties and a version of their own. | +| **reverse name** | The name an edge goes by read from its target, which an edge type may declare: `child-of` for `parent-of`. An edge type also says where it is **written at**, the end whose file writes it, or `none` where no file does. Never "inverse". | +| **label** | The heading a type or an edge type shows for itself. An edge type's label heads the source end of an edge, such as `Children` on `parent-of`, and every edge type has one. Distinct from the `id` and from a **reverse name**. | +| **reverse label** | The heading an edge type shows for the target end, such as `Parent` on `parent-of`. Optional at registration: where none is registered the **label** names both ends and a listing answers it as the reverse label. Distinct from a **reverse name**, which is a frontmatter key. | ### Versions and events @@ -131,14 +133,15 @@ Access is read the same way wherever a person sees it: on the pages where they a | **pin** | To hold one row in a working copy by id, whatever the slice says of it. A pinned row is read at once, kept current by catch-up and read again by every hydration, and goes once its pin is off and the slice does not take it (where a write to it waits, at an answered move, the next catch-up that touches it or the next hydration), or when it is purged or its create refused. | | **verdict** | What a device gives a queued write once the server answers it, or once it cannot be sent: one of `accepted`, `merged` (applied with changes), `conflicted` (written to a conflicted copy), `refused` (with a reason), `blocked` (held, for one of six reasons such as a refused credential or a write it waits on) and `dead` (given up after retries). | | **file cache** | The folder beside a device's store that holds the bytes of the files it fetched and of its queued uploads, named by hash. What a device can fetch again, other than an attachment's bytes, it may take away to fit the file budget, and keeps out of backups where the platform allows; the bytes of a queued upload stay in backups. | -| **attachment** | A file item out of the bin that is the source of an `attached-to` edge whose target a working copy holds out of the bin. Its bytes stay in the file cache outside the file budget, and a device fetches them when asked (`marfa device blobs fetch-kept`, `fetchKept()` in the Node module and the Swift binding). Once its edges to items the copy holds go, its bytes are cached as any other. | +| **attachment** | A file item out of the bin that is the source of an `attached-to` edge whose target a working copy holds out of the bin. Its bytes stay in the file cache outside the file budget, and a device fetches them when asked (`marfa device blobs fetch-kept`, `fetchKept()` in the Node module and the Swift binding). Once its edges to items the copy holds go, its bytes are cached as any other. In a folder, a document's attachments are written with it: where an embed says, else beside it, unless the folder's query holds one on its own. | | **file budget** | The most bytes the file cache is to hold, other than the bytes of attachments: a byte count a caller sets, none while every file is kept, or by default the lesser of the larger of 2 GiB and a tenth of the volume, and the bytes held other than attachments' plus the volume's free bytes less a twentieth of the volume, never below 256 MiB, and 2 GiB where the volume cannot be read. Bytes a queued upload names, an attachment's bytes, and bytes whose path or file a caller in the same process is still waiting for, are never taken to fit it. | | **drain** | To send a device's queued writes to the server and record each answer as the write's verdict. Also the noun: a drain is one such pass over the queue, as `marfa device drain` runs it. A write that met a network rather than an answer waits for the next drain. Catch-up, which brings the server's changes into the working copy, is a different act. | | **forget** | To clear a device's answered writes from its queue: `marfa device forget`, and `forgetAnswered()` in the Swift SDK and the Node module. Given ids (`--id` on the command, `forgetAnsweredIds()` in the libraries), it clears only the writes named. The contract's rules say "clear" for the same act, as in `queue-and-verdicts/clear-answered`. `marfa keys forget` keeps the ordinary sense: it removes the credential kept for a server. | | **folder** | A directory on a machine bound to a `system.folder`, whose settings every machine bound to it shares and each writes out as one editable file, `.marfa/folder.yaml`: a query that decides what the folder holds, the defaults a new file takes, and include and ignore lists. A device like any other (the same working copy, the same queue, the same verdicts) with a filesystem as its face: a file in it is an item on the server, and an item its query matches is a file in it. | | **placement** | Where an item's file sits in a folder: its `in-folder` edge to the folder's `system.folder`, whose `path` names the file from the folder's root. It never decides what a folder holds, and it is never written in a file. | | **registry** | The folder registry: one file on a machine listing its folders, each by its directory and the `system.folder` it follows, so each folder can find the others and a file moved or copied between two of them is told apart. It is never a folder's store, and `MARFA_FOLDER_REGISTRY` names another file in its place. The type registry is the server's catalog of types (`types.md`). | -| **embed** | A file an item's body shows inside it, `![](path)` or `![[file]]`: that file item's `attached-to` edge to the item, in a folder's Markdown file and in a body written through a working copy alike. In a folder, the file's `has-attachment` line lists only what the body does not embed, and the embedded file travels with the file showing it and is written where the link says. It names a file where a file answers to it or its extension names a MIME type; anything else, `![[A note]]` say, is an embed of a note, text in the body and neither a link nor an edge. | +| **embed** | A file an item's body shows inside it, `![](path)` or `![[file]]`: that file item's `attached-to` edge to the item, in a folder's Markdown file and in a body written through a working copy alike. In a folder, the file's `has-attachment` line lists only what the body does not embed, and an embedded file is put where the link says, any other attachment beside the document. It names a file where a file answers to it or its extension names a MIME type; anything else, `![[A note]]` say, is an embed of a note, text in the body and neither a link nor an edge. | +| **collection** | A container and its members: an item whose type declares the `container` role, such as an album or a series, and the items joined to it by an `in-collection` edge, each with an optional `position`. Flat: a container is never a member, `parent-of` is what hierarchy is for, and deleting the container leaves its members. Not a folder and not a view. | | **frontmatter** | The block of `key: value` lines a Markdown file in a folder opens with between `---` fences. Its `type`, `tier`, `tags`, `state` and `occurred_at` lines are the item's own fields; `marfa_id` and `marfa_version` name the item and the version the file was written from; a line named for an edge type or a reverse name is an edge's, written in the one file at the end the type names and naming its targets as `"[[name]]"` or `"[[]]"`; every other line is a property. The file's body is the property its type's `body_field` names. Never "header". | ### Connectors diff --git a/src/generated/catalog/builtin-catalog.json b/src/generated/catalog/builtin-catalog.json index c914cc0d..d03ba4e0 100644 --- a/src/generated/catalog/builtin-catalog.json +++ b/src/generated/catalog/builtin-catalog.json @@ -409,7 +409,7 @@ { "id": "core.file", "label": "File", - "description": "A file or binary reference — the generic fallback for non-media files.", + "description": "A file or binary reference — the generic fallback for any file that has no more specific type.", "version": 0, "fields": { "blob_ref": { @@ -936,8 +936,7 @@ }, "body": { "type": "string", - "description": "The article text", - "required": true + "description": "The article text" }, "author": { "type": "string", @@ -1445,8 +1444,7 @@ "fields": { "body": { "type": "string", - "description": "The message text", - "required": true + "description": "The message text" }, "from": { "type": "string", @@ -1477,8 +1475,7 @@ "fields": { "body": { "type": "string", - "description": "The note text", - "required": true + "description": "The note text" }, "title": { "type": "string", @@ -1683,7 +1680,7 @@ { "id": "system.view", "label": "View", - "description": "A saved list of items, held here and shared by every app: which items, and how each layout shows them. Written only through `/views`: created, changed at a version, and deleted, which removes it at once with no trash. Lifecycle bounded to active | revoked. Has no tier.", + "description": "A saved list of items, held here and shared by every app: which items, in what order and how they group. How an app shows them is the app's own. Written only through `/views`: created, changed at a version, and deleted, which removes it at once with no trash. Lifecycle bounded to active | revoked. Has no tier.", "version": 0, "fields": { "title": { @@ -1695,11 +1692,14 @@ "type": "object", "description": "Which items the view lists. `types`: type identifiers, each with its subtypes; empty or absent lists every type the reading key reads. `tier`: `library`, `feed` or `all`; absent is `all`. `state`: `active`, `archived` or both; absent is both. `filter`: an expression in the listing grammar's `filter`. `beneath`: an item id; the item and everything under it by `parent-of`." }, - "layouts": { + "sort": { "type": "array", - "description": "How the view shows its items, in order, at least one. Each layout has an `id` unique in the view, a `kind` (`list`, `table`, `board`, `gallery`, `calendar`, `timeline`, `chart` or `map`), and an optional `title`, `sort`, `group`, `shown` and the options its kind takes. A layout never narrows which items the view lists", - "required": true, + "description": "The keys the view sorts by, first key first. Each is a `by` field and a `direction`, `asc` or `desc`.", "items_type": "object" + }, + "group": { + "type": "object", + "description": "How the view groups the items it lists: `by` (a field, or `edge[]` or `backref[]`), and optionally `bucket` (`day`, `week`, `month` or `year`, for a date field), `order` (the values whose groups come first) and `hidden` (the values whose groups are left out)." } } } @@ -1708,6 +1708,7 @@ { "id": "about", "label": "About", + "reverse_label": "Subject of", "description": "Source item is about the target item. Topical connection between items across types.", "cardinality": "many-to-many", "source_type_constraints": [ @@ -1724,7 +1725,8 @@ { "id": "attached-to", "label": "Attached to", - "description": "Source is an attachment (a file or media item) belonging to the target. Many-to-many: a file may be attached to multiple hosts; a host may have multiple attachments. Deleting the host orphans the attachment (it may still be attached to other items). The attachment's own file writes the edge, saying what it was made for; where the attachment cannot carry frontmatter, such as an image, the host's file writes it under the reverse name, `has-attachment`.", + "reverse_label": "Attachments", + "description": "Source is a file attached to the target. Many-to-many: a file may be attached to multiple hosts; a host may have multiple attachments. Deleting the host orphans the attachment (it may still be attached to other items). The attachment's own file writes the edge, saying what it was made for; where the attachment cannot carry frontmatter, such as an image, the host's file writes it under the reverse name, `has-attachment`.", "cardinality": "many-to-many", "source_type_constraints": [ "*" @@ -1741,6 +1743,7 @@ { "id": "authored-by", "label": "Authored by", + "reverse_label": "Author of", "description": "Source was authored by the target (usually a core.entity.person).", "cardinality": "many-to-many", "source_type_constraints": [ @@ -1757,6 +1760,7 @@ { "id": "derived-from", "label": "Derived from", + "reverse_label": "Basis for", "description": "Source was produced from the target (imports, AI generation, transformations). Both items remain valid in parallel.", "cardinality": "many-to-many", "source_type_constraints": [ @@ -1773,6 +1777,7 @@ { "id": "in-collection", "label": "In collection", + "reverse_label": "Contents", "description": "Source item is a member of the target container. The target may be any type declaring the `container` role, such as a collection, an ongoing series, an album or a publisher's own container type, rather than a fixed list of type names, so a connector writing its own vocabulary can express containment without the platform naming its types first. An item may belong to any number of containers, and a container holds any number of members. A container may not itself be a member: containment here is flat, and `parent-of` is what hierarchy is for. Deleting the container leaves its members in place. Carries position for ordering within the container.", "cardinality": "many-to-many", "source_type_constraints": [ @@ -1794,6 +1799,7 @@ { "id": "in-folder", "label": "In folder", + "reverse_label": "Folder contents", "description": "Where an item's file sits in a folder: the source is the item, the target the folder's `system.folder`, and `path` the file's path relative to the folder's root. Placement only; a folder's query alone decides what it holds, and the edge is never written in a file's frontmatter. An item may sit in any number of folders. Revoking or removing the folder leaves its items in place, and a revoked folder takes no new placement.", "cardinality": "many-to-many", "source_type_constraints": [ @@ -1815,6 +1821,7 @@ { "id": "in-thread", "label": "In thread", + "reverse_label": "Messages", "description": "Source item is in the thread-target sequence. Each source is in at most one thread; the target is the thread item. Carries position for ordering.", "cardinality": "many-to-one", "source_type_constraints": [ @@ -1835,7 +1842,8 @@ }, { "id": "parent-of", - "label": "Parent of", + "label": "Children", + "reverse_label": "Parent", "description": "Source is the parent of the target. Target has at most one parent. Deleting the parent cascades to children. A child's file writes the edge, naming its parent under the reverse name, `child-of`, so a parent's file does not list every child.", "cardinality": "one-to-many", "source_type_constraints": [ @@ -1853,6 +1861,7 @@ { "id": "references", "label": "References", + "reverse_label": "Referenced by", "description": "Source explicitly references the target. Distinct from `about` (topical) and `derived-from` (provenance). Covers wikilink-style mentions and the highlight-on-a-book case.", "cardinality": "many-to-many", "source_type_constraints": [ @@ -1869,6 +1878,7 @@ { "id": "supersedes", "label": "Supersedes", + "reverse_label": "Superseded by", "description": "Source replaces the target as the current version. Linear chain; no cycles. Deleting the head does not promote the predecessor (orphan).", "cardinality": "one-to-one", "source_type_constraints": [ diff --git a/src/generated/catalog/core.media.md b/src/generated/catalog/core.media.md index 6c997bc4..d424812e 100644 --- a/src/generated/catalog/core.media.md +++ b/src/generated/catalog/core.media.md @@ -43,7 +43,7 @@ An article. Inherits all core.media fields. | Field | Kind | Required | Description | | ------------ | --------- | -------- | -------------------------- | -| `body` | `string` | Yes | The article text | +| `body` | `string` | No | The article text | | `section` | `string` | No | Section of the publication | | `word_count` | `integer` | No | Word count | diff --git a/src/generated/catalog/core.message.md b/src/generated/catalog/core.message.md index e3233383..4312e584 100644 --- a/src/generated/catalog/core.message.md +++ b/src/generated/catalog/core.message.md @@ -2,7 +2,7 @@ | Field | Kind | Required | Description | | ------ | ------------------- | -------- | ---------------------------------------------------------------------- | -| `body` | `string` | Yes | The message text | +| `body` | `string` | No | The message text | | `from` | `string` | Yes | Sender identifier: format-agnostic (phone number, email, handle, etc.) | | `to` | `array` of `string` | No | Recipient identifiers: same format-agnostic shape as from | diff --git a/src/generated/catalog/core.note.md b/src/generated/catalog/core.note.md index 66bf1d69..a8995d41 100644 --- a/src/generated/catalog/core.note.md +++ b/src/generated/catalog/core.note.md @@ -2,7 +2,7 @@ | Field | Kind | Required | Description | | ---------- | ------------------ | -------- | -------------------- | -| `body` | `string` | Yes | The note text | +| `body` | `string` | No | The note text | | `title` | `string` | No | Heading or title | | `language` | `string` (`bcp47`) | No | BCP 47 language code | | `notes` | `string` | No | Personal annotations | diff --git a/src/generated/catalog/edge-types.md b/src/generated/catalog/edge-types.md index 06e853a1..96b30665 100644 --- a/src/generated/catalog/edge-types.md +++ b/src/generated/catalog/edge-types.md @@ -1,12 +1,12 @@ -| Edge type | Reverse name | Cardinality | On delete | Target types | -| --------------- | ---------------- | -------------- | --------- | ---------------------------------- | -| `about` | None | `many-to-many` | `orphan` | Any type | -| `attached-to` | `has-attachment` | `many-to-many` | `orphan` | Any type | -| `authored-by` | None | `many-to-many` | `orphan` | Any type | -| `derived-from` | None | `many-to-many` | `orphan` | Any type | -| `in-collection` | None | `many-to-many` | `orphan` | Any type with the role `container` | -| `in-folder` | None | `many-to-many` | `orphan` | `system.folder` | -| `in-thread` | None | `many-to-one` | `orphan` | Any type | -| `parent-of` | `child-of` | `one-to-many` | `cascade` | Any type | -| `references` | None | `many-to-many` | `orphan` | Any type | -| `supersedes` | None | `one-to-one` | `orphan` | Any type | +| Edge type | Label | Reverse label | Reverse name | Cardinality | On delete | Target types | +| --------------- | ------------- | --------------- | ---------------- | -------------- | --------- | ---------------------------------- | +| `about` | About | Subject of | None | `many-to-many` | `orphan` | Any type | +| `attached-to` | Attached to | Attachments | `has-attachment` | `many-to-many` | `orphan` | Any type | +| `authored-by` | Authored by | Author of | None | `many-to-many` | `orphan` | Any type | +| `derived-from` | Derived from | Basis for | None | `many-to-many` | `orphan` | Any type | +| `in-collection` | In collection | Contents | None | `many-to-many` | `orphan` | Any type with the role `container` | +| `in-folder` | In folder | Folder contents | None | `many-to-many` | `orphan` | `system.folder` | +| `in-thread` | In thread | Messages | None | `many-to-one` | `orphan` | Any type | +| `parent-of` | Children | Parent | `child-of` | `one-to-many` | `cascade` | Any type | +| `references` | References | Referenced by | None | `many-to-many` | `orphan` | Any type | +| `supersedes` | Supersedes | Superseded by | None | `one-to-one` | `orphan` | Any type | diff --git a/src/generated/catalog/edges.md b/src/generated/catalog/edges.md index d9488db3..e8cc9df0 100644 --- a/src/generated/catalog/edges.md +++ b/src/generated/catalog/edges.md @@ -4,7 +4,7 @@ Source item is about the target item. Topical connection between items across ty ## attached-to -Source is an attachment (a file or media item) belonging to the target. Many-to-many: a file may be attached to multiple hosts; a host may have multiple attachments. Deleting the host orphans the attachment (it may still be attached to other items). The attachment's own file writes the edge, saying what it was made for; where the attachment cannot carry frontmatter, such as an image, the host's file writes it under the reverse name, `has-attachment`. +Source is a file attached to the target. Many-to-many: a file may be attached to multiple hosts; a host may have multiple attachments. Deleting the host orphans the attachment (it may still be attached to other items). The attachment's own file writes the edge, saying what it was made for; where the attachment cannot carry frontmatter, such as an image, the host's file writes it under the reverse name, `has-attachment`. ## authored-by diff --git a/src/generated/catalog/system.view.md b/src/generated/catalog/system.view.md index 37af68d7..4683d20d 100644 --- a/src/generated/catalog/system.view.md +++ b/src/generated/catalog/system.view.md @@ -1,7 +1,8 @@ ## Fields -| Field | Kind | Required | Description | -| --------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `title` | `string` | Yes | The view's name | -| `query` | `object` | No | Which items the view lists. `types`: type identifiers, each with its subtypes; empty or absent lists every type the reading key reads. `tier`: `library`, `feed` or `all`; absent is `all`. `state`: `active`, `archived` or both; absent is both. `filter`: an expression in the listing grammar's `filter`. `beneath`: an item id; the item and everything under it by `parent-of`. | -| `layouts` | `array` of `object` | Yes | How the view shows its items, in order, at least one. Each layout has an `id` unique in the view, a `kind` (`list`, `table`, `board`, `gallery`, `calendar`, `timeline`, `chart` or `map`), and an optional `title`, `sort`, `group`, `shown` and the options its kind takes. A layout never narrows which items the view lists | +| Field | Kind | Required | Description | +| ------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | `string` | Yes | The view's name | +| `query` | `object` | No | Which items the view lists. `types`: type identifiers, each with its subtypes; empty or absent lists every type the reading key reads. `tier`: `library`, `feed` or `all`; absent is `all`. `state`: `active`, `archived` or both; absent is both. `filter`: an expression in the listing grammar's `filter`. `beneath`: an item id; the item and everything under it by `parent-of`. | +| `sort` | `array` of `object` | No | The keys the view sorts by, first key first. Each is a `by` field and a `direction`, `asc` or `desc`. | +| `group` | `object` | No | How the view groups the items it lists: `by` (a field, or `edge[]` or `backref[]`), and optionally `bucket` (`day`, `week`, `month` or `year`, for a date field), `order` (the values whose groups come first) and `hidden` (the values whose groups are left out). | diff --git a/src/generated/catalog/types.md b/src/generated/catalog/types.md index efbdb2b2..3af02f8c 100644 --- a/src/generated/catalog/types.md +++ b/src/generated/catalog/types.md @@ -5,7 +5,7 @@ | [`core.bookmark`](/catalog/bookmark) | Content you captured from elsewhere: a saved URL, a highlight, an excerpt, a clipped paragraph. | | | [`core.entity`](/catalog/entity) | Any named entity: a person, a company, a band, a team, a charity, a brand, a school or a place. `core.entity.person` and `core.entity.place` narrow it. | [`person`](/catalog/entity#core-entity-person), [`place`](/catalog/entity#core-entity-place) | | [`core.event`](/catalog/event) | Something that happens at a time. | | -| [`core.file`](/catalog/file) | A file or binary reference: the generic fallback for non-media files. | [`audio`](/catalog/file#core-file-audio), [`image`](/catalog/file#core-file-image), [`video`](/catalog/file#core-file-video) | +| [`core.file`](/catalog/file) | A file or binary reference: the generic fallback for any file that has no more specific type. | [`audio`](/catalog/file#core-file-audio), [`image`](/catalog/file#core-file-image), [`video`](/catalog/file#core-file-video) | | [`core.highlight`](/catalog/highlight) | A user's engagement with content: the highlighted passage plus optional annotation. The canonical relationship (what was highlighted) is carried by a references edge; the moment of highlighting is the item's own `occurred_at`. | | | [`core.media`](/catalog/media) | Content produced by someone that the user engages with: a film, podcast, book, article, song, show. | [`album`](/catalog/media#core-media-album), [`article`](/catalog/media#core-media-article), [`book`](/catalog/media#core-media-book), [`episode`](/catalog/media#core-media-episode), [`film`](/catalog/media#core-media-film), [`series`](/catalog/media#core-media-series), [`song`](/catalog/media#core-media-song) | | [`core.message`](/catalog/message) | A light, cross-platform message. Threading is edge-based via in-thread; reply trees via parent-of. Tool-specific richness lives in app namespaces. | | @@ -14,8 +14,8 @@ ## System types -| Type | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`system.connection`](/catalog/connection) | An OAuth client this user has authorized, written when the user approves the client's consent or device sign-in. `kind` is `app`. Lifecycle bounded to active \| revoked. Has no tier. | -| [`system.folder`](/catalog/folder) | A folder's settings, held here and shared by every machine bound to the folder; each machine chooses only the directory. The settings are written only through `/folders`: created, changed at a version, and revoked. Lifecycle bounded to active \| revoked. Has no tier. | -| [`system.view`](/catalog/view) | A saved list of items, held here and shared by every app: which items, and how each layout shows them. Written only through `/views`: created, changed at a version, and deleted, which removes it at once with no trash. Lifecycle bounded to active \| revoked. Has no tier. | +| Type | Description | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`system.connection`](/catalog/connection) | An OAuth client this user has authorized, written when the user approves the client's consent or device sign-in. `kind` is `app`. Lifecycle bounded to active \| revoked. Has no tier. | +| [`system.folder`](/catalog/folder) | A folder's settings, held here and shared by every machine bound to the folder; each machine chooses only the directory. The settings are written only through `/folders`: created, changed at a version, and revoked. Lifecycle bounded to active \| revoked. Has no tier. | +| [`system.view`](/catalog/view) | A saved list of items, held here and shared by every app: which items, in what order and how they group. How an app shows them is the app's own. Written only through `/views`: created, changed at a version, and deleted, which removes it at once with no trash. Lifecycle bounded to active \| revoked. Has no tier. | diff --git a/src/generated/cli-commands.json b/src/generated/cli-commands.json index add4c187..c106969f 100644 --- a/src/generated/cli-commands.json +++ b/src/generated/cli-commands.json @@ -89,6 +89,7 @@ "types normalize": {"options": [{"flag": "--file", "value": true}, {"flag": "--body", "value": true}]}, "types replace": {"options": [{"flag": "--file", "value": true}, {"flag": "--body", "value": true}], "positional": true}, "types delete": {"options": [{"flag": "--force"}], "positional": true}, + "types move": {"options": [{"flag": "--dry-run"}, {"flag": "--properties", "value": true}], "positional": true}, "types retired": {"options": []}, "types delete-retired": {"options": [], "positional": true}, "search": {"options": [{"flag": "--type", "value": true}, {"flag": "--state", "value": true}, {"flag": "--tier", "value": true}, {"flag": "--tag", "value": true}, {"flag": "--filter", "value": true}, {"flag": "--occurred-after", "value": true}, {"flag": "--occurred-before", "value": true}, {"flag": "--include", "value": true}, {"flag": "--limit", "value": true}, {"flag": "--cursor", "value": true}], "positional": true}, @@ -124,8 +125,8 @@ "export": {"options": [{"flag": "--format", "value": true}, {"flag": "--output", "value": true}, {"flag": "--type", "value": true}, {"flag": "--state", "value": true}, {"flag": "--source", "value": true}, {"flag": "--occurred-after", "value": true}, {"flag": "--occurred-before", "value": true}]}, "restore": {"options": [], "positional": true}, "views": {"options": [], "subcommands": true}, - "views create": {"options": [{"flag": "--file", "value": true}, {"flag": "--body", "value": true}, {"flag": "--title", "value": true}, {"flag": "--query", "value": true}, {"flag": "--layouts", "value": true}, {"flag": "--idempotency-key", "value": true}]}, - "views change": {"options": [{"flag": "--version", "value": true}, {"flag": "--file", "value": true}, {"flag": "--body", "value": true}, {"flag": "--title", "value": true}, {"flag": "--query", "value": true}, {"flag": "--layouts", "value": true}, {"flag": "--idempotency-key", "value": true}], "positional": true}, + "views create": {"options": [{"flag": "--file", "value": true}, {"flag": "--body", "value": true}, {"flag": "--title", "value": true}, {"flag": "--query", "value": true}, {"flag": "--sort", "value": true}, {"flag": "--group", "value": true}, {"flag": "--idempotency-key", "value": true}]}, + "views change": {"options": [{"flag": "--version", "value": true}, {"flag": "--file", "value": true}, {"flag": "--body", "value": true}, {"flag": "--title", "value": true}, {"flag": "--query", "value": true}, {"flag": "--sort", "value": true}, {"flag": "--group", "value": true}, {"flag": "--idempotency-key", "value": true}], "positional": true}, "views delete": {"options": [{"flag": "--idempotency-key", "value": true}], "positional": true}, "views get": {"options": [], "positional": true}, "webhooks": {"options": [], "subcommands": true}, @@ -178,7 +179,7 @@ "device watch": {"options": [{"flag": "--for", "value": true}]}, "device changes": {"options": [{"flag": "--for", "value": true}]}, "device items": {"options": [], "subcommands": true}, - "device items list": {"options": [{"flag": "--type", "value": true}, {"flag": "--state", "value": true}, {"flag": "--all-states"}, {"flag": "--tier", "value": true}, {"flag": "--tag", "value": true}, {"flag": "--occurred-after", "value": true}, {"flag": "--occurred-before", "value": true}, {"flag": "--filter", "value": true}, {"flag": "--beneath", "value": true}, {"flag": "--sort", "value": true}, {"flag": "--direction", "value": true}, {"flag": "--limit", "value": true}, {"flag": "--offset", "value": true}, {"flag": "--view", "value": true}, {"flag": "--layout", "value": true}]}, + "device items list": {"options": [{"flag": "--type", "value": true}, {"flag": "--state", "value": true}, {"flag": "--all-states"}, {"flag": "--tier", "value": true}, {"flag": "--tag", "value": true}, {"flag": "--occurred-after", "value": true}, {"flag": "--occurred-before", "value": true}, {"flag": "--filter", "value": true}, {"flag": "--beneath", "value": true}, {"flag": "--sort", "value": true}, {"flag": "--direction", "value": true}, {"flag": "--limit", "value": true}, {"flag": "--offset", "value": true}, {"flag": "--view", "value": true}]}, "device items get": {"options": [], "positional": true}, "device items create": {"options": [{"flag": "--type", "value": true}, {"flag": "--properties", "value": true}, {"flag": "--tag", "value": true}, {"flag": "--tier", "value": true}, {"flag": "--source", "value": true}, {"flag": "--source-id", "value": true}, {"flag": "--occurred-at", "value": true}, {"flag": "--id", "value": true}, {"flag": "--version", "value": true}], "positional": true}, "device items update": {"options": [{"flag": "--properties", "value": true}, {"flag": "--version", "value": true}, {"flag": "--source-id", "value": true}, {"flag": "--as-read"}, {"flag": "--type", "value": true}, {"flag": "--tier", "value": true}, {"flag": "--replace"}], "positional": true}, @@ -200,7 +201,7 @@ "device views": {"options": [], "subcommands": true}, "device views list": {"options": []}, "device views get": {"options": [], "positional": true}, - "device views groups": {"options": [{"flag": "--layout", "value": true}, {"flag": "--time-zone", "value": true}], "positional": true}, + "device views groups": {"options": [{"flag": "--time-zone", "value": true}], "positional": true}, "device search": {"options": [{"flag": "--state", "value": true}, {"flag": "--all-states"}, {"flag": "--type", "value": true}, {"flag": "--tag", "value": true}, {"flag": "--filter", "value": true}, {"flag": "--beneath", "value": true}, {"flag": "--limit", "value": true}, {"flag": "--view", "value": true}], "positional": true}, "device queue": {"options": []}, "device drain": {"options": []}, diff --git a/src/generated/cli-download.md b/src/generated/cli-download.md index 4b431ffa..d3fe2142 100644 --- a/src/generated/cli-download.md +++ b/src/generated/cli-download.md @@ -1,5 +1,5 @@ ```bash -curl -fsSL https://github.com/withmarfa/marfa/releases/download/v0.0.33/marfa-0.0.33-darwin-arm64.tar.gz | tar -xz +curl -fsSL https://github.com/withmarfa/marfa/releases/download/v0.0.35/marfa-0.0.35-darwin-arm64.tar.gz | tar -xz sudo mkdir -p /usr/local/bin sudo mv marfa /usr/local/bin/ marfa --version diff --git a/src/generated/contract-index.json b/src/generated/contract-index.json index 8a747213..0fbe17bd 100644 --- a/src/generated/contract-index.json +++ b/src/generated/contract-index.json @@ -571,7 +571,8 @@ "device/filter-limits-answered": "1d058ac7cc72810c", "device/filter-length-units": "97c13cdf03991733", "device/filter-null-message": "1b1d3a29816e5488", - "device/filter-backref": "11a3614e01582729", + "device/filter-backref": "cc7833d2ed7c8f02", + "device/filter-backref-whole": "25217603008560ac", "device/filter-hidden-edge-type": "935d332a2a4165b9", "device/no-age-expiry": "e1cd694964752677", "device/hydrate-no-bytes": "03f1a2482a63426b", @@ -909,7 +910,7 @@ "device/catalog-replaced-follow": "90f5be33d116a6a7", "device/catalog-offline": "a6f7a18fec847f81", "device/catalog-type-read": "df589ad19049012b", - "device/catalog-edge-type-read": "b4f3ee3ee082b19a", + "device/catalog-edge-type-read": "c24c69e44f6f6983", "device/catalog-read-consistent": "ca25ce0516de3858", "device/catalog-replace-whole": "cec5668a64745365", "device/catalog-damaged-properties": "7e49d7e2f0a899c7", @@ -986,21 +987,23 @@ "device/order-replace": "f12551aab8bd096d", "device/order-replace-kept": "710b1dfe946f365e", "device/view-read": "17a3d979d44fab0a", - "device/view-sort-property": "3e955de7637c818d", - "device/view-sort-own": "c4c43d330bbbd6b0", - "device/view-sort-keys": "566447ba16e9ae3a", - "device/view-sort-missing": "ed8f6c6326571ed3", + "device/view-sort-property": "1c4cb51aef71ec18", + "device/view-sort-own": "cc565123de7a0a6d", + "device/view-sort-keys": "815745c0a96dd703", + "device/view-sort-missing": "1be31b7faab212f6", "device/view-sort-pages": "a168755a5e89b9b7", - "device/view-group-value": "febedd287c7e9b40", - "device/view-group-tags": "fe6584c6a638984d", - "device/view-group-order": "6a29f5321e1819bf", - "device/view-group-hidden": "556d93af29d4d18f", - "device/view-group-edge": "ce24b588620f4f06", - "device/view-group-edge-refusal": "05a9423260864145", - "device/view-date-bucket": "98a2dd448c07ea5f", - "device/view-date-missing": "99469bfc4cc3cbdc", - "device/view-group-sum": "7905f358d254d61d", - "device/view-unanswerable": "b698559cf6a01518", + "device/view-group-value": "ea885454b4e2fc60", + "device/view-group-tags": "2818ac351dd84415", + "device/view-group-order": "3c944c0d366d318d", + "device/view-group-hidden": "a9c79ca690deec21", + "device/view-group-edge": "49a97fcfb8c44b61", + "device/view-group-edge-refusal": "e501c1bf61453724", + "device/view-date-bucket": "d4912d8954e81385", + "device/view-date-missing": "95594572d203517f", + "device/view-unanswerable": "2ccfc7066c03bd7f", + "device/view-filter-edge": "f46d4b5800b795ca", + "device/view-filter-backref": "d55938056550998b", + "device/view-layouts-ignored": "268ee705ea1b35b9", "device/view-incomplete": "4be571d9a6b37397", "device/view-cli-selection": "fbc96b65db56d058", "device/view-write-at-once": "916321774c2b7b4a", @@ -1009,6 +1012,7 @@ "device/view-write-readback-failed": "6f1d431d74f1ea89", "device/view-create-replay": "9de37c346ec863f2", "device/view-change-version": "105d5d5be83e9975", + "device/view-group-clear": "a3c72cdc2189a568", "device/view-change-conflict": "5991c326fbd20b3b", "device/view-write-refused-kept": "68e8b35f2a3a76e6", "device/view-delete-held": "3adaa3e477fb0228", @@ -1020,19 +1024,19 @@ "device/view-write-no-server": "b0dfa79cdefb7a2d", "device/folder-system-tier": "fe0985226e4628f0", "device/folder-read": "900a26cb73352eb2", - "device/folder-read-backref": "4f2f7d16c53962ac", + "device/folder-read-backref": "8cbf7e03c4e9a16c", "device/folder-list": "1ed5678db1ad2247", "device/folder-list-beneath": "02481ef61da7ce0c", "device/folder-unanswerable": "e7dff644f4a94810", "device/folder-unanswerable-named": "9c3e4508b86ed9b4", - "device/folder-list-backref": "cbd1a038e4e2e89d", + "device/folder-list-backref": "d908823586667f6f", "device/folder-list-revoked": "8496d4bec50d8bc9", "device/folder-list-not-folder": "55a11fdc126a9833", "device/folder-not-held": "3d4b3f7be0e474a6", "device/folder-write-at-once": "64c6aeb727ecee62", "device/folder-write-unqueued": "3dfab684165ae1aa", "device/folder-write-offline": "0d1976d74dd2c55d", - "device/folder-write-backref": "d1c113b1d1bf02c6", + "device/folder-write-backref": "60707d097526a869", "device/folder-write-grammar": "a9fc7736059e641c", "device/folder-write-defaults": "423ddd64a7fc285c", "device/folder-change-as-stands": "62146255cec7b8f5", @@ -1363,20 +1367,25 @@ "edges/types-shipped-cardinality": "2b4eb6e7e679e5c5", "edges/types-shipped-cascade": "707c1e9ba09bee5a", "edges/types-in-collection": "1d6a402d2f275c44", + "edges/types-shipped-labels": "69c31b92d2922c9a", "edges/types-reverse-names": "629a27c077ca849c", "edges/types-written-at": "c99682be7619bcc1", "edges/types-list": "e7bc7a6396d9168c", "edges/types-list-whole": "2f5bde6a3e50a9ea", "edges/types-shipped-flag": "ecb017770ab62690", "edges/types-register": "3bb7e1a4e3db631f", + "edges/types-label": "094e91a48bc54780", + "edges/types-label-kept": "104e75505336095b", + "edges/types-reverse-label": "8c6ad3ddb879d128", "edges/types-usable": "30c85dbb883c253b", "edges/types-register-permission": "ff73583f9ce0f3c6", "edges/types-register-map": "de39b05e2ad14195", "edges/types-register-shipped": "e8e0abcab935a141", "edges/types-register-duplicate": "0e05d299a078c233", "edges/types-register-duplicate-kept": "608c9b2c00e455c4", - "edges/types-evolve": "f6ef3888f7882144", - "edges/types-evolve-safe": "e2ef64fcd960f515", + "edges/types-evolve": "e3d9bc5096d15ac8", + "edges/types-evolve-labels-kept": "0fa31b7018377cb4", + "edges/types-evolve-safe": "0ffcfae92636994c", "edges/types-evolve-missing": "b138d92d91fb79c0", "edges/types-evolve-edges-kept": "feaed92c8fdec042", "edges/types-evolve-atomic": "c1165efe98611bb4", @@ -2008,9 +2017,9 @@ "folders/watched-settings": "cad83752915116c1", "folders/binary-outside-search": "140ef8de25d23408", "folders/binary-outside-search-skipped-count": "64c837710a24b6cb", - "folders/embedded-outside-search": "38101355c370d0b9", + "folders/embedded-outside-search": "4e0b57cb9b798713", "folders/document-outside-search": "d6f18d7bd88a62f4", - "folders/new-file-only-members": "5d3b6d24a746d28f", + "folders/new-file-only-members": "f628c168c31963ad", "folders/outside-document-unmatched": "8e6349211a67242d", "folders/own-field-lines": "17c4deef7785947b", "folders/property-lines": "70de468c6d15c33e", @@ -2190,7 +2199,7 @@ "folders/body-unresolved-keeps-edges": "d5b0801fe6704b3a", "folders/embed-edge": "31e4293a85ef9654", "folders/embed-edge-removal": "acd643f9daa363b3", - "folders/embed-file-kept": "c1ad80f2bd559d0d", + "folders/embed-file-kept": "80b55b9f7773dc13", "folders/embed-markdown-only": "bdc5ccfe2d9f77e0", "folders/embed-file-item-bytes": "36635ddf4423e690", "folders/embed-raw-no-file": "acef5f86ca68276c", @@ -2201,7 +2210,7 @@ "folders/embed-path-case": "728b01c65f1cee09", "folders/embed-wiki-name": "f46dca4da22681c5", "folders/embed-upload": "297050d8619af09d", - "folders/embed-held": "e8da80204b25d743", + "folders/embed-held": "448daa1d1d5db190", "folders/embed-path-placement": "93b42c11eb9919e9", "folders/embed-name-placement": "7d7b587aab3a2171", "folders/embed-placement-follows": "40dd9b6a7858f5a0", @@ -2218,11 +2227,24 @@ "folders/embed-unreadable-target": "c629d78c0b7f0f9c", "folders/embed-unreadable-no-write": "cc0315395aacf774", "folders/embed-ambiguous-target": "00b4b9e170d1c730", - "folders/embed-ambiguous-no-write": "0497312c5b6978fb", + "folders/embed-ambiguous-no-write": "abfb247bb50cd5d1", "folders/body-self-link": "333120d8045e1430", "folders/embed-list-paragraph": "b6950ec034b0851c", "folders/embed-duplicate-line": "0f92efe120c81458", "folders/embed-upload-size": "8e9097cde63150a5", + "folders/attachment-beside": "4f210db6e3dd6809", + "folders/attachment-line": "f846c08a08baa3d2", + "folders/attachment-name": "f03f30d7d77510ae", + "folders/attachment-stays": "6ae7fffc509d65c0", + "folders/attachment-follows": "51b5b26ce5deb499", + "folders/attachment-written-once": "2630144891fadfbd", + "folders/attachment-written-once-report": "9e54546e862a0123", + "folders/attachment-embedded-once": "dfd6a3079c48cd8e", + "folders/attachment-edge-gone-kept": "45b2fa6cc40143fa", + "folders/attachment-departs": "f902cdc7a98ed9f0", + "folders/attachment-member-stays": "b95d6b7eb3c1cdd6", + "folders/attachment-deleted": "17a5af0973dcb2de", + "folders/attachment-absent": "2762d2b60334df29", "folders/markdown-extensions": "7d13ca30c264866c", "folders/id-rename": "57d9fa591548614f", "folders/id-missing-binding": "0bf5a6f40bff5755", @@ -2277,7 +2299,7 @@ "folders/placement-give-way-read-fails": "aa23933768369cdb", "folders/placement-give-way-drain-goes-on": "4535ec7426571c7d", "folders/placement-give-way-newer-kept": "298facffc561248a", - "folders/placement-give-way-watch": "8cd436f7ca10ebc1", + "folders/placement-give-way-watch": "da4890d0ee4dd5d9", "folders/placement-give-way-watch-throttled": "68a887286e145de8", "folders/placement-newer-copy": "0f1ec81340d55486", "folders/placement-give-way-atomic": "5f1308b284b6f8ec", @@ -2434,7 +2456,7 @@ "folders/unowned-file-admitted": "4c118913c452912b", "folders/same-scan-link-resolution": "7e36d585c8a36311", "folders/new-search-member": "7600cd58475b9fd1", - "folders/departed-file-removed": "551746656727b15e", + "folders/departed-file-removed": "d7964f15c670d55f", "folders/departed-binding-released": "f0c4bc9e645c6291", "folders/departed-item-not-deleted": "688eb3fb6c3f20e7", "folders/restored-file-written": "813230bd575709b8", @@ -2450,7 +2472,7 @@ "folders/unmatched-file-edits": "fd6d079bc9ebd48c", "folders/unmatched-file-updated": "a8ba7aa2309cebf5", "folders/replaced-binding-unpinned": "636e63266359c693", - "folders/embedded-archive-kept": "ccab3bb9c72cc99e", + "folders/embedded-archive-kept": "a3913cc22995ef23", "folders/binary-created": "2084a92fa1220915", "folders/binary-edited": "84021a258e7007bc", "folders/binary-title-preserved": "eebe4a7ca0612bac", @@ -2648,6 +2670,8 @@ "folders/take-in-file-taken-count": "1257fa2dfd916afd", "folders/take-in-edited-source-kept": "72bf45d2c52e9e3c", "folders/take-in-state-excluded": "6069133f0ca3b6c8", + "folders/take-in-attachment-excluded": "7340585dda86e105", + "folders/take-in-attachment-with-document": "4b0f0d8e78ff2c1f", "folders/take-in-destination-kept": "9b7a5b1a7d8cc9dd", "folders/take-in-destination-kept-unwritten-count": "62a332512b22d4a7", "folders/take-in-refused-source-kept": "61708c5fe88d6e3b", @@ -2670,6 +2694,8 @@ "folders/retained-source-edit-kept": "6e84a867e93346c9", "folders/let-go-existing-peer": "b765549bd3ed584d", "folders/let-go-existing-peer-let-go-count": "5449bc2d536facd9", + "folders/let-go-attachment-kept": "feb8c4e2ef49124c", + "folders/let-go-attachment-with-document": "89fdc1d8eeb303e1", "folders/unmatched-missing-not-rewritten": "92c20c9573609d22", "folders/two-folders-editable": "be3a1b9e421183af", "folders/two-folders-delete": "c931f4b0104b606e", @@ -2754,6 +2780,7 @@ "folders/confirm-pull-removes": "f468356d14ab2a44", "folders/confirm-pull-removes-removed-count": "32477b40ddea1fce", "folders/restore-disk-files": "690d1e5da7ebb169", + "folders/restore-disk-attachments": "3600942b608f0c8f", "folders/restore-disk-files-put-back-count": "3d38f1072f9d7e7d", "folders/restore-not-written-listed": "1688bd1030443ea5", "folders/restore-written-by-binding": "36521edf6a17096c", @@ -2763,10 +2790,10 @@ "folders/restore-departed-binding": "88f0c81068fdb75c", "folders/restore-departed-binding-not-put-back": "776942cf6064b3f9", "folders/restore-departed-binding-item-preserved": "3e7c143b2bfcbd5b", - "folders/restore-unmatched-count": "2c0fb11daece4f25", - "folders/restore-unmatched-count-not-put-back": "367f1dded60f30cb", - "folders/restore-embedded-departure": "72c27ac6e9105ebd", - "folders/restore-embedded-not-put-back": "94b6ad6ff1df1c45", + "folders/restore-unmatched-count": "39aa67754b4c6aff", + "folders/restore-unmatched-count-not-put-back": "0f8d3ad3e963e382", + "folders/restore-embedded-departure": "b21e54387f87e51d", + "folders/restore-embedded-not-put-back": "0251eb2594a9c618", "folders/restore-pull-items": "758567b9db61e37d", "folders/pause-said": "388ac590ac9b38e5", "folders/pause-watch-once": "d229d6dfedfadc5d", @@ -2809,7 +2836,7 @@ "folders/document-not-quarantined": "0b85e39d15f94214", "folders/busy-watch-progress": "ec2d9e955932612b", "folders/busy-watch-progress-server-changes": "3060346ccf87eee4", - "folders/watch-settle-ceiling": "c3e81b936604c749", + "folders/watch-settle-ceiling": "fd286dfd439bfd7f", "folders/binary-waits-for-settle": "bb1c352666e1521f", "folders/file-item-doc-name-settle": "c86552cddb0fcbc1", "folders/settling-rename-identity": "263236ad5431721e", @@ -5158,10 +5185,29 @@ "types/compatible-with-reach": "cad41147f000f5b3", "types/parent-kept": "db62751ee91dc787", "types/parent-shipped": "1b4264a3896c11ba", + "types/move": "36d55f14b171e7e5", + "types/move-subtypes": "b3e1679a66be3d68", + "types/move-properties": "8ac9706365cd62bd", + "types/move-dry-run": "d65ef17864a7df2c", + "types/move-target": "95877c4917982d54", + "types/move-source": "02db0f8813929bd6", + "types/move-permission": "933072234a68e4a4", + "types/move-settings": "7d6072cce62ad5ca", + "types/move-webhook-pattern": "a0e408a13a0d2d93", + "types/move-errors": "dbb870e7cc17d324", + "types/move-remaining": "020edbd1eb306224", + "types/move-partial": "41ba7a3cbb067b34", + "types/move-rerun": "182a45eb217be678", + "types/move-blocked": "25802ea9ee943992", + "types/move-settings-authority": "bd9976b4b473bf4e", + "types/move-settings-ids": "44ae364d3c74278e", + "types/move-cascade-mark": "cd63d4dfb7164aa3", + "types/move-grants": "6a47e5480f66fe2a", "types/delete": "c7ab4ee4aea4401b", - "types/delete-in-use": "73b27f0c8375442d", + "types/delete-in-use": "cb2306cd488cc545", + "types/delete-grants": "8847c52e29618220", "types/delete-subtypes": "d84630c66be17221", - "types/delete-force": "d0088d290fd8de2c", + "types/delete-force": "9604c94f86bd2e8d", "types/delete-force-value": "e4ab96a0308bdfae", "types/delete-permission": "d046f81f4dddbb0f", "types/orphan-write": "a99804511e3c78c1", @@ -5188,7 +5234,8 @@ "types/source-filter-by-id": "6617d24512e658f0", "types/shipped": "a3d618d635c669ae", "types/shipped-system": "aa1b148e069da6e4", - "types/message": "8002937a17b40e06", + "types/message": "b7749ce97bb970c1", + "types/body-optional": "6dcf4daee3e74bd6", "types/highlight": "14a1b11a8d1a648f", "types/entity": "699d3bbbab2cdc19", "types/connection": "f244e49517636a4f", @@ -5210,7 +5257,7 @@ "types/item-name-fields": "04b3a851edc17882", "types/item-name-text": "19ba94664f4e5de8", "types/item-name-empty": "84c62a6e3746e515", - "types/delete-owner": "604bdeea644be081", + "types/delete-owner": "72c3a238a5995f6e", "types/delete-owner-recent": "040a0433bb364909", "types/delete-owner-force": "ab6c5ad0debd5591", "types/creator-snapshot": "11d43738ca39477e", @@ -5367,19 +5414,20 @@ "versions/edge-update-race": "c641d1c3ed00b57e" }, "views": { - "views/create": "959566ca51ca25e7", - "views/settings": "85b636d00a956fcf", - "views/layout-parts": "f70483d70e133f3b", - "views/kind-options": "04c7e7e23f26508f", - "views/field": "02114bde3f58d547", - "views/option-fields": "055d14c971d5fd52", + "views/create": "f86353e784410b8e", + "views/settings": "514199cbd7b11e4f", + "views/sort-and-group": "cb70e54c3fd12432", + "views/field": "6f012eade4617057", + "views/filter-terms": "5714ac63f545cbbe", "views/announced": "b72c4295caa0af9b", "views/setting-checked": "5bc61f670459c9ac", - "views/setting-invalid": "d153f570006683a8", + "views/setting-invalid": "fdb85907b8e79502", "views/setting-unknown-type": "884ca8fbfed8757b", - "views/setting-missing": "56ebe0ee833a3400", - "views/setting-undeclared": "b1ad34741abb962a", - "views/change": "733147767b7bd86a", + "views/setting-missing": "f2d5583f71b82184", + "views/setting-undeclared": "5243690203591ef4", + "views/change": "839d5b5c78a66ff9", + "views/group-clear": "1d4425b8b5757e76", + "views/null-refused": "605f619f8db0b15c", "views/change-stale": "fed5a73b872a87fa", "views/change-conflict": "7a79133177b6d9ab", "views/change-refused": "0d246ea8a65fff2d", diff --git a/src/generated/error-codes.md b/src/generated/error-codes.md index d1a826e3..dfe3d101 100644 --- a/src/generated/error-codes.md +++ b/src/generated/error-codes.md @@ -17,7 +17,7 @@ | `validation_error` | 400 | The request is malformed or breaks a rule that no other code names, such as a bad body, parameter, cursor or limit. | | `setup_proof_invalid` | 401 | The setup code, handoff link or setup session is wrong, spent, expired or replaced. Ask for a new setup code or link. | | `unauthorized` | 401 | The request carries no credential the server accepts: none, an unknown or revoked one, or an expired or altered link. | -| `core_type_immutable` | 403 | The type ships with the platform, so it cannot be replaced or deleted. | +| `core_type_immutable` | 403 | The type ships with the platform, so it cannot be replaced, deleted or moved out of. | | `edge_permission_denied` | 403 | The credential lacks the edge-type permission the operation needs. | | `forbidden` | 403 | The credential is valid but lacks a standing permission, reach or origin the operation needs. | | `type_not_permitted` | 403 | The credential holds no grant on the type at the level the operation asks for. | @@ -57,8 +57,9 @@ | `type_already_exists` | 409 | A type is already registered under this identifier. | | `type_chain_unresolvable` | 409 | The stored parent chain of a type is circular or too deep to resolve. `PUT /types/{id}` still accepts a corrected schema. | | `type_has_subtypes` | 409 | Another type declares this type as its parent. Forcing the delete does not override this. | -| `type_in_use` | 409 | Items of the type still exist, the bin included, and the delete did not ask to force. | +| `type_in_use` | 409 | Items of the type still exist, the bin included, or a folder, view or webhook setting names it, and the delete did not ask to force. | | `type_mismatch` | 409 | The request declares a type other than the type of the item it resolved. | +| `type_move_blocked` | 409 | A folder, view or webhook setting names the type, and the move can't rewrite it. `details.settings` names each, with its `reason`. | | `version_conflict` | 409 | The write names a version that is no longer the current one. The answer carries the current state. | | `request_too_large` | 413 | The request body is over the cap for its operation. | | `range_not_satisfiable` | 416 | A `Range` request asks for bytes the blob does not have. `Content-Range` names its size. | diff --git a/src/generated/marfa-release.json b/src/generated/marfa-release.json index 7bdf227b..e9551564 100644 --- a/src/generated/marfa-release.json +++ b/src/generated/marfa-release.json @@ -1 +1 @@ -{"version":"v0.0.33"} +{"version":"v0.0.35"} diff --git a/src/generated/server-image.md b/src/generated/server-image.md index 68f37f85..276494ab 100644 --- a/src/generated/server-image.md +++ b/src/generated/server-image.md @@ -1,5 +1,5 @@ ```bash -docker pull ghcr.io/withmarfa/marfa-server:0.0.33 +docker pull ghcr.io/withmarfa/marfa-server:0.0.35 docker run --name marfa -d --restart unless-stopped -p 127.0.0.1:8600:8600 \ - -v marfa-data:/data --env-file marfa.env ghcr.io/withmarfa/marfa-server:0.0.33 + -v marfa-data:/data --env-file marfa.env ghcr.io/withmarfa/marfa-server:0.0.35 ``` diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts index 01dd4764..1749e129 100644 --- a/src/lib/catalog.test.ts +++ b/src/lib/catalog.test.ts @@ -36,6 +36,8 @@ interface CatalogType { interface CatalogEdgeType { id: string; + label: string; + reverse_label?: string; cardinality: string; cascade_on_delete: string; reverse_name?: string; @@ -239,7 +241,7 @@ describe('the catalog the pages follow', () => { it('is the synced copy, with the types the pages cover', () => { expect(catalog.types.length).toBeGreaterThan(10); expect(CORE_SLUGS).toContain('media'); - expect(catalog.types.find((type) => type.id === 'core.media.article')?.fields.body.required).toBe(true); + expect(catalog.types.find((type) => type.id === 'core.media.article')?.fields.title.required).toBe(true); }); it('is read for a create request from the API description', () => { @@ -421,6 +423,8 @@ describe('the edge type table', () => { const constrained = catalog.edge_types.some((edge) => edge.source_type_constraints[0] !== '*'); expect(header).toEqual([ 'Edge type', + 'Label', + 'Reverse label', 'Reverse name', 'Cardinality', 'On delete', @@ -430,6 +434,8 @@ describe('the edge type table', () => { expect(rows).toEqual( catalog.edge_types.map((edge) => [ `\`${edge.id}\``, + edge.label, + edge.reverse_label ?? edge.label, edge.reverse_name ? `\`${edge.reverse_name}\`` : 'None', `\`${edge.cardinality}\``, `\`${edge.cascade_on_delete}\``, @@ -440,7 +446,7 @@ describe('the edge type table', () => { }); it('writes a cardinality as code with plain hyphens, so one line holds it and the Markdown copy matches the contract and the API', () => { - for (const row of rows) expect(row[2]).toMatch(/^`(one|many)-to-(one|many)`$/); + for (const row of rows) expect(row[4]).toMatch(/^`(one|many)-to-(one|many)`$/); }); it('is included by the Edges page, which says that any type is a source exactly when the table has no column for it', () => { @@ -463,7 +469,7 @@ describe('the example check', () => { it('refuses what a create request would', () => { const cases: [string, unknown, string][] = [ ['a type nothing ships', { ...sample, type: 'core.recipe' }, 'is not a shipped type'], - ['a missing required field the parent does not require', { type: sample.type, properties: { title: 'A title' } }, 'body is required by core.media.article'], + ['a missing required field', { type: sample.type, properties: { body: 'Some text' } }, 'title is required by core.media.article'], ['a field the type lacks', { ...sample, properties: { ...sample.properties, rating: 5 } }, 'rating is not a field of core.media.article'], ['a string where a whole number goes', { ...sample, properties: { ...sample.properties, word_count: '120' } }, 'word_count is not a whole number'], ['a fraction where a whole number goes', { ...sample, properties: { ...sample.properties, word_count: 1.5 } }, 'word_count is not a whole number'],