beatrina 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
# journal.R — the durable document journal, answered by the SUPERVISOR.
|
|
2
|
+
#
|
|
3
|
+
# The page's history engine (lib/history/transaction-engine.js) accepts edits
|
|
4
|
+
# in memory; lib/history/journal.js turns each accepted transaction into a
|
|
5
|
+
# RECORD and hands ordered batches here. This file is the store: an append-only
|
|
6
|
+
# NDJSON journal plus a full checkpoint per document, under the user's own
|
|
7
|
+
# app-data directory, 0700/0600. It never evaluates anything and never reads a
|
|
8
|
+
# document's text for any purpose but writing it back.
|
|
9
|
+
#
|
|
10
|
+
# WHY THE SUPERVISOR AND NOT A FIFTH CHILD. The plan (robust.md B1) asked for a
|
|
11
|
+
# persistence service independent of the R worker. The supervisor already IS
|
|
12
|
+
# that for file ops (fileio.R, docs/document-model-plan.md D3): it evaluates
|
|
13
|
+
# no user code, it is never behind a running cell, and a 20 MB PUT measured
|
|
14
|
+
# 97 ms end to end in its loop. A journal append is a few hundred bytes; a
|
|
15
|
+
# checkpoint is the document plus its Undo stacks, single-digit megabytes at
|
|
16
|
+
# the outside. Spawning a fourth R process to write a kilobyte would buy
|
|
17
|
+
# nothing but a queue. Every function here is PURE in fileio.R's sense —
|
|
18
|
+
# takes a request, touches disk, RETURNS the frame — so if a measurement ever
|
|
19
|
+
# says otherwise, moving them into a child is a routing change.
|
|
20
|
+
#
|
|
21
|
+
# ── the on-disk shape ──────────────────────────────────────────────────────
|
|
22
|
+
#
|
|
23
|
+
# <root>/<key>/ key = the document's durable key, ':' → '__'
|
|
24
|
+
# journal.ndjson one record per line: "<bytes>\t<json>\n"
|
|
25
|
+
# checkpoint.json the newest checkpoint envelope (a JSON string
|
|
26
|
+
# the page's checkpoint codec wrote; its SHA-256
|
|
27
|
+
# is inside, verified by the page on load)
|
|
28
|
+
# checkpoint.rev the revision of that envelope
|
|
29
|
+
# pins/<rev>.json a named version, with its content
|
|
30
|
+
# meta.json display name, file binding, gaps, updated
|
|
31
|
+
# journal.corrupt-<time> bytes cut from a journal that did not end
|
|
32
|
+
# at a line boundary — kept as evidence
|
|
33
|
+
#
|
|
34
|
+
# The record line carries its own byte length so a torn write (power loss
|
|
35
|
+
# between the write and the fsync the OS never promised) is detected at the
|
|
36
|
+
# next open: the tail is cut at the last complete, contiguous line and the
|
|
37
|
+
# cut bytes are moved aside rather than deleted. Contiguity (each record's
|
|
38
|
+
# baseRevision equals the previous record's revision) is checked on every
|
|
39
|
+
# append; a mismatch is refused as `discontiguous` and the page re-bases with
|
|
40
|
+
# a full checkpoint. Nothing here rewrites an accepted record.
|
|
41
|
+
#
|
|
42
|
+
# What is NOT promised, in words: R has no fsync. `close()` hands the bytes to
|
|
43
|
+
# the operating system; a power cut before the OS writes them loses the tail,
|
|
44
|
+
# and the next open says so. The store is safe against the CarmaR process
|
|
45
|
+
# dying, not against the machine dying.
|
|
46
|
+
|
|
47
|
+
journal_root <- function() {
|
|
48
|
+
root <- Sys.getenv("CARMAR_HISTORY_DIR", "")
|
|
49
|
+
if (!nzchar(root)) {
|
|
50
|
+
state <- Sys.getenv("CARMAR_STATE", "")
|
|
51
|
+
if (!nzchar(state)) state <- tools::R_user_dir("carmar", "data")
|
|
52
|
+
root <- file.path(state, "history")
|
|
53
|
+
}
|
|
54
|
+
root
|
|
55
|
+
}
|
|
56
|
+
JOURNAL_ENABLED <- !identical(Sys.getenv("CARMAR_NO_JOURNAL", ""), "1")
|
|
57
|
+
# Per-document byte budget for the journal file. Above it, records at or
|
|
58
|
+
# below the newest checkpoint are compacted away (the checkpoint holds the
|
|
59
|
+
# state and both Undo stacks; pins hold their own content), never the tail.
|
|
60
|
+
journal_budget <- function() {
|
|
61
|
+
v <- suppressWarnings(as.numeric(Sys.getenv("CARMAR_HISTORY_BUDGET", "")))
|
|
62
|
+
if (!length(v) || is.na(v) || v < 65536) 64 * 1024 * 1024 else v
|
|
63
|
+
}
|
|
64
|
+
JOURNAL_KEY_RE <- "^[a-z]+:[A-Za-z0-9._~-]{1,200}$"
|
|
65
|
+
JOURNAL_MAX_REPLY_BYTES <- 6e6 # under the 8 MB frame cap, with room
|
|
66
|
+
|
|
67
|
+
journal_valid_key <- function(key) is.character(key) && length(key) == 1L && !is.na(key) && grepl(JOURNAL_KEY_RE, key)
|
|
68
|
+
|
|
69
|
+
journal_dir <- function(key) file.path(journal_root(), sub(":", "__", key, fixed = TRUE))
|
|
70
|
+
|
|
71
|
+
#' A directory only this user may enter; created on first use.
|
|
72
|
+
journal_ensure_dir <- function(path) {
|
|
73
|
+
if (!dir.exists(path)) {
|
|
74
|
+
dir.create(path, recursive = TRUE, showWarnings = FALSE)
|
|
75
|
+
if (!dir.exists(path)) stop("could not create the history directory")
|
|
76
|
+
}
|
|
77
|
+
Sys.chmod(path, "0700")
|
|
78
|
+
invisible(path)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
journal_error <- function(type, id, code, message, ...) {
|
|
82
|
+
list(type = type, id = id, error = message, code = code, ...)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# ── the journal file ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
#' Every complete, contiguous record line, with the byte offset where the
|
|
88
|
+
#' verified prefix ends. Reads the whole file: the tail after a checkpoint is
|
|
89
|
+
#' small by design (checkpoints every 40 edits), and a large one means the
|
|
90
|
+
#' page could not checkpoint for a long time, which `open` reports.
|
|
91
|
+
journal_scan <- function(path) {
|
|
92
|
+
empty <- list(records = character(), revisions = integer(), offsets = integer(), end = 0L, last = NA_integer_, torn = FALSE)
|
|
93
|
+
if (!file.exists(path)) return(empty)
|
|
94
|
+
size <- file.info(path)$size
|
|
95
|
+
if (is.na(size) || size == 0) return(empty)
|
|
96
|
+
raw <- readBin(path, "raw", n = size)
|
|
97
|
+
tab <- as.raw(9L); nl <- as.raw(10L)
|
|
98
|
+
records <- character(); revs <- integer(); offsets <- integer()
|
|
99
|
+
pos <- 1L; total <- length(raw); torn <- FALSE; last <- NA_integer_
|
|
100
|
+
# Byte scanning on purpose: the length prefix counts BYTES (that is what a
|
|
101
|
+
# torn write leaves), so character-indexed substring() would drift on the
|
|
102
|
+
# first multi-byte record. A while loop is the honest shape of "walk the
|
|
103
|
+
# buffer until it stops being well-formed".
|
|
104
|
+
while (pos <= total) {
|
|
105
|
+
t <- pos
|
|
106
|
+
while (t <= total && t < pos + 12L && raw[t] != tab) t <- t + 1L
|
|
107
|
+
if (t > total || raw[t] != tab) { torn <- TRUE; break }
|
|
108
|
+
len <- suppressWarnings(as.integer(rawToChar(raw[pos:(t - 1L)])))
|
|
109
|
+
if (is.na(len) || len < 2L) { torn <- TRUE; break }
|
|
110
|
+
start <- t + 1L; end <- start + len - 1L
|
|
111
|
+
if (end + 1L > total || raw[end + 1L] != nl) { torn <- TRUE; break }
|
|
112
|
+
# rawToChar refuses an embedded NUL (a zeroed sector reads as one): that is
|
|
113
|
+
# corruption, and corruption ends the verified prefix rather than the scan.
|
|
114
|
+
json <- tryCatch(rawToChar(raw[start:end]), error = function(e) NULL)
|
|
115
|
+
if (is.null(json)) { torn <- TRUE; break }
|
|
116
|
+
Encoding(json) <- "UTF-8"
|
|
117
|
+
parsed <- tryCatch(jsonlite::fromJSON(json, simplifyVector = FALSE), error = function(e) NULL)
|
|
118
|
+
if (is.null(parsed) || !is.numeric(parsed$revision) || !is.numeric(parsed$baseRevision)
|
|
119
|
+
|| (!is.na(last) && parsed$baseRevision != last)) { torn <- TRUE; break }
|
|
120
|
+
records[[length(records) + 1L]] <- json
|
|
121
|
+
revs[[length(revs) + 1L]] <- as.integer(parsed$revision)
|
|
122
|
+
offsets[[length(offsets) + 1L]] <- pos
|
|
123
|
+
last <- as.integer(parsed$revision)
|
|
124
|
+
pos <- end + 2L
|
|
125
|
+
}
|
|
126
|
+
list(records = records, revisions = revs, offsets = offsets, end = pos - 1L, last = last, torn = torn)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#' Cut a torn tail at the last complete record, keeping the cut bytes.
|
|
130
|
+
journal_repair <- function(path, scan) {
|
|
131
|
+
if (!scan$torn) return(FALSE)
|
|
132
|
+
size <- file.info(path)$size
|
|
133
|
+
if (is.na(size) || scan$end >= size) return(FALSE)
|
|
134
|
+
raw <- readBin(path, "raw", n = size)
|
|
135
|
+
evidence <- paste0(path, ".corrupt-", format(Sys.time(), "%Y%m%d-%H%M%S"))
|
|
136
|
+
writeBin(raw[(scan$end + 1L):size], evidence)
|
|
137
|
+
Sys.chmod(evidence, "0600")
|
|
138
|
+
writeBin(raw[seq_len(scan$end)], path)
|
|
139
|
+
Sys.chmod(path, "0600")
|
|
140
|
+
TRUE
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
journal_write_private <- function(path, text) {
|
|
144
|
+
con <- file(path, open = "wb")
|
|
145
|
+
on.exit(close(con), add = TRUE)
|
|
146
|
+
writeBin(charToRaw(enc2utf8(text)), con)
|
|
147
|
+
close(con); on.exit()
|
|
148
|
+
if (!isTRUE(Sys.chmod(path, "0600"))) stop("could not make the history file private")
|
|
149
|
+
invisible(TRUE)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#' Atomic replace within the same directory: write a sibling, rename over.
|
|
153
|
+
journal_replace <- function(path, text) {
|
|
154
|
+
temp <- tempfile(pattern = ".carmar-journal-", tmpdir = dirname(path))
|
|
155
|
+
journal_write_private(temp, text)
|
|
156
|
+
if (!isTRUE(suppressWarnings(file.rename(temp, path)))) {
|
|
157
|
+
unlink(temp)
|
|
158
|
+
stop("could not replace the history file atomically; the previous one was left unchanged")
|
|
159
|
+
}
|
|
160
|
+
invisible(TRUE)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
journal_read_meta <- function(dir) {
|
|
164
|
+
path <- file.path(dir, "meta.json")
|
|
165
|
+
if (!file.exists(path)) return(list(schema = 1L, gaps = list(), pins = list()))
|
|
166
|
+
meta <- tryCatch(jsonlite::fromJSON(path, simplifyVector = FALSE), error = function(e) NULL)
|
|
167
|
+
if (!is.list(meta)) list(schema = 1L, gaps = list(), pins = list()) else meta
|
|
168
|
+
}
|
|
169
|
+
journal_write_meta <- function(dir, meta) {
|
|
170
|
+
meta$updated <- as.numeric(Sys.time()) * 1000
|
|
171
|
+
journal_replace(file.path(dir, "meta.json"),
|
|
172
|
+
as.character(jsonlite::toJSON(meta, auto_unbox = TRUE, null = "null", digits = NA)))
|
|
173
|
+
}
|
|
174
|
+
journal_checkpoint_revision <- function(dir) {
|
|
175
|
+
path <- file.path(dir, "checkpoint.rev")
|
|
176
|
+
if (!file.exists(path)) return(NA_integer_)
|
|
177
|
+
v <- suppressWarnings(as.integer(readLines(path, warn = FALSE)[1L]))
|
|
178
|
+
if (length(v) != 1L || is.na(v)) NA_integer_ else v
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#' The store's view of one document: scanned, repaired if torn, cached.
|
|
182
|
+
#' `sockets` is the supervisor's; tests pass their own environment.
|
|
183
|
+
journal_state <- function(key, cache = journal_cache) {
|
|
184
|
+
st <- cache[[key]]
|
|
185
|
+
dir <- journal_dir(key)
|
|
186
|
+
if (!is.null(st) && identical(st$dir, dir)) return(st)
|
|
187
|
+
journal_ensure_dir(dir)
|
|
188
|
+
path <- file.path(dir, "journal.ndjson")
|
|
189
|
+
scan <- journal_scan(path)
|
|
190
|
+
repaired <- journal_repair(path, scan)
|
|
191
|
+
cp <- journal_checkpoint_revision(dir)
|
|
192
|
+
st <- new.env(parent = emptyenv())
|
|
193
|
+
st$dir <- dir; st$path <- path
|
|
194
|
+
st$last <- if (!is.na(scan$last)) scan$last else if (!is.na(cp)) cp else NA_integer_
|
|
195
|
+
st$checkpoint <- cp
|
|
196
|
+
st$repaired <- repaired
|
|
197
|
+
st$bytes <- if (file.exists(path)) file.info(path)$size else 0
|
|
198
|
+
cache[[key]] <- st
|
|
199
|
+
st
|
|
200
|
+
}
|
|
201
|
+
journal_cache <- new.env(parent = emptyenv())
|
|
202
|
+
|
|
203
|
+
# ── the ops ────────────────────────────────────────────────────────────────
|
|
204
|
+
# Each returns a FRAME. Records travel as VERBATIM JSON STRINGS in both
|
|
205
|
+
# directions: the page serialised each one and signed it (sha), and re-encoding
|
|
206
|
+
# through jsonlite would collapse `abandoned: ["x"]` to `"x"` and
|
|
207
|
+
# `operations: [op]` to `op` (auto_unbox) — the exact fidelity bug relay_frame
|
|
208
|
+
# exists to prevent. `journal_frame()` splices them into the reply by hand.
|
|
209
|
+
|
|
210
|
+
journal_open <- function(id, key = NULL, meta = NULL, takeover = FALSE) {
|
|
211
|
+
if (!JOURNAL_ENABLED) return(journal_error("journal_open", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1)."))
|
|
212
|
+
if (!journal_valid_key(key)) return(journal_error("journal_open", id, "invalid_key", "A journal needs a durable document key."))
|
|
213
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
214
|
+
if (inherits(st, "error")) return(journal_error("journal_open", id, "io", conditionMessage(st)))
|
|
215
|
+
stored <- journal_read_meta(st$dir)
|
|
216
|
+
if (is.list(meta) && length(meta)) {
|
|
217
|
+
stored$display <- meta
|
|
218
|
+
tryCatch(journal_write_meta(st$dir, stored), error = function(e) NULL)
|
|
219
|
+
}
|
|
220
|
+
# ADVISORY lease: which live process last opened this key. Two windows on
|
|
221
|
+
# one file would otherwise interleave records into one journal. The holder
|
|
222
|
+
# is reported so the page can decline to write; a dead pid is not a holder.
|
|
223
|
+
# This is disclosure, not a fence — writer authority is stage C's.
|
|
224
|
+
lease <- journal_lease(st$dir, key, takeover)
|
|
225
|
+
if (!is.null(lease$error)) return(journal_error("journal_open", id, "io", lease$error))
|
|
226
|
+
list(type = "journal_open", id = id, key = key, heldBy = lease$held, epoch = lease$epoch, takenOver = isTRUE(lease$takenOver),
|
|
227
|
+
exists = !is.na(st$last) || !is.na(st$checkpoint),
|
|
228
|
+
checkpointRevision = if (is.na(st$checkpoint)) NULL else st$checkpoint,
|
|
229
|
+
durableRevision = if (is.na(st$last)) NULL else st$last,
|
|
230
|
+
repaired = isTRUE(st$repaired), bytes = st$bytes, budget = journal_budget(),
|
|
231
|
+
pins = journal_pins(st$dir), meta = stored$display %||% NULL, gaps = stored$gaps %||% list())
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#' READ-ONLY: does the store keep a history for `key`, from what PATH was it
|
|
235
|
+
#' last saved, and does THAT path still exist on disk? The page asks this to
|
|
236
|
+
#' tell a copied file from a moved one (stage C1): a file carrying an embedded
|
|
237
|
+
#' id whose stored path differs from where it just opened is a COPY when the
|
|
238
|
+
#' old path still exists, a MOVE when it is gone. Unlike `journal_open` this
|
|
239
|
+
#' takes no lease and CREATES NOTHING — a probe of an unknown key must not
|
|
240
|
+
#' leave an empty history directory behind, so it never calls `journal_state`.
|
|
241
|
+
journal_probe <- function(id, key = NULL) {
|
|
242
|
+
if (!JOURNAL_ENABLED) return(journal_error("journal_probe", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1)."))
|
|
243
|
+
if (!journal_valid_key(key)) return(journal_error("journal_probe", id, "invalid_key", "A journal needs a durable document key."))
|
|
244
|
+
dir <- journal_dir(key)
|
|
245
|
+
if (!dir.exists(dir)) return(list(type = "journal_probe", id = id, key = key, exists = FALSE, path = NULL, pathExists = FALSE))
|
|
246
|
+
stored <- journal_read_meta(dir)
|
|
247
|
+
cp <- journal_checkpoint_revision(dir)
|
|
248
|
+
exists <- file.exists(file.path(dir, "journal.ndjson")) || !is.na(cp)
|
|
249
|
+
path <- stored$display$path
|
|
250
|
+
ok_path <- is.character(path) && length(path) == 1L && nzchar(path)
|
|
251
|
+
list(type = "journal_probe", id = id, key = key, exists = isTRUE(exists),
|
|
252
|
+
path = if (ok_path) path else NULL,
|
|
253
|
+
pathExists = isTRUE(ok_path && file.exists(path)))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#' The writer lease: WHO may append, decided on disk, fenced by an epoch.
|
|
257
|
+
#'
|
|
258
|
+
#' `open` issues a lease `{pid, port, epoch, at}` and returns the epoch; every
|
|
259
|
+
#' append and checkpoint must carry it, and a write whose epoch is not the
|
|
260
|
+
#' current one is refused as `fenced` — the old writer learns it lost the
|
|
261
|
+
#' document rather than interleaving into it. A LIVE other holder (its pid
|
|
262
|
+
#' answers signal 0) is reported, and the caller gets no epoch unless it asks
|
|
263
|
+
#' for a `takeover`, which bumps the epoch: the previous holder's next write is
|
|
264
|
+
#' then fenced, atomically, by the rename that replaced the lease file. A
|
|
265
|
+
#' dead holder is simply replaced. The epoch is monotone per key and comes
|
|
266
|
+
#' from the lease file itself, so a restarted supervisor cannot reissue one.
|
|
267
|
+
#'
|
|
268
|
+
#' Two supervisors on one machine see the same file: this is the cross-process
|
|
269
|
+
#' authority robust.md stage C asks for, at the journal. The FILE save has its
|
|
270
|
+
#' own guard (`expected` bytes, fileio.R); the two are deliberately separate.
|
|
271
|
+
journal_lease <- function(dir, key, takeover = FALSE) {
|
|
272
|
+
path <- file.path(dir, "lease.json")
|
|
273
|
+
prior <- if (file.exists(path)) tryCatch(jsonlite::fromJSON(path, simplifyVector = FALSE), error = function(e) NULL) else NULL
|
|
274
|
+
me_pid <- Sys.getpid()
|
|
275
|
+
alive <- is.list(prior) && is.numeric(prior$pid) && prior$pid != me_pid &&
|
|
276
|
+
isTRUE(tryCatch(tools::pskill(as.integer(prior$pid), 0L), error = function(e) FALSE))
|
|
277
|
+
if (alive && !isTRUE(takeover)) return(list(held = list(pid = as.integer(prior$pid), port = prior$port, epoch = prior$epoch), epoch = NULL))
|
|
278
|
+
epoch <- if (is.list(prior) && is.numeric(prior$epoch)) as.integer(prior$epoch) + 1L else 1L
|
|
279
|
+
me <- list(pid = me_pid, port = as.integer(Sys.getenv("CARMAR_PORT", "0")), epoch = epoch, at = as.numeric(Sys.time()) * 1000)
|
|
280
|
+
ok <- tryCatch({ journal_replace(path, as.character(jsonlite::toJSON(me, auto_unbox = TRUE, digits = NA))); TRUE }, error = function(e) FALSE)
|
|
281
|
+
if (!ok) return(list(held = NULL, epoch = NULL, error = "The writer lease could not be written."))
|
|
282
|
+
list(held = NULL, epoch = epoch, takenOver = alive)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#' Is `epoch` the current lease for this key? Read from disk on every write:
|
|
286
|
+
#' the lease file is the authority, not this process's memory of it.
|
|
287
|
+
journal_lease_current <- function(dir, epoch) {
|
|
288
|
+
if (!is.numeric(epoch) || length(epoch) != 1L || is.na(epoch)) return(FALSE)
|
|
289
|
+
prior <- journal_lease_read(dir)
|
|
290
|
+
is.list(prior) && is.numeric(prior$epoch) && as.integer(prior$epoch) == as.integer(epoch)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#' The lease on disk, or NULL when the key has none (never opened, or its
|
|
294
|
+
#' folder was archived — the lease went with it).
|
|
295
|
+
journal_lease_read <- function(dir) {
|
|
296
|
+
path <- file.path(dir, "lease.json")
|
|
297
|
+
if (!file.exists(path)) return(NULL)
|
|
298
|
+
tryCatch(jsonlite::fromJSON(path, simplifyVector = FALSE), error = function(e) NULL)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#' Why a write is refused, or NULL when the lease is current. Two answers,
|
|
302
|
+
#' because they ask for different things: `unleased` (no epoch at all — this
|
|
303
|
+
#' window never opened the key, or opened it before the kernel was up) is
|
|
304
|
+
#' cured by `journal_open`; `fenced` (an epoch that is no longer current) is
|
|
305
|
+
#' another window's takeover and nothing here cures it.
|
|
306
|
+
journal_lease_refusal <- function(type, id, dir, epoch) {
|
|
307
|
+
if (!is.numeric(epoch) || length(epoch) != 1L || is.na(epoch) || is.null(journal_lease_read(dir)))
|
|
308
|
+
return(journal_error(type, id, "unleased", "This window holds no writer lease for this document; open it first."))
|
|
309
|
+
if (!journal_lease_current(dir, epoch))
|
|
310
|
+
return(journal_error(type, id, "fenced", "Another window took over this document's history. This window no longer writes it."))
|
|
311
|
+
NULL
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#' Save As: the history follows the document to its new key. The old key's
|
|
315
|
+
#' directory is renamed; the old key is kept in `meta.aliases` so a checkpoint
|
|
316
|
+
#' the engine stamped with it still verifies. A history already under the new
|
|
317
|
+
#' key (an earlier document at that path) is set aside as abandoned first —
|
|
318
|
+
#' the file the user just wrote there supersedes it.
|
|
319
|
+
journal_rename <- function(id, from = NULL, to = NULL) {
|
|
320
|
+
if (!journal_valid_key(from) || !journal_valid_key(to)) return(journal_error("journal_rename", id, "invalid_key", "A rename needs two durable document keys."))
|
|
321
|
+
if (identical(from, to)) return(list(type = "journal_rename", id = id, from = from, to = to, renamed = FALSE))
|
|
322
|
+
src <- journal_dir(from); dst <- journal_dir(to)
|
|
323
|
+
if (!dir.exists(src)) return(journal_error("journal_rename", id, "missing", "There is no history under the old key."))
|
|
324
|
+
for (key in c(from, to)) if (exists(key, envir = journal_cache, inherits = FALSE)) rm(list = key, envir = journal_cache)
|
|
325
|
+
if (dir.exists(dst)) {
|
|
326
|
+
aside <- paste0(dst, ".abandoned-", format(Sys.time(), "%Y%m%d-%H%M%S"))
|
|
327
|
+
if (!isTRUE(suppressWarnings(file.rename(dst, aside)))) return(journal_error("journal_rename", id, "io", "The history already at the new key could not be set aside."))
|
|
328
|
+
}
|
|
329
|
+
if (!isTRUE(suppressWarnings(file.rename(src, dst)))) return(journal_error("journal_rename", id, "io", "The history could not be moved to its new key."))
|
|
330
|
+
meta <- journal_read_meta(dst)
|
|
331
|
+
meta$aliases <- unique(c(meta$aliases %||% list(), list(from)))
|
|
332
|
+
tryCatch(journal_write_meta(dst, meta), error = function(e) NULL)
|
|
333
|
+
list(type = "journal_rename", id = id, from = from, to = to, renamed = TRUE, aliases = meta$aliases)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#' Set aside a document's whole history (the user declined to recover it):
|
|
337
|
+
#' the directory is renamed, listed as abandoned, deletable from Settings.
|
|
338
|
+
journal_archive <- function(id, key = NULL) {
|
|
339
|
+
if (!journal_valid_key(key)) return(journal_error("journal_archive", id, "invalid_key", "A journal needs a durable document key."))
|
|
340
|
+
dir <- journal_dir(key)
|
|
341
|
+
if (exists(key, envir = journal_cache, inherits = FALSE)) rm(list = key, envir = journal_cache)
|
|
342
|
+
if (!dir.exists(dir)) return(list(type = "journal_archive", id = id, key = key, archived = FALSE))
|
|
343
|
+
to <- paste0(dir, ".abandoned-", format(Sys.time(), "%Y%m%d-%H%M%S"))
|
|
344
|
+
ok <- isTRUE(suppressWarnings(file.rename(dir, to)))
|
|
345
|
+
if (!ok) return(journal_error("journal_archive", id, "io", "The history could not be set aside."))
|
|
346
|
+
list(type = "journal_archive", id = id, key = key, archived = TRUE)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
journal_append <- function(id, key = NULL, records = NULL, epoch = NULL) {
|
|
350
|
+
if (!JOURNAL_ENABLED) return(journal_error("journal_append", id, "unavailable", "The document journal is disabled."))
|
|
351
|
+
if (!journal_valid_key(key)) return(journal_error("journal_append", id, "invalid_key", "A journal needs a durable document key."))
|
|
352
|
+
refused <- journal_lease_refusal("journal_append", id, journal_dir(key), epoch)
|
|
353
|
+
if (!is.null(refused)) return(refused)
|
|
354
|
+
records <- as.list(records)
|
|
355
|
+
if (!length(records) || !all(vapply(records, function(r) is.character(r) && length(r) == 1L && nzchar(r), logical(1))))
|
|
356
|
+
return(journal_error("journal_append", id, "invalid_record", "Append needs one or more serialised records."))
|
|
357
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
358
|
+
if (inherits(st, "error")) return(journal_error("journal_append", id, "io", conditionMessage(st)))
|
|
359
|
+
# Validate the WHOLE batch before writing a byte of it.
|
|
360
|
+
last <- st$last
|
|
361
|
+
lines <- character(length(records))
|
|
362
|
+
for (i in seq_along(records)) {
|
|
363
|
+
json <- records[[i]]
|
|
364
|
+
parsed <- tryCatch(jsonlite::fromJSON(json, simplifyVector = FALSE), error = function(e) NULL)
|
|
365
|
+
if (is.null(parsed) || !identical(parsed$key, key) || !is.numeric(parsed$revision)
|
|
366
|
+
|| !is.numeric(parsed$baseRevision) || parsed$revision != parsed$baseRevision + 1
|
|
367
|
+
|| !is.character(parsed$sha) || !grepl("^[a-f0-9]{64}$", parsed$sha))
|
|
368
|
+
return(journal_error("journal_append", id, "invalid_record", "A record is malformed or belongs to another document."))
|
|
369
|
+
if (!is.na(last) && parsed$baseRevision != last) {
|
|
370
|
+
# An exact retry of the record already at the tail is acknowledged, not
|
|
371
|
+
# refused: the ack for it may have been lost, and the page must be able
|
|
372
|
+
# to ask again with the same bytes.
|
|
373
|
+
if (!is.na(last) && parsed$revision == last && i == 1L && length(records) == 1L
|
|
374
|
+
&& identical(journal_tail_json(st), json)) {
|
|
375
|
+
return(list(type = "journal_append", id = id, key = key, durableRevision = last, retried = TRUE))
|
|
376
|
+
}
|
|
377
|
+
return(journal_error("journal_append", id, "discontiguous",
|
|
378
|
+
sprintf("The journal ends at revision %d, not %d.", last, as.integer(parsed$baseRevision)),
|
|
379
|
+
lastRevision = last))
|
|
380
|
+
}
|
|
381
|
+
if (is.na(last) && parsed$baseRevision != 0 && is.na(st$checkpoint))
|
|
382
|
+
return(journal_error("journal_append", id, "discontiguous", "An empty journal starts at revision 0.", lastRevision = 0L))
|
|
383
|
+
lines[[i]] <- paste0(nchar(json, type = "bytes"), "\t", json, "\n")
|
|
384
|
+
last <- as.integer(parsed$revision)
|
|
385
|
+
}
|
|
386
|
+
added <- sum(nchar(lines, type = "bytes"))
|
|
387
|
+
if (st$bytes + added > journal_budget() * 2) {
|
|
388
|
+
return(journal_error("journal_append", id, "quota",
|
|
389
|
+
sprintf("This document's history has reached its %d MB budget; checkpoint to compact it.", as.integer(journal_budget() / 1048576))))
|
|
390
|
+
}
|
|
391
|
+
ok <- tryCatch({
|
|
392
|
+
con <- file(st$path, open = "ab")
|
|
393
|
+
on.exit(close(con), add = TRUE)
|
|
394
|
+
writeBin(charToRaw(enc2utf8(paste0(lines, collapse = ""))), con)
|
|
395
|
+
close(con); on.exit()
|
|
396
|
+
Sys.chmod(st$path, "0600")
|
|
397
|
+
TRUE
|
|
398
|
+
}, error = function(e) conditionMessage(e))
|
|
399
|
+
if (!isTRUE(ok)) return(journal_error("journal_append", id, "io", ok))
|
|
400
|
+
st$last <- last
|
|
401
|
+
st$bytes <- st$bytes + added
|
|
402
|
+
list(type = "journal_append", id = id, key = key, durableRevision = last)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#' The JSON of the last record, for the exact-retry rule.
|
|
406
|
+
journal_tail_json <- function(st) {
|
|
407
|
+
scan <- journal_scan(st$path)
|
|
408
|
+
if (!length(scan$records)) return(NULL)
|
|
409
|
+
scan$records[[length(scan$records)]]
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
journal_checkpoint <- function(id, key = NULL, revision = NULL, generation = NULL, envelope = NULL, meta = NULL, rebase = FALSE, epoch = NULL) {
|
|
413
|
+
if (!JOURNAL_ENABLED) return(journal_error("journal_checkpoint", id, "unavailable", "The document journal is disabled."))
|
|
414
|
+
if (!journal_valid_key(key)) return(journal_error("journal_checkpoint", id, "invalid_key", "A journal needs a durable document key."))
|
|
415
|
+
refused <- journal_lease_refusal("journal_checkpoint", id, journal_dir(key), epoch)
|
|
416
|
+
if (!is.null(refused)) return(refused)
|
|
417
|
+
if (!is.numeric(revision) || length(revision) != 1L || is.na(revision) || revision < 0
|
|
418
|
+
|| !is.character(envelope) || length(envelope) != 1L || !nzchar(envelope))
|
|
419
|
+
return(journal_error("journal_checkpoint", id, "invalid_checkpoint", "A checkpoint needs a revision and its envelope."))
|
|
420
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
421
|
+
if (inherits(st, "error")) return(journal_error("journal_checkpoint", id, "io", conditionMessage(st)))
|
|
422
|
+
revision <- as.integer(revision)
|
|
423
|
+
tail_end <- if (!is.na(st$last)) st$last else if (!is.na(st$checkpoint)) st$checkpoint else 0L
|
|
424
|
+
if (revision < tail_end && !isTRUE(rebase))
|
|
425
|
+
return(journal_error("journal_checkpoint", id, "stale_checkpoint",
|
|
426
|
+
sprintf("The journal is already at revision %d.", tail_end), lastRevision = tail_end))
|
|
427
|
+
if (revision < tail_end) {
|
|
428
|
+
# A RE-BASE: the page's history no longer follows this tail (a recovery it
|
|
429
|
+
# could not replay, an opening it declined). The records above `revision`
|
|
430
|
+
# are not deleted — they are moved aside as evidence, exactly like a torn
|
|
431
|
+
# tail — and the journal continues from the checkpoint.
|
|
432
|
+
cut <- tryCatch(journal_supersede(st, revision), error = function(e) conditionMessage(e))
|
|
433
|
+
if (is.character(cut)) return(journal_error("journal_checkpoint", id, "io", cut))
|
|
434
|
+
}
|
|
435
|
+
stored <- journal_read_meta(st$dir)
|
|
436
|
+
ok <- tryCatch({
|
|
437
|
+
journal_replace(file.path(st$dir, "checkpoint.json"), envelope)
|
|
438
|
+
journal_replace(file.path(st$dir, "checkpoint.rev"), paste0(revision, "\n"))
|
|
439
|
+
TRUE
|
|
440
|
+
}, error = function(e) conditionMessage(e))
|
|
441
|
+
if (!isTRUE(ok)) return(journal_error("journal_checkpoint", id, "io", ok))
|
|
442
|
+
gap <- NULL
|
|
443
|
+
if (revision > tail_end) {
|
|
444
|
+
# Records between the tail and this checkpoint never reached the store
|
|
445
|
+
# (an outage, an overflow, another opening). Say so, durably.
|
|
446
|
+
gap <- list(from = tail_end, to = revision, at = as.numeric(Sys.time()) * 1000)
|
|
447
|
+
stored$gaps <- c(stored$gaps %||% list(), list(gap))
|
|
448
|
+
}
|
|
449
|
+
if (is.list(meta) && length(meta)) stored$display <- meta
|
|
450
|
+
tryCatch(journal_write_meta(st$dir, stored), error = function(e) NULL)
|
|
451
|
+
st$checkpoint <- revision
|
|
452
|
+
st$last <- revision
|
|
453
|
+
pruned <- journal_compact(st, revision)
|
|
454
|
+
list(type = "journal_checkpoint", id = id, key = key, checkpointRevision = revision,
|
|
455
|
+
prunedBytes = pruned, gap = gap)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#' Move every record above `revision` into a superseded-evidence file.
|
|
459
|
+
journal_supersede <- function(st, revision) {
|
|
460
|
+
scan <- journal_scan(st$path)
|
|
461
|
+
keep <- scan$revisions <= revision
|
|
462
|
+
if (all(keep)) return(FALSE)
|
|
463
|
+
gone <- scan$records[!keep]
|
|
464
|
+
evidence <- paste0(st$path, ".superseded-", format(Sys.time(), "%Y%m%d-%H%M%S"))
|
|
465
|
+
journal_write_private(evidence, paste0(nchar(gone, type = "bytes"), "\t", gone, "\n", collapse = ""))
|
|
466
|
+
kept <- scan$records[keep]
|
|
467
|
+
journal_replace(st$path, if (length(kept)) paste0(nchar(kept, type = "bytes"), "\t", kept, "\n", collapse = "") else "")
|
|
468
|
+
st$bytes <- file.info(st$path)$size
|
|
469
|
+
st$last <- if (length(kept)) max(scan$revisions[keep]) else NA_integer_
|
|
470
|
+
TRUE
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#' Drop records at or below the checkpoint once the file is over budget.
|
|
474
|
+
#' Never a byte of the tail; pins carry their own content.
|
|
475
|
+
journal_compact <- function(st, checkpoint) {
|
|
476
|
+
if (st$bytes <= journal_budget()) return(0)
|
|
477
|
+
scan <- journal_scan(st$path)
|
|
478
|
+
keep <- scan$revisions > checkpoint
|
|
479
|
+
before <- st$bytes
|
|
480
|
+
kept <- scan$records[keep]
|
|
481
|
+
text <- if (length(kept)) paste0(nchar(kept, type = "bytes"), "\t", kept, "\n", collapse = "") else ""
|
|
482
|
+
ok <- tryCatch({ journal_replace(st$path, text); TRUE }, error = function(e) FALSE)
|
|
483
|
+
if (!ok) return(0)
|
|
484
|
+
st$bytes <- nchar(text, type = "bytes")
|
|
485
|
+
before - st$bytes
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#' Everything recovery needs: the checkpoint envelope and the tail after it,
|
|
489
|
+
#' page by page when the tail is long.
|
|
490
|
+
journal_load <- function(id, key = NULL, afterRevision = NULL) {
|
|
491
|
+
if (!JOURNAL_ENABLED) return(journal_error("journal_load", id, "unavailable", "The document journal is disabled."))
|
|
492
|
+
if (!journal_valid_key(key)) return(journal_error("journal_load", id, "invalid_key", "A journal needs a durable document key."))
|
|
493
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
494
|
+
if (inherits(st, "error")) return(journal_error("journal_load", id, "io", conditionMessage(st)))
|
|
495
|
+
cp_path <- file.path(st$dir, "checkpoint.json")
|
|
496
|
+
envelope <- if (is.null(afterRevision) && file.exists(cp_path)) {
|
|
497
|
+
raw <- readBin(cp_path, "raw", n = file.info(cp_path)$size); s <- rawToChar(raw); Encoding(s) <- "UTF-8"; s
|
|
498
|
+
} else NULL
|
|
499
|
+
from <- if (is.numeric(afterRevision)) as.integer(afterRevision) else if (is.na(st$checkpoint)) -1L else st$checkpoint
|
|
500
|
+
scan <- journal_scan(st$path)
|
|
501
|
+
wanted <- which(scan$revisions > from)
|
|
502
|
+
records <- scan$records[wanted]
|
|
503
|
+
# Page the tail under the frame cap; the page asks again from the last one.
|
|
504
|
+
budget <- JOURNAL_MAX_REPLY_BYTES - nchar(envelope %||% "", type = "bytes") - 4096
|
|
505
|
+
sizes <- cumsum(nchar(records, type = "bytes") + 2L)
|
|
506
|
+
fits <- if (length(records)) sizes <= max(budget, 0) else logical(0)
|
|
507
|
+
more <- length(records) > sum(fits)
|
|
508
|
+
records <- records[fits]
|
|
509
|
+
frame <- list(type = "journal_load", id = id, key = key, aliases = journal_read_meta(st$dir)$aliases %||% list(),
|
|
510
|
+
checkpointRevision = if (is.na(st$checkpoint)) NULL else st$checkpoint,
|
|
511
|
+
durableRevision = if (is.na(st$last)) NULL else st$last,
|
|
512
|
+
more = more, repaired = isTRUE(st$repaired),
|
|
513
|
+
gaps = journal_read_meta(st$dir)$gaps %||% list(), pins = journal_pins(st$dir))
|
|
514
|
+
structure(frame, raw = list(records = records, checkpoint = envelope))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#' Records in a revision range, verbatim, for the time machine's walk-back.
|
|
518
|
+
journal_read <- function(id, key = NULL, fromRevision = NULL, toRevision = NULL) {
|
|
519
|
+
if (!journal_valid_key(key)) return(journal_error("journal_read", id, "invalid_key", "A journal needs a durable document key."))
|
|
520
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
521
|
+
if (inherits(st, "error")) return(journal_error("journal_read", id, "io", conditionMessage(st)))
|
|
522
|
+
scan <- journal_scan(st$path)
|
|
523
|
+
lo <- if (is.numeric(fromRevision)) as.integer(fromRevision) else 0L
|
|
524
|
+
hi <- if (is.numeric(toRevision)) as.integer(toRevision) else .Machine$integer.max
|
|
525
|
+
wanted <- which(scan$revisions >= lo & scan$revisions <= hi)
|
|
526
|
+
records <- scan$records[wanted]
|
|
527
|
+
sizes <- cumsum(nchar(records, type = "bytes") + 2L)
|
|
528
|
+
fits <- if (length(records)) sizes <= JOURNAL_MAX_REPLY_BYTES else logical(0)
|
|
529
|
+
frame <- list(type = "journal_read", id = id, key = key, more = length(records) > sum(fits))
|
|
530
|
+
structure(frame, raw = list(records = records[fits]))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#' The time-machine list: one row per record, newest first, bounded.
|
|
534
|
+
journal_versions <- function(id, key = NULL, beforeRevision = NULL, limit = 200L) {
|
|
535
|
+
if (!journal_valid_key(key)) return(journal_error("journal_versions", id, "invalid_key", "A journal needs a durable document key."))
|
|
536
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
537
|
+
if (inherits(st, "error")) return(journal_error("journal_versions", id, "io", conditionMessage(st)))
|
|
538
|
+
scan <- journal_scan(st$path)
|
|
539
|
+
limit <- max(1L, min(as.integer(limit %||% 200L), 1000L))
|
|
540
|
+
before <- if (is.numeric(beforeRevision)) as.integer(beforeRevision) else .Machine$integer.max
|
|
541
|
+
idx <- rev(which(scan$revisions < before))
|
|
542
|
+
idx <- idx[seq_len(min(length(idx), limit))]
|
|
543
|
+
rows <- lapply(idx, function(i) {
|
|
544
|
+
r <- jsonlite::fromJSON(scan$records[[i]], simplifyVector = FALSE)
|
|
545
|
+
list(revision = r$revision, kind = r$kind, id = r$id, at = r$at,
|
|
546
|
+
label = if (identical(r$kind, "edit")) r$entry$label else r$label,
|
|
547
|
+
origin = if (identical(r$kind, "edit")) r$entry$origin else "user",
|
|
548
|
+
generation = r$generation,
|
|
549
|
+
abandoned = if (identical(r$kind, "edit")) length(r$abandoned) else 0L)
|
|
550
|
+
})
|
|
551
|
+
list(type = "journal_versions", id = id, key = key, rows = rows,
|
|
552
|
+
more = length(idx) == limit && length(idx) < length(scan$revisions),
|
|
553
|
+
pins = journal_pins(st$dir), gaps = journal_read_meta(st$dir)$gaps %||% list())
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
journal_pins <- function(dir) {
|
|
557
|
+
path <- file.path(dir, "pins")
|
|
558
|
+
if (!dir.exists(path)) return(list())
|
|
559
|
+
files <- list.files(path, pattern = "^[0-9]+\\.json$", full.names = TRUE)
|
|
560
|
+
pins <- lapply(files, function(f) {
|
|
561
|
+
p <- tryCatch(jsonlite::fromJSON(f, simplifyVector = FALSE), error = function(e) NULL)
|
|
562
|
+
if (is.list(p)) list(revision = p$revision, name = p$name, at = p$at) else NULL
|
|
563
|
+
})
|
|
564
|
+
Filter(Negate(is.null), pins)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#' A named version: the page sends the content it reconstructed for that
|
|
568
|
+
#' revision, so a pin outlives compaction of the records that led to it.
|
|
569
|
+
journal_pin <- function(id, key = NULL, revision = NULL, name = NULL, state = NULL) {
|
|
570
|
+
if (!journal_valid_key(key)) return(journal_error("journal_pin", id, "invalid_key", "A journal needs a durable document key."))
|
|
571
|
+
if (!is.numeric(revision) || length(revision) != 1L || is.na(revision) || revision < 0
|
|
572
|
+
|| !is.character(name) || length(name) != 1L || !nzchar(trimws(name))
|
|
573
|
+
|| !is.character(state) || length(state) != 1L || !nzchar(state))
|
|
574
|
+
return(journal_error("journal_pin", id, "invalid_pin", "A pin needs a revision, a name and the content at that revision."))
|
|
575
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
576
|
+
if (inherits(st, "error")) return(journal_error("journal_pin", id, "io", conditionMessage(st)))
|
|
577
|
+
dir <- file.path(st$dir, "pins")
|
|
578
|
+
ok <- tryCatch({
|
|
579
|
+
journal_ensure_dir(dir)
|
|
580
|
+
body <- list(schema = 1L, revision = as.integer(revision), name = substr(trimws(name), 1L, 120L),
|
|
581
|
+
at = as.numeric(Sys.time()) * 1000)
|
|
582
|
+
head <- as.character(jsonlite::toJSON(body, auto_unbox = TRUE, digits = NA))
|
|
583
|
+
# `state` is the page's JSON, spliced verbatim for the same fidelity reason as records.
|
|
584
|
+
journal_replace(file.path(dir, paste0(as.integer(revision), ".json")),
|
|
585
|
+
paste0(substr(head, 1L, nchar(head) - 1L), ",\"state\":", state, "}"))
|
|
586
|
+
TRUE
|
|
587
|
+
}, error = function(e) conditionMessage(e))
|
|
588
|
+
if (!isTRUE(ok)) return(journal_error("journal_pin", id, "io", ok))
|
|
589
|
+
list(type = "journal_pin", id = id, key = key, revision = as.integer(revision), pins = journal_pins(st$dir))
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
journal_unpin <- function(id, key = NULL, revision = NULL) {
|
|
593
|
+
if (!journal_valid_key(key) || !is.numeric(revision) || length(revision) != 1L)
|
|
594
|
+
return(journal_error("journal_unpin", id, "invalid_pin", "Unpin needs a document key and a revision."))
|
|
595
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
596
|
+
if (inherits(st, "error")) return(journal_error("journal_unpin", id, "io", conditionMessage(st)))
|
|
597
|
+
unlink(file.path(st$dir, "pins", paste0(as.integer(revision), ".json")))
|
|
598
|
+
list(type = "journal_unpin", id = id, key = key, pins = journal_pins(st$dir))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#' The content of one pin, verbatim.
|
|
602
|
+
journal_pin_read <- function(id, key = NULL, revision = NULL) {
|
|
603
|
+
if (!journal_valid_key(key) || !is.numeric(revision) || length(revision) != 1L)
|
|
604
|
+
return(journal_error("journal_pin_read", id, "invalid_pin", "Reading a pin needs a document key and a revision."))
|
|
605
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
606
|
+
if (inherits(st, "error")) return(journal_error("journal_pin_read", id, "io", conditionMessage(st)))
|
|
607
|
+
path <- file.path(st$dir, "pins", paste0(as.integer(revision), ".json"))
|
|
608
|
+
if (!file.exists(path)) return(journal_error("journal_pin_read", id, "missing_pin", "No such named version."))
|
|
609
|
+
raw <- readBin(path, "raw", n = file.info(path)$size); text <- rawToChar(raw); Encoding(text) <- "UTF-8"
|
|
610
|
+
structure(list(type = "journal_pin_read", id = id, key = key, revision = as.integer(revision)), raw = list(pin = text))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#' Every document the store knows, for the recovery list and Settings.
|
|
614
|
+
journal_list <- function(id) {
|
|
615
|
+
if (!JOURNAL_ENABLED) return(list(type = "journal_list", id = id, documents = list(), enabled = FALSE))
|
|
616
|
+
root <- journal_root()
|
|
617
|
+
dirs <- if (dir.exists(root)) list.dirs(root, recursive = FALSE, full.names = TRUE) else character()
|
|
618
|
+
docs <- lapply(dirs, function(dir) {
|
|
619
|
+
name <- basename(dir)
|
|
620
|
+
abandoned <- grepl("\\.abandoned-[0-9-]+$", name)
|
|
621
|
+
key <- sub("__", ":", sub("\\.abandoned-[0-9-]+$", "", name), fixed = FALSE)
|
|
622
|
+
key <- sub("__", ":", key, fixed = TRUE)
|
|
623
|
+
if (!journal_valid_key(key)) return(NULL)
|
|
624
|
+
meta <- journal_read_meta(dir)
|
|
625
|
+
path <- file.path(dir, "journal.ndjson")
|
|
626
|
+
list(key = key, dir = name, abandoned = abandoned, meta = meta$display %||% NULL, updated = meta$updated %||% NULL,
|
|
627
|
+
checkpointRevision = { v <- journal_checkpoint_revision(dir); if (is.na(v)) NULL else v },
|
|
628
|
+
bytes = (if (file.exists(path)) file.info(path)$size else 0)
|
|
629
|
+
+ (if (file.exists(file.path(dir, "checkpoint.json"))) file.info(file.path(dir, "checkpoint.json"))$size else 0),
|
|
630
|
+
pins = length(journal_pins(dir)))
|
|
631
|
+
})
|
|
632
|
+
list(type = "journal_list", id = id, enabled = TRUE, root = root, documents = Filter(Negate(is.null), docs))
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#' Deliberate deletion of one document's history. The page confirms first.
|
|
636
|
+
journal_delete <- function(id, key = NULL) {
|
|
637
|
+
if (!journal_valid_key(key)) return(journal_error("journal_delete", id, "invalid_key", "A journal needs a durable document key."))
|
|
638
|
+
dir <- journal_dir(key)
|
|
639
|
+
if (exists(key, envir = journal_cache, inherits = FALSE)) rm(list = key, envir = journal_cache)
|
|
640
|
+
if (dir.exists(dir)) unlink(dir, recursive = TRUE)
|
|
641
|
+
list(type = "journal_delete", id = id, key = key, deleted = !dir.exists(dir))
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
#' The whole history of one document as one JSON text, for the user to keep.
|
|
645
|
+
journal_export <- function(id, key = NULL) {
|
|
646
|
+
if (!journal_valid_key(key)) return(journal_error("journal_export", id, "invalid_key", "A journal needs a durable document key."))
|
|
647
|
+
st <- tryCatch(journal_state(key), error = function(e) e)
|
|
648
|
+
if (inherits(st, "error")) return(journal_error("journal_export", id, "io", conditionMessage(st)))
|
|
649
|
+
scan <- journal_scan(st$path)
|
|
650
|
+
cp_path <- file.path(st$dir, "checkpoint.json")
|
|
651
|
+
envelope <- if (file.exists(cp_path)) { raw <- readBin(cp_path, "raw", n = file.info(cp_path)$size); s <- rawToChar(raw); Encoding(s) <- "UTF-8"; s } else NULL
|
|
652
|
+
head <- list(format = "carmar-history-export", schema = 1L, key = key, exported = as.numeric(Sys.time()) * 1000,
|
|
653
|
+
checkpointRevision = if (is.na(st$checkpoint)) NULL else st$checkpoint, meta = journal_read_meta(st$dir))
|
|
654
|
+
text <- as.character(jsonlite::toJSON(head, auto_unbox = TRUE, null = "null", digits = NA))
|
|
655
|
+
text <- paste0(substr(text, 1L, nchar(text) - 1L),
|
|
656
|
+
",\"checkpoint\":", if (is.null(envelope)) "null" else jsonlite::toJSON(jsonlite::unbox(envelope)),
|
|
657
|
+
",\"records\":[", paste0(scan$records, collapse = ","), "]}")
|
|
658
|
+
if (nchar(text, type = "bytes") > JOURNAL_MAX_REPLY_BYTES)
|
|
659
|
+
return(journal_error("journal_export", id, "too_large", "This history is too large to export over the kernel connection."))
|
|
660
|
+
structure(list(type = "journal_export", id = id, key = key), raw = list(bundle = text))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
# ── the frame writer ───────────────────────────────────────────────────────
|
|
664
|
+
|
|
665
|
+
#' Serialise a journal frame, splicing verbatim JSON fields in by hand.
|
|
666
|
+
#' `attr(frame, "raw")` holds them: `records` (a character vector of JSON
|
|
667
|
+
#' objects) and any scalar JSON text (`checkpoint`, `pin`, `bundle`), which
|
|
668
|
+
#' are emitted as JSON STRINGS (quoted) because the page decodes them itself.
|
|
669
|
+
journal_frame <- function(frame) {
|
|
670
|
+
raw <- attr(frame, "raw")
|
|
671
|
+
attr(frame, "raw") <- NULL
|
|
672
|
+
head <- as.character(jsonlite::toJSON(frame, auto_unbox = TRUE, null = "null", na = "null", digits = NA))
|
|
673
|
+
if (is.null(raw)) return(head)
|
|
674
|
+
extra <- character()
|
|
675
|
+
if (!is.null(raw$records)) extra <- c(extra, paste0("\"records\":[", paste0(raw$records, collapse = ","), "]"))
|
|
676
|
+
for (name in setdiff(names(raw), "records")) {
|
|
677
|
+
value <- raw[[name]]
|
|
678
|
+
extra <- c(extra, paste0("\"", name, "\":", if (is.null(value)) "null" else as.character(jsonlite::toJSON(jsonlite::unbox(value)))))
|
|
679
|
+
}
|
|
680
|
+
if (!length(extra)) return(head)
|
|
681
|
+
paste0(substr(head, 1L, nchar(head) - 1L), if (identical(head, "{}")) "" else ",", paste0(extra, collapse = ","), "}")
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
`%||%` <- function(a, b) if (is.null(a)) b else a
|