Batch execution with zot
guarded-workflow.Rmdzot provides a preview, approval-record, execution,
ledger, and verification sequence for batch plans. This vignette runs
the sequence against an in-memory request performer. It makes no network
requests and changes no Zotero library.
Create a plan
A plan is a data.table with one action per row.
Supported actions are "patch", "create",
"attach", and "trash". Patch and Trash rows
identify an existing item; create rows contain a new-item payload;
attach rows describe a local file to upload as a new attachment.
library(data.table)
#>
#> Attaching package: 'data.table'
#> The following object is masked from 'package:base':
#>
#> %notin%
library(zot)
Plan <- data.table(
action = "patch",
library = "user",
itemKey = "ABCD1234",
payload = list(list(title = "A reviewed title"))
)
Preview <- zotPreview(Plan, describe = "Correct one reviewed title")
Preview
#> <zotPreview> Correct one reviewed title
#> digest: 34f12497d5349ed6e1397c32f5bedfa29625bb9006ad73c5258cb4aa38f0a9b3
#> action library N bytes
#> <char> <char> <int> <num>
#> 1: patch user 1 0zotPreview() validates the required columns and action
values, copies the rows, and records a SHA-256 digest. It does not make
an API request.
Create an approval record
zotApprove() stores the preview digest, optional notes,
and a timestamp.
Approval <- zotApprove(Preview, notes = "Offline documentation example")
identical(Approval$digest, Preview$digest)
#> [1] TRUEThe function records application metadata; it does not authenticate a
person or determine whether execution is authorized. The executor
requires the approval and preview digests to match, then recalculates
the digest from preview$plan. Changing the plan after
approval is refused before any request; the changed plan requires its
own preview and approval.
Execute with a ledger
The performer below models a versioned Zotero item. It supports the GET and PATCH calls used by the example.
Store <- new.env(parent = emptyenv())
Store$version <- 1L
Store$title <- "Original title"
Performer <- function(config, method, path, query, body, version) {
if (method == "GET") {
return(list(
status = 200L,
headers = list(),
body = list(
version = Store$version,
data = list(title = Store$title)
)
))
}
if (method == "PATCH" && identical(as.integer(version), Store$version)) {
Store$title <- body$title
Store$version <- Store$version + 1L
return(list(status = 204L, headers = list(), body = NULL))
}
list(status = 412L, headers = list(), body = NULL)
}
Config <- zotConfig(
userID = "42",
key = "example-key",
performer = Performer
)
LedgerPath <- tempfile(fileext = ".json")
Ledger <- zotExecute(
Config,
preview = Preview,
approval = Approval,
ledgerPath = LedgerPath
)
Ledger$items[["user:ABCD1234"]]$status
#> [1] "done"
Store$title
#> [1] "A reviewed title"For a patch row, zotExecute() reads the current item
version, sends a conditional patch, reads the item again, and compares
the requested fields. A matching result is recorded as
"done". A create first saves its returned key as
"created", then reads that key and confirms the payload
before marking it done. A failed read retains the key and error;
resuming only retries the read, never the POST. A transport exception
during POST leaves "attempt" because the effect is unknown
and requires reconciliation. Trash rows use the status returned by
zotTrash().
An attach row, usually built by ztAttachPlan(), carries
the file’s local path, its md5 and
filesize, the stored filename,
contentType, an optional charset, and
parentItem (NULL for a standalone attachment).
The preview digest covers those fields, so the approval binds the exact
bytes, and counts reports the bytes each library will
receive. Execution moves the row through these states:
| State | Meaning |
|---|---|
"attempt" |
Recorded before the attachment item is posted; if it remains, the POST outcome is unknown and resuming stops for reconciliation. |
"failed" |
Nothing was created: the file’s MD5 differs from the approved one, refused before any request, or Zotero rejected the item. Resuming retries the row. |
"created" |
The attachment item exists and its key is saved; the upload is
pending, failed, or not yet confirmed, with the error
recorded. Resuming checks the file again and repeats the upload on that
key, never posting a second item. |
"done" |
The upload was authorized (an identical stored file counts), sent and registered, and a read of the item confirmed its MD5, filename, content type and parent. |
The upload uses the configuration’s fileTransport, which
tests and examples replace as they replace the request performer.
New ledgers have schema = 2 and identify existing items
by library:key; creates retain
create-<row> for block moves. Legacy ledgers without
a schema keep their key-only format when every id maps to exactly one
plan row. A legacy ledger with the same key in two libraries is refused
without being rewritten. A legacy failed create also needs
reconciliation, because earlier executors could lose its key after the
POST succeeded.
Reusing the ledger path for the same digest skips entries already marked done:
LedgerAgain <- zotExecute(
Config,
preview = Preview,
approval = Approval,
ledgerPath = LedgerPath
)
LedgerAgain$items[["user:ABCD1234"]]$status
#> [1] "done"Sample completed patches
zotVerify() summarizes ledger statuses and re-reads at
most ten completed patch rows. It does not sample create, attach or
Trash rows.
Verification <- zotVerify(Config, preview = Preview, ledger = LedgerAgain)
Verification
#> $counts
#> Statuses
#> done
#> 1
#>
#> $sampleChecked
#> [1] 1
#>
#> $sampleConfirmed
#> [1] 1
#>
#> $unconfirmed
#> character(0)
#>
#> $unfinished
#> character(0)
#>
#> $complete
#> [1] TRUE
unlink(LedgerPath)Missing plan rows are counted as "pending" in
counts and listed in unfinished.
complete is false for unfinished rows or a failed
read-back; a successful sample still does not prove every completed row
was re-read. Use full = TRUE to re-read all completed rows,
attach rows included. Applications should inspect failed entries
separately. Local SQLite results are useful for discovery but do not
replace an API read of remote state.