From b012a9a48c9f9f95350df15f782acf70cfabed2d Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sat, 24 Oct 2026 22:55:06 +0100 Subject: [PATCH] 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 37d3702e..10478bca 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 522b5ab1..ad5c48e1 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`.