Skip to contents

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, 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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
#> <rack_id: sales-report>

With the record saved, rack_list() reports one entry and rack_load() reads back exactly what we stored.

rack_list(store)
#> [[1]]
#> <rack_record: sales-report (Sales report)>

identical(rack_load(id, store), board)
#> [1] TRUE

Saving again appends a version rather than replacing the record, and rack_info() shows both, newest first.

board$blocks <- c(board$blocks, list(list(id = "b", type = "filter")))

rack_append(id, store, board)
#> <rack_id: sales-report>

rack_info(id, store)
#>   version             created ref
#> 1       2 2026-07-24 19:55:46   2
#> 2       1 2026-07-24 19:55:46   1

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.

rack_rename(id, store, "Quarterly sales")
#> <rack_id: sales-report>
rack_name(as_rack_id(list(id = "sales-report"), store), store)
#> [1] "Quarterly sales"

rack_content_hash(id, store)
#> [1] "53abb582de74d031ce033ff3ee295976"

Deleting the record leaves the store empty.

rack_purge(id, store)
rack_list(store)
#> list()

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.

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. ```