Views and folders

Read and write saved views from a Swift app's working copy, and keep a folder of files on a Mac in step with the instance.

A saved view and a folder are both items whose settings Marfa keeps: a system.view and a system.folder. The working copy answers a view offline. A view or folder write goes to the instance at once and is never queued, so with no server, or none reachable, it throws and nothing waits to be sent.

Saved views

A SavedView holds a title, a query, an optional sort and an optional grouping, in its settings. A view holds no layout: how an app draws the items is the app's own. Because the copy answers a view itself, every app lists and groups the same items the same way.

Read a view explains what the copy needs: system.view and every type the view's query names, at the tier it lists, and the edge type of a backref condition or an edge group, hydrated whole.

_ = try await copy.hydrate(types: ["system.view", "core.task"], tier: .all)

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)
ViewSettings fieldHolds
titleThe view's name.
queryA ViewQuery: the types, each with its subtypes, the tier, the states, a filter and beneath. Empty types lists every type except system.* ones.
sortViewSort values, first key first, each a field in by and a direction. Empty keeps the default order.
groupA ViewGrouping, or nil for a view that groups nothing.

A ViewGrouping names a field in by, or edge[<type>] or backref[<type>] for the item an edge of that type joins. A date field takes a bucket, the span each group covers. order lists the groups that come first, and hidden the groups to leave out.

Read a view

CallAnswers
items.views()Every view the copy holds.
items.view(_:)One view, or nil when the copy doesn't hold it.
items.list(inView:limit:offset:)A page of the view's items, in its sort. Items that tie go by ID.
items.groups(inView:timeZone:)The view's groups, each a ViewGroup with its key and count. Date groups follow timeZone, or the device's.
copy.search(_:inView:limit:)A full-text search within what the view lists.
  • Groups come in the view's order: those order names first, then the rest by key, with the null group of items that have no value last.
  • A hidden group leaves groups(inView:) and not list(inView:). The view still lists its items.
  • A view that groups nothing answers one null group that holds every item.

A view's items, groups and search throw hydrationIncomplete during a fill and while the copy is stale, since a count from a partial copy would be wrong. Read the view again once the copy is whole. The copy's other reads go on answering meanwhile, and items.views() lists the views that have arrived. The rule is in the contract.

A view the copy can't answer whole throws invalid, naming the reason: a type or tier the query lists that the copy doesn't hold, beneath without parent-of held whole, a backref or edge group whose edge type the copy doesn't hold whole, or a revoked view. A view the copy doesn't hold throws notFound with the code not_held.

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 current:

@MainActor @Observable
final class Board {
    let groups: LiveQuery<Rows<ViewGroup>>
    let tasks: LiveQuery<Rows<Item>>

    init(copy: WorkingCopy, viewID: String) {
        groups = LiveQuery(copy, reading: .groups(inView: viewID))
        tasks = LiveQuery(copy, reading: .list(inView: viewID))
    }
}

Write a view

createView(_:idempotencyKey:), changeView(_:_:baseVersion:idempotencyKey:) and deleteView(_:idempotencyKey:) write at once. A change replaces each setting it names whole, and a nil setting stays as it is.

let work = FilterExpression(conditions: [
    FilterCondition(field: .tags, operator: .contains, value: .text("work"))
])
let made = try await copy.createView(
    ViewSettings(
        title: "Open tasks",
        query: ViewQuery(types: ["core.task"], filter: try work.encoded()),
        sort: [ViewSort(by: "created_at", direction: .descending)],
        group: ViewGrouping(
            by: "properties.status", order: ["in_progress", "not_started"],
            hidden: ["completed", "canceled"])),
    idempotencyKey: UUID().uuidString)

let renamed = try await copy.changeView(
    made.id, ViewSettingsChange(title: "Work tasks"), baseVersion: made.version)
  • To remove a sort, change it to []. To remove the grouping, change it to .clear. The grouping is the one setting a change can remove.
  • Retry a create or a delete with the same idempotencyKey. If the connection drops after the request went out, the instance may have made the change, and the same key answers it again. Without a key, the SDK makes one for each call.
  • Name the version you changed from. The instance merges a change to settings nobody else touched since baseVersion. A setting changed since throws versionConflict, and the copy keeps the view it held: read it again and decide.
  • A delete removes the view for good, and the copy drops it at once. A revoked view can be deleted.
RefusalThrown as
A query, sort or grouping the instance doesn't acceptvalidation with the code validation_error
A key that may not write system.viewforbidden with the code type_not_permitted
A change to a revoked viewvalidation with the code invalid_transition
A change to a setting that moved since baseVersionversionConflict
A change or delete of a view that's gonenotFound with the code item_not_found
No server, or none reachablenoServer, or network

The rules are in the contract's 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 explains what a folder is and does the same with the CLI.

copy.createFolder(_:idempotencyKey:), changeFolder(_:_:baseVersion:idempotencyKey:) and revokeFolder(_:idempotencyKey:) write a folder's settings, and copy.items.folder(_:) reads them. Folders(server:) works the directories on this Mac:

CallWhat it does
add(_:following:)Makes a directory a folder that follows the settings item you name. The result's storage says when the directory is in cloud-synced or network storage: show its warning once.
list(), status(of:)The folders on this Mac, and each file's FileState in one, read without the server.
sync(_:)Syncs once. While the first sync waits, it only reads the folder and returns .awaitingConfirmation with the plan.
confirmFirstSync(in:)Lets the first sync go.
confirmRemoval(in:), restoreRemoval(in:)Lets a paused large removal go, or cancels it.
remove(_:)Takes the folder off this Mac and leaves its files. It throws invalid while writes are unsent.
watch(_:)Keeps the folder in step while the app runs, and returns a FolderWatch. It throws firstSyncWaiting until the first sync is confirmed.

A FileState is .inStep, .waiting for a queued write, .held (the file's status gives the reason), .unmatched, .unreached (held rather than deleted) or .outside.

Set up a folder

This code creates a folder, binds a directory to it and confirms the first sync, with serverURL, key and storeURL as in Open a working copy. Until the first sync is confirmed, sync(_:) returns .awaitingConfirmation with the plan:

import Marfa

let server = Server(url: serverURL, key: key)
let copy = try await WorkingCopy.open(store: storeURL, server: server)
let folder = try await copy.createFolder(
    FolderSettings(title: "Notes", query: FolderQuery(types: ["core.note"])))

let folders = Folders(server: server)
let directory = URL(filePath: "/Users/sam/Notes")
_ = try await folders.add(directory, following: folder.id)
_ = try await folders.sync(directory)               // .awaitingConfirmation(plan)
_ = try await folders.confirmFirstSync(in: directory)
_ = try await folders.sync(directory)

A client without the SDK calls Create a folder and the other folder operations.

Watch a folder

A FolderWatch is an AsyncSequence of FolderEvent. It throws a MarfaError when the key is refused, the instance's changes stop reaching the folder, or the directory can't be watched. Leaving a loop early doesn't stop it: call await watch.stop() when its owner finishes. Its threads run at utility quality of service, so they give way to the app's own work.

FolderEventMeaning
.watching, .passedThe watch started, or finished a pass with something to report.
.watcherFailed, .retrying, .unreachable, .reachable, .waitingStates the watch rides out. .waiting means the directory is gone.
.handedOver, .takenBackAnother call or command held the folder in between.
.heldElsewhereAnother process kept the folder unasked.
.removed, .unlistedThe folder left this Mac's list, and the watch ends without an error.

One process works a folder at a time. A call such as sync(_:), or a command such as marfa folders confirm, asks the watch to let the folder go, and throws readingHandle when it isn't free within 10 seconds. A sandboxed app has its own home directory, and so its own list of folders.

On this page