Writing a projectData adapter

This tutorial shows how to teach projectData to find and open a specialized storage format. It builds a small working adapter, tinydisk, step by step, then covers distribution, manifests, and the behavior you should know about before shipping one. You should be comfortable writing R packages. You do not need prior experience with projectData, although the main vignette explains the collection features used here in more detail.

1. Why adapters exist

Most R data is restored by reading an .rda or .rds file, and projectData does that itself. Some data cannot be restored that way: a matrix stored in files and read lazily, a handle to a database, a memory-mapped file. These objects are restored with the format’s own functions, and often those functions come from a large package that most users of projectData do not need.

An adapter is the piece of code that knows one such format. Three parties are involved, each with a distinct job.

Component Responsibility
Data package Distributes documented datasets, an optional manifest, and any backing files (under inst/).
projectData Registers collections, inventories resources, resolves names and conflicts, defers access, records provenance, and caches each opened resource once.
Adapter Understands one storage format: finds its resources, checks that they are intact, and restores them on request.

Keeping the adapter separate means the format’s dependencies stay out of the projectData core. An adapter is a package of its own (or a script) that you choose to install; projectData never installs one for you.

You do not need an adapter in these cases:

Write an adapter when restoring the resource needs format-specific code, or when you want projectData to discover resources of a given format automatically.

2. How the adapter architecture works

Resources and objects

A resource is one loadable unit, such as a directory or file. Opening a resource produces one or more named R objects. Resource names, file names, and object names are separate concepts and need not match. Catalog and conflict resolution work on the recorded mapping from resources to objects, and an unknown mapping stays unknown until it is inspected.

The loader

An adapter contributes one or more loaders, built with data_loader(). Each loader has a namespaced identifier such as "tinydisk::tinymat" and up to three callbacks:

Operation Callback Receives Returns
Discovery discover(root, context) the package root and a context list a list of descriptors, or NULL
Validation validate(descriptor, root, context) one descriptor TRUE, or a character message that rejects it
Opening open(descriptor, paths, context) one descriptor, resolved paths, and context a named list of the objects produced

Only open may read data. Discovery and validation work from file names and small metadata and must not restore the dataset. This is what lets projectData build a catalog of a large collection without touching it.

When each operation runs

 install data package
        |
        v
 register collection ---- discovery (only for loaders that provide it,
        |                  and only if the resource is not in a manifest)
        |                 validation (if the loader is already registered)
        v
 catalog / data_query()   metadata only, nothing opened
        |
        v
 first access: data_get(), or a bare name in the attached environment
        |
        +-- loader missing? resolve it from the manifest's AdapterPackage
        +-- validation again, against the loader about to open
        +-- open()  -> objects cached under the resource
        v
 later access: cached; no further validation or opening

Discovery answers “what resources does this package contain?” It returns descriptors, which are plain lists with these fields:

Field Meaning
resource Resource name, unique within the collection.
source Path relative to the installed package root (for example extdata/counts.tinymat).
objects Names of the objects the resource is expected to produce.
loader The loader id (set for you by discovery; required in manifests).
options A named list of strings the loader may interpret.
title, help_topic, recorded_class Optional, recorded metadata.
adapter Optional list(package =, export =) saying where to find the loader.

Validation answers “is this descriptor well formed, and are the files it needs present?” It runs when a collection is registered, if the loader is already registered, and again before every uncached opening attempt against the loader selected for that attempt. This means a collection can be registered while an optional adapter is missing, and an adapter that arrives later is still validated before it opens anything. A rejection marks the resource failed without calling open; fix the problem and call data_retry().

Opening restores the resource when someone first asks for an object from it. The result is cached, so later access does not repeat validation or opening.

Manifests and discovery are alternatives

There are two ways a resource becomes known:

They can be combined. If both name the same resource, the manifest entry wins. Details are in the advanced section.

3. Building a minimal adapter: tinydisk

The example adapter lives in inst/examples/tinydisk.R. It is used by the package tests and is not part of the projectData API, so the vignette sources it. The sections below explain how it is built.

The format

A tinymat is a directory named <name>.tinymat/ with two files: dim.txt ("nrow ncol") and values.bin (column-major doubles). An opened tinymat is a small object holding the directory path and dimensions. It reads values from disk only when you subset it with [. That makes it a miniature version of a disk-backed matrix.

The loader declaration

projectData::data_loader(
  "tinydisk::tinymat",
  storage = "disk-backed",
  needs_work_dir = TRUE,
  discover = function(root, context) { ... },
  validate = function(descriptor, root, context) { ... },
  open = function(descriptor, paths, context) { ... }
)

The id has the form package::name, and the projectData:: prefix is reserved for built-in loaders. Using your package name as the prefix keeps ids stable and avoids collisions with other adapters.

storage = "disk-backed" tells projectData that the returned object reads from files lazily. The catalog and the first-access message then avoid suggesting that data are in memory. needs_work_dir = TRUE asks for a writable directory to be provided at opening, explained below.

Discovery

discover = function(root, context) {
  dirs <- list.files(file.path(root, "extdata"), pattern = "\\.tinymat$")
  lapply(sub("\\.tinymat$", "", dirs), function(nm) {
    list(resource = nm, source = paste0("extdata/", nm, ".tinymat"), objects = nm)
  })
}

root is the installed package root, where data/ and the contents of inst/ sit side by side. The callback uses an explicit convention (the .tinymat suffix), and it only lists names. It does not read dim.txt. projectData does not assume that every file under extdata/ is a public data object, so a convention like this is how an adapter avoids claiming unrelated files. Here the object is named after the resource, but that is a choice of this adapter, not a requirement.

Validation

validate = function(descriptor, root, context) {
  src <- file.path(root, descriptor$source)
  ok <- file.exists(file.path(src, "dim.txt")) && file.exists(file.path(src, "values.bin"))
  if (ok) TRUE else "tinymat directory is missing dim.txt or values.bin"
}

The validator checks that the files the format requires exist, and returns either TRUE or a message explaining why not. An error raised inside validate also rejects the descriptor. It never opens the data, so it is cheap enough to run before every opening attempt, and it turns a damaged installation into a clear message instead of a failure deep inside the format’s reader.

Opening

open = function(descriptor, paths, context) {
  src <- paths$source
  if (identical(descriptor$options$work_copy, "true")) {
    dest <- file.path(paths$work_dir, basename(src))
    file.copy(src, paths$work_dir, recursive = TRUE)
    src <- dest
  }
  stats::setNames(list(tinymat_open(src)), descriptor$objects)
}

projectData resolves the paths for you, so open never builds a library location itself:

open must return a named list, with one element for each object the resource produces, so projectData can cache them together. Here, tinymat_open() reads only dim.txt and records the path. It returns a restored R object, but the values stay on disk until the user subsets them. Restoring an object is not the same as materializing its data; for a disk-backed format the first is cheap and the second is not. The catalog state ready therefore means only that the loader returned.

4. Registering and using an adapter

The next chunks walk through the whole workflow. First, prepare a data resource: the fixture helper installs a small package into a temporary library (not your user library) containing one tinymat directory under extdata/.

library(projectData)
source(system.file("examples", "tinydisk.R", package = "projectData"))
source(system.file("examples", "fixtures.R", package = "projectData"))

lib <- fixture_library()
fixture_package(lib, "diskData", "1.0.0", lazy = FALSE, datasets = list(),
  files = list("extdata/counts.tinymat" = function(path) {
    tinymat_write(matrix(as.double(1:12), 3, 4), path)
  }))

Next, register the adapter. data_loader_add() registers one loader explicitly, which is required here because the adapter is not in a package that projectData could find. Registration stores the declaration only: no callback runs.

data_loader_add(tinydisk_loader())
data_loaders()
#>                     id     storage discovers builtin
#> 1 projectData::dataset      memory     FALSE    TRUE
#> 2     projectData::rds      memory     FALSE    TRUE
#> 3    tinydisk::tinymat disk-backed      TRUE   FALSE

Querying the package shows its catalog without attaching anything. The tinymat was found by the adapter’s discovery callback and its object is still unopened.

data_query("diskData", lib.loc = lib)[, c("object", "loader", "storage", "state")]
#> <projectData catalog>: 1 row(s); metadata only, no data opened.
#>  object    state
#>  counts unopened

Attaching registers the collection (inventory only, still no open) and binds its names in an environment on the search path. The suffix below adds a second name for each object, to show that aliases refer to the same resource.

data_attach("diskData", lib.loc = lib, suffix = ".disk")
data_catalog()[, c("exposed_name", "object", "resource", "state")]
#> <projectData catalog>: 2 row(s); metadata only, no data opened.
#>  exposed_name object resource    state
#>        counts counts   counts unopened
#>   counts.disk counts   counts unopened

Both names point to one resource record, so the first access does the only opening. This is when validate runs and then open:

counts <- data_get("counts")
#> projectData: opening resource 'counts' of collection 'diskData' (diskData 1.0.0) with object(s) counts. Loader tinydisk::tinymat; opened from the installed package (projectData does not install packages); disk-backed data are opened, not necessarily loaded into memory.
dim(counts)
#> [1] 3 4
counts[2, 3]
#> [1] 8

dim() answered from the recorded dimensions, and counts[2, 3] read one column from disk. Accessing the object by a different name, or qualifying the collection, returns the same cached object without another opening:

identical(counts, data_get("counts.disk"))
#> [1] TRUE
identical(counts, data_get("counts", collection = "diskData"))
#> [1] TRUE
data_catalog()[, c("exposed_name", "state")]
#> <projectData catalog>: 2 row(s); metadata only, no data opened.
#>  exposed_name state
#>        counts ready
#>   counts.disk ready

data_get() also works without attaching, which avoids adding names to the search path (data_get("counts", package = "diskData", lib.loc = lib)). It shares the same resource records, so there is still exactly one opening per resource whichever route comes first.

Detaching removes the bindings and forgets the registration with its cached references. It does not delete backing files, so counts stays usable:

data_detach()
counts[1, 1]
#> [1] 1

Summary of what happens when:

Step Discovery Validation Opening
data_loader_add() no no no
data_query() / data_attach() / data_catalog() yes if the loader is registered no
First access to an object no yes yes
Later access no no no

5. Distributing adapters and declaring resources

An adapter package is not a data package

An adapter package contains code for one format and has no data. A data package contains data and can say which adapter it needs, but contains no loader code. Many data packages can use one adapter, and the adapter can change without rebuilding them as long as the file format stays compatible.

Packaging the adapter

An adapter package exports a function that returns a data_loader(), or a list of them. By default projectData looks for an export named projectData_loaders:

# in the adapter package, e.g. tinydiskAdapter
#' @export
projectData_loaders <- function() tinydisk_loader()

Use a stable id with your package name as prefix ("tinydisk::tinymat"), because data packages record it. Put optional heavy dependencies in Suggests or Imports of the adapter, not of projectData or of the data packages.

Users register an adapter in one call:

data_loader_adapter("tinydiskAdapter")

data_loader_adapter() loads the namespace without attaching it, calls the export with no arguments, checks the whole declaration, and registers everything or nothing. It never calls discover, validate or open. It fails with a projectData_loader_unavailable error if the package or export is missing.

Declaring resources in a manifest

A data package author can describe resources in inst/projectData/manifest.dcf. A manifest holds data only: relative paths and loader references, never code.

ManifestVersion: 1

Resource: counts
Loader: tinydisk::tinymat
Source: extdata/counts.tinymat
Objects: counts
Options: work_copy=true
AdapterPackage: tinydiskAdapter
AdapterExport: projectData_loaders

Resource and Loader are required. Objects is a comma-separated list of names the resource produces, Options is key=value pairs separated by ;, and Title, HelpTopic, and RecordedClass are optional recorded metadata. AdapterPackage (and optionally AdapterExport, default projectData_loaders) tell projectData where to find the loader. If the loader is not registered when the object is first accessed, projectData loads that namespace (without attaching it) and registers the loader.

The same fields are described by the descriptor lists above, so a manifest is a serialization of descriptors. Because it holds no code, projectData can validate it without running anything.

You can write the file by hand, but projectData can also draft and write it. data_manifest_draft() asks the registered adapters what they would claim under a root, using discovery only. data_manifest_write() validates and writes the descriptors, then reads the text back to confirm it reproduces them. data_manifest_validate() checks an existing file.

Authoring root: source tree vs. installed layout

Source paths (and what discover()/validate() callbacks see) are always relative to the installed package root, where data/ and the contents of inst/ sit side by side. The recommended entry point for authoring is a package source tree (a directory with DESCRIPTION, data/ and inst/ kept apart): data_manifest_draft()/data_manifest_write() build a temporary view with the installed shape by creating directory links to data/ and to each top-level entry of inst/, hand that view to the callbacks, and remove it afterward. Nothing is read, executed or copied to build this view.

That view requires a file system and R session that can create directory links (symlinks or, on Windows, junctions). Some environments — including some CRAN and CI Windows builds — do not allow this. projectData never falls back to copying a data collection, since that copy could be large; if a link cannot be created, the call fails with a projectData_staging_error naming the entry, and nothing is written. In that situation, author from an environment that supports links, or pass an installed package directory, or an explicitly prepared installed-layout directory (one where data/ and the contents of inst/ already sit side by side, as they would after installation) as root, with layout = "installed" or "standalone". No staging is needed for either, because the shape callbacks expect is already there.

The example below builds such an installed-layout directory directly, so it runs the same way regardless of link support:

pkg <- tempfile("pkg-")
dir.create(file.path(pkg, "data"), recursive = TRUE)
dir.create(file.path(pkg, "extdata"), recursive = TRUE)
example_obj <- 1:3
save(example_obj, file = file.path(pkg, "data", "example.rda"))
tinymat_write(matrix(as.double(1:6), 2, 3),
              file.path(pkg, "extdata", "counts.tinymat"))

drafts <- data_manifest_draft(pkg, layout = "standalone")
vapply(drafts, function(d) d$source, "")
#> [1] "extdata/counts.tinymat"

(Authoring directly against a package source tree, with data/ and inst/ kept apart, looks the same except that root is the source directory and layout defaults to "auto", which detects it from DESCRIPTION.)

The .rda dataset is a standard dataset and needs no manifest entry. The adapter found the tinymat. A draft does not know which package supplies the loader, so add that, then write the manifest. Inside an actual package source, the conventional location is inst/projectData/manifest.dcf:

drafts <- lapply(drafts, function(d) {
  d$title <- "Counts"
  d$options <- list(work_copy = "true")
  d$adapter <- list(package = "tinydiskAdapter")
  d
})
manifest <- file.path(pkg, "projectData", "manifest.dcf")
data_manifest_write(drafts, manifest, root = pkg, layout = "standalone")
writeLines(readLines(manifest))
#> ManifestVersion: 1
#> 
#> Resource: counts
#> Loader: tinydisk::tinymat
#> Source: extdata/counts.tinymat
#> Objects: counts
#> Title: Counts
#> Options: work_copy=true
#> AdapterPackage: tinydiskAdapter

The result records whether the loader’s own validate() ran. Without a registered loader, the descriptor is checked for structure only and a projectData_validation_skipped message says so. Register the adapter and run data_manifest_validate() before shipping. A manifest holds less than a descriptor can, so the writer refuses rather than change anything silently:

data_manifest_write(list(resource = "counts", loader = "tinydisk::tinymat",
                         source = "../elsewhere", objects = "counts"),
                    file.path(pkg, "other.dcf"), root = pkg, layout = "standalone")
#> Error: resource "counts": Source must be a relative path without '..' components or absolute/backslash forms; got "../elsewhere"

When the adapter is not installed

Data packages can use an adapter without making it a hard dependency. If the adapter named by a manifest is not installed, only the resources that need it are affected. The next example installs a package with an ordinary dataset and a tinymat whose manifest names an adapter that does not exist. The loader is removed first so projectData has to resolve it from the manifest.

data_loader_remove("tinydisk::tinymat")
fixture_package(lib, "mixedData", "1.0.0", lazy = FALSE,
  datasets = list(plain = list(x = 1:3)),
  files = list("extdata/m.tinymat" = function(path) {
    tinymat_write(matrix(1, 2, 2), path)
  }),
  manifest = c("ManifestVersion: 1", "",
               "Resource: m", "Loader: tinydisk::tinymat",
               "Source: extdata/m.tinymat", "Objects: m",
               "AdapterPackage: noSuchAdapter"))
data_attach("mixedData", lib.loc = lib)
data_get("x")
#> projectData: opening resource 'plain' of collection 'mixedData' (mixedData 1.0.0) with object(s) x [111 bytes on disk: data/plain.rda]. Loader projectData::dataset; opened from the installed package (projectData does not install packages).
#> [1] 1 2 3
data_get("m")
#> Error: Adapter package 'noSuchAdapter' is not installed. projectData never installs packages automatically; install it and retry. (needed for loader 'tinydisk::tinymat', resource 'm' of collection 'mixedData'). Other objects are unaffected.
data_catalog()[, c("exposed_name", "origin", "state")]
#> <projectData catalog>: 2 row(s); metadata only, no data opened.
#>  exposed_name   origin              state
#>             x    index              ready
#>             m manifest unavailable-loader
data_detach()

The ordinary dataset opened normally and the catalog marks the other resource unavailable-loader. Installing the adapter and retrying fixes it without re-registering the collection.

6. Disk-backed resources and lifecycle considerations

storage = "disk-backed" is a statement about the returned object: it keeps references to files and reads from them lazily. projectData reports a resource as ready once open returns, and accurately says “opened, not necessarily loaded into memory.” Whether the complete matrix is ever in memory depends on the format and on what the user does next.

Things to decide and document in your adapter:

7. Advanced behavior and troubleshooting

Resource identity and caching

Each resource has one record per collection. Aliases (suffix names), the unqualified name, and data_get(collection = ) all point to that record, so a successful opening happens once and is shared. Reentrant opening of a resource that is already being opened is rejected rather than started twice. If opening fails, the resource is marked failed, nothing is cached, and data_retry() allows another attempt. Resources already ready are never validated or opened again. Replacing a loader with data_loader_add(replace = TRUE) affects only resources opened afterwards, and removing one with data_loader_remove() marks unopened resources unavailable-loader and leaves ready objects alone.

Discovery precedence

Discovery uses metadata only. For each resource name, in this order:

  1. A manifest entry replaces every other candidate for that resource. A manifest can therefore hand a standard dataset to a specialized loader, or choose between competing adapters. Declaring one resource twice in a manifest is an error, even if the records agree.
  2. A resource in the standard package data inventory (.rda and script datasets in data/) belongs to the built-in reader. An adapter claim on one is ignored with a projectData_ignored_claim warning naming the claimant, unless a manifest says otherwise. Adapters that routinely scan extdata/ can make this a message or silent with data_config(ignored_claims = "message") or "silent". The rule is about resource identity rather than file type: an adapter can still claim a directory resource that contains .rda files internally.
  3. Otherwise, several adapters claiming one resource is ambiguous and fails with projectData_ambiguous_discovery, naming the claimants. The outcome does not depend on the order in which the loaders were registered.

Two distinct resources that expose the same object name are a separate error (projectData_duplicate_object), and a manifest does not merge them. The check is repeated on the full proposed mapping in data_inspect(). Identical names in separate collections are supported.

Registration-time and first-access checks

At registration, validation runs only for loaders that are already registered. Registration therefore succeeds when an optional adapter is missing. At first access, validation runs again, against whichever loader is selected to open. A replacement loader is never covered by an earlier loader’s validation. Validators should inspect paths and small metadata only.

Roots, layouts, and relative paths

Source paths in a manifest, and the paths your discover() and validate() callbacks work with, are relative to the installed package root, where data/ and the contents of inst/ sit side by side (data/example.rda, extdata/counts.tinymat). A source tree is laid out differently (inst/extdata/...), so authoring accepts three kinds of root:

The context your callbacks receive describes which one applies:

root package, version library root
installed package installed metadata the library holding it the package directory
package source tree from DESCRIPTION NA (not installed) temporary installed-shaped view
standalone directory NA (never guessed from the name) NA the directory

context$collection is NA while authoring and in a standalone data_manifest_validate(), and context$resource is NA during discovery. A callback that stores the root it received during authoring must not rely on it afterwards, because the source-tree view is temporary.

Optional dependencies and failures

Safety checklist

8. A real-world example: projectData.monocle3

projectData.monocle3 is a separately distributed adapter built on the architecture described here. It restores Monocle 3 cell_data_set objects, including those whose expression matrices are stored on disk with BPCells. Monocle 3 and BPCells are needed only by that adapter package, and projectData itself does not depend on either.

This is the same pattern as tinydisk: a data package carries the files, an adapter package carries the format-specific discovery and restoration code, and projectData handles registration, naming, caching, and provenance. Refer to the projectData.monocle3 repository and its user vignette for instructions on creating and consuming CDS data packages. They are not repeated here.

9. Further reading

mirror server hosted at Truenetwork, Russian Federation.