Test Release — gate latest.txt on query suite

Published

2026-07-17

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. If every query returns without error, 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,
  yaml,
  quiet = T)

# which release to test? read the just-uploaded version from the local
# release directory (most recent date-stamped folder under data/releases/)
releases_dir <- here("data/releases")
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.07.17
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:")
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)
  }

  txt
}

# 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_)
  }
})

# 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
rel_base <- glue(
  "https://storage.googleapis.com/calcofi-db/ducklake/releases/{release_version}/parquet")
rp <- function(t) glue("read_parquet('{rel_base}/{t}.parquet')")

# 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,
  "consumer: match env (bottle temperature)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs')}
     WHERE realm='env' AND dataset_key='calcofi_bottle' AND measurement_type='temperature'"),
  "consumer: match/download bio (ichthyo abundance x taxon x effort)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('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'"),
  "integrity: every non-null obs.taxon_key resolves to taxon", "zero", glue(
    "SELECT count(*) n FROM {rp('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)"),
  "consumer: station coverage rollup (obs GROUP BY grid,dataset)", "nonzero", glue(
    "SELECT count(*) n FROM (
       SELECT grid_key, dataset_key, count(*), count(DISTINCT sample_key), count(DISTINCT cruise_key)
       FROM {rp('obs')} WHERE grid_key IS NOT NULL GROUP BY 1,2)"),
  "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"),
  "consumer: obs single-file readable over HTTPS (browser match.js)", "nonzero", glue(
    "SELECT count(*) n FROM {rp('obs')} WHERE realm='bio' LIMIT 1"),
  "contract: obs.measurement_type all in registry", "zero", glue(
    "SELECT count(*) n FROM {rp('obs')}
     WHERE measurement_type NOT IN (SELECT measurement_type FROM {rp('measurement_type')})"),
  "contract: obs.sample_key resolves in sample", "zero", glue(
    "SELECT count(*) n FROM {rp('obs')} WHERE sample_key NOT IN (SELECT sample_key FROM {rp('sample')})"),
  "contract: obs.hex_id present where lat/lng", "zero", glue(
    "SELECT count(*) n FROM {rp('obs')} WHERE hex_id IS NULL AND latitude IS NOT NULL AND longitude IS NOT NULL"))

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}"))
})

# 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 Save results sidecar

Code
results_path <- file.path(here("data/releases"), 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")

gcs_bucket  <- "calcofi-db"
gcs_release <- glue("ducklake/releases/{release_version}")
put_gcs_file(results_path,
  glue("gs://{gcs_bucket}/{gcs_release}/test_results.json"))
ℹ 2026-07-17 12:57:18.997865 > File size detected as  3.8 Kb
gs://calcofi-db/ducklake/releases/v2026.07.17/test_results.json
Code
message(glue("test_results.json uploaded for {release_version}"))
test_results.json uploaded for v2026.07.17

6 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 — promote
latest_local <- tempfile()
writeLines(release_version, latest_local)
put_gcs_file(latest_local,
  glue("gs://{gcs_bucket}/ducklake/releases/latest.txt"))
ℹ 2026-07-17 12:57:21.33965 > File size detected as  12 bytes
gs://calcofi-db/ducklake/releases/latest.txt
Code
message(glue(
  "PROMOTED {release_version}: {n_pass} passed, {n_skip} skipped, ",
  "0 failed. latest.txt is now {release_version}."))
PROMOTED v2026.07.17: 19 passed, 4 skipped, 0 failed. latest.txt is now v2026.07.17.
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`.
gh_bin <- Sys.which("gh")
if (nzchar(gh_bin)) {
  disp <- system2(gh_bin,
    c("workflow", "run", "bump-default-version.yml", "--repo", "CalCOFI/db-query"),
    stdout = TRUE, stderr = TRUE)
  if (!identical(attr(disp, "status") %||% 0L, 0L))
    warning(glue("query bump dispatch failed: {paste(disp, collapse = '; ')}"))
  else
    message("dispatched CalCOFI/db-query bump-default-version workflow")
} else {
  message("gh not found; CalCOFI/db-query default_version needs a manual bump dispatch")
}
dispatched CalCOFI/db-query bump-default-version workflow
Code
# also refresh the station data portal: rebuild its prebuilt per-station coverage
# JSON (public/data/*.json) from the new release's ingest parquet, then redeploy.
# Same gating + non-fatal pattern as the db-query bump above.
if (nzchar(gh_bin)) {
  disp2 <- system2(gh_bin,
    c("workflow", "run", "refresh.yml", "--repo", "CalCOFI/db-viz-station"),
    stdout = TRUE, stderr = TRUE)
  if (!identical(attr(disp2, "status") %||% 0L, 0L))
    warning(glue("station-portal refresh dispatch failed: {paste(disp2, collapse = '; ')}"))
  else
    message("dispatched CalCOFI/db-viz-station refresh workflow")
}
dispatched CalCOFI/db-viz-station refresh workflow

7 Cleanup

Code
close_duckdb(con_test)
Code
devtools::session_info()
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.5.2 (2025-10-31)
 os       macOS Sequoia 15.7.1
 system   aarch64, darwin20
 ui       X11
 language (EN)
 collate  en_US.UTF-8
 ctype    en_US.UTF-8
 tz       Europe/Rome
 date     2026-07-17
 pandoc   3.8.3 @ /opt/homebrew/bin/ (via rmarkdown)
 quarto   1.8.25 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 ! package             * version   date (UTC) lib source
   arrow                 24.0.0    2026-04-29 [1] CRAN (R 4.5.2)
   askpass               1.2.1     2024-10-04 [1] CRAN (R 4.5.0)
   assertthat            0.2.1     2019-03-21 [1] CRAN (R 4.5.0)
   backports             1.5.1     2026-04-03 [1] CRAN (R 4.5.2)
   bit                   4.6.0     2025-03-06 [1] CRAN (R 4.5.0)
   bit64                 4.8.2     2026-05-19 [1] CRAN (R 4.5.2)
   blob                  1.3.0     2026-01-14 [1] CRAN (R 4.5.2)
   brio                  1.1.5     2024-04-24 [1] CRAN (R 4.5.0)
   bslib                 0.11.0    2026-05-16 [1] CRAN (R 4.5.2)
   cachem                1.1.0     2024-05-16 [1] CRAN (R 4.5.0)
 P calcofi4db          * 2.10.0    2026-07-17 [?] load_all()
   cli                   3.6.6     2026-04-09 [1] CRAN (R 4.5.2)
   crayon                1.5.3     2024-06-20 [1] CRAN (R 4.5.0)
   crosstalk             1.2.2     2025-08-26 [1] CRAN (R 4.5.0)
   curl                  7.1.0     2026-04-22 [1] CRAN (R 4.5.2)
   DBI                 * 1.3.0     2026-02-25 [1] CRAN (R 4.5.2)
   dbplyr                2.5.2     2026-02-13 [1] CRAN (R 4.5.2)
   desc                  1.4.3     2023-12-10 [1] CRAN (R 4.5.0)
   devtools              2.5.0     2026-03-14 [1] CRAN (R 4.5.2)
   DiagrammeR            1.0.12    2026-04-27 [1] CRAN (R 4.5.2)
   DiagrammeRsvg         0.1       2016-02-04 [1] CRAN (R 4.5.0)
   digest                0.6.39    2025-11-19 [1] CRAN (R 4.5.2)
   dm                    1.1.2     2026-05-17 [1] CRAN (R 4.5.2)
   dplyr               * 1.2.1     2026-04-03 [1] CRAN (R 4.5.2)
   DT                  * 0.34.0    2025-09-02 [1] CRAN (R 4.5.0)
   duckdb              * 1.5.2     2026-04-13 [1] CRAN (R 4.5.2)
   ellipsis              0.3.2     2021-04-29 [1] CRAN (R 4.5.0)
   evaluate              1.0.5     2025-08-27 [1] CRAN (R 4.5.0)
   fastmap               1.2.0     2024-05-15 [1] CRAN (R 4.5.0)
   fs                  * 2.1.0     2026-04-18 [1] CRAN (R 4.5.2)
   gargle                1.6.1     2026-01-29 [1] CRAN (R 4.5.2)
   generics              0.1.4     2025-05-09 [1] CRAN (R 4.5.0)
   glue                * 1.8.1     2026-04-17 [1] CRAN (R 4.5.2)
   googleAuthR           2.0.2.1   2026-01-09 [1] CRAN (R 4.5.2)
   googleCloudStorageR   0.7.0     2021-12-16 [1] CRAN (R 4.5.0)
   googledrive           2.1.2     2025-09-10 [1] CRAN (R 4.5.0)
   here                * 1.0.2     2025-09-15 [1] CRAN (R 4.5.0)
   hms                   1.1.4     2025-10-17 [1] CRAN (R 4.5.0)
   htmltools             0.5.9     2025-12-04 [1] CRAN (R 4.5.2)
   htmlwidgets           1.6.4     2023-12-06 [1] CRAN (R 4.5.0)
   httpuv                1.6.17    2026-03-18 [1] CRAN (R 4.5.2)
   httr                  1.4.8     2026-02-13 [1] CRAN (R 4.5.2)
   igraph                2.3.2     2026-05-29 [1] CRAN (R 4.5.2)
   janitor               2.2.1     2024-12-22 [1] CRAN (R 4.5.0)
   jquerylib             0.1.4     2021-04-26 [1] CRAN (R 4.5.0)
   jsonlite            * 2.0.0     2025-03-27 [1] CRAN (R 4.5.0)
   knitr                 1.51      2025-12-20 [1] CRAN (R 4.5.2)
   later                 1.4.8     2026-03-05 [1] CRAN (R 4.5.2)
   librarian             1.8.1     2021-07-12 [1] CRAN (R 4.5.0)
   lifecycle             1.0.5     2026-01-08 [1] CRAN (R 4.5.2)
   lubridate             1.9.5     2026-02-04 [1] CRAN (R 4.5.2)
   magrittr              2.0.5     2026-04-04 [1] CRAN (R 4.5.2)
   memoise               2.0.1     2021-11-26 [1] CRAN (R 4.5.0)
   mime                  0.13      2025-03-17 [1] CRAN (R 4.5.0)
   openssl               2.4.1     2026-05-14 [1] CRAN (R 4.5.2)
   otel                  0.2.0     2025-08-29 [1] CRAN (R 4.5.0)
   pillar                1.11.1    2025-09-17 [1] CRAN (R 4.5.0)
   pkgbuild              1.4.8     2025-05-26 [1] CRAN (R 4.5.0)
   pkgconfig             2.0.3     2019-09-22 [1] CRAN (R 4.5.0)
   pkgload               1.5.1     2026-04-01 [1] CRAN (R 4.5.2)
   promises              1.5.0     2025-11-01 [1] CRAN (R 4.5.0)
   purrr               * 1.2.2     2026-04-10 [1] CRAN (R 4.5.2)
   R6                    2.6.1     2025-02-15 [1] CRAN (R 4.5.0)
   RColorBrewer          1.1-3     2022-04-03 [1] CRAN (R 4.5.0)
   Rcpp                  1.1.1-1.1 2026-04-24 [1] CRAN (R 4.5.2)
   readr                 2.2.0     2026-02-19 [1] CRAN (R 4.5.2)
   rlang                 1.2.0     2026-04-06 [1] CRAN (R 4.5.2)
   rmarkdown             2.31      2026-03-26 [1] CRAN (R 4.5.2)
   RPostgres             1.4.10    2026-02-16 [1] CRAN (R 4.5.2)
   rprojroot             2.1.1     2025-08-26 [1] CRAN (R 4.5.0)
   rstudioapi            0.18.0    2026-01-16 [1] CRAN (R 4.5.2)
   sass                  0.4.10    2025-04-11 [1] CRAN (R 4.5.0)
   sessioninfo           1.2.3     2025-02-05 [1] CRAN (R 4.5.0)
   shiny                 1.14.0    2026-06-21 [1] CRAN (R 4.5.2)
   snakecase             0.11.1    2023-08-27 [1] CRAN (R 4.5.0)
   stringi               1.8.7     2025-03-27 [1] CRAN (R 4.5.0)
   stringr               1.6.0     2025-11-04 [1] CRAN (R 4.5.0)
   testthat            * 3.3.2     2026-01-11 [1] CRAN (R 4.5.2)
   tibble              * 3.3.1     2026-01-11 [1] CRAN (R 4.5.2)
   tidyr                 1.3.2     2025-12-19 [1] CRAN (R 4.5.2)
   tidyselect            1.2.1     2024-03-11 [1] CRAN (R 4.5.0)
   timechange            0.4.0     2026-01-29 [1] CRAN (R 4.5.2)
   tzdb                  0.5.0     2025-03-15 [1] CRAN (R 4.5.0)
   usethis               3.2.1     2025-09-06 [1] CRAN (R 4.5.0)
   uuid                  1.2-2     2026-01-23 [1] CRAN (R 4.5.2)
   V8                    8.2.0     2026-04-21 [1] CRAN (R 4.5.2)
   vctrs                 0.7.3     2026-04-11 [1] CRAN (R 4.5.2)
   visNetwork            2.1.4     2025-09-04 [1] CRAN (R 4.5.0)
   withr                 3.0.3     2026-06-19 [1] CRAN (R 4.5.2)
   xfun                  0.59      2026-06-19 [1] CRAN (R 4.5.2)
   xtable                1.8-8     2026-02-22 [1] CRAN (R 4.5.2)
   yaml                * 2.3.12    2025-12-10 [1] CRAN (R 4.5.2)
   zip                   2.3.3     2025-05-13 [1] CRAN (R 4.5.0)

 [1] /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library

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

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