Working copy

Open, fill, refill and remove the working copy a Swift app reads from, and choose the server and the slice it holds.

A WorkingCopy keeps its data in a SQLite file that the app chooses, and holds the queue of writes in the same file. Devices explains the working copy, its slice and its fill.

Open a copy

Opening makes the file if it is absent, and reads nothing from the instance. The copy holds its key in memory and never writes it to its store, so the app keeps the key in a KeyStore and reads it at each launch:

import Marfa

let keys = KeychainKeyStore()
// When the person gives the app its key:
try keys.save(key, for: serverURL)

// At each launch:
// nil: ask the person to connect.
guard let key = try keys.key(for: serverURL) else { return }
let copy = try await WorkingCopy.open(
    store: storeURL, server: Server(url: serverURL, key: key))

key(for:) returns nil when it keeps no key, and throws when it keeps one it can't read, such as in a locked keychain. Only nil means the person must connect again. KeychainKeyStore keeps one key for each server under the service <bundle identifier>.marfa-key, and KeychainKeyStore(keychain: Keychain(accessGroup:)) shares the keys with the app's extensions. Manage keys makes a key that reaches only your app's types, and Sign in replaces the key with the owner's approval.

CallWhat it does
WorkingCopy.open(store:server:fileBudget:ifRefused:)Opens the store, making it when absent. Without a Server, the copy works offline only. fileBudget sets the file budget as the copy opens.
WorkingCopy.openReader(store:)Opens a store another process writes, to read only. It refuses a path with no store.
WorkingCopy.remove(store:unsent:)Removes a store, once the person has agreed to lose its unsent writes.
handle.writer or .reader. A second opener of one store is a reader, which refuses writes, hydration and catch-up with readingHandle.
useKey(_:)Gives the open copy a new key. Held streams go on, and a call made while the key changes throws keyChanging.
close()Ends every stream and releases the store. Later calls throw closed.

Open a store another build made

A store that another build of the core made is refused with wrongSchema, and its unsent counts the writes the instance hasn't taken. Only the build that made the store can send them, as Staying in step describes.

  • With ifRefused: .keep, the default, open throws and leaves the store as it is.
  • With ifRefused: .replace, a refused store with no unsent writes is replaced by an empty one and opened. Hydrate it as you would a new copy. A store that holds unsent writes still throws wrongSchema with the count: show it to the person, call remove(store:unsent:) with it, and open again.

A replacement keeps the files the copy downloaded and its file budget. The core keeps files by their content, beside the store. What only the old store held is gone: pins, the position in the instance's events and declared types.

Remove a copy

WorkingCopy.remove(store:unsent:) removes the store, its logs, the files it downloaded and its lock, and nothing else. It removes a store of any build. Pass the count of unsent writes the person agreed to lose: the call checks it under the store's lock, so no write arrives between the check and the removal.

  • A different count throws unsentDiffers, with the store's own count, and removes nothing. A store whose count can't be read is never removed.
  • A store that another copy writes throws readingHandle, including another open copy in your own app, so close yours first.
  • A folder's store throws invalid. It goes only with the folder.

A reader, such as one opened with openReader(store:), doesn't stop the removal, and shows the old copy until it opens again.

The server

A Server is the instance's address and a key, an app's access token, or a sign-in.

CallWhat it does
Server(url:key:), Server(url:credential:)A server reached with a key, or with a kept sign-in.
Server.fromEnvironment()For a command-line tool: reads MARFA_API_URL and MARFA_API_KEY. Returns nil when both are absent, and throws EnvironmentError when one is missing or the URL names no server.
instanceDescription()The instance's name, ID, version, contract and features, read without the key and without a working copy. The name is Marfa when the owner set none.
plainHTTPOriginThe origin of an http address on another machine, or nil.

A store follows its instance to a new address. The first call that asks the server, such as a hydration, first checks which instance answers. Where it is the instance the store was made against, the store takes the new address. Where it is another, the call throws wrongServer and sends nothing. To start again on that server, read the queue for what would be lost, close the copy and call remove(store:unsent:).

Plain http

An http address sends the key in the clear. plainHTTPOrigin names the origin to warn about: nil for https, localhost, 127.0.0.0/8 and ::1, as the CLI decides. The SDK shows nothing itself, so the app chooses whether and when to warn.

App Transport Security applies to requests made through URLSession: sign-in, the server calls and the API Client. The working copy's own requests don't use URLSession, so hydration, catch-up and drains aren't affected. For the rest, add the server to NSExceptionDomains in the app's Info.plist, or set NSAllowsLocalNetworking for local names, as Apple's NSAppTransportSecurity documents. Prefer https.

Fill the copy

hydrate(types:tier:edgeTypes:inPlaceOnly:) fills the copy with every item of the types you name, at the tier you choose (.library, .feed or .all), with their tags and outgoing edges. edgeTypes also holds each named edge type whole. The queue survives every hydration.

let report = try await copy.hydrate(
    types: ["core.note", "core.task"], tier: .library)
  • ["*"] names every type except system.* ones. Name system.* types beside it to add them. Any other type beside "*" throws invalid, because "*" already holds it.
  • A fill that stops resumes from the page it reached, unless the slice changed. The HydrateReport says resumed, or names why it restarted.
  • Progress is in status(): itemsListed against itemsExpected, the count the instance gave when the fill began, or nil where it gave none. items also counts pinned items and queued creates, so it isn't a measure of the fill.
  • Reads answer during the fill from what has arrived, marking a list or search .partial. Items and edges lists what is refused until the copy is whole.

A hydration announces .refreshed(.hydrated) when it completes, and .refreshed(.unfinished) when it fails or is canceled, because the rows it wrote stay. The copy runs one hydration at a time: a second call waits for the first, and returns its report when it filled the same slice under the same key.

Status.hydration says where the copy stands:

HydrationMeaning
.neverNo fill has begun. Reads work and writes queue.
.inProgressA fill is under way, or stopped part way.
.completeThe copy holds its whole slice and follows the instance.
.staleThe instance's event log let the copy's cursor go. Reads answer what the copy held, marked .stale, until a fill refills it in place.
.expiredThe copy has ended, such as when the key's reach changed. It refuses reads and writes until a fill, which empties it first.

Refill a stale copy

Hydrating a .stale copy with the slice it was built under fills it again over the rows it holds, and the report says inPlace. A held updates() sequence does this by itself.

With inPlaceOnly: true, a hydration that couldn't refill in place throws before it changes anything, so the app can ask the person first:

ThrownWhenWhat the app does
instanceChangedAnother instance answers at the address.Ask the person, then hydrate. Writes queued for the old instance are blocked instanceChanged.
copyExpired, reason reach_changedThe key reaches other items than the copy was built under.Ask the person, then hydrate. The copy is emptied first.
copyExpired, reason slice_changedThe types, tier or edge types differ from the copy's.Hydrate with the copy's own slice to refill it.
copyExpired, reason head_past_logThe instance was restored to a point before the copy's.Ask the person, then hydrate. The copy is emptied first.
copyExpired, reason not_staleThe copy isn't stale.Nothing, or an ordinary hydration.

A server that refuses the key, or the reach of a stale copy, ends the copy all the same: .stale is a copy kept, and .expired a copy ended. The refill rules are in the contract.

Slice, pins and types

CallWhat it does
status()A Status: server origin, instance ID, slice, pinned IDs, event cursor, hydration, counts, fill progress, catalogVersion, fileCache and lastSynced.
catchUp()Reads the type catalog again and applies what changed since the copy's cursor. A held updates() sequence does this for you.
pin(_:), unpin(_:)Hold an item whatever the slice says, or let it go unless queued writes still need it. An item in the bin throws notFound with the code trashed.
catalog.itemTypes(), catalog.edgeTypes()The types the copy holds. Before the first fill, the types Marfa ships and the app's declared ones.
catalog.fromServer()The instance's types now, so an app can offer a choice before it fills the copy. It changes nothing in the copy.

Declare an app type

An app brings its own types under its own namespace, such as app.readinglist.entry:

try await copy.declareTypes([
    [
        "id": "app.readinglist.entry",
        "fields": ["title": ["type": "string", "required": true]],
    ]
])

declareTypes(_:) replaces the app's whole set, so pass every type on each call. Until the first fill, a queued create is checked against these declarations and the types Marfa ships, so the app works offline from its first launch. Each fill then brings the instance up to date:

  • A declared type the instance lacks is registered, if the key may register types. The report lists it in registeredTypes, or in unregisteredTypes with the instance's code and message.
  • A declared app.* type the instance holds in another definition is replaced, if the key may change it, and listed in replacedTypes. A type the instance refuses to replace stays as it is, listed in unreplacedTypes, and the fill goes on.
  • A type outside app.* is registered when missing and never replaced.

Define a type explains what a definition holds, and Staying in step what to do when a type is refused.

On this page