CalCOFI.io CalCOFI.io Workflows

Test Release — gate latest.txt on query suite

Published

2026-09-11

Overview

Goal: Promote a freshly-built release to latest.txt only when every pre-baked query in CalCOFI/db-query runs cleanly against it.

Pipeline position: this notebook runs after release_database.qmd has uploaded the parquet, metadata.json, relationships.json, erd.mmd, and catalog.json to gs://calcofi-db/ducklake/releases/{release_version}/, but before latest.txt is updated. Every .md query in the _queries/ folder is rendered with its YAML defaults, executed against the just-uploaded release on GCS, and reported below — followed by the consumer-contract queries, the client packages’ README examples (calcofi4r, calcofi4py) and the catalog records. If every check passes, latest.txt is written; otherwise the target fails and consumer apps keep pointing at the previously-promoted version.

1 Setup

Code
devtools::load_all(here::here("../calcofi4db"))
ℹ Loading calcofi4db
Code
librarian::shelf(
  CalCOFI / calcofi4db,
  DBI,
  duckdb,
  dplyr,
  DT,
  fs,
  glue,
  here,
  jsonlite,
  purrr,
  tibble,
  withr,
  yaml,
  quiet = T)

# which release to test? read the just-uploaded version from the local
# release directory (most recent date-stamped folder). A STAGING run
# (CALCOFI_RELEASE_PREFIX=ducklake-staging/releases) keeps its sidecars under
# data/releases-staging/ — the same rule release_database.qmd applies — so the
# version MUST be picked there: until 2026-09-04 this always read data/releases/,
# and the 2026-08-28 staging run "passed" by testing the promoted v2026.08.25
# instead of the staging catalog (no test_results.json ever landed under
# data/releases-staging/). A false green is worse than a failure.
release_prefix <- Sys.getenv("CALCOFI_RELEASE_PREFIX", "ducklake/releases")
staging        <- release_prefix != "ducklake/releases"
releases_dir   <- here(if (staging) "data/releases-staging" else "data/releases")
if (staging) message(glue("STAGING run: testing the release under {releases_dir}"))
release_dirs <- list.dirs(releases_dir, recursive = FALSE)
release_dirs <- release_dirs[grepl("v[0-9]{4}[.][0-9]{2}[.]?[0-9]*$",
                                   basename(release_dirs))]
stopifnot(length(release_dirs) > 0)
release_version <- basename(release_dirs[which.max(file.mtime(release_dirs))])
message(glue("Testing release: {release_version}"))
Testing release: v2026.09.11
Code
# where the pre-baked queries live
queries_dir <- here("../db-query/_queries")
if (!dir.exists(queries_dir)) {
  stop(glue(
    "Could not find {queries_dir}. Clone CalCOFI/db-query as a sibling of ",
    "this repo, or set queries_dir manually."))
}
message(glue("Query repo: {queries_dir}"))
Query repo: /Users/bbest/Github/CalCOFI/workflows/../db-query/_queries
Code
# fresh in-memory DuckDB with httpfs + spatial — every query reads parquet
# from GCS over httpfs, mirroring what the query app's DuckDB-WASM does
con_test <- get_duckdb_con(":memory:")
duckdb is storing downloaded extensions and secrets under ~/.duckdb:
ℹ /Users/bbest/.duckdb
This persists across sessions and is shared with the DuckDB CLI and other clients.
ℹ Run duckdb(shared_home = FALSE) to use a temporary directory instead.
ℹ See ?duckdb_storage for details and alternatives.
Code
load_duckdb_extension(con_test, "httpfs")
Loaded extension: httpfs
Code
load_duckdb_extension(con_test, "spatial")
Loaded extension: spatial

2 Render the query templates

The query app uses Handlebars at runtime. For tests we replicate the subset of Handlebars features actually used in _queries/*.md:

  • {var} — simple substitution
  • {{var}} — raw substitution (no escaping; same effect here)
  • {sqlesc var} — escape single quotes for SQL string literals
  • {#if var}...{{/if}} — include block when var is truthy
Code
sqlesc <- function(x) gsub("'", "''", as.character(x %||% ""), fixed = TRUE)

# truthy test mirrors Handlebars: non-NULL, non-NA, non-empty string,
# non-zero number, non-FALSE logical
is_truthy <- function(val) {
  if (is.null(val))           return(FALSE)
  if (length(val) == 0)       return(FALSE)
  if (is.logical(val))        return(isTRUE(val))
  if (is.character(val))      return(any(!is.na(val) & nzchar(val)))
  if (is.numeric(val))        return(any(!is.na(val) & val != 0))
  !is.na(val[[1]])
}

# resolve innermost {{#if X}}...{{/if}} repeatedly until none remain.
# [\\s\\S] (not .) so the body spans newlines; (?!\\{\\{#if) guard forces
# "innermost only" so we strip from the inside out.
render_if_blocks <- function(txt, params) {
  pat <- "\\{\\{#if\\s+([A-Za-z_][A-Za-z0-9_]*)\\}\\}((?:(?!\\{\\{#if)[\\s\\S])*?)\\{\\{/if\\}\\}"
  repeat {
    m <- regexpr(pat, txt, perl = TRUE)
    if (m == -1) break
    full <- regmatches(txt, m)
    parts <- regmatches(full, regexec(pat, full, perl = TRUE))[[1]]
    var_name <- parts[2]
    body     <- parts[3]
    replacement <- if (is_truthy(params[[var_name]])) body else ""
    txt <- paste0(
      substr(txt, 1, m - 1),
      replacement,
      substr(txt, m + attr(m, "match.length"), nchar(txt)))
  }
  txt
}

render_template <- function(template, params) {
  txt <- template

  # 1. {{#if X}}...{{/if}} blocks
  txt <- render_if_blocks(txt, params)

  # 2. {{sqlesc VAR}}
  helper_pat <- "\\{\\{\\s*sqlesc\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}"
  repeat {
    m <- regexpr(helper_pat, txt, perl = TRUE)
    if (m == -1) break
    full <- regmatches(txt, m)
    name <- sub(helper_pat, "\\1", full, perl = TRUE)
    txt <- sub(full, sqlesc(params[[name]]), txt, fixed = TRUE)
  }

  # 3. {{{VAR}}} (triple-brace, raw) and {{VAR}} (simple) — handle the
  #    triple-brace first so the {{VAR}} pass doesn't eat the inner pair
  triple_pat <- "\\{\\{\\{\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}\\}"
  repeat {
    m <- regexpr(triple_pat, txt, perl = TRUE)
    if (m == -1) break
    full <- regmatches(txt, m)
    name <- sub(triple_pat, "\\1", full, perl = TRUE)
    val <- as.character(params[[name]] %||% "")
    txt <- sub(full, val, txt, fixed = TRUE)
  }
  single_pat <- "\\{\\{\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}"
  repeat {
    m <- regexpr(single_pat, txt, perl = TRUE)
    if (m == -1) break
    full <- regmatches(txt, m)
    name <- sub(single_pat, "\\1", full, perl = TRUE)
    val <- as.character(params[[name]] %||% "")
    txt <- sub(full, val, txt, fixed = TRUE)
  }

  # query-app convention: literal __VERSION__ placeholder -> release version,
  # mirroring _includes/form-field.html ({% replace "__VERSION__",
  # site.default_version %}). Used in free-form defaults like sql-shell/shell.md.
  if (!is.null(params$version)) {
    txt <- gsub("__VERSION__", as.character(params$version), txt, fixed = TRUE)
    # query-app convention since the content-addressed releases: __TBL:obs__ (or
    # __TBL:obs:dataset_key=calcofi_bottle__ for one partition) -> the
    # read_parquet(...) resolved through that version's catalog, mirroring
    # db-query lib/release.js. Never a hand-built releases/{v}/parquet path.
    tbl_pat <- "__TBL:([A-Za-z0-9_]+)(?::([A-Za-z0-9_]+)=([^_]+(?:_[^_]+)*?))?__"
    repeat {
      m <- regexpr(tbl_pat, txt, perl = TRUE)
      if (m == -1) break
      full <- regmatches(txt, m)
      tb   <- sub(tbl_pat, "\\1", full, perl = TRUE)
      col  <- sub(tbl_pat, "\\2", full, perl = TRUE)
      val  <- sub(tbl_pat, "\\3", full, perl = TRUE)
      txt <- sub(full, tbl_sql(as.character(params$version), tb,
                               if (nzchar(col)) col else NULL, if (nzchar(val)) val else NULL),
                 txt, fixed = TRUE)
    }
  }

  txt
}

# read_parquet(...) for a table (or one partition of it) in a release, through
# the catalog; one catalog fetch per version. objects[].path is bucket-relative,
# so the staging prefix resolves the same way. A catalog VIEW (`obs` over
# obs_bio + obs_env since D-S1, calcofi4db 3.31.0) expands to its SQL over the
# objects of the tables it reads, parenthesised — exactly what db-query's
# readParquetFor() does for `__TBL:obs__`, so this suite exercises the view the
# browser will run; a partition token on a view becomes a WHERE on it.
.tbl_cats <- new.env(parent = emptyenv())
tbl_sql <- function(version, table, col = NULL, val = NULL) {
  if (is.null(.tbl_cats[[version]])) {
    pre <- Sys.getenv("CALCOFI_RELEASE_PREFIX", "ducklake/releases")
    .tbl_cats[[version]] <- jsonlite::fromJSON(
      glue("https://storage.googleapis.com/calcofi-db/{pre}/{version}/catalog.json"),
      simplifyVector = FALSE)
  }
  cat_ <- .tbl_cats[[version]]
  if (table %in% names(calcofi4r::cc_catalog_views(cat_))) {
    v <- paste0("(", calcofi4r::cc_view_sql(cat_, table, function(t) tbl_sql(version, t)), ")")
    if (!is.null(col)) v <- glue("(SELECT * FROM {v} WHERE \"{col}\" = '{val}')")
    return(v)
  }
  src <- calcofi4r::cc_release_sources(cat_, table)
  if (!is.null(col)) {
    keep <- grepl(glue("/{col}={val}/"), src$urls, fixed = TRUE)
    if (isTRUE(src$canonical) && any(keep)) src$urls <- src$urls[keep]
    else if (!isTRUE(src$canonical))          # legacy layout: the partition's own path
      src$urls <- sub("/\\*\\*/\\*\\.parquet$", glue("/{col}={val}/*.parquet"), src$urls)
  }
  calcofi4r::cc_read_parquet_sql(src)
}

# parse YAML front-matter + body from a query .md file
parse_query_md <- function(path) {
  raw <- readLines(path, warn = FALSE)
  delims <- which(trimws(raw) == "---")
  if (length(delims) < 2) {
    stop(glue("Missing YAML delimiters in {path}"))
  }
  yaml_text <- paste(raw[(delims[1] + 1):(delims[2] - 1)], collapse = "\n")
  meta <- yaml::yaml.load(yaml_text)
  body <- paste(raw[(delims[2] + 1):length(raw)], collapse = "\n")
  list(meta = meta, body = body, path = path)
}

# build the params list for a query: each parameter's `default`, with
# `version` overridden to the release under test
defaults_for <- function(meta, release_version) {
  params <- list(version = release_version)
  if (!is.null(meta$parameters)) {
    for (name in names(meta$parameters)) {
      spec <- meta$parameters[[name]]
      params[[name]] <- spec$default
    }
  }
  params$version <- release_version
  params
}

3 Discover and execute every query

Code
query_files <- list.files(queries_dir, pattern = "[.]md$",
                          recursive = TRUE, full.names = TRUE)
message(glue("Found {length(query_files)} query files"))
Found 13 query files
Code
results <- purrr::map_dfr(query_files, function(qpath) {
  rel  <- sub(paste0(normalizePath(queries_dir), "/"), "",
              normalizePath(qpath), fixed = TRUE)
  q    <- parse_query_md(qpath)
  meta <- q$meta

  # sql_builder queries are JS-only — flag them; the query app exercises
  # them in the browser
  if (!is.null(meta$sql_builder)) {
    return(tibble::tibble(
      query    = rel,
      label    = meta$label %||% NA_character_,
      status   = "skip",
      reason   = paste0("sql_builder=", meta$sql_builder),
      rows     = NA_integer_,
      ms       = NA_real_,
      error    = NA_character_))
  }

  if (is.null(meta$sql)) {
    return(tibble::tibble(
      query = rel, label = meta$label %||% NA_character_,
      status = "skip", reason = "no inline sql",
      rows = NA_integer_, ms = NA_real_, error = NA_character_))
  }

  params <- defaults_for(meta, release_version)
  sql    <- render_template(meta$sql, params)

  t0  <- Sys.time()
  out <- tryCatch(
    DBI::dbGetQuery(con_test, sql),
    error = function(e) e)
  elapsed_ms <- as.numeric(difftime(Sys.time(), t0, units = "secs")) * 1000

  if (inherits(out, "error")) {
    tibble::tibble(
      query = rel, label = meta$label %||% NA_character_,
      status = "fail", reason = NA_character_,
      rows   = NA_integer_, ms = round(elapsed_ms, 1),
      error  = conditionMessage(out))
  } else {
    tibble::tibble(
      query = rel, label = meta$label %||% NA_character_,
      status = "pass", reason = NA_character_,
      rows   = nrow(out), ms = round(elapsed_ms, 1),
      error  = NA_character_)
  }
})
Registered S3 method overwritten by 'quantmod':
  method            from
  as.zoo.data.frame zoo 
Code
# status badges for the report
status_badge <- function(s) {
  color <- c(pass = "#2e7d32", fail = "#c62828", skip = "#757575")[s]
  glue("<span style='background:{color};color:#fff;padding:2px 8px;",
       "border-radius:10px;font-size:11px;'>{toupper(s)}</span>")
}

results |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, label, rows, ms, reason, error) |>
  DT::datatable(
    escape    = FALSE,
    rownames  = FALSE,
    options   = list(pageLength = 20, order = list(list(0, "asc"))),
    colnames  = c("", "query", "label", "rows", "ms", "skip reason", "error"))

4 Consumer contract queries

The _queries/*.md suite covers the query app, but the sql_builder (match) and the reproducible-download / app SQL live in calcofi4r + the Shiny apps and are otherwise only exercised in the browser — so a schema change (a renamed column, a retired table) slips past the gate and breaks downloads/apps in production. These contract queries pin the exact shapes those consumers depend on (bio↔︎env match, station rollup, sample/cruise counts) plus core-integrity asserts, run server-side against the frozen release, and feed the same promote gate.

Code
# tables resolved through this release's catalog (content-addressed since
# v2026.09; objects[].path is bucket-relative, so the staging prefix works too),
# exactly as calcofi4r::cc_get_db() and every migrated consumer resolve them
rel_prefix <- Sys.getenv("CALCOFI_RELEASE_PREFIX", "ducklake/releases")
rel_cat <- jsonlite::fromJSON(
  glue("https://storage.googleapis.com/calcofi-db/{rel_prefix}/{release_version}/catalog.json"),
  simplifyVector = FALSE)
rp <- function(t) calcofi4r::cc_read_parquet_sql(calcofi4r::cc_release_sources(rel_cat, t))
# the catalog's `obs` VIEW over obs_bio + obs_env (D-S1, calcofi4db 3.31.0), as cc_get_db() and
# db-query serve `obs` from this release on: its SQL over the pair's objects, parenthesised so it
# stands wherever rp('obs') stood. Every contract row that reads obs runs twice — against the
# deprecated table's own objects (rp) and against the view (rpv) — until the objects are dropped.
rpv <- function(t) paste0("(", calcofi4r::cc_view_sql(rel_cat, t, rp), ")")
# a partitioned table through its single-file twin (obs publishes one): the table's own column order
rp1 <- function(t) calcofi4r::cc_read_parquet_sql(calcofi4r::cc_release_sources(rel_cat, t), prefer_single_file = TRUE)
has_obs_view <- "obs" %in% names(calcofi4r::cc_catalog_views(rel_cat))
stopifnot(has_obs_view)   # the catalog must carry the view: consumers read obs through it

# the rows that read obs, parameterised on HOW obs is read (R = rp or rpv) and tagged
obs_rows <- function(R, tag) tibble::tribble(
  ~name, ~expect, ~sql,
  glue("consumer: match env (bottle temperature) [{tag}]"), "nonzero", glue(
    "SELECT count(*) n FROM {R('obs')}
     WHERE realm='env' AND dataset_key='calcofi_bottle' AND measurement_type='temperature'"),
  glue("consumer: match/download bio (ichthyo abundance x taxon x effort) [{tag}]"), "nonzero", glue(
    "SELECT count(*) n FROM {R('obs')} o
       JOIN {rp('taxon')} t ON t.taxon_key = o.taxon_key
       LEFT JOIN {rp('sample_measurement')} shf ON shf.sample_key=o.sample_key AND shf.measurement_type='std_haul_factor'
     WHERE o.realm='bio' AND o.dataset_key='swfsc_ichthyo' AND o.measurement_type='abundance'"),
  glue("integrity: every non-null obs.taxon_key resolves to taxon [{tag}]"), "zero", glue(
    "SELECT count(*) n FROM {R('obs')} o
     WHERE o.taxon_key IS NOT NULL
       AND NOT EXISTS (SELECT 1 FROM {rp('taxon')} t WHERE t.taxon_key=o.taxon_key)"),
  # db-viz-hex::get_sp() picks taxa by worms_id and walks the WoRMS hierarchy for
  # children. Seabirds and marine mammals key `itis:` (WoRMS bird taxonomy lags),
  # and nothing populated their worms_id COLUMN — so every one of them returned
  # ZERO rows: 59,858 of 64,956 Farallon obs, 92.2% of the dataset, with no error
  # anywhere. Resolving a taxon and making it REACHABLE are different things;
  # assert the second.
  glue("consumer: seabird/mammal taxa reachable by worms_id (db-viz-hex get_sp) [{tag}]"), "nonzero", glue(
    "SELECT count(*) n FROM {R('obs')} o
       JOIN {rp('taxon')} t ON t.taxon_key = o.taxon_key
     WHERE o.dataset_key='farallon_bird-mammal' AND t.worms_id IS NOT NULL"),
  # `o.taxon_key IS NOT NULL` is load-bearing, not defensive: 1,316 of this
  # dataset's 66,272 obs carry NO taxon at all — species codes the source marks
  # Include=0 (fish, and non-target sightings) — and a LEFT JOIN reports those as
  # a NULL worms_id too. Without the filter this asserts "every observation has a
  # taxon", which is a different (and false) claim than the one intended.
  # birds key on ITIS by rule (class Aves, calcofi4db >= 3.29.0), and WoRMS has no record at all for
  # some of them — Larus brachyrhynchus and the subspecies Hydrobates leucorhous chapmani, 123
  # farallon rows at v2026.09.04 — so "reachable by worms_id" stopped being the invariant. What
  # must never happen again is a farallon observation with NO authority id (59,858 rows in 2026-07).
  glue("integrity: no farallon obs left unreachable by an authority id (worms_id or itis_id) [{tag}]"), "zero", glue(
    "SELECT count(*) n FROM {R('obs')} o
       LEFT JOIN {rp('taxon')} t ON t.taxon_key = o.taxon_key
     WHERE o.dataset_key='farallon_bird-mammal'
       AND o.taxon_key IS NOT NULL AND t.worms_id IS NULL AND t.itis_id IS NULL"),
  glue("consumer: station coverage rollup (obs GROUP BY grid,dataset) [{tag}]"), "nonzero", glue(
    "SELECT count(*) n FROM (
       SELECT grid_key, dataset_key, count(*), count(DISTINCT sample_key), count(DISTINCT cruise_key)
       FROM {R('obs')} WHERE grid_key IS NOT NULL GROUP BY 1,2)"),
  glue("consumer: obs readable over HTTPS as the browser reads it (db-query match.js) [{tag}]"), "nonzero", glue(
    "SELECT count(*) n FROM {R('obs')} WHERE realm='bio' LIMIT 1"),
  glue("contract: obs.measurement_type all in registry [{tag}]"), "zero", glue(
    "SELECT count(*) n FROM {R('obs')}
     WHERE measurement_type NOT IN (SELECT measurement_type FROM {rp('measurement_type')})"),
  glue("contract: obs.sample_key resolves in sample [{tag}]"), "zero", glue(
    "SELECT count(*) n FROM {R('obs')} WHERE sample_key NOT IN (SELECT sample_key FROM {rp('sample')})"),
  glue("contract: obs.hex_id present where lat/lng [{tag}]"), "zero", glue(
    "SELECT count(*) n FROM {R('obs')} WHERE hex_id IS NULL AND latitude IS NOT NULL AND longitude IS NOT NULL"))

# the same contract, read from the pair DIRECTLY under its own names (value, no realm) — what a
# migrated consumer writes; obs_bio carries the effort inline, so the bio row needs no join
pair_rows <- tibble::tribble(
  ~name, ~expect, ~sql,
  "consumer: match env (bottle temperature) [obs_env]", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_env')}
     WHERE dataset_key='calcofi_bottle' AND measurement_type='temperature' AND value IS NOT NULL"),
  "consumer: match/download bio (ichthyo abundance x taxon x effort) [obs_bio]", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')} o JOIN {rp('taxon')} t ON t.taxon_key = o.taxon_key
     WHERE o.dataset_key='swfsc_ichthyo' AND o.measurement_type='abundance' AND o.std_haul_factor IS NOT NULL"),
  "integrity: every non-null obs_bio.taxon_key resolves to taxon [obs_bio]", "zero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')} o
     WHERE o.taxon_key IS NOT NULL AND NOT EXISTS (SELECT 1 FROM {rp('taxon')} t WHERE t.taxon_key=o.taxon_key)"),
  "consumer: seabird/mammal taxa reachable by worms_id [obs_bio]", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')} o JOIN {rp('taxon')} t ON t.taxon_key = o.taxon_key
     WHERE o.dataset_key='farallon_bird-mammal' AND t.worms_id IS NOT NULL"),
  "integrity: no farallon obs_bio left unreachable by an authority id (worms_id or itis_id) [obs_bio]", "zero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')} o LEFT JOIN {rp('taxon')} t ON t.taxon_key = o.taxon_key
     WHERE o.dataset_key='farallon_bird-mammal' AND o.taxon_key IS NOT NULL AND t.worms_id IS NULL AND t.itis_id IS NULL"),
  "consumer: station coverage rollup (obs_bio + obs_env GROUP BY grid,dataset) [pair]", "nonzero", glue(
    "SELECT count(*) n FROM (
       SELECT grid_key, dataset_key, count(*), count(DISTINCT sample_key), count(DISTINCT cruise_key)
       FROM (SELECT grid_key, dataset_key, sample_key, cruise_key FROM {rp('obs_bio')}
             UNION ALL SELECT grid_key, dataset_key, sample_key, cruise_key FROM {rp('obs_env')})
       WHERE grid_key IS NOT NULL GROUP BY 1,2)"),
  "consumer: obs_bio single object readable over HTTPS (Explorer, db-query) [obs_bio]", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')} LIMIT 1"),
  "contract: pair measurement_type all in registry [pair]", "zero", glue(
    "SELECT count(*) n FROM (SELECT measurement_type FROM {rp('obs_bio')} UNION ALL SELECT measurement_type FROM {rp('obs_env')})
     WHERE measurement_type NOT IN (SELECT measurement_type FROM {rp('measurement_type')})"),
  "contract: pair sample_key resolves in sample [pair]", "zero", glue(
    "SELECT count(*) n FROM (SELECT sample_key FROM {rp('obs_bio')} UNION ALL SELECT sample_key FROM {rp('obs_env')})
     WHERE sample_key NOT IN (SELECT sample_key FROM {rp('sample')})"),
  "contract: pair hex_id present where lat/lng [pair]", "zero", glue(
    "SELECT count(*) n FROM (SELECT hex_id, latitude, longitude FROM {rp('obs_bio')} UNION ALL SELECT hex_id, latitude, longitude FROM {rp('obs_env')})
     WHERE hex_id IS NULL AND latitude IS NOT NULL AND longitude IS NOT NULL"),
  # the view IS obs: same rows per realm (the gate release_database.qmd ran per dataset with a signature)
  "integrity: the obs view reproduces obs row counts (bio = obs_bio, env = obs_env)", "zero", glue(
    "SELECT abs((SELECT count(*) FROM {rp('obs')} WHERE realm='bio') - (SELECT count(*) FROM {rp('obs_bio')}))
          + abs((SELECT count(*) FROM {rp('obs')} WHERE realm='env') - (SELECT count(*) FROM {rp('obs_env')}))
          + abs((SELECT count(*) FROM {rp('obs')}) - (SELECT count(*) FROM {rpv('obs')})) AS n"),
  # against the single-file twin, which carries the TABLE's column order; a read over the hive
  # partition list appends the partition column (dataset_key) last, which is a reader artefact
  "integrity: the obs view has obs's 18 columns in order", "zero", glue(
    "SELECT count(*) n FROM (
       SELECT row_number() OVER () AS i, column_name FROM (DESCRIBE SELECT * FROM {rpv('obs')})) v
     FULL JOIN (
       SELECT row_number() OVER () AS i, column_name FROM (DESCRIBE SELECT * FROM {rp1('obs')})) o USING (i)
     WHERE v.column_name IS DISTINCT FROM o.column_name"))

# name | expect ('nonzero' rows for a consumer read, or 'zero' for an integrity
# assert) | SQL returning a single column `n`
contract <- tibble::tribble(
  ~name, ~expect, ~sql,
  # The spatial tables had NO contract coverage at all until 2026-08-03, which is
  # why renaming _spatial -> spatial and _spatial_attr -> spatial_attribute could
  # not have been caught by the gate that exists to catch exactly that. These read
  # them the way a consumer does — including the JOIN KEY, since `id` is per-layer
  # and joining on it alone silently mixes layers.
  "consumer: spatial layer with attributes (joined on spatial_key)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('spatial')} s
       JOIN {rp('spatial_attribute')} a USING (spatial_key)
     WHERE s.layer = 'Marine Protected Areas'"),
  "integrity: every spatial_attribute row resolves to a spatial feature", "zero", glue(
    "SELECT count(*) n FROM {rp('spatial_attribute')} a
     WHERE NOT EXISTS (SELECT 1 FROM {rp('spatial')} s WHERE s.spatial_key = a.spatial_key)"),
  "integrity: spatial_key is unique", "zero", glue(
    "SELECT count(*) n FROM (SELECT spatial_key FROM {rp('spatial')}
                             GROUP BY 1 HAVING count(*) > 1)"),
  # a sample-to-polygon join is the thing consumers actually do, and it ERRORS
  # outright when CRS tags disagree — which they did until v2026.08.03
  "consumer: sample joins spatial (CRS tags must agree)", "nonzero", glue(
    "SELECT count(*) n FROM (SELECT geom FROM {rp('sample')}
                             WHERE geom IS NOT NULL LIMIT 20000) s
       JOIN {rp('spatial')} sp ON ST_Intersects(sp.geom, s.geom)"),
  # browser-shaped objects (Explorer plan D4, Phase 1): the app fetches these whole and aggregates in
  # the browser; every consumer inherits the exact polygon membership and the D8 densities
  "consumer: obs_bio carries the D8 densities for the default taxon (sardine larvae per 10 m2)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')}
     WHERE taxon_key = 'worms:217452' AND life_stage = 'larva' AND density_per_10m2 IS NOT NULL"),
  "integrity: obs_bio effort_class is the D8 vocabulary", "zero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')}
     WHERE effort_class NOT IN ('count_with_effort', 'raw_count_no_effort', 'density_as_published', 'other_unit')"),
  "integrity: obs_bio never derives a density without effort", "zero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')}
     WHERE effort_class = 'raw_count_no_effort' AND (density_per_10m2 IS NOT NULL OR density_per_1000m3 IS NOT NULL)"),
  "consumer: one env variable is one object (temperature, quality-filtered)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_env')} WHERE measurement_type = 'temperature' AND qual_ok"),
  "integrity: every sample_spatial root resolves to sample_root", "zero", glue(
    "SELECT count(*) n FROM {rp('sample_spatial')} ss
     WHERE NOT EXISTS (SELECT 1 FROM {rp('sample_root')} r WHERE r.root_id = ss.root_id)"),
  "integrity: sample_spatial (root_id, spatial_key) is unique", "zero", glue(
    "SELECT count(*) n FROM (SELECT root_id, spatial_key FROM {rp('sample_spatial')}
                             GROUP BY 1, 2 HAVING count(*) > 1)"),
  "consumer: a region summary joins obs_bio to sample_spatial on root_id", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs_bio')} o JOIN {rp('sample_spatial')} s USING (root_id)
     WHERE s.layer = 'National Marine Sanctuaries'"),
  "integrity: no non-finite coordinates in sample", "zero", glue(
    "SELECT count(*) n FROM {rp('sample')}
     WHERE isnan(latitude) OR isnan(longitude) OR isinf(latitude) OR isinf(longitude)"),
  # (the rows that read obs live in obs_rows() above: once against the deprecated table's
  #  own objects, once against the catalog view; pair_rows() reads obs_bio / obs_env directly)
  # an itis:-keyed taxon must KEEP its itis: key once worms_id is filled — the
  # cross-reference is not a licence to re-key onto the lagging authority
  "integrity: seabirds still key itis:, not worms:", "nonzero", glue(
    "SELECT count(*) n FROM {rp('dataset_taxon')} dt
     WHERE dt.dataset_key='farallon_bird-mammal' AND dt.taxon_key LIKE 'itis:%'"),
  # taxonomic_status was the literal string "accepted" on all 2,090 taxa, stamped
  # rather than fetched. A single distinct value means it has regressed to that.
  "integrity: taxonomic_status is fetched, not stamped", "nonzero", glue(
    "SELECT count(*) n FROM (
       SELECT 1 FROM {rp('taxon')} WHERE status_checked IS NOT NULL LIMIT 1)"),
  "consumer: sample event grain (bottle cast)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('sample')} WHERE dataset_key='calcofi_bottle' AND sample_type='cast'"),
  "consumer: cruise enriched per-dataset counts", "nonzero", glue(
    "SELECT count(*) n FROM {rp('cruise')} WHERE ichthyo IS NOT NULL"),
  # the one baseline every anomaly subtracts (2026-08-31): read the way ctd-transects and the
  # Explorer's Sections lens do — the cast's month-matched cell for the CTD headline series
  "consumer: climatology month-matched cell (line 90 st 60, July, 0 m, temperature_ave)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('climatology')}
     WHERE dataset_key = 'calcofi_ctd-cast' AND measurement_type = 'temperature_ave'
       AND grid_key = 'st60-ln90' AND month = 7 AND depth_bin = 0 AND n_cruises >= 3"),
  "integrity: climatology carries one window, stamped on every row", "zero", glue(
    "SELECT count(*) - 1 AS n FROM (SELECT DISTINCT clim_yr_min, clim_yr_max FROM {rp('climatology')})"),
  "integrity: climatology depth_bin is a 10 m floor bin within 0-500", "zero", glue(
    "SELECT count(*) n FROM {rp('climatology')} WHERE depth_bin % 10 <> 0 OR depth_bin < 0 OR depth_bin > 500"),
  # station_uuid (WS-B / Ed Weber's ask, calcofi4db >= 3.32.0): the SWFSC station
  # occupation each event belongs to. Read the way a consumer would — obs -> its
  # sample -> the ichthyo site sample.station_uuid names — and require a real
  # majority of bottle casts to resolve (measured 78.0% of 35,644 at v2026.08.25;
  # 25,000 is a conservative floor a schema regression would trip well before).
  "consumer: obs -> sample.station_uuid resolves to an ichthyo site (bottle)", "nonzero", glue(
    "SELECT CASE WHEN count(DISTINCT s.sample_key) >= 25000
                 THEN count(DISTINCT s.sample_key) ELSE 0 END AS n
     FROM {rp('obs')} o
     JOIN {rp('sample')} s ON s.sample_key = o.sample_key
     JOIN {rp('sample')} i ON i.source_uuid = s.station_uuid
     WHERE o.dataset_key = 'calcofi_bottle' AND s.station_uuid IS NOT NULL
       AND i.dataset_key = 'swfsc_ichthyo' AND i.sample_type = 'site'"))

# obs three ways — the deprecated table's own objects, the catalog view, the pair directly — so the
# contract covers every path a consumer can take through the deprecation window (D-S1)
contract <- dplyr::bind_rows(contract, obs_rows(rp, "obs"), obs_rows(rpv, "obs view"), pair_rows)

contract_res <- purrr::pmap_dfr(contract, function(name, expect, sql) {
  t0  <- Sys.time()
  out <- tryCatch(DBI::dbGetQuery(con_test, sql), error = function(e) e)
  ms  <- as.numeric(difftime(Sys.time(), t0, units = "secs")) * 1000
  if (inherits(out, "error"))
    return(tibble::tibble(query = name, label = "consumer-contract", status = "fail",
      reason = NA_character_, rows = NA_integer_, ms = round(ms, 1), error = conditionMessage(out)))
  n  <- as.numeric(out$n[1])
  ok <- if (expect == "zero") n == 0 else n > 0
  tibble::tibble(query = name, label = "consumer-contract",
    status = if (ok) "pass" else "fail", reason = NA_character_,
    rows = as.integer(n), ms = round(ms, 1),
    error = if (ok) NA_character_ else glue("expected {expect}, got n={n}"))
})

# the measured keys travel with the release: integrity.json (calcofi4db >= 4.7.0, written by
# release_database.qmd's relationship_integrity chunk) must exist and report every key ok
integrity_path <- file.path(releases_dir, release_version, "integrity.json")
integ <- if (file.exists(integrity_path)) jsonlite::fromJSON(integrity_path) else NULL
integ_row <- tibble::tibble(
  query  = "sidecar: integrity.json present and every declared key measured ok",
  label  = "consumer-contract",
  status = if (!is.null(integ) && isTRUE(integ$ok)) "pass" else "fail",
  reason = NA_character_,
  rows   = if (is.null(integ)) NA_integer_ else as.integer(integ$n_foreign_keys),
  ms     = NA_real_,
  error  = if (is.null(integ)) "integrity.json missing" else
           if (!isTRUE(integ$ok)) "integrity.json reports a failing key" else NA_character_)
contract_res <- dplyr::bind_rows(contract_res, integ_row)

# fold into the same results table the promote gate reads
results <- dplyr::bind_rows(results, contract_res)

contract_res |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, rows, ms, error) |>
  DT::datatable(escape = FALSE, rownames = FALSE,
    options = list(pageLength = 20), colnames = c("", "contract query", "n", "ms", "error"))

5 Package examples — calcofi4r and calcofi4py are consumers too

The two client packages are the first thing a reader copies. On 2026-09-09 a researcher ran the opening example of the calcofi4py README against v2026.09.06 and it failed on sample.datetime_utc (the column is datetime); the calcofi4r README had drifted further — to tables the core consolidation retired — because its chunks were never executed. The contract queries above pin the shapes consumers depend on; this section runs the packages’ own README examples against the just-uploaded release, as a reader would:

  • ../calcofi4r/tests/testthat/test-readme.R knits README.Rmd for real (every chunk executes);
  • ../calcofi4py/tests/test_readme.py executes every python block of README.md.

Both packages resolve "latest" through CALCOFI_RELEASE_VERSION and the releases prefix through CALCOFI_RELEASE_PREFIX (calcofi4r ≥ 1.24.0, calcofi4py ≥ 0.9.1), so they read this release under this prefix — on a staging run, the staging bucket — never the promoted one. One row per package joins the promote gate. The sibling checkouts are required, as ../db-query is: the gate tests the packages as they are on disk, so pull them first. CALCOFI_PY names the Python to use (default: the sibling’s .venv/bin/python, else python3).

Code
pkg_env <- c(
  CALCOFI_RELEASE_VERSION = release_version,
  CALCOFI_RELEASE_PREFIX  = release_prefix,
  # the package tests skip their PostgreSQL blocks without this; the gate is the release
  CALCOFI_PG_TEST = "",
  # a bare Rscript has no NOT_CRAN, and testthat's skip_if_offline() then skips "On CRAN" —
  # which this gate counts as a failure (the first dry run, 2026-09-09, failed exactly so)
  NOT_CRAN = "true")
pkg_row <- function(name, ok, ms, err) tibble::tibble(
  query = name, label = "package-examples", status = if (isTRUE(ok)) "pass" else "fail",
  reason = NA_character_, rows = NA_integer_, ms = ms,
  error = if (isTRUE(ok)) NA_character_ else as.character(err))
run_logged <- function(cmd, args, dir) {
  t0  <- Sys.time()
  out <- withr::with_envvar(pkg_env, withr::with_dir(dir,
    system2(cmd, args, stdout = TRUE, stderr = TRUE)))
  list(status = attr(out, "status") %||% 0L, out = out,
       ms = as.numeric(difftime(Sys.time(), t0, units = "secs")) * 1000)
}
tail_txt <- function(x, n = 25) paste(utils::tail(x, n), collapse = "\n")

# calcofi4r: the README knit test, on the sibling checkout (its DESCRIPTION version is what runs)
r_dir <- here("../calcofi4r")
stopifnot("clone CalCOFI/calcofi4r as a sibling of this repo" = dir.exists(r_dir))
r_ver <- read.dcf(file.path(r_dir, "DESCRIPTION"), fields = "Version")[[1]]
r_run <- run_logged(file.path(R.home("bin"), "Rscript"), c("-e", shQuote(
  "res <- testthat::test_file('tests/testthat/test-readme.R', reporter = 'summary', package = 'calcofi4r', load_package = 'source');
   df <- as.data.frame(res); cat('\nreadme: failed', sum(df$failed), 'error', sum(df$error), 'skipped', sum(df$skipped), '\n');
   quit(status = as.integer(sum(df$failed) + sum(df$error) + sum(df$skipped) > 0))")), r_dir)
# a SKIP is a failure here: offline or a missing README.Rmd means the release was not tested
r_ok <- identical(r_run$status, 0L)
if (!r_ok) cat(tail_txt(r_run$out), "\n")

# calcofi4py: every README block, through the sibling's venv
py_dir <- here("../calcofi4py")
stopifnot("clone CalCOFI/calcofi4py as a sibling of this repo" = dir.exists(py_dir))
py_bin <- Sys.getenv("CALCOFI_PY", "")
if (!nzchar(py_bin)) py_bin <- if (file.exists(file.path(py_dir, ".venv/bin/python")))
  file.path(py_dir, ".venv/bin/python") else Sys.which("python3")
py_ver <- trimws(system2(py_bin, c("-c", shQuote("import calcofi4py; print(calcofi4py.__version__)")),
                         stdout = TRUE, stderr = FALSE))
# -rs prints the skip reasons; a skipped release block (offline) fails the gate below
py_run <- run_logged(py_bin, c("-m", "pytest", "-q", "-rs", "tests/test_readme.py"), py_dir)
py_summary <- grep("passed|failed|error", py_run$out, value = TRUE)
py_skipped_release <- any(grepl("SKIPPED .*no network", py_run$out))
py_ok <- identical(py_run$status, 0L) && !py_skipped_release
if (!py_ok) cat(tail_txt(py_run$out), "\n")

pkg_res <- dplyr::bind_rows(
  pkg_row(glue("package: calcofi4r {r_ver} README.Rmd knits against {release_version}"),
          r_ok, r_run$ms, tail_txt(r_run$out, 12)),
  pkg_row(glue("package: calcofi4py {py_ver} README.md blocks run against {release_version} [{paste(py_summary, collapse = '; ')}]"),
          py_ok, py_run$ms, tail_txt(py_run$out, 12)))
results <- dplyr::bind_rows(results, pkg_res)
pkg_res |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, ms, error) |>
  DT::datatable(escape = FALSE, rownames = FALSE, colnames = c("", "package example run", "ms", "error"))

6 Dataset catalog record

datasets.json (calcofi4db >= 4.1.0; plan 2026-09-05 § D-1) is the one dataset list every product reads — the landing page’s dataset grid and pages, cc_datasets(), the sitemap, data.json, STAC. Four rows join the promote gate: the file validates against datasets.schema.json, it declares the schema version this release was built with (1.1 from calcofi4db 4.5.0 — a record that says 1.0 means the release ran an older builder than the checks assume), it carries every dataset metadata.json carries, and check_dataset_catalog() reports no blocking finding (a dead endpoint on a page is worse than absence; the network half follows CALCOFI_SKIP_LINK_CHECK).

Schema 1.1’s three new findings are reported here with the rest and only one of them blocks: grain_without_description is an error — the builder should never emit an ERDDAP grain nobody has described, and a new grain means a new sentence in erddap_grain_description() — while registration_without_id and bbox_implausible are warnings a human reads. bbox_implausible is expected to fire for swfsc_ichthyo (its asserted extent reads 0–54° N × 180–77° W from bad upstream coordinates while its sampled positions sit in the California Current; question Q16 to SWFSC), so its count is printed rather than gated.

Code
ds_url  <- glue("https://storage.googleapis.com/calcofi-db/{rel_prefix}/{release_version}/datasets.json")
ds_txt  <- tryCatch(paste(readLines(ds_url, warn = FALSE, encoding = "UTF-8"), collapse = "\n"),
                    error = function(e) NULL)
cat_row <- function(name, ok, err) tibble::tibble(
  query = name, label = "dataset-catalog", status = if (isTRUE(ok)) "pass" else "fail",
  reason = NA_character_, rows = NA_integer_, ms = NA_real_,
  error = if (isTRUE(ok)) NA_character_ else as.character(err))

catalog_res <- if (is.null(ds_txt)) {
  cat_row("catalog: datasets.json present", FALSE, glue("{ds_url} not readable"))
} else {
  ds_rec  <- jsonlite::fromJSON(ds_txt, simplifyVector = FALSE)
  meta_n  <- length(jsonlite::fromJSON(
    glue("https://storage.googleapis.com/calcofi-db/{rel_prefix}/{release_version}/metadata.json"),
    simplifyVector = FALSE)$datasets)
  schema_ok <- tryCatch(validate_dataset_catalog(ds_txt), error = function(e) conditionMessage(e))
  ds_chk <- check_dataset_catalog(ds_rec, read_catalog_registries(here("metadata")),
                                  network = !nzchar(Sys.getenv("CALCOFI_SKIP_LINK_CHECK")))
  ds_bad <- ds_chk[ds_chk$level == "error" & !ds_chk$exempt, , drop = FALSE]
  # the schema 1.1 findings, named so a reader sees them without opening the table
  n_find <- function(f) sum(ds_chk$finding == f)
  dplyr::bind_rows(
    cat_row("catalog: datasets.json validates against datasets.schema.json", isTRUE(schema_ok), schema_ok),
    cat_row(glue("catalog: schema_version is 1.1 [{ds_rec$schema_version %||% 'absent'}]"),
            identical(ds_rec$schema_version, "1.1"),
            glue("built by a calcofi4db older than 4.5.0 — the D-9 fields are absent")),
    cat_row(glue("catalog: every ERDDAP grain is described ",
                 "(grain_without_description: {n_find('grain_without_description')}; ",
                 "registration_without_id: {n_find('registration_without_id')} warn; ",
                 "bbox_implausible: {n_find('bbox_implausible')} warn)"),
            n_find("grain_without_description") == 0,
            "add the grain to calcofi4db::erddap_grain_description()"),
    cat_row(glue("catalog: n(datasets) == n(metadata.json datasets) [{length(ds_rec$datasets)} vs {meta_n}]"),
            length(ds_rec$datasets) == meta_n, "count mismatch"),
    cat_row(glue("catalog: no blocking finding (check_dataset_catalog: {nrow(ds_bad)} error, ",
                 "{sum(ds_chk$level == 'warn')} warn, {sum(ds_chk$exempt)} exempt)"),
            nrow(ds_bad) == 0,
            paste(sprintf("%s %s %s", ds_bad$dataset_key, ds_bad$finding, dplyr::coalesce(ds_bad$url, "")), collapse = "; ")))
}
results <- dplyr::bind_rows(results, catalog_res)
catalog_res |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, error) |>
  DT::datatable(escape = FALSE, rownames = FALSE, colnames = c("", "catalog check", "error"))

7 Species catalog record

taxa.json (calcofi4db >= 4.9.0; plan 2026-09-09 § D6/D7) is what calcofi.io/species/ generates every species page from — one entry per taxon of this release’s taxon table with an observation at or below it, its lineage and rollups, and the name each dataset uses for it. Six rows join the promote gate: the file validates against taxa.schema.json, it declares the schema version this release was built with (1.0), its counts.taxa_observed equals the distinct taxon_key of this release’s own obs_bio, every parent_taxon_key resolves inside taxa[] (a page with a dangling breadcrumb is a 404 on the site), the roots’ rollup.n_obs sums to obs_bio’s keyed row count, and every dataset_key it names is in datasets.json.

The record is generated, so unlike the dataset catalog there is nothing here a provider question can excuse: a failing row is a bug in build_taxa_catalog() or a break in the release’s own taxon / obs_bio, and it stops the promotion. taxon and obs_bio are read back through this release’s catalog, exactly as a consumer reads them, not from the connection that built the record.

Code
taxa_url <- glue("https://storage.googleapis.com/calcofi-db/{rel_prefix}/{release_version}/taxa.json")
taxa_txt <- tryCatch(paste(readLines(taxa_url, warn = FALSE, encoding = "UTF-8"), collapse = "\n"),
                     error = function(e) NULL)
tx_row <- function(name, ok, err) tibble::tibble(
  query = name, label = "taxa-catalog", status = if (isTRUE(ok)) "pass" else "fail",
  reason = NA_character_, rows = NA_integer_, ms = NA_real_,
  error = if (isTRUE(ok)) NA_character_ else as.character(err))

taxa_res <- if (is.null(taxa_txt)) {
  tx_row("taxa: taxa.json present", FALSE, glue("{taxa_url} not readable"))
} else {
  taxa_rec  <- jsonlite::fromJSON(taxa_txt, simplifyVector = FALSE)
  schema_ok <- tryCatch(validate_taxa_catalog(taxa_txt), error = function(e) conditionMessage(e))
  # the release's own tables, resolved through its catalog like every consumer
  dbExecute(con_test, glue("CREATE OR REPLACE VIEW taxon   AS SELECT * FROM {rp('taxon')}"))
  dbExecute(con_test, glue("CREATE OR REPLACE VIEW obs_bio AS SELECT * FROM {rp('obs_bio')}"))
  tx_chk <- check_taxa_catalog(taxa_rec, con_test, if (exists("ds_rec")) ds_rec else NULL)
  chk <- function(nm) {
    r <- tx_chk[tx_chk$check == nm, , drop = FALSE]
    if (nrow(r)) as.list(r[1, ]) else
      list(ok = FALSE, expected = NA_character_, observed = NA_character_, detail = "check not reported")
  }
  a <- chk("taxa_observed"); pr <- chk("parents_resolve")
  rt <- chk("rollup_total");  dk <- chk("datasets_known")
  n_bad <- sum(!tx_chk$ok)
  dplyr::bind_rows(
    tx_row("taxa: taxa.json validates against taxa.schema.json", isTRUE(schema_ok), schema_ok),
    # the version the package that BUILT the record declares, never a literal: a schema bump
    # (1.1 added n_present, 2026-09-11) otherwise fails the release on a test that was not updated
    tx_row(glue("taxa: schema_version is {calcofi4db:::CC_TAXA_SCHEMA_VERSION} ",
                "[{taxa_rec$schema_version %||% 'absent'}]"),
           identical(taxa_rec$schema_version, calcofi4db:::CC_TAXA_SCHEMA_VERSION),
           "taxa.json was built by a different calcofi4db than the one this test loads"),
    tx_row(glue("taxa: counts.taxa_observed == distinct obs_bio.taxon_key ",
                "[{a$observed} vs {a$expected}]"), isTRUE(a$ok), a$detail),
    tx_row(glue("taxa: every parent_taxon_key resolves within taxa[] ",
                "[{taxa_rec$counts$pages} pages]"), isTRUE(pr$ok), pr$detail),
    tx_row(glue("taxa: the roots' rollup.n_obs sums to obs_bio's keyed rows [{rt$observed}]"),
           isTRUE(rt$ok), rt$detail),
    tx_row(glue("taxa: every dataset_key is in datasets.json [{taxa_rec$counts$datasets} datasets]"),
           isTRUE(dk$ok), dk$detail),
    tx_row(glue("taxa: check_taxa_catalog: {nrow(tx_chk) - n_bad}/{nrow(tx_chk)} checks pass"),
           n_bad == 0, paste(tx_chk$check[!tx_chk$ok], collapse = "; ")))
}
results <- dplyr::bind_rows(results, taxa_res)
taxa_res |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, error) |>
  DT::datatable(escape = FALSE, rownames = FALSE, colnames = c("", "species catalog check", "error"))

8 Measurements catalog record

measurements.json (calcofi4db >= 4.12.0; plan 2026-09-10 § D2/D4) is taxa.json’s twin for the environment half of the catalog, and what calcofi.io/measurements/ generates every measurement page from — one entry per measurement key this release’s obs_env carries (the registry’s variable where one is set, else the measurement_type), with one series[] per measurement_type x dataset. Six rows join the promote gate: the file validates against measurements.schema.json, it declares the schema version this release was built with (1.0), its counts.obs_env_rows equals this release’s own obs_env row count, the sum of series[].n_values over every key equals that same number (a series counted twice — the risk a wrong variable merge carries — is exactly what this catches), every series[].measurement_type is in metadata/measurement_type.csv, and every dataset_key it names is in datasets.json.

The record is generated: a failing row is a bug in build_measurements_catalog() or a break in obs_env, never something a provider question can excuse, and it stops the promotion. obs_env is read back through this release’s catalog, exactly as a consumer reads it, not from the connection that built the record.

Code
meas_url <- glue("https://storage.googleapis.com/calcofi-db/{rel_prefix}/{release_version}/measurements.json")
meas_txt <- tryCatch(paste(readLines(meas_url, warn = FALSE, encoding = "UTF-8"), collapse = "\n"),
                     error = function(e) NULL)
mm_row <- function(name, ok, err) tibble::tibble(
  query = name, label = "measurements-catalog", status = if (isTRUE(ok)) "pass" else "fail",
  reason = NA_character_, rows = NA_integer_, ms = NA_real_,
  error = if (isTRUE(ok)) NA_character_ else as.character(err))

meas_res <- if (is.null(meas_txt)) {
  mm_row("measurements: measurements.json present", FALSE, glue("{meas_url} not readable"))
} else {
  meas_rec  <- jsonlite::fromJSON(meas_txt, simplifyVector = FALSE)
  schema_ok <- tryCatch(validate_measurements_catalog(meas_txt), error = function(e) conditionMessage(e))
  dbExecute(con_test, glue("CREATE OR REPLACE VIEW obs_env AS SELECT * FROM {rp('obs_env')}"))
  mm_reg <- read_measurement_type(here("metadata/measurement_type.csv"))
  mm_chk <- check_measurements_catalog(
    meas_rec, con_test, if (exists("ds_rec")) ds_rec else NULL, mm_reg)
  chk <- function(nm) {
    r <- mm_chk[mm_chk$check == nm, , drop = FALSE]
    if (nrow(r)) as.list(r[1, ]) else
      list(ok = FALSE, expected = NA_character_, observed = NA_character_, detail = "check not reported")
  }
  ro <- chk("obs_env_rows"); st <- chk("series_total")
  tk <- chk("types_known");  dk <- chk("datasets_known")
  n_bad <- sum(!mm_chk$ok)
  # the mocked page's own arithmetic: the temperature entry's totals are its two series
  tmp <- Filter(function(m) identical(m$key, "temperature"), meas_rec$measurements)
  tmp_ok <- length(tmp) == 1 &&
    isTRUE(as.numeric(tmp[[1]]$totals$n_values) ==
             sum(vapply(tmp[[1]]$series, function(s) as.numeric(s$n_values), 0)))
  dplyr::bind_rows(
    mm_row("measurements: measurements.json validates against measurements.schema.json",
           isTRUE(schema_ok), schema_ok),
    mm_row(glue("measurements: schema_version is {calcofi4db:::CC_MEASUREMENTS_SCHEMA_VERSION} ",
                "[{meas_rec$schema_version %||% 'absent'}]"),
           identical(meas_rec$schema_version, calcofi4db:::CC_MEASUREMENTS_SCHEMA_VERSION),
           "measurements.json was built by a different calcofi4db than the one this test loads"),
    mm_row(glue("measurements: counts.obs_env_rows == obs_env rows [{ro$observed} vs {ro$expected}]"),
           isTRUE(ro$ok), ro$detail),
    mm_row(glue("measurements: sum of series[].n_values == obs_env rows over ",
                "{meas_rec$counts$measurements} keys / {meas_rec$counts$series} series [{st$observed}]"),
           isTRUE(st$ok), st$detail),
    mm_row(glue("measurements: every series[].measurement_type is in measurement_type.csv ",
                "[{meas_rec$counts$series} series]"), isTRUE(tk$ok), tk$detail),
    mm_row(glue("measurements: every dataset_key is in datasets.json ",
                "[{meas_rec$counts$datasets} datasets]"), isTRUE(dk$ok), dk$detail),
    mm_row(glue("measurements: the temperature entry's totals.n_values is its series' sum ",
                "[{if (length(tmp) == 1) tmp[[1]]$totals$n_values else 'no temperature key'}]"),
           tmp_ok, "totals.n_values != sum(series[].n_values) for `temperature`"),
    mm_row(glue("measurements: check_measurements_catalog: {nrow(mm_chk) - n_bad}/{nrow(mm_chk)} checks pass"),
           n_bad == 0, paste(mm_chk$check[!mm_chk$ok], collapse = "; ")))
}
results <- dplyr::bind_rows(results, meas_res)
meas_res |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, error) |>
  DT::datatable(escape = FALSE, rownames = FALSE, colnames = c("", "measurements catalog check", "error"))

9 Static STAC catalog

The STAC catalog release_database.qmd wrote to gs://calcofi-db/stac/ (plan 2026-09-05 § D-5.3; calcofi4db >= 4.3.0) is checked as published, not as built: the root, every child collection and this release’s item are fetched back from the bucket into a local mirror and run through check_stac() — the structural half always, stac-validator (pip) over every document when it is installed. That is what stac-browser at calcofi.io/stac/ will read.

Code
stac_prefix <- if (grepl("staging", rel_prefix)) "stac-staging" else "stac"
stac_base   <- glue("https://storage.googleapis.com/calcofi-db/{stac_prefix}")
dir_mirror  <- file.path(tempdir(), "stac_mirror")
unlink(dir_mirror, recursive = TRUE)

get_json <- function(url) tryCatch(
  paste(readLines(url, warn = FALSE, encoding = "UTF-8"), collapse = "\n"), error = function(e) NULL)
save_rel <- function(txt, rel) {
  p <- file.path(dir_mirror, rel)
  dir.create(dirname(p), recursive = TRUE, showWarnings = FALSE)
  writeLines(txt, p)
  p
}

root_txt <- get_json(glue("{stac_base}/catalog.json"))
stac_res <- if (is.null(root_txt)) {
  cat_row("stac: catalog.json present", FALSE, glue("{stac_base}/catalog.json not readable"))
} else {
  save_rel(root_txt, "catalog.json")
  root  <- jsonlite::fromJSON(root_txt, simplifyVector = FALSE)
  kids  <- Filter(function(l) identical(l$rel, "child"), root$links)
  # the record was read above; if it was not readable this row still runs, on the root's own count
  n_pub <- if (exists("ds_rec"))
    sum(vapply(ds_rec$datasets, function(d) identical(d$visibility %||% "public", "public"), logical(1)))
    else sum(!grepl("/layer_", vapply(kids, function(k) k$href, "")))
  # mirror every child collection, and each dataset collection's item for THIS release
  n_miss <- 0L
  for (k in kids) {
    rel <- sub(glue("^{stac_base}/"), "", k$href)
    txt <- get_json(k$href)
    if (is.null(txt)) { n_miss <- n_miss + 1L; next }
    save_rel(txt, rel)
    if (!grepl("/layer_", rel)) {
      id  <- basename(dirname(rel))
      irel <- glue("collections/{id}/items/{release_version}.json")
      itxt <- get_json(glue("{stac_base}/{irel}"))
      if (is.null(itxt)) n_miss <- n_miss + 1L else save_rel(itxt, irel)
    }
  }
  chk <- check_stac(dir_mirror, network = !nzchar(Sys.getenv("CALCOFI_SKIP_LINK_CHECK")))
  bad <- chk[chk$level == "error", , drop = FALSE]
  n_items <- length(list.files(dir_mirror, pattern = "^v.*[.]json$", recursive = TRUE))
  dplyr::bind_rows(
    cat_row(glue("stac: every child collection and this release's item fetched ({length(kids)} children)"),
            n_miss == 0, glue("{n_miss} document(s) missing on the bucket")),
    cat_row(glue("stac: one item per public dataset at {release_version} [{n_items} vs {n_pub}]"),
            n_items == n_pub, "item count mismatch"),
    cat_row(glue("stac: check_stac 0 errors ({sum(chk$finding == 'ok')} ok, {sum(chk$level == 'warn')} warn)"),
            nrow(bad) == 0, paste(sprintf("%s %s", bad$document, bad$finding), collapse = "; ")))
}
results <- dplyr::bind_rows(results, stac_res)
stac_res |>
  mutate(status_html = status_badge(status)) |>
  select(status_html, query, error) |>
  DT::datatable(escape = FALSE, rownames = FALSE, colnames = c("", "STAC check", "error"))

10 Save results sidecar

Code
results_path <- file.path(releases_dir, release_version, "test_results.json")
jsonlite::write_json(
  list(
    release_version = release_version,
    tested_at       = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"),
    n_pass          = sum(results$status == "pass"),
    n_fail          = sum(results$status == "fail"),
    n_skip          = sum(results$status == "skip"),
    results         = results),
  results_path,
  auto_unbox = TRUE, pretty = TRUE, null = "null")

# under the staging prefix too: this line hardcoded "ducklake/releases" while every other path in the
# file read CALCOFI_RELEASE_PREFIX, so a staging test wrote test_results.json to the REAL prefix (a
# phantom releases/v2026.09.04/ on 2026-09-04, deleted by hand). release_prefix is set in `setup`.
gcs_bucket  <- "calcofi-db"
gcs_release <- glue("{release_prefix}/{release_version}")
put_gcs_file(results_path,
  glue("gs://{gcs_bucket}/{gcs_release}/test_results.json"))
ℹ 2026-09-11 22:47:02.28282 > File size detected as  15.9 Kb
gs://calcofi-db/ducklake/releases/v2026.09.11/test_results.json
Code
message(glue("test_results.json uploaded for {release_version}"))
test_results.json uploaded for v2026.09.11

11 Promote (gated on all-pass)

latest.txt is written only if no query failed. Skipped queries (JS-only sql_builder) do not block promotion — they’re exercised in the browser by the query app.

Code
n_fail <- sum(results$status == "fail")
n_pass <- sum(results$status == "pass")
n_skip <- sum(results$status == "skip")

if (n_fail > 0) {
  failures <- results |> filter(status == "fail")
  for (i in seq_len(nrow(failures))) {
    message(glue("FAIL [{failures$query[i]}]: {failures$error[i]}"))
  }
  stop(glue(
    "{n_fail} query(ies) failed against {release_version}. ",
    "latest.txt NOT updated."))
}

# All green — but a green suite is NOT sufficient to promote, and on 2026-08-14
# that distinction cost an hour of broken consumers. `release_database` died at
# `upload_frozen` with the parquet uploaded and the JSON sidecars not; this suite
# then passed 28/28 against that parquet — correctly, the data was fine — and
# moved `latest.txt` to a release with no catalog.json, the file `cc_get_db()`
# opens. The queries test the DATA; they cannot see whether the release is
# READABLE, because they never open the catalog.
#
# promote_release() answers the second question first: it refuses to move the
# pointer unless catalog.json / metadata.json / relationships.json are all
# present, and writes the object with Cache-Control: no-cache so the change
# reaches consumers immediately instead of up to an hour later.
promote_release(release_version, bucket = gcs_bucket,
                prefix = Sys.getenv("CALCOFI_RELEASE_PREFIX", "ducklake/releases"))
promoted v2026.09.11 -> gs://calcofi-db/ducklake/releases/latest.txt (no-cache)
Code
message(glue(
  "PROMOTED {release_version}: {n_pass} passed, {n_skip} skipped, ",
  "0 failed. latest.txt is now {release_version}."))
PROMOTED v2026.09.11: 87 passed, 4 skipped, 0 failed. latest.txt is now v2026.09.11.
Code
# re-publish this version's RELEASE_NOTES.md now that catalog.json (with its
# total size) and test_results.json exist and latest.txt points here — the
# appendix carries the validation result and the "promoted" mark. Notes are not
# data, so a failure here is a warning, never an un-promotion.
tryCatch(
  publish_release_notes(
    release_version, here::here("RELEASES.md"), releases_dir,
    bucket = gcs_bucket,
    pkg_versions = c(
      calcofi4db = as.character(packageVersion("calcofi4db")),
      calcofi4r  = as.character(packageVersion("calcofi4r")))),
  error = function(e) warning(glue("release notes not re-published: {conditionMessage(e)}")))
no Zenodo record for v2026.09.11 yet; notes cite the db-schema URL
ℹ 2026-09-11 22:47:16.10013 > File size detected as  8.9 Kb
unchanged, not re-uploaded: gs://calcofi-db/ducklake/releases/RELEASES.md
published release notes for v2026.09.11 -> gs://calcofi-db/ducklake/releases/v2026.09.11/RELEASE_NOTES.md
Code
# regenerate the browsable release index so the public listing cannot lag the
# bucket. `versions.json` and `latest.txt` are written by the release itself and
# were always current, but the HTML at
# storage.calcofi.io/calcofi-db/ducklake/releases/ is a STATIC page built by
# scripts/build_release_index.R -- which nothing called. It was last run by hand
# on 2026-07-29, so it advertised "16 releases, latest v2026.07.17" while the
# bucket held 20 and had long since promoted past it. Data consumers were
# unaffected (they read latest.txt); it was humans who were misinformed.
#
# Runs AFTER the promotion above, because the index reads latest.txt to mark the
# "latest" badge. Non-fatal for the same reason as the dispatch below: a stale
# index is a documentation bug, not a reason to fail a validated release.
# a staging run must not touch the public index or the hosted consumers: both read the REAL
# latest.txt, so a staging pass would rebuild every consumer against the unchanged promoted
# release (measured 2026-09-10 while preparing v2026.09.10: nothing here was staging-aware).
idx_script <- here::here("scripts", "build_release_index.R")
if (staging) {
  message("STAGING run: release index and consumer dispatches skipped")
} else if (file.exists(idx_script)) {
  idx_rc <- system2(file.path(R.home("bin"), "Rscript"), idx_script,
                    stdout = TRUE, stderr = TRUE)
  if ((attr(idx_rc, "status") %||% 0L) != 0) {
    warning(glue("release index rebuild FAILED (latest.txt is still correct): ",
                 "{paste(utils::tail(idx_rc, 5), collapse = ' | ')}"))
  } else {
    message("release index rebuilt")
  }
}
release index rebuilt
Code
# nudge the query site to adopt the new default_version now (previously a
# 6-hourly cron poll): dispatch CalCOFI/db-query's bump-default-version workflow,
# which mirrors latest.txt -> _config.yml and triggers a Pages redeploy. Gated
# here on the all-pass promotion, so the site never points at a release whose
# queries haven't been validated. Non-fatal — a missing gh just means the bump
# waits for a manual `gh workflow run`.
# Declared as a table rather than one if-block per consumer: the first two were
# copy-pasted and had already drifted (only one warned when `gh` was missing), and
# each new static consumer would add another copy of the same eight lines.
gh_dispatch <- tribble(
  ~repo,                      ~workflow,                     ~what,
  "CalCOFI/db-query",         "bump-default-version.yml",    "query-site default_version bump",
  "CalCOFI/db-viz-station",   "refresh.yml",                 "station-portal coverage refresh",
  # rebuilds the per-(line, cruise) section shards from the new release
  "CalCOFI/ctd-transects",    "refresh.yml",                 "CTD transect sections refresh",
  # re-fetches datasets.json and rebuilds the dataset grid + pages (plan 2026-09-05 D-2; the
  # workflow is created by WS-P1 — until then the dispatch warns, it does not fail)
  "CalCOFI/CalCOFI.github.io", "refresh.yml",                "dataset catalog refresh (landing page + dataset pages)",
  # the client packages: re-run their README/vignette examples against the promoted latest and
  # republish their sites (pkgdown executes the vignettes; the pytest job executes the README)
  "CalCOFI/calcofi4r",        "pkgdown.yaml",                "calcofi4r site + vignettes against latest",
  "CalCOFI/calcofi4py",       "test.yml",                    "calcofi4py README examples against latest")

gh_bin <- Sys.which("gh")
if (staging) {
  # skipped above: nothing to nudge until a real promotion
} else if (!nzchar(gh_bin)) {
  message(glue(
    "gh not found; these need a manual `gh workflow run`: ",
    "{paste(gh_dispatch$repo, collapse = ', ')}"))
} else {
  pwalk(gh_dispatch, function(repo, workflow, what) {
    disp <- system2(gh_bin,
      c("workflow", "run", workflow, "--repo", repo),
      stdout = TRUE, stderr = TRUE)
    if (!identical(attr(disp, "status") %||% 0L, 0L))
      warning(glue("{what} dispatch failed: {paste(disp, collapse = '; ')}"))
    else
      message(glue("dispatched {repo} {workflow} ({what})"))
  })
}
dispatched CalCOFI/db-query bump-default-version.yml (query-site default_version bump)
dispatched CalCOFI/db-viz-station refresh.yml (station-portal coverage refresh)
dispatched CalCOFI/ctd-transects refresh.yml (CTD transect sections refresh)
dispatched CalCOFI/CalCOFI.github.io refresh.yml (dataset catalog refresh (landing page + dataset pages))
dispatched CalCOFI/calcofi4r pkgdown.yaml (calcofi4r site + vignettes against latest)
dispatched CalCOFI/calcofi4py test.yml (calcofi4py README examples against latest)
Code
# Server-side consumers (db-viz-hex, db-viz-cruise, the h3t tile API) are NOT
# deployed here. They are their own target — `deploy_consumers`, which depends on
# this one — so that "the release shipped but consumers were never updated" is a
# state visible in tar_visnetwork()/tar_outdated() rather than a silent branch
# inside this chunk, and so the deploy can be re-run without re-running the query
# suite. The dispatches above stay here because they are instantaneous and target
# GitHub-hosted consumers that redeploy themselves.

12 Cleanup

Code
close_duckdb(con_test)
Code
devtools::session_info()
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.1 (2026-06-24)
 os       macOS Sequoia 15.7.1
 system   aarch64, darwin23
 ui       X11
 language (EN)
 collate  en_US.UTF-8
 ctype    en_US.UTF-8
 tz       Europe/Rome
 date     2026-09-11
 pandoc   3.8.3 @ /opt/homebrew/bin/ (via rmarkdown)
 quarto   1.8.25 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 !  package             * version    date (UTC) lib source
    abind                 1.4-8      2024-09-12 [1] CRAN (R 4.6.0)
    arrow                 25.0.1     2026-08-23 [1] CRAN (R 4.6.1)
    askpass               1.2.1      2024-10-04 [1] CRAN (R 4.6.0)
    assertthat            0.2.1      2019-03-21 [1] CRAN (R 4.6.0)
    backports             1.5.1      2026-04-03 [1] CRAN (R 4.6.0)
    base64enc             0.1-6      2026-02-02 [1] CRAN (R 4.6.0)
    bit                   4.6.0      2025-03-06 [1] CRAN (R 4.6.0)
    bit64                 4.8.6      2026-09-01 [1] CRAN (R 4.6.1)
    blob                  1.3.0      2026-01-14 [1] CRAN (R 4.6.0)
    brio                  1.1.5      2024-04-24 [1] CRAN (R 4.6.0)
    broom                 1.0.13     2026-05-14 [1] CRAN (R 4.6.0)
    bslib                 0.12.0     2026-08-04 [1] CRAN (R 4.6.1)
    cachem                1.1.0      2024-05-16 [1] CRAN (R 4.6.0)
 VP calcofi4db          * 4.14.0     2026-09-11 [?] load_all() (on disk 4.13.0)
    calcofi4r             1.24.2     2026-09-10 [1] local
    class                 7.3-24     2026-08-03 [1] CRAN (R 4.6.1)
    classInt              0.4-11     2025-01-08 [1] CRAN (R 4.6.0)
    cli                   3.6.6      2026-04-09 [1] CRAN (R 4.6.0)
    codetools             0.2-20     2024-03-31 [1] CRAN (R 4.6.1)
    crayon                1.5.3      2024-06-20 [1] CRAN (R 4.6.0)
    crosstalk             1.2.2      2025-08-26 [1] CRAN (R 4.6.0)
    curl                  8.0.0      2026-08-25 [1] CRAN (R 4.6.1)
    data.table            1.18.6.1   2026-08-24 [1] CRAN (R 4.6.1)
    DBI                 * 1.3.0      2026-02-25 [1] CRAN (R 4.6.0)
    dbplyr                2.6.0      2026-06-17 [1] CRAN (R 4.6.0)
    desc                  1.4.3      2023-12-10 [1] CRAN (R 4.6.0)
    devtools              2.5.2      2026-04-30 [1] CRAN (R 4.6.0)
    DiagrammeR            1.0.12     2026-04-27 [1] CRAN (R 4.6.0)
    DiagrammeRsvg         0.1        2016-02-04 [1] CRAN (R 4.6.0)
    digest                0.6.39     2025-11-19 [1] CRAN (R 4.6.0)
    dm                    1.1.2      2026-05-17 [1] CRAN (R 4.6.0)
    dplyr               * 1.2.1      2026-04-03 [1] CRAN (R 4.6.0)
    DT                  * 0.34.0     2025-09-02 [1] CRAN (R 4.6.0)
    duckdb              * 1.5.5      2026-07-25 [1] CRAN (R 4.6.1)
    dygraphs              1.1.1.6    2018-07-11 [1] CRAN (R 4.6.0)
    e1071                 1.7-17     2025-12-18 [1] CRAN (R 4.6.0)
    ellipsis              0.3.3      2026-04-04 [1] CRAN (R 4.6.0)
    evaluate              1.0.5      2025-08-27 [1] CRAN (R 4.6.0)
    farver                2.1.2      2024-05-13 [1] CRAN (R 4.6.0)
    fastmap               1.2.0      2024-05-15 [1] CRAN (R 4.6.0)
    fs                  * 2.1.0      2026-04-18 [1] CRAN (R 4.6.0)
    fuzzyjoin             0.1.8      2026-02-20 [1] CRAN (R 4.6.0)
    gargle                1.6.1      2026-01-29 [1] CRAN (R 4.6.0)
    generics              0.1.4      2025-05-09 [1] CRAN (R 4.6.0)
    geojsonsf             2.0.5      2025-11-26 [1] CRAN (R 4.6.0)
    ggplot2               4.0.3      2026-04-22 [1] CRAN (R 4.6.0)
    glue                * 1.8.1      2026-04-17 [1] CRAN (R 4.6.0)
    googleAuthR           2.0.2.1    2026-01-09 [1] CRAN (R 4.6.0)
    googleCloudStorageR   0.7.0      2021-12-16 [1] CRAN (R 4.6.0)
    googledrive           2.1.2      2025-09-10 [1] CRAN (R 4.6.0)
    gtable                0.3.6      2024-10-25 [1] CRAN (R 4.6.0)
    here                * 1.0.2      2025-09-15 [1] CRAN (R 4.6.0)
    highcharter           0.9.5      2026-04-22 [1] CRAN (R 4.6.0)
    hms                   1.1.4      2025-10-17 [1] CRAN (R 4.6.0)
    htmltools             0.5.9      2025-12-04 [1] CRAN (R 4.6.0)
    htmlwidgets           1.6.4      2023-12-06 [1] CRAN (R 4.6.0)
    httpuv                1.6.17     2026-03-18 [1] CRAN (R 4.6.0)
    httr                  1.4.9      2026-09-01 [1] CRAN (R 4.6.1)
    httr2                 1.3.0      2026-07-13 [1] CRAN (R 4.6.1)
    igraph                2.3.3      2026-06-26 [1] CRAN (R 4.6.1)
    isoband               0.3.0      2025-12-07 [1] CRAN (R 4.6.0)
    janitor               2.2.1      2024-12-22 [1] CRAN (R 4.6.0)
    jquerylib             0.1.4      2021-04-26 [1] CRAN (R 4.6.0)
    jsonlite            * 2.0.0      2025-03-27 [1] CRAN (R 4.6.0)
    jsonvalidate          1.5.0      2025-02-07 [1] CRAN (R 4.6.0)
    KernSmooth            2.23-27    2026-08-12 [1] CRAN (R 4.6.1)
    knitr                 1.52       2026-09-06 [1] CRAN (R 4.6.1)
    later                 1.4.8      2026-03-05 [1] CRAN (R 4.6.0)
    lattice               0.23-1     2026-08-12 [1] CRAN (R 4.6.1)
    leafem                0.2.5      2025-08-28 [1] CRAN (R 4.6.0)
    leaflet               2.2.3      2025-09-04 [1] CRAN (R 4.6.0)
    librarian             1.8.1      2021-07-12 [1] CRAN (R 4.6.0)
    lifecycle             1.0.5      2026-01-08 [1] CRAN (R 4.6.0)
    lubridate             1.9.5      2026-02-04 [1] CRAN (R 4.6.0)
    magrittr              2.0.5      2026-04-04 [1] CRAN (R 4.6.0)
    mapgl                 0.5.0.9000 2026-09-01 [1] Github (bbest/mapgl@484e869)
    mapview               2.11.4     2025-09-08 [1] CRAN (R 4.6.0)
    markdown              2.0        2025-03-23 [1] CRAN (R 4.6.0)
    Matrix                1.7-6      2026-07-25 [1] CRAN (R 4.6.1)
    memoise               2.0.1      2021-11-26 [1] CRAN (R 4.6.0)
    mgcv                  1.9-4      2025-11-07 [1] CRAN (R 4.6.1)
    mime                  0.13       2025-03-17 [1] CRAN (R 4.6.0)
    nlme                  3.1-171    2026-09-01 [1] CRAN (R 4.6.1)
    openssl               2.4.2      2026-06-09 [1] CRAN (R 4.6.0)
    otel                  0.2.0      2025-08-29 [1] CRAN (R 4.6.0)
    pillar                1.11.1     2025-09-17 [1] CRAN (R 4.6.0)
    pkgbuild              1.4.8      2025-05-26 [1] CRAN (R 4.6.0)
    pkgconfig             2.0.3      2019-09-22 [1] CRAN (R 4.6.0)
    pkgload               1.5.3      2026-06-15 [1] CRAN (R 4.6.0)
    plotly                4.12.1     2026-07-22 [1] CRAN (R 4.6.1)
    png                   0.1-9      2026-03-15 [1] CRAN (R 4.6.0)
    promises              1.5.0      2025-11-01 [1] CRAN (R 4.6.0)
    proxy                 0.4-29     2025-12-29 [1] CRAN (R 4.6.0)
    purrr               * 1.2.2      2026-04-10 [1] CRAN (R 4.6.0)
    quantmod              0.4.29     2026-06-28 [1] CRAN (R 4.6.1)
    R6                    2.6.1      2025-02-15 [1] CRAN (R 4.6.0)
    raster                3.6-32     2025-03-28 [1] CRAN (R 4.6.0)
    RColorBrewer          1.1-3      2022-04-03 [1] CRAN (R 4.6.0)
    Rcpp                  1.1.2      2026-07-05 [1] CRAN (R 4.6.1)
    readr                 2.2.0      2026-02-19 [1] CRAN (R 4.6.0)
    rlang                 1.3.0      2026-07-05 [1] CRAN (R 4.6.1)
    rlist                 0.4.6.2    2021-09-03 [1] CRAN (R 4.6.0)
    rmarkdown             2.32       2026-09-01 [1] CRAN (R 4.6.1)
    RPostgres             1.4.10     2026-02-16 [1] CRAN (R 4.6.0)
    rprojroot             2.1.1      2025-08-26 [1] CRAN (R 4.6.0)
    rstudioapi            0.19.0     2026-06-11 [1] CRAN (R 4.6.0)
    S7                    0.2.2      2026-04-22 [1] CRAN (R 4.6.0)
    sass                  0.4.10     2025-04-11 [1] CRAN (R 4.6.0)
    satellite             1.0.6      2025-08-21 [1] CRAN (R 4.6.0)
    scales                1.4.0      2025-04-24 [1] CRAN (R 4.6.0)
    sessioninfo           1.2.4      2026-06-04 [1] CRAN (R 4.6.0)
    sf                    1.1-2      2026-07-23 [1] CRAN (R 4.6.1)
    shiny                 1.14.0     2026-06-21 [1] CRAN (R 4.6.0)
    shinyWidgets          0.9.1      2026-03-09 [1] CRAN (R 4.6.0)
    snakecase             0.11.1     2023-08-27 [1] CRAN (R 4.6.0)
    sp                    2.2-3      2026-07-19 [1] CRAN (R 4.6.1)
    stars                 0.7-3      2026-07-20 [1] CRAN (R 4.6.1)
    stringi               1.8.9      2026-08-04 [1] CRAN (R 4.6.1)
    stringr               1.6.0      2025-11-04 [1] CRAN (R 4.6.0)
    terra                 1.9-46     2026-08-22 [1] CRAN (R 4.6.1)
    testthat            * 3.3.2      2026-01-11 [1] CRAN (R 4.6.0)
    tibble              * 3.3.1      2026-01-11 [1] CRAN (R 4.6.0)
    tidyr                 1.3.2      2025-12-19 [1] CRAN (R 4.6.0)
    tidyselect            1.2.1      2024-03-11 [1] CRAN (R 4.6.0)
    timechange            0.4.0      2026-01-29 [1] CRAN (R 4.6.0)
    TTR                   0.24.4     2023-11-28 [1] CRAN (R 4.6.0)
    tzdb                  0.5.0      2025-03-15 [1] CRAN (R 4.6.0)
    units                 1.0-1      2026-03-11 [1] CRAN (R 4.6.0)
    usethis               3.2.1      2025-09-06 [1] CRAN (R 4.6.0)
    uuid                  1.2-2      2026-01-23 [1] CRAN (R 4.6.0)
    V8                    8.2.0      2026-04-21 [1] CRAN (R 4.6.0)
    vctrs                 0.7.3      2026-04-11 [1] CRAN (R 4.6.0)
    viridisLite           0.4.3      2026-02-04 [1] CRAN (R 4.6.0)
    visNetwork            2.1.4      2025-09-04 [1] CRAN (R 4.6.0)
    vroom                 1.7.1      2026-03-31 [1] CRAN (R 4.6.0)
    withr               * 3.0.3      2026-06-19 [1] CRAN (R 4.6.0)
    xfun                  0.60       2026-07-09 [1] CRAN (R 4.6.1)
    xtable                1.8-8      2026-02-22 [1] CRAN (R 4.6.0)
    xts                   0.14.2     2026-02-28 [1] CRAN (R 4.6.0)
    yaml                * 2.3.12     2025-12-10 [1] CRAN (R 4.6.0)
    zip                   3.0.2      2026-08-04 [1] CRAN (R 4.6.1)
    zoo                   1.9-0      2026-07-31 [1] CRAN (R 4.6.1)

 [1] /Library/Frameworks/R.framework/Versions/4.6/Resources/library

 * ── Packages attached to the search path.
 V ── Loaded and on-disk version mismatch.
 P ── Loaded and on-disk path mismatch.

──────────────────────────────────────────────────────────────────────────────