diff --git a/content/docs/(reference)/swift-sdk.mdx b/content/docs/(reference)/swift-sdk.mdx index d0dc7cab..061a9633 100644 --- a/content/docs/(reference)/swift-sdk.mdx +++ b/content/docs/(reference)/swift-sdk.mdx @@ -194,6 +194,83 @@ A `Reading` is `.list(_:sort:)` or `.search(_:filters:limit:)` for `[Item]`, `.i `refresh()` reads now and returns after a read that began after the call, so it fits `.refreshable`. On an `.ended` query, it also asks the copy for a new stream. **A query doesn't page**: bound a large list with `ListFilters.limit`. +## 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. + +```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) +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: + +```swift +@MainActor @Observable +final class Board { + let groups: LiveQuery<[ViewGroup]> + let tasks: LiveQuery<[Item]> + + init(copy: WorkingCopy, viewID: String) { + groups = LiveQuery(copy, reading: .groups(inView: viewID, layout: "board")) + tasks = LiveQuery(copy, reading: .list(inView: viewID, layout: "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. | +| `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`. | + +**A view the copy can't answer whole is refused with its reason,** never answered in part: `invalid` names a type or tier the slice lacks, `notFound` with the code `not_held` means the copy doesn't hold the view, and `hydrationIncomplete` means a hydration is still filling the copy. + +### Create, change and delete a view + +`createView(_:idempotencyKey:)`, `changeView(_:_:baseVersion:idempotencyKey:)` and `deleteView(_:idempotencyKey:)` write at once and never queue, like the folder writes: with no server, or none reachable, they throw and nothing waits to be sent. A change replaces each setting it names whole and leaves the others as they are. + +```swift +let attempt = UUID().uuidString +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), + ]), + idempotencyKey: attempt) + +let renamed = try await copy.changeView( + made.id, ViewSettingsChange(title: "Work tasks"), baseVersion: made.version) + +try await copy.deleteView(renamed.id) +``` + +- **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 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` | +| A delete of a view that is gone | `notFound` with the code `item_not_found` | +| No server, or none reachable | `noServer`, or `network` | + +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. + ## 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. diff --git a/content/docs/guides/read-a-view.mdx b/content/docs/guides/read-a-view.mdx index eece5eaa..03825425 100644 --- a/content/docs/guides/read-a-view.mdx +++ b/content/docs/guides/read-a-view.mdx @@ -62,7 +62,7 @@ marfa device --db ./copy.sqlite views groups --layout main --time-zone ## Read a view in an app -Use the core's view reads on the same handle as other working-copy reads. Rust exposes `list_views`, `view`, `list_in_view`, `search_in_view` and `view_groups`; the core's Node and Swift bindings expose the corresponding camel-case methods. +Use the core's view reads on the same handle as other working-copy reads. Rust exposes `list_views`, `view`, `list_in_view`, `search_in_view` and `view_groups`; the core's Node binding exposes the corresponding camel-case methods, and the [Swift SDK](/swift-sdk#saved-views) wraps them in its own types. For example, with an open Rust `Core` handle, read a page and the layout's totals: @@ -71,15 +71,22 @@ let page = core.list_in_view(&view_id, Some("main"), Some(50), None)?; let groups = core.view_groups(&view_id, "main", None)?; ``` -| Read | Answer | -| --- | --- | -| `list_views` | The views held in the copy. | -| `view` | One view's settings and version. | -| `list_in_view` | A page of matching items, ordered by the selected layout. | -| `search_in_view` | Search matches within the view's query. | -| `view_groups` | The selected layout's group keys, counts and sums. | +With a Swift `WorkingCopy`, the same two reads are: -**These are core APIs.** The separately released [Swift SDK](/swift-sdk) adopts core APIs on its own release schedule. Read the selected layout to choose the presentation, and pass the device's time zone when asking for date groups. +```swift +let page = try await copy.items.list(inView: viewID, layout: "main", limit: 50) +let groups = try await copy.items.groups(inView: viewID, layout: "main") +``` + +| Core read | Swift SDK | Answer | +| --- | --- | --- | +| `list_views` | `items.views()` | The views held in the copy. | +| `view` | `items.view(_:)` | One view's settings and version. | +| `list_in_view` | `items.list(inView:layout:limit:offset:)` | A page of matching items, ordered by the selected layout. | +| `search_in_view` | `search(_:inView:limit:)` | Search matches within the view's query. | +| `view_groups` | `items.groups(inView:layout:timeZone:)` | The selected layout's group keys, counts and sums. | + +Read the selected layout to choose the presentation, and pass the device's time zone when asking for date groups. The Swift SDK also writes views and follows a layout with a live query: [Saved views](/swift-sdk#saved-views) shows both. ## Handle a refused view