From a00e4fa35134762fe64ad0385890d37311d73e11 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 22:55:06 +0100 Subject: [PATCH 01/12] docs(swift-sdk): add the server catalog, replaceRefused, addFile and the plain http notice --- content/docs/(overview)/devices.mdx | 2 +- content/docs/(reference)/swift-sdk.mdx | 41 +++++++++++++++++++++++++ content/docs/guides/troubleshooting.mdx | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/content/docs/(overview)/devices.mdx b/content/docs/(overview)/devices.mdx index a21a77fb..cb6ce272 100644 --- a/content/docs/(overview)/devices.mdx +++ b/content/docs/(overview)/devices.mdx @@ -37,7 +37,7 @@ The hydration **empties the copy first** if the instance's event log now ends be **For any other reason, the copy refuses reads and writes until a hydration, which empties it first.** Examples are another instance answering at the same address, or a credential whose reach changed. -A copy made by an earlier build of the core can have a different shape. The device **refuses to open it** with `wrong_schema`, deletes nothing, and says how many writes the file holds that the server hasn't taken, because only the build that made it can send them. If some are of a kind the current build no longer sends, it names the kind. [Troubleshooting](/guides/troubleshooting#staying-in-step) says what to do. An app built on the core's Node or Swift binding can replace a refused copy that holds nothing unsent with an empty one. +A copy made by an earlier build of the core can have a different shape. The device **refuses to open it** with `wrong_schema`, deletes nothing, and says how many writes the file holds that the server hasn't taken, because only the build that made it can send them. If some are of a kind the current build no longer sends, it names the kind. [Troubleshooting](/guides/troubleshooting#staying-in-step) says what to do. An app built on the core's Node or Swift binding can replace a refused copy that holds nothing unsent with an empty one; the files the copy downloaded stay. ## A write made offline diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 6b21ba48..acc542b8 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -48,13 +48,49 @@ let copy = try await WorkingCopy.open(store: storeURL, server: Server(url: serve | Call | What it does | | --- | --- | | `WorkingCopy.open(store:server:fileBudget:)` | Opens the file. Without a `Server`, the copy works offline only. A `fileBudget` sets the [file budget](#keep-files-on-the-device) as the copy opens, and a reader refuses it with `readingHandle`. | +| `WorkingCopy.replaceRefused(store:server:fileBudget:)` | Replaces a store that `open` refuses with `wrongSchema` by an empty one, and opens it. [Replace a refused copy](#replace-a-refused-copy) says what it keeps. | | `WorkingCopy.openReader(store:)` | Opens a store another process writes, to read only. It refuses a path where no store exists. | | `handle` | `.writer` or `.reader`. A reader refuses writes, hydration and catch-up with `readingHandle`. | | `useKey(_:)` | Gives the open copy a new credential. Held `changes()` streams go on. | | `close()` | Ends every stream and releases the store. Later calls throw `MarfaError.closed`. | | `Server(url:key:)` | The instance's URL and a key or an app's access token. | +| `Server.isPlainHTTPToAnotherHost`, `takePlainHTTPNotice()` | Whether the address is plain http on another host, and a notice to show the first time. [Reach a plain http server](#reach-a-plain-http-server) explains both. | | `Server.fromEnvironment()` | For a command-line tool: reads `MARFA_API_URL` and `MARFA_API_KEY`. Returns `nil` when both are absent, and throws when only one is set or the URL names no server. | +### Replace a refused copy + +A store that another build of the core made throws `wrongSchema` from `open`, and nothing is deleted. Its `unsent` count is the number of writes the instance has not taken. **When it is `0`**, `WorkingCopy.replaceRefused(store:server:fileBudget:)` replaces the file with an empty one and opens it. Hydrate the new copy as you would a new one. + +- **The files the copy downloaded stay.** The core keeps them by content, beside the store, not in it. +- **What only the old store held is gone:** pins, the position in the instance's events, declared types and the file budget's setting. Pass `fileBudget` to set it again; otherwise it is the default. +- **A store that holds an unsent write is left as it is**, and the call throws `wrongSchema` with the count. A store that opens as it is, or a path with no store, throws `invalid`. [Troubleshooting](/guides/troubleshooting#staying-in-step) covers what to do with unsent writes. + +## Reach a plain http server + +An `http` address sends the key in the clear. **The SDK warns about that only through your app**: it shows nothing itself. `Server.isPlainHTTPToAnotherHost` is `true` for an `http` address on any host but the machine itself, by the same rule as the command line. `localhost`, every address in `127.0.0.0/8` and `::1` are the machine itself; an IPv4-mapped address such as `::ffff:127.0.0.1` is not. `takePlainHTTPNotice()` returns a `PlainHTTPNotice` the first time any server with that origin asks in the process, and `nil` after, for `https` and for the machine itself: + +```swift +if let notice = server.takePlainHTTPNotice() { + cleartext = notice // an @State var of type PlainHTTPNotice? +} +// In the view: +.alert(cleartext?.title ?? "", isPresented: Binding(get: { cleartext != nil }, set: { _ in cleartext = nil })) { + Button("OK") {} +} message: { + Text(cleartext?.message ?? "") +} +``` + +**App Transport Security (ATS) applies to some calls and not others.** Apple's documentation says ATS requires that connections made with the URL Loading System, typically `URLSession`, use HTTPS ([NSAppTransportSecurity](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity)). The core makes its own requests with a Rust HTTP client, not `URLSession`, so **ATS doesn't affect the working copy's hydration, catch-up and drain**. The SDK's sign-in requests, and any client of your own built on `URLSession`, are subject to it. + +For those, declare the server in your app's Info.plist. As Apple documents them for iOS 17, iPadOS 17 and macOS 14 and later: + +- **A DNS name:** add it to `NSExceptionDomains` with `NSExceptionAllowsInsecureHTTPLoads` set to `YES`. Use lowercase, no port, and `NSIncludesSubdomains` for subdomains ([NSExceptionDomains](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity/nsexceptiondomains)). +- **An IP address:** ATS no longer allows IP addresses by default. Add the address, or a CIDR range such as `192.168.42.0/24`, to `NSExceptionDomains`. An exception for a name doesn't cover the same host reached by address, and the reverse ([NSExceptionDomains](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity/nsexceptiondomains)). +- **Unqualified names and `.local` names on the local network:** `NSAllowsLocalNetworking` set to `YES` allows them, and Apple recommends setting it as a declaration of intent ([NSAllowsLocalNetworking](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity/nsallowslocalnetworking)). + +Prefer an `https` address. Apple warns that loosening ATS reduces your app's security. + ## Identify the connected instance Use **`Server.instanceDescription()`** to display the connected instance's name. It reads the [public instance description](/api-reference/instance/getInstance) through the core, without sending the key or opening a working copy: @@ -97,6 +133,10 @@ The `HydrateReport` counts this call's listing pages in `pages` and includes ear While a fill runs, `status()` gives `itemsListed`, the items the fill has brought so far, and `itemsExpected`, how many the slice held when the fill began, where the instance counted them. **Compare those two for progress**, not `items`, which also includes pinned items and queued creates. +### Read the instance's types before the first fill + +`copy.catalog.fromServer()` asks the instance for its item types and edge types now, so an app can offer a choice of types before it hydrates. It returns a `ServerCatalog` and changes nothing in the copy, whose own `catalog` reads keep answering from the store. A copy without a server throws `noServer`, and one that can't reach the instance throws `network`. Cancel the task to end the request. + ### 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`: @@ -147,6 +187,7 @@ Besides `items`, the copy has `edges` (`from`, `to`, `ofType`, `create`, `update | `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. | | `conflictedCopies(of:)`, `original(ofConflictedCopy:)` | An item's held conflicted copies, and the ID of the item a copy was made from. | diff --git a/content/docs/guides/troubleshooting.mdx b/content/docs/guides/troubleshooting.mdx index cd9c045e..c1b53df6 100644 --- a/content/docs/guides/troubleshooting.mdx +++ b/content/docs/guides/troubleshooting.mdx @@ -269,7 +269,7 @@ A working copy is a file that one build of the core made, and a build with a dif The message says how many writes the file holds that the server has not taken: waiting, blocked, refused or dead. Only the build that made the file can send or show them, so what you do depends on that count: -- **None.** Every write the file holds reached the server. Run `marfa device hydrate` to build a new working copy. An app built on the core's Node or Swift binding can call `replaceRefused` instead: it replaces the old file with an empty copy, so what only the old file held, such as pins, is lost. +- **None.** Every write the file holds reached the server. Run `marfa device hydrate` to build a new working copy. An app built on the core's Node binding can call `replaceRefused` instead, and one built on the [Swift SDK](/swift-sdk#replace-a-refused-copy) can call `WorkingCopy.replaceRefused(store:server:fileBudget:)`: it replaces the old file with an empty copy, so what only the old file held, such as pins, is lost. The files the copy downloaded stay. - **Some.** Open the file with the build that made it, run `marfa device drain` to send the writes, and use `marfa device discard` on any it refuses that you don't want. Then hydrate a new copy with the newer build. Keep the old file until you have done this, because deleting it loses those writes. If some of the writes are of a kind the current build no longer queues, the message names the kind and how many, such as `1 write of kind merge_metadata`. That one is a tag merge, which an instance at v0.0.24 or later answers `404 not_found`. Read the tags it carries with `marfa device queue`, and add them with `marfa items tag`. From 74eb612e194dd95d4537cd2f69b7f37f3680d647 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 23:31:30 +0100 Subject: [PATCH 02/12] 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 acc542b8..2828b9e5 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -163,6 +163,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`. @@ -236,7 +238,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 c1b53df6..13bcc16c 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 bd058a7036465109d4d58354c838f830e1440ec9 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 23:36:54 +0100 Subject: [PATCH 03/12] 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 2828b9e5..01509efc 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -163,7 +163,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 b602e4401f5e949fb8f608b111216f50115bc136 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 23:57:54 +0100 Subject: [PATCH 04/12] 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 01509efc..058207bd 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -163,7 +163,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 @@ -238,7 +247,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 b71ae10fa6bc8abfec79400ea14c4417c58b0400 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:07:00 +0100 Subject: [PATCH 05/12] 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 058207bd..a2265190 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -165,7 +165,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 1c634f6c6eca02fed9bd551b9f1f37804ba0a7e5 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:16:28 +0100 Subject: [PATCH 06/12] 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 a2265190..ba9ef48f 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -172,7 +172,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 cc0d37c69121e8ef15fa9405586ca9a223273355 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:23:24 +0100 Subject: [PATCH 07/12] 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 ba9ef48f..6ddcd692 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -172,7 +172,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 From 912cce23ddafe176abd3b461400470b57cd5685f Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:00:14 +0100 Subject: [PATCH 08/12] docs(swift-sdk): document the client for server-only calls --- content/docs/(overview)/how-marfa-works.mdx | 2 +- content/docs/(reference)/swift-sdk.mdx | 34 +++++++++++++++++++-- content/docs/guides/define-a-type.mdx | 25 ++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/content/docs/(overview)/how-marfa-works.mdx b/content/docs/(overview)/how-marfa-works.mdx index b0f2c3da..1b88517d 100644 --- a/content/docs/(overview)/how-marfa-works.mdx +++ b/content/docs/(overview)/how-marfa-works.mdx @@ -33,7 +33,7 @@ Nothing reads or writes your data without a **credential**, and a credential rea Every program that uses Marfa runs outside it and sends requests to its HTTP API. Programs differ in what sits between them and the API: -- **The Swift SDK and the CLI** embed the **core**, a Rust engine that sends their requests and can keep a working copy of the data they need. A program that keeps a working copy is a **device**: it reads offline and sends its writes when it can reach the instance, as [Devices](/devices) explains. +- **The Swift SDK and the CLI** embed the **core**, a Rust engine that sends their requests and can keep a working copy of the data they need. A program that keeps a working copy is a **device**: it reads offline and sends its writes when it can reach the instance, as [Devices](/devices) explains. The Swift SDK also sends the operations only the instance can do, such as registering a type, straight to the API. - **The TypeScript SDK** sends each request straight to the API, and keeps no copy. - **AI agents** use the CLI or the API like any script, with a key of their own, as [Use Marfa with AI agents](/guides/ai-agents) shows. - **Connectors** are programs you run that bring in records from another service, such as Todoist, as [Connectors](/connectors) explains. diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 6ddcd692..486f5370 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -5,7 +5,7 @@ description: Give an iOS or macOS app a working copy of your instance that reads The Swift SDK gives an app on iPhone, iPad or Mac a **working copy** of an instance: a local database it reads with or without a connection, and a queue that holds its writes until it can send them. **An app built on it is a [device](/devices)**: that page explains the working copy, the queue and verdicts. -Use it for an app that must keep working offline. A script or a server needs no working copy: use the [TypeScript SDK](/typescript-sdk) or the [HTTP API](/api-reference). [Build an app with Swift](/guides/swift-app) builds a SwiftUI app on it. +Use it for an app that must keep working offline. A script or a server needs no working copy: use the [TypeScript SDK](/typescript-sdk) or the [HTTP API](/api-reference). For the calls only the instance can answer, such as registering a type, the SDK also has a [client](#call-the-server-directly). [Build an app with Swift](/guides/swift-app) builds a SwiftUI app on it. ## Install @@ -24,6 +24,7 @@ Each release carries Marfa's core already built, so your app needs no Rust toolc | Product | Holds | | --- | --- | | `Marfa` | The SDK. Add it to your app target. | +| `MarfaAPI` | `Client`, for the [calls only the instance can answer](#call-the-server-directly). It includes `MarfaTypes`. | | `MarfaTesting` | `WorkingCopy.fake(items:itemTypes:)`, a working copy for tests and previews with no store, server or core. Pass its `copy` where your code takes a `WorkingCopy`. It compiles empty in a release build, so wrap its uses in `#if DEBUG`. | | `MarfaTypes` | The server's wire types, generated from the API description, and `marfaContractVersion`. | @@ -234,6 +235,34 @@ try await copy.declareTypes([ `declareTypes(_:)` replaces the app's whole set, so pass every type on each call. **Until the copy first hydrates, a queued create is checked against these declarations** and the types Marfa ships, so the app works offline from its first launch. On hydration, the copy registers each declared type the instance lacks, if the credential may register types, and the `HydrateReport` lists them in `registeredTypes` and `unregisteredTypes`. [Define a type](/guides/define-a-type) explains what a definition holds. +## Call the server directly + +Some operations only the instance can do: registering, replacing and deleting a type, managing keys and webhooks, reading an item's versions, stats and occurrences, and reading the audit log. **`Client` from the `MarfaAPI` product sends these straight to the instance**, with the same `Server` the working copy uses: + +```swift +import MarfaAPI + +let api = Client(server) +let title = Components.Schemas.FieldDefinition(_type: .string, required: true) +let entry = Components.Schemas.TypeDefinitionInput( + fields: .init(additionalProperties: ["title": title]), id: "app.readinglist.entry") + +_ = try await api.check(itemType: entry) +_ = try await api.register(itemType: entry) +``` + +| Call | Operation | +| --- | --- | +| `check(itemType:)` | [Check a type definition](/api-reference/types/normalizeType) without registering it. | +| `register(itemType:)`, `replace(itemType:with:)`, `delete(itemType:force:)` | [Register](/api-reference/types/registerType), [replace](/api-reference/types/replaceType) or [delete](/api-reference/types/deleteType) an item type. | +| `register(edgeType:)`, `edgeTypes()`, `delete(edgeType:force:)` | [Register](/api-reference/edge-types/registerEdgeType), [list](/api-reference/edge-types/listEdgeTypes) or [delete](/api-reference/edge-types/deleteEdgeType) an edge type. | + +Every other operation the client makes is a method named after its operation ID, such as `listItemVersions(path:)` or `listAuditLog()`. A refusal throws a `MarfaError`, so read a success from the answer's `ok` case: `try await api.listAuditLog().ok.body.json`. **The client has no item, edge, search or event operations**: those go through the working copy, so there is one path for each item. Connector operations and those for the owner's session alone are not in it either. + +**Each call needs a connection.** On a device that is offline, a call throws `noConnection` and sends nothing; it isn't queued. A call waits up to 30 seconds for an answer, then throws `network`. When the instance refuses an expired access token, the client renews the sign-in once and sends the call again, and once the instance ends the sign-in, calls throw `signedOut`. **It never follows a redirect**, so the credential reaches no other host. + +`declareTypes(_:)` registers an app's types when the copy hydrates, which suits an app that must start offline. Use `register(itemType:)` when the app needs the type on the instance now, or to change or delete one. + ## Follow changes Each `Change` names the `itemId` or `edgeId` it concerns, and its `origin` says where it came from: @@ -413,13 +442,14 @@ Its `FolderEvent` values are `.watching` when it starts and `.passed` after a pa ## Handle errors -Every call on a working copy or `Folders` throws a `MarfaError`, whose `message` is fit to show a person. `Keychain` throws `Keychain.Failure`, and `Server.fromEnvironment()` throws `Server.EnvironmentError`. +Every call on a working copy, `Folders` or `Client` throws a `MarfaError`, whose `message` is fit to show a person. `Keychain` throws `Keychain.Failure`, and `Server.fromEnvironment()` throws `Server.EnvironmentError`. | Case | Meaning | | --- | --- | | `notFound`, `unauthorized`, `forbidden`, `validation`, `server` | A refusal, from the server or from the copy before anything was sent. Each carries a `code`: [Errors](/api-reference/errors) lists the server's. | | `rateLimited` | The server asked for a wait. `retryAfterSeconds` says how long, when it said. | | `network`, `unnamed` | The server couldn't be reached, or something in front of it answered naming no contract. A queued write stays queued. | +| `noConnection` | A `Client` call found the device offline, so nothing was sent. | | `contractMismatch` | The server speaks a contract this build wasn't made for. A write it answered stays queued, and goes again once the app speaks the server's contract. | | `wrongServer` | The server at the copy's address is another instance than the copy recorded. The copy sends it no credential or data: [Troubleshooting](/guides/troubleshooting) explains. | | `copyExpired`, `hydrationIncomplete`, `readingHandle`, `firstSyncWaiting`, `noServer`, `closed` | States covered on this page. | diff --git a/content/docs/guides/define-a-type.mdx b/content/docs/guides/define-a-type.mdx index d69dc2b0..dd75cc39 100644 --- a/content/docs/guides/define-a-type.mdx +++ b/content/docs/guides/define-a-type.mdx @@ -78,7 +78,16 @@ const definition = JSON.parse(await readFile("receipt.json", "utf8")); const { data, error } = await marfa.POST("/types", { body: definition }); ``` -The TypeScript examples use a client made as [the TypeScript SDK](/typescript-sdk) shows. Marfa answers `201` with the type as it stored it: +```swift tab="Swift" +import MarfaAPI + +let api = Client(server) +let definition = try JSONDecoder().decode( + Components.Schemas.TypeDefinitionInput.self, from: Data(contentsOf: receiptURL)) +let registered = try await api.register(itemType: definition) +``` + +The TypeScript examples use a client made as [the TypeScript SDK](/typescript-sdk) shows, and the Swift examples a `Client` made as [the Swift SDK](/swift-sdk#call-the-server-directly) shows, with `receiptURL` the file's location. Marfa answers `201` with the type as it stored it: ```json { @@ -130,6 +139,10 @@ Content-Type: application/json const { data, error } = await marfa.POST("/types/normalize", { body: definition }); ``` +```swift tab="Swift" +let normalized = try await api.check(itemType: definition) +``` + The answer's `type` is your definition with its defaults made explicit, and `registered_type` is the registered one in the same form. Compare those two, not `resolved_type`, which adds what the registered type inherits. Both registered forms are `null` when the identifier isn't registered. An unknown schema key, anywhere in a definition, is refused `400 invalid_schema`. Then replace the type: @@ -153,6 +166,12 @@ const { data, error } = await marfa.PUT("/types/{id}", { }); ``` +```swift tab="Swift" +let update = try JSONDecoder().decode( + Components.Schemas.TypeDefinitionUpdate.self, from: Data(contentsOf: receiptURL)) +_ = try await api.replace(itemType: "app.example.receipt", with: update) +``` + A replacement **changes no item you already hold**. An item meets a property you made required the next time its properties are written. A property you leave out is gone from the type, but items keep their values for it, and a later write isn't refused for them. Send the `version` every time: a replacement without one sets it back to 0. You can't replace or delete a type Marfa ships. @@ -178,6 +197,10 @@ await marfa.DELETE("/types/{id}", { }); ``` +```swift tab="Swift" +try await api.delete(itemType: "app.example.receipt") +``` + Otherwise it refuses with `409 type_in_use` when items hold the type or a setting names it, `409 type_has_subtypes` when a type names it as its parent, and `422 compatible_with_violation` when a type names it in `compatible_with`. A `type_in_use` refusal counts the items in `details.item_count` and names each folder, view and webhook setting in `details.settings`, without the `id` of a setting your key can't read. **[Move the items to another type](/guides/move-items-to-another-type) first:** the move changes those settings too. `details.grants` lists the keys and apps whose type map names the type, if your key may manage them. They don't stop a delete, so change them through [Manage keys](/guides/manage-keys) when you're ready. From 465d1d5eda01ffc498f2e4a1eac4e086a491be99 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:10:03 +0100 Subject: [PATCH 09/12] docs(swift-sdk): say what the client reads and how long it waits --- 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 486f5370..d5f1244c 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -257,9 +257,9 @@ _ = try await api.register(itemType: entry) | `register(itemType:)`, `replace(itemType:with:)`, `delete(itemType:force:)` | [Register](/api-reference/types/registerType), [replace](/api-reference/types/replaceType) or [delete](/api-reference/types/deleteType) an item type. | | `register(edgeType:)`, `edgeTypes()`, `delete(edgeType:force:)` | [Register](/api-reference/edge-types/registerEdgeType), [list](/api-reference/edge-types/listEdgeTypes) or [delete](/api-reference/edge-types/deleteEdgeType) an edge type. | -Every other operation the client makes is a method named after its operation ID, such as `listItemVersions(path:)` or `listAuditLog()`. A refusal throws a `MarfaError`, so read a success from the answer's `ok` case: `try await api.listAuditLog().ok.body.json`. **The client has no item, edge, search or event operations**: those go through the working copy, so there is one path for each item. Connector operations and those for the owner's session alone are not in it either. +Every other operation the client makes is a method named after its operation ID, such as `listItemVersions(path:)` or `listAuditLog()`. A refusal throws a `MarfaError`, so read a success from the answer's `ok` case: `try await api.listAuditLog().ok.body.json`. **The client has no call that creates, gets, changes or deletes one item or edge, and none for search or events**: those go through the working copy, so there is one path for each item, and bulk changes aren't offered. It does read what only the instance holds, such as an item's versions, statistics and occurrences. Connector operations and those for the owner's session alone are not in it either. -**Each call needs a connection.** On a device that is offline, a call throws `noConnection` and sends nothing; it isn't queued. A call waits up to 30 seconds for an answer, then throws `network`. When the instance refuses an expired access token, the client renews the sign-in once and sends the call again, and once the instance ends the sign-in, calls throw `signedOut`. **It never follows a redirect**, so the credential reaches no other host. +**Each call needs a connection.** On a device that is offline, a call throws `noConnection` and sends nothing; it isn't queued. A call waits 30 seconds for the instance to send more, for the answer to start and again between the parts of a streamed body, then throws `network`. Pass `Client(server, timeout:)` a longer time for a call that answers only when its work is done, such as `runHousekeeping`. When the instance refuses an expired access token, the client renews the sign-in once and sends the call again, and once the instance ends the sign-in, calls throw `signedOut`. **It never follows a redirect**, so the credential reaches no other host. `declareTypes(_:)` registers an app's types when the copy hydrates, which suits an app that must start offline. Use `register(itemType:)` when the app needs the type on the instance now, or to change or delete one. From a2be672f668d5ce6cdbbf336e3eae86c81f098e1 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:41:45 +0100 Subject: [PATCH 10/12] docs(swift-sdk): describe view sort and group, edge reverse labels and moving a type --- content/docs/(reference)/swift-sdk.mdx | 76 +++++++++++++++++++------- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index d5f1244c..bc07ad79 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -189,7 +189,7 @@ 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. +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. | `items` call | What it does | | --- | --- | @@ -341,18 +341,20 @@ A `Reading` is `.list(_:sort:)` or `.search(_:filters:limit:)` for `Rows` ## Saved views -A `SavedView` is a [`system.view`](/catalog/view) item: a query, and the layouts that show its items. **The working copy answers a view offline**, so every app lists and groups the same items the same way. [Read a view offline](/guides/read-a-view) explains what the copy needs: hydrate `system.view` and every type the view's query names, at the tier the query lists. +A `SavedView` is a [`system.view`](/catalog/view) item: a title, a query, an optional sort and an optional grouping. **A view holds no layout**: how an app draws the items is the app's own. **The working copy answers a view offline**, so every app lists and groups the same items the same way. [Read a view offline](/guides/read-a-view) explains what the copy needs: hydrate `system.view` and every type the view's query names, at the tier the query lists, and the edge type of a `backref` condition or an edge group. ```swift _ = try await copy.hydrate(types: ["system.view", "core.task"], tier: .all) -guard let view = try await copy.items.view(viewID), let layout = view.settings.layouts.first else { return } -let tasks = try await copy.items.list(inView: view.id, layout: layout.id, limit: 50) -let groups = try await copy.items.groups(inView: view.id, layout: layout.id) +guard let view = try await copy.items.view(viewID) else { return } +let tasks = try await copy.items.list(inView: view.id, limit: 50) +let groups = try await copy.items.groups(inView: view.id) for group in groups { print(group.key, group.count) } ``` -A layout's `kind` says how it shows items, such as `.board` or `.chart(x:y:)`, and carries the options that kind takes. A `ViewGroup` has the group's `key`, its `count` and, for a chart that sums a property, its `sum`. A live query over a layout reads again when an item enters the view, leaves it or changes group, so a SwiftUI view keeps its counts without refresh code: +A view's `settings.sort` is a list of `ViewSort` values, each a field in `by` and a `direction`, first key first. It is empty for a view that doesn't sort. `settings.group` is a `ViewGrouping` with the field in `by`, an optional `bucket` for a date field, an `order` of the groups that come first and a `hidden` list of groups to leave out, or `nil` for a view that groups nothing. **A hidden group leaves `groups(inView:)` and not `list(inView:)`**: the view still lists its items. A `ViewGroup` has the group's `key` and its `count`. A view with no grouping answers one group with the key `null` that holds every item. + +A live query over a view reads again when an item enters the view, leaves it or changes group, so a SwiftUI view keeps its counts without refresh code: ```swift @MainActor @Observable @@ -361,8 +363,8 @@ final class Board { let tasks: LiveQuery> init(copy: WorkingCopy, viewID: String) { - groups = LiveQuery(copy, reading: .groups(inView: viewID, layout: "board")) - tasks = LiveQuery(copy, reading: .list(inView: viewID, layout: "board")) + groups = LiveQuery(copy, reading: .groups(inView: viewID)) + tasks = LiveQuery(copy, reading: .list(inView: viewID)) } } ``` @@ -370,12 +372,12 @@ final class Board { | Call | What it does | | --- | --- | | `items.views()`, `items.view(_:)` | Every view the copy holds, or one, `nil` when it isn't held. | -| `items.list(inView:layout:limit:offset:)` | A page of the view's items in a layout's sort. `layout: nil` is the first layout. | -| `items.groups(inView:layout:timeZone:)` | A layout's groups with their counts. Date groups follow `timeZone`, or the device's. | +| `items.list(inView:limit:offset:)` | A page of the view's items in the view's sort. | +| `items.groups(inView:timeZone:)` | The view's groups with their counts. Date groups follow `timeZone`, or the device's. | | `search(_:inView:limit:)` | Full-text search within the view's query. | -| `Reading.views`, `.view(_:)`, `.list(inView:)`, `.groups(inView:layout:)` | The same reads, for a `LiveQuery`. | +| `Reading.views`, `.view(_:)`, `.list(inView:)`, `.groups(inView:)` | The same reads, for a `LiveQuery`. | -**View lists, groups and searches carry completeness** in `Rows`, just like other reads: `.partial` during a first fill and `.stale` during an in-place refill. A query outside the copy's slice throws `invalid`, naming the type or tier it lacks. A view the copy doesn't hold throws `notFound` with the code `not_held`. +**View lists, groups and searches carry completeness** in `Rows`, just like other reads: `.partial` during a first fill and `.stale` during an in-place refill. A query outside the copy's slice throws `invalid`, naming the type or tier it lacks. So does a `backref` condition or an edge group whose edge type the copy doesn't hold whole: pass the edge type as an `edgeTypes` of `hydrate`. A view the copy doesn't hold throws `notFound` with the code `not_held`. ### Create, change and delete a view @@ -387,12 +389,8 @@ let made = try await copy.createView( ViewSettings( title: "Open tasks", query: ViewQuery(types: ["core.task"], filter: "tags contains \"work\""), - layouts: [ - ViewLayout( - id: "board", kind: .board, sort: [ViewSort(by: "created_at", direction: .descending)], - group: ViewGrouping(by: "properties.status", order: ["todo", "doing"])), - ViewLayout(id: "list", kind: .list), - ]), + sort: [ViewSort(by: "created_at", direction: .descending)], + group: ViewGrouping(by: "properties.status", order: ["todo", "doing"], hidden: ["done"])), idempotencyKey: attempt) let renamed = try await copy.changeView( @@ -401,13 +399,22 @@ let renamed = try await copy.changeView( try await copy.deleteView(renamed.id) ``` +**To remove a view's sort, change it to an empty list. To remove its grouping, change it to `.clear`.** The grouping is the one setting a change can remove, because a `nil` setting in a change means "leave it as it is": + +```swift +let unsorted = try await copy.changeView( + view.id, ViewSettingsChange(sort: []), baseVersion: view.version) +let ungrouped = try await copy.changeView( + unsorted.id, ViewSettingsChange(group: .clear), baseVersion: unsorted.version) +``` + - **Retry a create with the same key.** If the connection drops after the request went out, the server may have made the view. Retrying with the same `idempotencyKey` answers the first view instead of making a second. Without a key, the SDK makes one for each call. - **Name the version you changed from.** The server merges a change to settings nobody else touched since `baseVersion`. A setting changed since then throws `server` with status 409 and the code `version_conflict`, and the copy keeps the view it held. Read the view again and decide. - **A delete removes the view for good.** The copy drops it at once, and a live query over the views stops listing it. A view the server no longer has throws `notFound` with the code `item_not_found`. | Refusal | Thrown as | | --- | --- | -| A query the server doesn't accept | `validation` with the code `validation_error` | +| A query, sort or grouping the server doesn't accept | `validation` with the code `validation_error` | | A key that may not write `system.view` | `forbidden` with the code `type_not_permitted` | | A change to a revoked view | `validation` with the code `invalid_transition` | | A stale change to a setting that moved | `server`, status 409, the code `version_conflict` | @@ -416,6 +423,37 @@ try await copy.deleteView(renamed.id) The rules are in the contract's [views written through a copy](https://github.com/withmarfa/marfa/blob/main/conformance/spec/device.md#views-written-through-a-copy) section. +## Move the items of a type + +A type's identifier never changes, so renaming a type means registering the new one, moving the items and deleting the old one. **`Client` from the `MarfaAPI` product sends the move straight to the instance**, because only the instance can run it. The move is a job on the instance: it gives every item of the type that your credential can see, in any state, the new type, and once none remains it rewrites the folders, views and webhooks that name the old type. + +```swift +import MarfaAPI + +let api = Client(server) + +let preview = try await api.previewMove(ofType: "app.deal", to: "app.contract") +print(preview.matched, preview.settings.map(\.field)) + +let started = try await api.startMove( + ofType: "app.deal", to: "app.contract", properties: ["stage": "signed"]) +let job = try await api.follow(moveJob: started.id) +if let result = job.result, result.remaining == 0 { + try await api.delete(itemType: "app.deal") +} +``` + +| Call | What it does | +| --- | --- | +| `previewMove(ofType:to:properties:)` | Answers the items the move would change and the settings that name the type, and writes nothing. | +| `startMove(ofType:to:properties:)` | Starts the job and answers it at once. `properties` merge into each item as it moves, such as a field the new type requires, and a `null` value removes a property. | +| `follow(moveJob:every:)` | Polls the job until it is `completed`, `failed` or `canceled`, and answers it with its `result`. Cancelling the task that waits stops the wait, not the job. | +| `cancel(moveJob:)` | Asks the instance to stop the job, and answers it as it stands. A job that has ended is answered as it ended. | + +**Read the `result` before you delete the old type.** `remaining` counts the items of the type that are left, and `remainingHidden` says whether any the credential can't see are among them. `settingsRewritten` is `true` only when none remains. Items already moved stay moved when a job is canceled or leaves some behind, and no setting is rewritten, so run the move again to finish it. + +A move refuses with `server` and the code `type_move_blocked` when a setting names the type in a way it can't rewrite, and moves nothing. **A delete of a type that an item, folder, view or webhook still names is refused with `server`, status 409 and the code `type_in_use`.** The `message` of both ends with the refusal's `details` as JSON, which lists each setting by its `kind`, `id` and `field`. + ## Folders on a Mac On macOS, `Folders` keeps a directory of files in step with a folder on the instance, and shares the Mac's list of folders with `marfa folders`. **Nothing is written into the directory or sent until the first sync is confirmed.** [Sync a folder](/guides/sync-a-folder) sets one up, with Swift examples. From 4c56115162cf440ce1704736c977da4a5b43210a Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 00:45:29 +0100 Subject: [PATCH 11/12] docs(swift-sdk): list the MarfaAPI product and delete the old type only after a completed move --- 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 bc07ad79..0a1c7c8b 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -26,6 +26,7 @@ Each release carries Marfa's core already built, so your app needs no Rust toolc | `Marfa` | The SDK. Add it to your app target. | | `MarfaAPI` | `Client`, for the [calls only the instance can answer](#call-the-server-directly). It includes `MarfaTypes`. | | `MarfaTesting` | `WorkingCopy.fake(items:itemTypes:)`, a working copy for tests and previews with no store, server or core. Pass its `copy` where your code takes a `WorkingCopy`. It compiles empty in a release build, so wrap its uses in `#if DEBUG`. | +| `MarfaAPI` | `Client`, for the calls only the instance can run, such as moving the items of a type. | | `MarfaTypes` | The server's wire types, generated from the API description, and `marfaContractVersion`. | ## Open a working copy @@ -438,7 +439,7 @@ print(preview.matched, preview.settings.map(\.field)) let started = try await api.startMove( ofType: "app.deal", to: "app.contract", properties: ["stage": "signed"]) let job = try await api.follow(moveJob: started.id) -if let result = job.result, result.remaining == 0 { +if job.status == .completed, job.result?.settingsRewritten == true { try await api.delete(itemType: "app.deal") } ``` @@ -450,7 +451,7 @@ if let result = job.result, result.remaining == 0 { | `follow(moveJob:every:)` | Polls the job until it is `completed`, `failed` or `canceled`, and answers it with its `result`. Cancelling the task that waits stops the wait, not the job. | | `cancel(moveJob:)` | Asks the instance to stop the job, and answers it as it stands. A job that has ended is answered as it ended. | -**Read the `result` before you delete the old type.** `remaining` counts the items of the type that are left, and `remainingHidden` says whether any the credential can't see are among them. `settingsRewritten` is `true` only when none remains. Items already moved stay moved when a job is canceled or leaves some behind, and no setting is rewritten, so run the move again to finish it. +**Check the job's `status` and `result` before you delete the old type.** A `failed` job has a `result` too, and a `canceled` one has none. `remaining` counts the items of the type that are left, and `remainingHidden` says whether any the credential can't see are among them. `settingsRewritten` is `true` only when none remains. Items already moved stay moved when a job is canceled or leaves some behind, and no setting is rewritten, so run the move again to finish it. A move refuses with `server` and the code `type_move_blocked` when a setting names the type in a way it can't rewrite, and moves nothing. **A delete of a type that an item, folder, view or webhook still names is refused with `server`, status 409 and the code `type_in_use`.** The `message` of both ends with the refusal's `details` as JSON, which lists each setting by its `kind`, `id` and `field`. From ea1853e79ac1a70715278cd04d761743f347a4a9 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 25 Oct 2026 01:11:51 +0000 Subject: [PATCH 12/12] docs(swift-sdk): merge the 0.0.32 pages into one reference One products table, the move of a type under the direct client, and the client named among the calls App Transport Security applies to. --- content/docs/(reference)/swift-sdk.mdx | 65 ++++++++++++-------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index 0a1c7c8b..afd43cba 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -24,10 +24,9 @@ Each release carries Marfa's core already built, so your app needs no Rust toolc | Product | Holds | | --- | --- | | `Marfa` | The SDK. Add it to your app target. | -| `MarfaAPI` | `Client`, for the [calls only the instance can answer](#call-the-server-directly). It includes `MarfaTypes`. | -| `MarfaTesting` | `WorkingCopy.fake(items:itemTypes:)`, a working copy for tests and previews with no store, server or core. Pass its `copy` where your code takes a `WorkingCopy`. It compiles empty in a release build, so wrap its uses in `#if DEBUG`. | -| `MarfaAPI` | `Client`, for the calls only the instance can run, such as moving the items of a type. | | `MarfaTypes` | The server's wire types, generated from the API description, and `marfaContractVersion`. | +| `MarfaAPI` | `Client`, for the [calls only the instance can answer](#call-the-server-directly), such as registering a type or [moving the items of one](#move-the-items-of-a-type). It includes `MarfaTypes`. | +| `MarfaTesting` | `WorkingCopy.fake(items:itemTypes:)`, a working copy for tests and previews with no store, server or core. Pass its `copy` where your code takes a `WorkingCopy`. It compiles empty in a release build, so wrap its uses in `#if DEBUG`. | ## Open a working copy @@ -83,7 +82,7 @@ if let notice = server.takePlainHTTPNotice() { } ``` -**App Transport Security (ATS) applies to some calls and not others.** Apple's documentation says ATS requires that connections made with the URL Loading System, typically `URLSession`, use HTTPS ([NSAppTransportSecurity](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity)). The core makes its own requests with a Rust HTTP client, not `URLSession`, so **ATS doesn't affect the working copy's hydration, catch-up and drain**. The SDK's sign-in requests, and any client of your own built on `URLSession`, are subject to it. +**App Transport Security (ATS) applies to some calls and not others.** Apple's documentation says ATS requires that connections made with the URL Loading System, typically `URLSession`, use HTTPS ([NSAppTransportSecurity](https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity)). The core makes its own requests with a Rust HTTP client, not `URLSession`, so **ATS doesn't affect the working copy's hydration, catch-up and drain**. The SDK's sign-in requests, the [`Client`](#call-the-server-directly) and any client of your own built on `URLSession` are subject to it. For those, declare the server in your app's Info.plist. As Apple documents them for iOS 17, iPadOS 17 and macOS 14 and later: @@ -264,6 +263,33 @@ Every other operation the client makes is a method named after its operation ID, `declareTypes(_:)` registers an app's types when the copy hydrates, which suits an app that must start offline. Use `register(itemType:)` when the app needs the type on the instance now, or to change or delete one. +### Move the items of a type + +A type's identifier never changes, so renaming a type means registering the new one, moving the items and deleting the old one. The move is a job on the instance, which only `Client` can start: it gives every item of the type that your credential can see, in any state, the new type, and once none remains it rewrites the folders, views and webhooks that name the old type. + +```swift +let preview = try await api.previewMove(ofType: "app.deal", to: "app.contract") +print(preview.matched, preview.settings.map(\.field)) + +let started = try await api.startMove( + ofType: "app.deal", to: "app.contract", properties: ["stage": "signed"]) +let job = try await api.follow(moveJob: started.id) +if job.status == .completed, job.result?.settingsRewritten == true { + try await api.delete(itemType: "app.deal") +} +``` + +| Call | What it does | +| --- | --- | +| `previewMove(ofType:to:properties:)` | Answers the items the move would change and the settings that name the type, and writes nothing. | +| `startMove(ofType:to:properties:)` | Starts the job and answers it at once. `properties` merge into each item as it moves, such as a field the new type requires, and a `null` value removes a property. | +| `follow(moveJob:every:)` | Polls the job until it is `completed`, `failed` or `canceled`, and answers it with its `result`. Cancelling the task that waits stops the wait, not the job. | +| `cancel(moveJob:)` | Asks the instance to stop the job, and answers it as it stands. A job that has ended is answered as it ended. | + +**Check the job's `status` and `result` before you delete the old type.** A `failed` job has a `result` too, and a `canceled` one has none. `remaining` counts the items of the type that are left, and `remainingHidden` says whether any the credential can't see are among them. `settingsRewritten` is `true` only when none remains and no setting is blocked. Items already moved stay moved when a job is canceled or leaves some behind, and no setting is rewritten, so run the move again to finish it. + +A move refuses with `server` and the code `type_move_blocked` when a setting names the type in a way it can't rewrite, and moves nothing. **A delete of a type that an item, folder, view or webhook still names is refused with `server`, status 409 and the code `type_in_use`.** The `message` of both ends with the refusal's `details` as JSON, which lists each setting by its `kind`, `id` and `field`. + ## Follow changes Each `Change` names the `itemId` or `edgeId` it concerns, and its `origin` says where it came from: @@ -424,37 +450,6 @@ let ungrouped = try await copy.changeView( The rules are in the contract's [views written through a copy](https://github.com/withmarfa/marfa/blob/main/conformance/spec/device.md#views-written-through-a-copy) section. -## Move the items of a type - -A type's identifier never changes, so renaming a type means registering the new one, moving the items and deleting the old one. **`Client` from the `MarfaAPI` product sends the move straight to the instance**, because only the instance can run it. The move is a job on the instance: it gives every item of the type that your credential can see, in any state, the new type, and once none remains it rewrites the folders, views and webhooks that name the old type. - -```swift -import MarfaAPI - -let api = Client(server) - -let preview = try await api.previewMove(ofType: "app.deal", to: "app.contract") -print(preview.matched, preview.settings.map(\.field)) - -let started = try await api.startMove( - ofType: "app.deal", to: "app.contract", properties: ["stage": "signed"]) -let job = try await api.follow(moveJob: started.id) -if job.status == .completed, job.result?.settingsRewritten == true { - try await api.delete(itemType: "app.deal") -} -``` - -| Call | What it does | -| --- | --- | -| `previewMove(ofType:to:properties:)` | Answers the items the move would change and the settings that name the type, and writes nothing. | -| `startMove(ofType:to:properties:)` | Starts the job and answers it at once. `properties` merge into each item as it moves, such as a field the new type requires, and a `null` value removes a property. | -| `follow(moveJob:every:)` | Polls the job until it is `completed`, `failed` or `canceled`, and answers it with its `result`. Cancelling the task that waits stops the wait, not the job. | -| `cancel(moveJob:)` | Asks the instance to stop the job, and answers it as it stands. A job that has ended is answered as it ended. | - -**Check the job's `status` and `result` before you delete the old type.** A `failed` job has a `result` too, and a `canceled` one has none. `remaining` counts the items of the type that are left, and `remainingHidden` says whether any the credential can't see are among them. `settingsRewritten` is `true` only when none remains. Items already moved stay moved when a job is canceled or leaves some behind, and no setting is rewritten, so run the move again to finish it. - -A move refuses with `server` and the code `type_move_blocked` when a setting names the type in a way it can't rewrite, and moves nothing. **A delete of a type that an item, folder, view or webhook still names is refused with `server`, status 409 and the code `type_in_use`.** The `message` of both ends with the refusal's `details` as JSON, which lists each setting by its `kind`, `id` and `field`. - ## Folders on a Mac On macOS, `Folders` keeps a directory of files in step with a folder on the instance, and shares the Mac's list of folders with `marfa folders`. **Nothing is written into the directory or sent until the first sync is confirmed.** [Sync a folder](/guides/sync-a-folder) sets one up, with Swift examples.