From d554a201e3af56d0ae012b5d29453a1282ffd533 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Fri, 30 Oct 2026 03:02:20 +0000 Subject: [PATCH 1/5] docs(swift-sdk): add batch reads and changes since a cursor --- content/docs/(overview)/devices.mdx | 2 +- content/docs/(reference)/swift-sdk.mdx | 42 +++++++++++++++++++++----- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index 1464ac37..7a4f82e3 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -13,7 +13,7 @@ A device chooses the **slice** of the instance it keeps: a list of types, a tier 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](/build/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`. +**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 of one item by ID answers for an item that has arrived, and is refused `hydration_incomplete` for one that hasn't; a read of many by ID answers those that have arrived and marks the answer `partial`. Until the copy is whole, edge reads are refused `hydration_incomplete`, because an edge's two ends can arrive on different pages, and so is a read of what changed since a cursor. 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`. **A fill that stops partway resumes from the page it reached.** The device saves its place with each page, so a fill that was stopped, lost its connection or ended with the app keeps the items it has, and the next fill reads only the pages left. It then applies what changed on the instance while it was stopped, so the copy is as whole as one filled in a single run. The next fill **starts again from the first page** when resuming couldn't give a whole copy: the slice is different, another instance answers at the address, the credential reads other items, the instance's types changed, the event log no longer reaches back to where the fill began or ends before it, as after a restore, or the server refuses the page the device saved. The fill's report says whether it resumed, and if it started again, why. A fill that completed is never resumed: the next one reads every page. diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 6bcf96dc..6eb441d2 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -159,7 +159,7 @@ While a fill runs, `status()` gives `itemsListed`, the items the fill has brough ### Read while the copy fills -While a hydration is in progress, a list or a search answers the items that have arrived and says so. `items.list(_:sort:)`, `search(_:filters:limit:)` and the saved-view reads return `Rows`, a collection of `rows` with a `completeness`: +While a hydration is in progress, a list or a search answers the items that have arrived and says so. `items.list(_:sort:)`, `items.get(_:includingBin:)`, `search(_:filters:limit:)` and the saved-view reads return `Rows`, a collection of `rows` with a `completeness`: | `Completeness` | Meaning | | --- | --- | @@ -173,7 +173,7 @@ if notes.completeness == .partial { /* show the notes, and a note that more are for note in notes { print(note.title ?? "") } ``` -`Rows` is a random-access collection, so a loop or a `ForEach` takes it as it is. **Some calls can't be answered until the copy is whole and throw `hydrationIncomplete`:** `items.get(_:)` for an item that hasn't arrived, edge reads, and every edge write (`edges.create`, `edges.update` and `edges.delete`). A write to an item the copy holds, or to a new item, is queued during a fill, and so is a file item or an `attach`; a write to an item that hasn't arrived throws `hydrationIncomplete`, because there is no version to base it on. +`Rows` is a random-access collection, so a loop or a `ForEach` takes it as it is. **Some calls can't be answered until the copy is whole and throw `hydrationIncomplete`:** `items.get(_:)` for an item that hasn't arrived, edge reads, `changes(since:most:)`, and every edge write (`edges.create`, `edges.update` and `edges.delete`). A write to an item the copy holds, or to a new item, is queued during a fill, and so is a file item or an `attach`; a write to an item that hasn't arrived throws `hydrationIncomplete`, because there is no version to base it on. A `hydrate` call announces `.refreshed(.hydrated)` when it completes, and `.refreshed(.unfinished)` when it throws or is canceled, because the rows it wrote stay. A `LiveQuery` over a partial copy therefore reads again after a fill that failed part way, and a follow failure it shows stays shown. @@ -208,18 +208,19 @@ guard let id = created.itemId, let note = try await copy.items.get(id) else { re _ = try await copy.items.update(id, Edit(properties: ["title": "Books to read"], baseVersion: note.version)) ``` -Besides `items`, the copy has `edges` (`from`, `to`, `ofType`, `create`, `update`, `delete`), `tags` (`add` and `remove`, one at a time), `metadata` (`replaceTags`, which writes the whole set), `extensions` (`write` and `delete` by namespace), `blobs` (`put(file:mimeType:)` queues an upload, and `withFile(_:_:)` calls your closure with where a blob's bytes are held, fetching them first if they aren't, within the copy's [file budget](#keep-files-on-the-device)), the [`queue`](#send-writes-and-read-verdicts) and the `catalog` of types the copy holds. Each `EdgeType` in the catalog has a `label` for the source's end and a `reverseLabel` for the target's, such as `Children` and `Parent`. **An edge type registered with no reverse label has a `reverseLabel` equal to its `label`**, which names both ends alike. +Besides `items`, the copy has `edges` (`from`, `to`, `ofType`, `get` for one ID or many, `create`, `update`, `delete`), `tags` (`add` and `remove`, one at a time), `metadata` (`replaceTags`, which writes the whole set), `extensions` (`write` and `delete` by namespace), `blobs` (`put(file:mimeType:)` queues an upload, and `withFile(_:_:)` calls your closure with where a blob's bytes are held, fetching them first if they aren't, within the copy's [file budget](#keep-files-on-the-device)), the [`queue`](#send-writes-and-read-verdicts) and the `catalog` of types the copy holds. Each `EdgeType` in the catalog has a `label` for the source's end and a `reverseLabel` for the target's, such as `Children` and `Parent`. **An edge type registered with no reverse label has a `reverseLabel` equal to its `label`**, which names both ends alike. | `items` call | What it does | | --- | --- | | `list(_:sort:)`, `get(_:)` | Items matching a `ListFilters`, newest first unless a `Sort` says otherwise. One item, or `nil` when it's in the bin or not held. | +| `get(_:includingBin:)` | The items of many IDs, read at one moment, each once and in the order first named. An ID the copy doesn't hold is left out, and so is an item in the bin unless `includingBin` is `true`. | | `create(_:)`, `update(_:_:)` | Queue a `Draft` or an `Edit`. An update is refused unless `baseVersion` is the version the copy holds. | | `updateAsRead(_:_:)` | Queues an edit based on an older version, which the server merges. | | `delete(_:)`, `restore(_:)`, `transition(_:to:)` | Move an item to the bin or out of it, or change its `ItemState`. | | `bin(type:after:limit:)`, `purge(_:version:)` | Read a page of the server's bin, and destroy an item in it. Both need the server and neither is queued. A page can be short or empty and still carry a `nextCursor`. A purge needs `items.purge` and write on the type. | | `attach(to:file:_:)`, `embed(file:in:_:)` | Queue the upload, a file item and an `attached-to` edge, each with its own verdict. `embed` also adds the file's embed to the body, and throws `EmbedFailure` if that step fails. | | `addFile(_:_:tags:)` | Adds a file as an item of its own, attached to nothing: it queues the upload and the file item, and a write for each tag. It returns an `Added` with the first two. | -| `links(in:)`, `embedText(of:in:)` | The links and file embeds in a body, and the `![[title]]` text for an attached file. | +| `links(in:)`, `embedText(of:in:)` | The links and file embeds in a body, and the `![[title]]` text for an attached file. `links(in:)` also takes many IDs and returns a dictionary by ID, leaving out an item that isn't held, is in the bin or can't be read. | | `conflictedCopies(of:)`, `original(ofConflictedCopy:)` | An item's held conflicted copies, and the ID of the item a copy was made from. | - **Read `title` and `body`** rather than `properties`: they read the properties the item's type uses for them. Where `properties` is empty, `unreadableProperties` says why. @@ -356,7 +357,7 @@ Each `Change` names the `itemId` or `edgeId` it concerns, and its `origin` says | `Change.Origin` | Meaning | What the app does | | --- | --- | --- | | `.local`, `.server` | A write in this copy, or an event from the instance. | Show it. | -| `.refreshed`, `.saved` | Many rows may have changed at once, or the writer saved. | Read again. | +| `.refreshed`, `.saved` | Many rows may have changed at once, or the writer saved. | Read again, or [reread only what changed](#reread-only-what-changed). | | `.answered` | A drain answered a write. | Show the verdict. | | `.serverUnreachable`, `.serverReachable` | The server went away or came back. | Show the app as offline or online, and start nothing. | | `.queueDrained`, `.queueNotDrained` | The drain the stream ran when the server came back ended, or stopped with an error. | Nothing: the stream drains by itself. | @@ -364,6 +365,33 @@ Each `Change` names the `itemId` or `edgeId` it concerns, and its `origin` says **Two stops need the app's help.** On `.copyExpired`, hydrate again: the queue survives. Read `status().hydration` first: a `.stale` copy is told this stop only when it [can't refill itself](#refill-a-stale-copy), and hydrating the same slice fills it again in place where the instance and reach allow. Any other expired copy must be filled from scratch. The `reason`, such as `reach_changed`, `instance_changed` or `catchup_too_old`, is an open string, so keep a `default`. On `.unauthorized` or `.signedOut`, give the copy a new credential with `useKey(_:)`. +### Reread only what changed + +An app that keeps a large library in memory reads it whole once, then rereads only the rows that changed. `changes(since:most:)` names the items and edges that changed since a `ChangeCursor`, and gives the cursor to pass next. **Take the cursor before you read what it covers**, so a change made between the two is named again rather than missed. + +```swift +var cursor = try await copy.changes(since: nil).next +var notes = try await copy.items.list(ListFilters(type: "core.note")) + +// After a .refreshed or .saved change: +switch try await copy.changes(since: cursor) { +case .everything(let next): + notes = try await copy.items.list(ListFilters(type: "core.note")) + cursor = next +case .named(let changed, let next): + let reread = try await copy.items.get(changed.items) + // Replace those rows; drop changed.removedItems and any ID the reread left out. + cursor = next +} +``` + +- **`.everything` asks for a full read.** It's the answer with no cursor; with a cursor from another store, from beyond what the copy has recorded, from before a change to the types, or from before a removed edge the copy no longer records; and when more than `most` items or more than `most` edges changed (1,000 unless you pass another limit). Read the whole copy again, then keep `next`. +- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:)` leaves out has gone to the bin. Read changed edges with `edges.get(_:)`. +- **A name can change nothing on screen.** An item is named when its row, its tags, an edge at either end, its pin or a write queued for it changed. +- **Changed body links aren't always named.** An item whose links read differently only because another item changed, such as a link that resolves once its target arrives, isn't named. An app that shows links reads `items.links(in:)` again for the rows it shows whenever an answer names an item added, changed or removed. +- **A refusal during a fill keeps the cursor.** Until the copy is whole, while a hydration is part way or the copy is stale, `changes(since:most:)` and the edge reads throw `hydrationIncomplete`. Keep the cursor you passed, and ask again from it after `.refreshed(.hydrated)`. Move to `next` only after the reread of what an answer names succeeds. +- **A cursor keeps across launches.** Store its `rawValue`, or encode it, since it's `Codable`. Text that isn't a cursor throws `invalid`. + ## Send writes and read verdicts A drain returns the instance's verdict on each write it sent. Answered writes stay in the queue, with their verdicts, until you clear them. @@ -579,12 +607,12 @@ Each call returns at once and does its work away from the main actor. A report t The fake answers these calls: -- **Reads:** `items.list(_:sort:)` without a `filter`, including the `source`, updated and occurred ranges and a sort on a property; `items.get(_:)`; the edge reads; the `catalog` reads; `declaredTypes()`; and `status()`. +- **Reads:** `items.list(_:sort:)` without a `filter`, including the `source`, updated and occurred ranges and a sort on a property; `items.get(_:)` and `items.get(_:includingBin:)`; the edge reads, including `edges.get(_:)`; the `catalog` reads; `declaredTypes()`; and `status()`. - **Writes:** `items.create(_:)` without a `sourceId`, including `state`, a capture location and `edges`; `items.update(_:_:)`, including `occurredAt` and `clearOccurredAt`; the other item, edge, tag, metadata and extension writes; and `declareTypes(_:)`. - **Views:** `createView(_:idempotencyKey:)`, `changeView(_:_:baseVersion:idempotencyKey:)`, `deleteView(_:idempotencyKey:)`, `items.views()`, `items.view(_:)`, `items.list(inView:limit:offset:)` and `items.groups(inView:timeZone:)`. Pass `views:` to hold saved views from the start. A view write is never queued and announces the same changes as a real copy, and a change based on an older version merges or throws `version_conflict` as the instance does. A view lists the items its `query` names (`types` with their subtypes, `tier`, `state`, `beneath`), in its `sort`, and groups them as the core does. The fake can't judge a `filter` in a view's query: it stores one, and a list or a grouping of that view throws `unsupported`. - **The queue and changes:** `queue.all()`, `queue.drain()`, `queue.withdraw(_:)`, `queue.discard(_:)`, `queue.forgetAnswered()` and `changes()`. -**A call, or an input to a call, that the fake can't match exactly throws `FakeCopyError.unsupported(call:)`** instead of answering differently. That covers a `filter`, `search` (including `search(_:inView:)`), hydration, files and folders, a capture coordinate that is out of range or has no partner, and an `occurredAt` in a form the fake doesn't check. The fake knows only the types you give it and the ones your app declares, not Marfa's built-in catalog, and it doesn't check an edge type's constraints. Because the fake has no instance, a create never lands on an item the instance already holds. +**A call, or an input to a call, that the fake can't match exactly throws `FakeCopyError.unsupported(call:)`** instead of answering differently. That covers a `filter`, `search` (including `search(_:inView:)`), body links, `changes(since:most:)`, hydration, files and folders, a capture coordinate that is out of range or has no partner, and an `occurredAt` in a form the fake doesn't check. The fake knows only the types you give it and the ones your app declares, not Marfa's built-in catalog, and it doesn't check an edge type's constraints. Because the fake has no instance, a create never lands on an item the instance already holds. ## Handle errors From 207fc0bcfcc1fb944662419dcfe0c5427d65391d Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Fri, 30 Oct 2026 03:16:47 +0000 Subject: [PATCH 2/5] docs(swift-sdk): name the batch read and drop a contradiction --- content/docs/(reference)/swift-sdk.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 6eb441d2..d75d2160 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -386,10 +386,10 @@ case .named(let changed, let next): ``` - **`.everything` asks for a full read.** It's the answer with no cursor; with a cursor from another store, from beyond what the copy has recorded, from before a change to the types, or from before a removed edge the copy no longer records; and when more than `most` items or more than `most` edges changed (1,000 unless you pass another limit). Read the whole copy again, then keep `next`. -- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:)` leaves out has gone to the bin. Read changed edges with `edges.get(_:)`. +- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:includingBin:)` leaves out has gone to the bin. Read changed edges with `edges.get(_:)`. - **A name can change nothing on screen.** An item is named when its row, its tags, an edge at either end, its pin or a write queued for it changed. - **Changed body links aren't always named.** An item whose links read differently only because another item changed, such as a link that resolves once its target arrives, isn't named. An app that shows links reads `items.links(in:)` again for the rows it shows whenever an answer names an item added, changed or removed. -- **A refusal during a fill keeps the cursor.** Until the copy is whole, while a hydration is part way or the copy is stale, `changes(since:most:)` and the edge reads throw `hydrationIncomplete`. Keep the cursor you passed, and ask again from it after `.refreshed(.hydrated)`. Move to `next` only after the reread of what an answer names succeeds. +- **A refusal during a fill keeps the cursor.** While a hydration is part way or the copy is stale, `changes(since:most:)` and the edge reads throw `hydrationIncomplete`. Keep the cursor you passed, and ask again from it after `.refreshed(.hydrated)`. Move to `next` only after the reread of what an answer names succeeds. - **A cursor keeps across launches.** Store its `rawValue`, or encode it, since it's `Codable`. Text that isn't a cursor throws `invalid`. ## Send writes and read verdicts From 5dba1a1bde589a4fe645ad915b3f72cb66e39474 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Fri, 30 Oct 2026 03:31:35 +0000 Subject: [PATCH 3/5] docs(swift-sdk): say a partial reread does not prove the bin --- content/docs/(reference)/swift-sdk.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index d75d2160..a32f2dda 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -380,13 +380,14 @@ case .everything(let next): cursor = next case .named(let changed, let next): let reread = try await copy.items.get(changed.items) - // Replace those rows; drop changed.removedItems and any ID the reread left out. + // Replace those rows and drop changed.removedItems. Drop an ID the reread + // left out too, unless reread.completeness is .partial. cursor = next } ``` - **`.everything` asks for a full read.** It's the answer with no cursor; with a cursor from another store, from beyond what the copy has recorded, from before a change to the types, or from before a removed edge the copy no longer records; and when more than `most` items or more than `most` edges changed (1,000 unless you pass another limit). Read the whole copy again, then keep `next`. -- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:includingBin:)` leaves out has gone to the bin. Read changed edges with `edges.get(_:)`. +- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:includingBin:)` leaves out has gone to the bin, unless the answer is `.partial`: then a hydration started after `changes(since:most:)` answered, and the item may not have arrived again yet. Read changed edges with `edges.get(_:)`. - **A name can change nothing on screen.** An item is named when its row, its tags, an edge at either end, its pin or a write queued for it changed. - **Changed body links aren't always named.** An item whose links read differently only because another item changed, such as a link that resolves once its target arrives, isn't named. An app that shows links reads `items.links(in:)` again for the rows it shows whenever an answer names an item added, changed or removed. - **A refusal during a fill keeps the cursor.** While a hydration is part way or the copy is stale, `changes(since:most:)` and the edge reads throw `hydrationIncomplete`. Keep the cursor you passed, and ask again from it after `.refreshed(.hydrated)`. Move to `next` only after the reread of what an answer names succeeds. From 705c028cb9f7dfa9c907a50843c98d1db63ae5d8 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Fri, 30 Oct 2026 03:31:56 +0000 Subject: [PATCH 4/5] docs(swift-sdk): say which stored properties read as unreadable --- content/docs/(reference)/swift-sdk.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index a32f2dda..55595352 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -223,7 +223,7 @@ Besides `items`, the copy has `edges` (`from`, `to`, `ofType`, `get` for one ID | `links(in:)`, `embedText(of:in:)` | The links and file embeds in a body, and the `![[title]]` text for an attached file. `links(in:)` also takes many IDs and returns a dictionary by ID, leaving out an item that isn't held, is in the bin or can't be read. | | `conflictedCopies(of:)`, `original(ofConflictedCopy:)` | An item's held conflicted copies, and the ID of the item a copy was made from. | -- **Read `title` and `body`** rather than `properties`: they read the properties the item's type uses for them. Where `properties` is empty, `unreadableProperties` says why. +- **Read `title` and `body`** rather than `properties`: they read the properties the item's type uses for them. Where `properties` is empty, `unreadableProperties` says why: the copy holds properties that aren't JSON, aren't an object, or nest more than 64 levels deep, counting the properties object. - **An `Edit` changes the properties it names.** With `replaceProperties: true`, they become the item's whole set. - **A `Draft` takes every field a create takes.** Besides the type and the properties, it carries `state` (`.active`, `.archived` or `.trashed`; `.revoked` is refused), `captureLatitude` with `captureLongitude`, and `edges`, a dictionary from an edge type to the IDs of the items it points at. An `Item` reads the capture location back in the same two properties. - **A create is one write.** Its state, location and edges go in the body of the write that creates the item. If the instance refuses any edge, for example because a target doesn't exist, it creates neither the item nor its edges, and the write stays in the queue as refused. The copy shows each edge at once, and an empty list for an edge type queues no edge. An edge may point at an item that is still waiting to be created: the write waits for that create. If that create lands on an item the instance already holds, because the two share a source and a `sourceId`, the edge points at the item the instance holds. From bac406cb71d6b0e4293eab322ae910e2a7bf435d Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Fri, 30 Oct 2026 05:36:41 +0000 Subject: [PATCH 5/5] docs(swift-sdk): say a stale copy refuses changes and a reread may find an item removed --- content/docs/(overview)/devices.mdx | 2 +- content/docs/(reference)/swift-sdk.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index 7a4f82e3..3e9990d9 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -31,7 +31,7 @@ On Apple platforms, the device keeps **each file it fetched out of backups**, in Sometimes a copy can't be brought up to date, and has to be filled again by a hydration. Its **queued writes survive** either way. -**After the device was away longer than the instance keeps events, it fills the copy again in place.** This holds while the instance, the credential's reach and the slice are the ones the copy was built under. Until the hydration finishes, the device's status reports the copy's hydration as `stale`, and a list or search answers from the items the copy holds and marks the answer `stale`, because some may have changed or gone on the instance. A read by ID answers for an item the copy holds. Edge reads are refused `hydration_incomplete`. Writes are taken as during a first fill. A drain or a catch-up is refused `copy_expired` with the reason `catchup_too_old` until the hydration finishes. A follow started before the hydration is refused the same way, and one started while the hydration runs, or after it stopped part way, waits for it to finish. The hydration updates the items that changed and adds the new ones. Once it has read every page, it removes the items the instance no longer lists, except pinned items, items a queued write names, and the items at either end of an edge a queued write creates, moves or edits. A hydration that stops part way leaves the copy `stale`, removes nothing, and carries on in place when run again. +**After the device was away longer than the instance keeps events, it fills the copy again in place.** This holds while the instance, the credential's reach and the slice are the ones the copy was built under. Until the hydration finishes, the device's status reports the copy's hydration as `stale`, and a list or search answers from the items the copy holds and marks the answer `stale`, because some may have changed or gone on the instance. A read by ID answers for an item the copy holds. Edge reads are refused `hydration_incomplete`, and so is a read of what changed since a cursor. Writes are taken as during a first fill. A drain or a catch-up is refused `copy_expired` with the reason `catchup_too_old` until the hydration finishes. A follow started before the hydration is refused the same way, and one started while the hydration runs, or after it stopped part way, waits for it to finish. The hydration updates the items that changed and adds the new ones. Once it has read every page, it removes the items the instance no longer lists, except pinned items, items a queued write names, and the items at either end of an edge a queued write creates, moves or edits. A hydration that stops part way leaves the copy `stale`, removes nothing, and carries on in place when run again. The hydration **empties the copy first** if the instance's event log now ends before the point the copy had reached, or before the point where a stopped hydration started, as it does on an instance restored from a backup, because the copy may hold versions the restored instance never had. It also does so if the hydration finds another instance at the address or a credential whose reach changed. The copy stops being `stale` at once, even if that hydration then fails, and it does the same when the instance refuses the hydration because the credential has ended or its reach changed, so it never answers rows the credential can no longer read. A hydration that fails for another reason, such as the instance being unavailable, leaves the copy `stale`. diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 55595352..2eb8dbc4 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -387,7 +387,7 @@ case .named(let changed, let next): ``` - **`.everything` asks for a full read.** It's the answer with no cursor; with a cursor from another store, from beyond what the copy has recorded, from before a change to the types, or from before a removed edge the copy no longer records; and when more than `most` items or more than `most` edges changed (1,000 unless you pass another limit). Read the whole copy again, then keep `next`. -- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:includingBin:)` leaves out has gone to the bin, unless the answer is `.partial`: then a hydration started after `changes(since:most:)` answered, and the item may not have arrived again yet. Read changed edges with `edges.get(_:)`. +- **`ChangedRows` holds four lists of IDs.** `items` and `edges` are rows the copy holds, and `removedItems` and `removedEdges` are rows it no longer holds. An item in `items` that `items.get(_:includingBin:)` leaves out has gone to the bin or been removed since the answer, unless the answer is `.partial`: then a hydration started after `changes(since:most:)` answered, and the item may not have arrived again yet. Read changed edges with `edges.get(_:)`. - **A name can change nothing on screen.** An item is named when its row, its tags, an edge at either end, its pin or a write queued for it changed. - **Changed body links aren't always named.** An item whose links read differently only because another item changed, such as a link that resolves once its target arrives, isn't named. An app that shows links reads `items.links(in:)` again for the rows it shows whenever an answer names an item added, changed or removed. - **A refusal during a fill keeps the cursor.** While a hydration is part way or the copy is stale, `changes(since:most:)` and the edge reads throw `hydrationIncomplete`. Keep the cursor you passed, and ask again from it after `.refreshed(.hydrated)`. Move to `next` only after the reread of what an answer names succeeds.