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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. 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