From dd030eb05c04c9cf55c9cea2c85dfce37ee3a13b Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 23:31:30 +0100 Subject: [PATCH 1/6] docs(swift-sdk): say a held stream refills a stale copy --- content/docs/(reference)/swift-sdk.mdx | 4 +++- content/docs/guides/troubleshooting.mdx | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 6b21ba48..b140efa7 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -123,6 +123,8 @@ When a catch-up learns that the event log no longer reaches the copy's cursor, i Call `hydrate(types:tier:edgeTypes:)` with the slice the copy was built under. The copy is filled again **over the rows it holds**, without clearing them first, and the `HydrateReport` says `inPlace`. The copy stays `.stale` through the fill, and through one that was interrupted, until a fill completes. If the instance, the credential's reach or the slice differs, the copy is cleared first and `inPlace` is `false`. +**While the app holds a `changes()` stream, the copy refills itself.** A copy can go stale while the device sleeps or is offline for longer than the instance keeps events. When the follow finds a stale copy, it fills the copy again over the slice it holds, as `hydrate` does, and the streams hear `.refreshed(.hydrated)` instead of a stop. An attempt that fails because the network or the server failed is announced as `.refreshed(.unfinished)` and retried, at once the first time and then further apart, up to five minutes. If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. + ## Read and write Reads come from the copy. Every write shows in the copy at once, with or without a connection, and waits in the queue as a `QueuedWrite`. @@ -195,7 +197,7 @@ Each `Change` names the `itemId` or `edgeId` it concerns, and its `origin` says | `.queueDrained`, `.queueNotDrained` | The drain the stream ran when the server came back ended, or stopped with an error. | Nothing: the stream drains by itself. | | `.stopped(MarfaError)` | What fed the stream stopped. Local writes are still told. | See below. | -**Two stops need the app's help.** On `.copyExpired`, hydrate again: the queue survives. Its `reason`, such as `reach_changed` or `instance_changed`, is an open string, so keep a `default`. On `.unauthorized` or `.signedOut`, give the copy a new credential with `useKey(_:)`. +**Two stops need the app's help.** On `.copyExpired`, hydrate again: the queue survives. A stale copy doesn't stop the streams, because it [refills itself](#refill-a-stale-copy), so this stop means the copy must be filled from scratch. Its `reason`, such as `reach_changed` or `instance_changed`, is an open string, so keep a `default`. On `.unauthorized` or `.signedOut`, give the copy a new credential with `useKey(_:)`. ## Send writes and read verdicts diff --git a/content/docs/guides/troubleshooting.mdx b/content/docs/guides/troubleshooting.mdx index 522b5ab1..620cc462 100644 --- a/content/docs/guides/troubleshooting.mdx +++ b/content/docs/guides/troubleshooting.mdx @@ -253,6 +253,8 @@ This is how the CLI's device commands report `catchup_too_old` and `reach_change Run `marfa device hydrate` again. After `catchup_too_old`, `marfa device status` reports the copy's hydration as `stale`, and the copy keeps answering reads, marked `stale`, while the hydrate fills it in place. Until the hydrate finishes, `marfa device drain` and `marfa device catch-up` keep answering `copy_expired` with `catchup_too_old`. If the credential no longer reads one of the types you held, leave that type out of `--types`, or the hydrate is refused with `type_not_permitted`. +An app built on the Swift SDK that holds a `changes()` stream refills a stale copy by itself. [Refill a stale copy](/swift-sdk#refill-a-stale-copy) says how. + For a folder, run `marfa folders push` again instead: it builds the copy again itself. [Sync a folder](/guides/sync-a-folder#replace-a-folders-key) covers a key you replaced. From 1dff30c943228c7ef0fc984c69ba5b39b1b69940 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 23:36:54 +0100 Subject: [PATCH 2/6] docs(swift-sdk): say the first refill retry waits one second --- 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 b140efa7..6205eadf 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -123,7 +123,7 @@ When a catch-up learns that the event log no longer reaches the copy's cursor, i Call `hydrate(types:tier:edgeTypes:)` with the slice the copy was built under. The copy is filled again **over the rows it holds**, without clearing them first, and the `HydrateReport` says `inPlace`. The copy stays `.stale` through the fill, and through one that was interrupted, until a fill completes. If the instance, the credential's reach or the slice differs, the copy is cleared first and `inPlace` is `false`. -**While the app holds a `changes()` stream, the copy refills itself.** A copy can go stale while the device sleeps or is offline for longer than the instance keeps events. When the follow finds a stale copy, it fills the copy again over the slice it holds, as `hydrate` does, and the streams hear `.refreshed(.hydrated)` instead of a stop. An attempt that fails because the network or the server failed is announced as `.refreshed(.unfinished)` and retried, at once the first time and then further apart, up to five minutes. If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. +**While the app holds a `changes()` stream, the copy refills itself.** A copy can go stale while the device sleeps or is offline for longer than the instance keeps events. When the follow finds a stale copy, it fills the copy again over the slice it holds, as `hydrate` does, and the streams hear `.refreshed(.hydrated)` instead of a stop. An attempt that fails because the network or the server failed is announced as `.refreshed(.unfinished)` and retried after one second, then further apart, up to five minutes apart. If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. ## Read and write From 33f5a1d1bf929938ec82be30a4e3b87ff490dfb7 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 23:57:54 +0100 Subject: [PATCH 3/6] docs(swift-sdk): say when a stale copy refills itself and that hydrations wait --- content/docs/(reference)/swift-sdk.mdx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 6205eadf..a3823691 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -123,7 +123,16 @@ When a catch-up learns that the event log no longer reaches the copy's cursor, i Call `hydrate(types:tier:edgeTypes:)` with the slice the copy was built under. The copy is filled again **over the rows it holds**, without clearing them first, and the `HydrateReport` says `inPlace`. The copy stays `.stale` through the fill, and through one that was interrupted, until a fill completes. If the instance, the credential's reach or the slice differs, the copy is cleared first and `inPlace` is `false`. -**While the app holds a `changes()` stream, the copy refills itself.** A copy can go stale while the device sleeps or is offline for longer than the instance keeps events. When the follow finds a stale copy, it fills the copy again over the slice it holds, as `hydrate` does, and the streams hear `.refreshed(.hydrated)` instead of a stop. An attempt that fails because the network or the server failed is announced as `.refreshed(.unfinished)` and retried after one second, then further apart, up to five minutes apart. If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. +**While the app holds a `changes()` stream, the copy refills itself.** A copy can go stale while the device sleeps or is offline for longer than the instance keeps events. When the follow finds that the event log no longer holds the copy's cursor, it fills the copy again over the slice it holds, as `hydrate` does, and the streams hear `.refreshed(.hydrated)` instead of a stop. An attempt that fails because the network or the server failed is announced as `.refreshed(.unfinished)` and retried after one second, then further apart, up to five minutes apart. + +The copy refills itself only when the refill will be in place, so the streams are told `.stopped(.copyExpired)` instead in these cases: + +- Another instance answers at the server's address. The reason is `instance_changed`. +- The copy was already stale when the follow started, for example after `useKey(_:)` or when the app opens a stale store. The reason is `catchup_too_old`. The SDK can't confirm that the credential still reads under the reach the copy was built under, so the refill is left to the app. + +If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. + +**A copy runs one hydration at a time.** A `hydrate` call made while the refill runs waits for it and then runs. If the refill already filled the same slice and the copy is still whole, the call returns the refill's report instead of filling the copy again. A refill that finds the app's own hydration running waits for it, and leaves a copy that hydration made whole. ## Read and write @@ -197,7 +206,7 @@ Each `Change` names the `itemId` or `edgeId` it concerns, and its `origin` says | `.queueDrained`, `.queueNotDrained` | The drain the stream ran when the server came back ended, or stopped with an error. | Nothing: the stream drains by itself. | | `.stopped(MarfaError)` | What fed the stream stopped. Local writes are still told. | See below. | -**Two stops need the app's help.** On `.copyExpired`, hydrate again: the queue survives. A stale copy doesn't stop the streams, because it [refills itself](#refill-a-stale-copy), so this stop means the copy must be filled from scratch. Its `reason`, such as `reach_changed` or `instance_changed`, is an open string, so keep a `default`. On `.unauthorized` or `.signedOut`, give the copy a new credential with `useKey(_:)`. +**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(_:)`. ## Send writes and read verdicts From 164b622519921739dabff5d035deae21ace3c8b3 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:07:00 +0100 Subject: [PATCH 4/6] docs(swift-sdk): say the refill expects, not promises, to be in place --- 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 a3823691..d729bfa8 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -125,7 +125,7 @@ Call `hydrate(types:tier:edgeTypes:)` with the slice the copy was built under. T **While the app holds a `changes()` stream, the copy refills itself.** A copy can go stale while the device sleeps or is offline for longer than the instance keeps events. When the follow finds that the event log no longer holds the copy's cursor, it fills the copy again over the slice it holds, as `hydrate` does, and the streams hear `.refreshed(.hydrated)` instead of a stop. An attempt that fails because the network or the server failed is announced as `.refreshed(.unfinished)` and retried after one second, then further apart, up to five minutes apart. -The copy refills itself only when the refill will be in place, so the streams are told `.stopped(.copyExpired)` instead in these cases: +The copy refills itself only when it expects the refill to be in place, so the streams are told `.stopped(.copyExpired)` instead in these cases: - Another instance answers at the server's address. The reason is `instance_changed`. - The copy was already stale when the follow started, for example after `useKey(_:)` or when the app opens a stale store. The reason is `catchup_too_old`. The SDK can't confirm that the credential still reads under the reach the copy was built under, so the refill is left to the app. From 33cf4473f6e9f9a075509b498865633c8d31d3c3 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:16:28 +0100 Subject: [PATCH 5/6] docs(swift-sdk): say a catch-up waits for a running refill --- 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 d729bfa8..de650503 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -132,7 +132,7 @@ The copy refills itself only when it expects the refill to be in place, so the s If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. -**A copy runs one hydration at a time.** A `hydrate` call made while the refill runs waits for it and then runs. If the refill already filled the same slice and the copy is still whole, the call returns the refill's report instead of filling the copy again. A refill that finds the app's own hydration running waits for it, and leaves a copy that hydration made whole. +**A copy runs one hydration at a time.** A `hydrate` call made while the refill runs waits for it and then runs. If the refill already filled the same slice and the copy is still whole, the call returns the refill's report instead of filling the copy again. A refill that finds the app's own hydration running waits for it, and leaves a copy that hydration made whole. A `catchUp` call made while the refill runs also waits for it, then catches up from the cursor the refill left. ## Read and write From 04fc1b4c56f121e1ca814412c1459e56234eef5a Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:23:24 +0100 Subject: [PATCH 6/6] docs(swift-sdk): say a reused refill report needs the same key --- 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 de650503..2ec01b52 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -132,7 +132,7 @@ The copy refills itself only when it expects the refill to be in place, so the s If the instance refuses the refill, for example because the credential ended, the streams are told `.stopped` with that error. -**A copy runs one hydration at a time.** A `hydrate` call made while the refill runs waits for it and then runs. If the refill already filled the same slice and the copy is still whole, the call returns the refill's report instead of filling the copy again. A refill that finds the app's own hydration running waits for it, and leaves a copy that hydration made whole. A `catchUp` call made while the refill runs also waits for it, then catches up from the cursor the refill left. +**A copy runs one hydration at a time.** A `hydrate` call made while the refill runs waits for it and then runs. If the refill already filled the same slice under the same key and the copy is still whole, the call returns the refill's report instead of filling the copy again. A refill that finds the app's own hydration running waits for it, and leaves a copy that hydration made whole. A `catchUp` call made while the refill runs also waits for it, then catches up from the cursor the refill left. ## Read and write