Test Release — gate latest.txt on query suite

Published

2026-06-26

Overview

Goal: Promote a freshly-built release to latest.txt only when every pre-baked query in CalCOFI/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.06.26
Code
# where the pre-baked queries live
queries_dir <- here("../query/_queries")
if (!dir.exists(queries_dir)) {
  stop(glue(
    "Could not find {queries_dir}. Clone CalCOFI/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/../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 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-06-26 20:01:51.463637 > File size detected as  2.1 Kb
gs://calcofi-db/ducklake/releases/v2026.06.26/test_results.json
Code
message(glue("test_results.json uploaded for {release_version}"))
test_results.json uploaded for v2026.06.26

5 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-06-26 20:01:54.832514 > 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.06.26: 9 passed, 4 skipped, 0 failed. latest.txt is now v2026.06.26.

6 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-06-26
 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)
   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.8.2     2026-06-07 [?] 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)
   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.

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