--- title: "Implementing a custom storage backend" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Implementing a custom storage backend} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` ```{r setup} library(blockr.session) ``` The `blockr.session` package persists a board through a *rack*: a storage abstraction that turns a save into a stored, versioned record and a load back into board state. The shipped backend keeps records as [pins](https://pins.rstudio.com), so on Posit Connect each user reads and writes under their own account and off Connect everything lands in a local pin folder. That default already covers plain on-disk storage: point the `session_mgmt_backend` option at a `pins::board_folder()` or `pins::board_local()` and workflows are saved as files, no extra code. This vignette is about the other case -- storing session state somewhere pins does not reach: a relational database, an object store, a REST service. The rack contract is storage-agnostic, so any of these is a matter of implementing a handful of S3 generics. We build a small file-based backend as a runnable, dependency-free stand-in; the same shape is what a Postgres or PocketBase backend fills in. ## The contract Two kinds of object flow through the rack layer. - A **backend** is the store itself -- the board, database handle or client object. It is the dispatch target for the generics that operate on the store as a whole: `as_rack_id()`, `rack_capabilities()`, `rack_list()`, `rack_upload()` and `rack_find_users()`. - A **`rack_id`** is a handle to one stored record. It is the dispatch target for the generics that read or mutate a single record: `rack_download()`, `rack_exists()`, `rack_info()`, `rack_name()`, `rack_delete()` and the rest. Application code never calls the generics directly. It uses the high-level entry points -- `rack_create()` to insert a new record, `rack_append()` to add a version to an existing one, `rack_load()` to read one back -- and those are built on the generics. For instance, `rack_create()` coerces the board id to a `rack_id` with `as_rack_id()`, checks `rack_exists()`, and stores the payload with `rack_upload()`. Implement the generics and the whole high-level API comes along for free. The two objects are minted with exported constructors, `new_rack_id()` and `new_rack_record()`, each taking a `class` argument so your backend carries its own subclass. That subclass is what the per-record generics dispatch on. See `?"rack-backend"` for the full generic-by-generic reference; the sections below are a worked path through it. ## Capabilities Not every store supports every feature. The `rack_capabilities()` generic is the switch: it returns a named list of logical flags, and the management UI reads them to decide which optional features to offer. | Flag | Turns on | |------|----------| | `versioning` | a per-record version history (`rack_info()`, versioned `rack_download()` / `rack_delete()`) | | `metadata` | a display name (`rack_name()`, `rack_rename()`) and a content hash (`rack_content_hash()`) | | `tags` | tag read/write (`rack_tags()`, `rack_set_tags()`) | | `sharing` | sharing a record with other users (`rack_share()`, `rack_unshare()`, `rack_shares()`) | | `visibility` | access-control levels (`rack_acl()`, `rack_set_acl()`) | | `user_discovery` | looking up users to share with (`rack_find_users()`) | Return `FALSE` for a flag and you can leave the generics behind it unimplemented -- the UI does not surface that feature. A backend that returns `FALSE` for `sharing`, `visibility` and `user_discovery` -- as ours will -- needs none of the sharing, ACL or user-lookup methods, and the navbar simply omits the Sharing tab. ## A file-based backend The store keeps one directory per record under a root. Each record directory holds the version payloads (`1.json`, `2.json`, ...) plus a `meta.json` sidecar carrying the display name and, per version, a timestamp and content hash. ```{r constructor} demo_store <- function(dir = tempfile("demo_store")) { dir.create(dir, showWarnings = FALSE, recursive = TRUE) structure(list(dir = dir), class = c("demo_store", "rack_backend")) } ``` The `rack_backend` class in second position is what matters: it is the ticket past the `session_mgmt_backend` option's validation, which accepts a pins board or anything inheriting `rack_backend`. The first class, `demo_store`, is what the backend generics dispatch on. A few storage helpers keep the methods short. They are plain file I/O -- the part a real backend swaps for SQL or HTTP. ```{r helpers} record_dir <- function(backend, id) { file.path(backend$dir, id) } read_meta <- function(backend, id) { path <- file.path(record_dir(backend, id), "meta.json") if (!file.exists(path)) { return(list(name = id, versions = list())) } jsonlite::read_json(path) } write_meta <- function(backend, id, meta) { jsonlite::write_json( meta, file.path(record_dir(backend, id), "meta.json"), auto_unbox = TRUE, null = "null" ) } ``` ### Store-level generics The `as_rack_id()` generic coerces a raw identifier into the backend's own `rack_id` subclass. The raw identifier is either a component list (`id`, and optionally `version` and `user`, as carried in a session URL) or a `rack_record` listing row; both expose the fields through `$`, so one line covers them. It dispatches on the *backend*, not on `x`. ```{r as-rack-id} as_rack_id.demo_store <- function(x, backend, ...) { new_rack_id(x$id, version = x$version, class = "rack_id_demo") } ``` The `rack_upload()` generic writes a local file as a new version. Both `rack_create()` and `rack_append()` funnel through it, handing over the serialized payload as a temp file plus the record's `id`, its `content_hash`, and -- on a create -- a display `name`. We assign the next integer version, copy the payload in, and record the version in the sidecar. The return value is a `rack_id` pinned to the version just written. ```{r rack-upload} rack_upload.demo_store <- function(backend, path, id, name = NULL, content_hash = NULL, ...) { dir <- record_dir(backend, id$id) dir.create(dir, showWarnings = FALSE, recursive = TRUE) meta <- read_meta(backend, id$id) version <- as.character(length(meta$versions) + 1L) file.copy(path, file.path(dir, paste0(version, ".json")), overwrite = TRUE) if (!is.null(name)) { meta$name <- name } meta$versions <- c( meta$versions, list( list(version = version, created = format(Sys.time()), hash = content_hash) ) ) write_meta(backend, id$id, meta) new_rack_id(id$id, version = version, class = "rack_id_demo") } ``` The `rack_list()` generic returns the listing rows the workflow menu renders, one `rack_record()` per record. The `saved` field feeds the "saved N minutes ago" label; a `tags` argument (unused here) would filter the listing. ```{r rack-list} rack_list.demo_store <- function(backend, tags = NULL, ...) { ids <- list.dirs(backend$dir, full.names = FALSE, recursive = FALSE) lapply( ids, function(rid) { meta <- read_meta(backend, rid) latest <- meta$versions[[length(meta$versions)]] new_rack_record(id = rid, name = meta$name, saved = latest$created) } ) } ``` ### Record-level generics These dispatch on the `rack_id_demo` subclass: `rack_exists()` decides insert versus append; `rack_download()` returns a local path to the payload, resolving the latest version when the `rack_id` pins none; `rack_info()` lists the versions newest-first as a data frame with `version`, `created` and `ref` columns. ```{r record-read} rack_exists.rack_id_demo <- function(id, backend, ...) { file.exists(file.path(record_dir(backend, id$id), "meta.json")) } rack_download.rack_id_demo <- function(id, backend, ...) { info <- rack_info(id, backend) if (nrow(info) == 0L) { stop("No versions stored for record ", id$id) } version <- if (is.null(id$version)) info$version[1L] else id$version file.path(record_dir(backend, id$id), paste0(version, ".json")) } rack_info.rack_id_demo <- function(id, backend, ...) { versions <- read_meta(backend, id$id)$versions if (length(versions) == 0L) { return( data.frame( version = character(), created = as.POSIXct(character()), ref = character(), stringsAsFactors = FALSE ) ) } version <- vapply(versions, `[[`, character(1L), "version") created <- vapply(versions, `[[`, character(1L), "created") newest_first <- rev(seq_along(version)) data.frame( version = version[newest_first], created = as.POSIXct(created[newest_first]), ref = version[newest_first], stringsAsFactors = FALSE ) } ``` The display name lives in the sidecar, so `rack_name()` reads it and `rack_rename()` rewrites it. The `rack_content_hash()` generic returns the stored hash of the latest version; the app compares it against the current board to skip a save that would change nothing. ```{r record-meta} rack_name.rack_id_demo <- function(id, backend, ...) { read_meta(backend, id$id)$name } rack_rename.rack_id_demo <- function(id, backend, name, ...) { meta <- read_meta(backend, id$id) meta$name <- name write_meta(backend, id$id, meta) new_rack_id(id$id, version = id$version, class = "rack_id_demo") } rack_content_hash.rack_id_demo <- function(id, backend, ...) { versions <- read_meta(backend, id$id)$versions if (length(versions) == 0L) { return(NULL) } versions[[length(versions)]]$hash } ``` Deletion comes in two grains: `rack_delete()` drops a single version (and the whole record once its last version goes), while `rack_purge()` removes the record outright -- the grain the workflow menu's delete uses. ```{r record-delete} rack_delete.rack_id_demo <- function(id, backend, ...) { meta <- read_meta(backend, id$id) version <- if (is.null(id$version)) { meta$versions[[length(meta$versions)]]$version } else { id$version } unlink(file.path(record_dir(backend, id$id), paste0(version, ".json"))) meta$versions <- Filter( function(v) !identical(v$version, version), meta$versions ) if (length(meta$versions) == 0L) { unlink(record_dir(backend, id$id), recursive = TRUE) } else { write_meta(backend, id$id, meta) } invisible(TRUE) } rack_purge.rack_id_demo <- function(id, backend, ...) { unlink(record_dir(backend, id$id), recursive = TRUE) invisible(TRUE) } ``` ### Declaring capabilities This store keeps versions and a name-and-hash sidecar, so it advertises `versioning` and `metadata`. It has no notion of users, sharing or access control, so those flags are `FALSE` -- which is why none of `rack_share()`, `rack_acl()` or `rack_find_users()` appears above. ```{r capabilities} rack_capabilities.demo_store <- function(backend, ...) { list( versioning = TRUE, metadata = TRUE, tags = FALSE, sharing = FALSE, visibility = FALSE, user_discovery = FALSE ) } ``` ## The round-trip That is the whole backend. Because the storage layer needs no Shiny, we can exercise it directly -- create, list, load, append, inspect, rename, delete. ```{r roundtrip-create} store <- demo_store() board <- list( blocks = list(list(id = "a", type = "dataset")), links = list() ) id <- rack_create(store, board, id = "sales-report", name = "Sales report") id ``` With the record saved, `rack_list()` reports one entry and `rack_load()` reads back exactly what we stored. ```{r roundtrip-list-load} rack_list(store) identical(rack_load(id, store), board) ``` Saving again appends a version rather than replacing the record, and `rack_info()` shows both, newest first. ```{r roundtrip-append} board$blocks <- c(board$blocks, list(list(id = "b", type = "filter"))) rack_append(id, store, board) rack_info(id, store) ``` The name is stored independently of the payload, so a rename sticks across a reload, and the content hash is what lets the app tell an unchanged board from a modified one. ```{r roundtrip-meta} rack_rename(id, store, "Quarterly sales") rack_name(as_rack_id(list(id = "sales-report"), store), store) rack_content_hash(id, store) ``` Deleting the record leaves the store empty. ```{r roundtrip-delete} rack_purge(id, store) rack_list(store) ``` ## Trimming to the minimum The capability switch decides how much you must implement. Our store turns on `versioning` and `metadata`; a backend that has neither -- say a key-value store that keeps only the latest state -- returns `FALSE` for every optional flag and drops the matching methods, leaving just the create / load / list core: `as_rack_id()`, `rack_upload()`, `rack_download()`, `rack_exists()`, `rack_list()` and `rack_name()`. Even then `rack_content_hash()` is worth keeping, since the app calls it to skip no-op saves. Add capabilities back one at a time as the store grows: flip `versioning` on and implement `rack_info()`; flip `sharing` and `visibility` on and implement the `rack_share()` / `rack_acl()` family. Each flag is independent, so a backend can support exactly the features its store can back. ## Wiring it into an app The `session_mgmt_backend` option selects the backend. It accepts a backend object or a zero-argument function returning one; the function form is resolved once per session, so credentials and paths are picked up at runtime rather than when the app is defined. ```{r wiring, eval = FALSE} options( blockr.session_mgmt_backend = function() demo_store("~/blockr-workflows") ) library(blockr.core) library(blockr.dock) serve( new_dock_board(), plugins = custom_plugins(manage_project()), loader = rack_loader() ) ``` The `manage_project()` plugin renders the navbar's save controls and workflow menu; `rack_loader()` is the board loader that restores the record named in the request URL from the same backend. With the option set to our `demo_store`, saves and loads now flow through the file store instead of pins. ```