Items and edges
Read, search and write items, edges and tags in a Swift app's working copy, offline or online.
Reads come from the working copy, with no request. Every write shows in the copy at once and waits in the queue as a QueuedWrite until the copy can send it.
let created = try await copy.items.create(
Draft(
type: "core.note",
properties: ["title": "Reading list", "body": "Essays first."]))
guard let id = created.itemId,
let note = try await copy.items.get(id) else { return }
_ = try await copy.items.update(
id, Edit(properties: ["title": "Books to read"], baseVersion: note.version))The copy's parts are items, edges, tags, metadata, extensions, blobs, queue, views and catalog.
Read items
items call | Answers |
|---|---|
list(_:sort:) | The items a ListFilters selects, as Rows<Item>. |
get(_:) | One item, or nil when it's in the bin or not held. |
get(_:includingBin:) | The items of many IDs, read at one moment, each once and in the order first named. An ID the copy doesn't hold is left out. |
links(in:) | The links and file embeds in an item's body, each with the item it names. Given many IDs, a dictionary by ID. |
thumbnail(_:) | The thumbnail the copy holds for an item, with no request. |
conflictedCopies(of:), original(ofConflictedCopy:) | An item's conflicted copies, and the item a copy was made from. |
displays(of:), displays(for:) | Each item's name and preview. |
linkCandidates(of:in:) | The items a link being typed could name. |
copy.search(_:filters:limit:) searches indexed fields and tags, best match first, and returns Rows<SearchHit>. A hit holds its item, a score and a snippet as HTML; read its excerpt to draw the matched runs in your own style. A search never matches the bin.
- Read
titleandbodyrather thanproperties: they read the properties the type's display hints name. Wherepropertiesis empty,unreadablePropertiessays why the copy couldn't read them. - Read dates as
DatewithcreatedDate,updatedDate,occurredDateandtrashedDate.date(forProperty:calendar:)reads adateordatetimefield as anItemDate, whosehasTimetells a day from an instant. Write a value back withItemDate.day(_:calendar:)orItemDate.instant(_:). - An
EdgeTypehas alabelfor the source's end and areverseLabelfor the target's, such asChildrenandParent. One registered with no reverse label has areverseLabelequal to itslabel.
Rows and completeness
A list, a search and get(_:includingBin:) return Rows, a random-access collection of rows with a completeness, so a ForEach takes it as it is:
Completeness | Meaning |
|---|---|
.complete | The copy holds everything the read asked for. |
.partial | A fill is under way, so more rows may arrive. |
.stale | The instance's event log let the copy's cursor go. The rows are those the copy held then, and some may have changed on the instance. |
While the copy fills
During a fill, a list or a search answers the rows that have arrived and says .partial. Some reads and writes can't be answered until the copy is whole, and throw hydrationIncomplete:
items.get(_:)for an item that hasn't arrived, since it may yet arrive. During a refill, the copy answers from the rows it holds.- Reads of edges, conflicted copies and body links, and
changes(since:most:), during a fill and while the copy is stale. - A saved view's items, groups and search, during a fill and while the copy is stale. Views and folders explains.
- Every edge write, and a write or an attached file that names an item the copy doesn't hold, during a fill and while the copy is stale.
- Every read and write of an
.expiredcopy, until a fill.
A write to an item the copy holds, or a new item, is queued during a fill. The contract's device rules list each refusal.
Filter and sort
ListFilters and SearchFilters narrow what a read answers:
| Filter | Keeps |
|---|---|
types | The items of up to ten types, each with its subtypes. An empty set is refused, and so is a type the copy doesn't hold. |
states | The items in these states. nil keeps active items only, unless allStates is true. An empty set is refused, and so is a set beside allStates. |
tier, tags | The items in one tier, and the items holding every tag named. |
occurredAfter, occurredBefore | A range of the items' own times, both ends exclusive. |
source | The items one writer created. List only. |
updatedAfter, updatedBefore | A range of change times, from inclusive to exclusive. List only. |
filter | An expression in the listing grammar, which the filter model writes. A backref condition is refused invalid. |
beneath | An item and every item below it along parent-of edges the copy holds. |
limit, offset | A page of the list. |
Set a time bound as a Date through occurredAfterDate and its siblings. A bound given as a day reads as the start of that day in UTC.
states: [.trashed] lists the bin from the copy, newest to the bin first unless a Sort names a field. items.bin(type:after:limit:) reads the instance's whole bin online instead, whatever the copy's slice.
A Sort names a field, a direction, both or neither. The field is .createdAt, .updatedAt, .occurredAt or .property(name:), and a list is newest created first by default.
- A list with
updatedAftercomes oldest change first. ASortthat names another field or direction is refusedvalidation. - An ascending property sort puts numbers and booleans first, then text. A descending one reverses that. Items without the property come last either way, and ties go by ID, oldest first.
- A property name outside
atoz,0to9and_is refusedvalidation.
Build a filter
A FilterExpression is a filter as typed values: conditions joined by .all or .any. Each FilterCondition has a field, an operator and a value, and field.operators lists the operators the grammar takes for it. Marfa's core writes and reads the grammar, so the app quotes and parses nothing:
try expression.encoded()gives the text forListFilters.filter, and throwsvalidationfor a filter the instance would refuse.try FilterExpression(decoding: text)reads a saved view's or folder's filter back.
Status groups
The status_group field selects items by the group of their status word, the same four for every type: notStarted, inProgress, completed and canceled. An item whose type has no status field, or that holds no declared word, is in no group. catalog.statusGroup(of:) and catalog.statusGroups(of:) read an item's group, and catalog.eventStatus(of:) reads an event's status.
Parts
The .part field selects parts, such as attachments and comments: items that belong to a host out of the bin through an attached-to, comments-on or other with-hosts edge. A list shows parts unless its filter leaves them out, so a list of notes that hides its comments takes part eq false:
let notParts = FilterExpression(conditions: [
FilterCondition(field: .part, operator: .eq, value: .bool(false)),
])part eq true selects only parts, and part neq true the same as part eq false; any other operator or value is refused validation. The copy counts a host it doesn't hold as out of the bin, and moving a host to the bin offline doesn't take its parts until the copy syncs.
Write items
items call | What it does |
|---|---|
create(_:) | Queues a Draft. |
update(_:_:) | Queues an Edit, refused unless its baseVersion is the version the copy holds. |
updateAsRead(_:_:) | Queues an edit based on an older version, which the instance merges. An editor that held a row while the copy caught up saves this way. |
delete(_:), restore(_:), transition(_:to:) | Moves an item to the bin or out of it, or changes its ItemState. An item the copy doesn't hold, such as one read from bin, can still be restored. |
bin(type:after:limit:) | Reads one page of the instance's bin, newest change first. A page can be short or empty and still carry a nextCursor. |
purge(_:version:) | Destroys an item in the bin on the instance now. |
The calls that queue a file are on the Files page.
bin and purge need the server and are never queued. A purge is sent at the version the person was shown, or the one the copy holds, so an item changed elsewhere throws versionConflict. It needs items.purge and write on the item's type. Before sending, it throws notFound with the code not_held for an item the copy doesn't hold, validation with invalid_transition for one the copy shows outside the bin, and invalid for one with a write still waiting.
Drafts
A Draft takes every field a create takes: type, properties, tags, tier, state, id, source, sourceId, occurredAt, captureLatitude with captureLongitude, edges and baseVersion.
- A create is one write. Its state, location and edges go in the body of the write. If the instance refuses any edge, it makes neither the item nor its edges, and the write stays queued as refused.
edgesmaps an edge type to the IDs of the items it points at. An edge may point at an item still waiting to be created: the write waits for that create.- Each tag is a write of its own, waiting on the create.
- A create the instance would refuse as malformed is refused before it's queued, with the code the instance would answer: a
.revokedstate, a coordinate that isn't a finite number, or an edge type or target that isn't valid.
Edits
An Edit changes the properties it names, and leaves the rest. A null clears nothing. To remove a property, name it in clearProperties.
replaceProperties: truemakespropertiesthe item's whole set, so one left out is cleared.type,tierandsourceIdmove the item to another type, tier or natural key. Once the instance accepts a change of type, the copy expires, so hydrate again.occurredAtsets the item's own time, andclearOccurredAt: truegives it none, so it sits at its creation time. The two can't be combined.- A whole number Marfa would serve back with other digits is refused
validationwith the codeinvalid_propertiesbefore anything is queued. Field kinds explains.
Edges, tags and extensions
| Call | What it does |
|---|---|
edges.from(_:), edges.to(_:), edges.ofType(_:) | The edges the copy holds from or to an item, or of one type, unsent ones included. |
edges.get(_:) | One edge by ID, or many. |
edges.create(from:to:type:properties:id:) | Queues an edge, refusing first one the instance would refuse for what the copy holds. |
edges.check(from:to:type:properties:id:) | Throws what create would throw now, and queues nothing. |
edges.update(_:_:), edges.delete(_:) | Queue an EdgeEdit, or a delete. |
edges.check(_:moving:) | Throws what update would throw now for an EdgeEdit.Move of the edge's end, and queues nothing. |
tags.add(_:to:), tags.remove(_:from:) | Queue one tag change, leaving the item's other tags. |
metadata.replaceTags(of:with:) | Queues every tag of an item in one write. |
extensions.write(_:_:on:), extensions.delete(_:from:) | Queue an extension write or delete by namespace. |
- The copy holds the edges going out from its items. An edge from an item outside the slice is held only when its type was hydrated whole or its source is pinned.
Ask before the person confirms a choice, such as dropping a task under another project, so a refusal shows as they choose. This checks moving the task's parent-of edge to a new parent:
do {
try await copy.edges.check(parentEdge.id, moving: .source(otherProject.id))
} catch MarfaError.validation(let code, _, let details) {
// `edge_constraint_violation` or `edge_cycle`, with the instance's details.
showRefusal(code, constraint: details?.object?["constraint"]?.string)
}- The copy refuses an edge write the instance would refuse, before it queues anything.
create,update, an item create's edges and both checks judge from what the copy holds, in the instance's order: an end in the bin, an end's type or nesting under a flat edge type, a duplicate of an edge the copy shows, a second edge at an end that holds its one edge of the type, then a cycle. Write edges in a working copy lists each code. Returning isn't the instance's verdict. - A refusal carries the instance's
details.details["constraint"]is"duplicate","cardinality"or"nesting", beside the edge type and the items or end concerned. - An
EdgeEditchanges an edge's properties, moves one end with.source(id)or.target(id), or both. A move is refusedvalidationwhen the end that stays can hold more than one edge of the type, andedge_cyclewhen the edge would point to itself; otherwise it's judged as a new edge. An edit that changes nothing returnsnil. Once the instance accepts a source move, the copy expires. - Add several tags with one call each. A call that would give an item more than 100 tags is refused
validation_error, and the tags added before it stay queued.
Names and previews
items.displays(of:) gives each item's ItemDisplay: its name and a preview of its body field with the Markdown removed, up to 240 characters. displays(for:) takes ItemDisplayInput values for an item not yet saved. Marfa's core works both out from the copy's catalog, by the rule in Names and previews, so every app names an item alike. Where the instance would say Untitled, name is nil, and the app shows its own words.
let notes = try await copy.items.list(ListFilters(types: ["core.note"]))
let displays = try await copy.items.displays(of: notes.rows)
for display in displays {
print(display.name ?? "Untitled", display.preview ?? "")
}Links in a body
for link in linkParts(in: "See [[Reading list#Essays|my essays]].") {
print(link.name.text, link.heading?.text ?? "-", link.alias?.text ?? "-")
}
// Reading list Essays my essayslinkParts(in:)splits every[[link]]in a text into its name, heading and alias, each with its range in UTF-16 code units, so a text view takes them as they are. It needs no copy, so an editor can call it on every change.- A link runs from the last
[[before its]]on the same line. An embed, blank brackets and a link in code or a comment aren't links, and a backslash stays as typed: Links in a note's body has the rules. items.linkCandidates(of:in:)says what each target could name, from the copy alone:boundto one item,ambiguousorunresolved, with the candidates. An item in the bin is never a candidate. Given an item, it reads each target as a link in that item's body.items.links(in:)answers for a saved body. Each link names its item, or says why it names none:pendinguntil the instance's lookup answers,missing,ambiguousorrefused.