Release CalCOFI Database

Published

2026-06-08

Overview

Goal: Create a frozen (immutable) release of the CalCOFI integrated database by assembling all ingest parquet outputs. This is the “caboose” notebook that always runs last, after all ingest notebooks complete.

Upstream notebooks are auto-discovered from calcofi: YAML frontmatter in each .qmd. All workflows with workflow_type: ingest or spatial feed into this release notebook via dependency: [auto] in _targets.R.

Code
flowchart LR
  corrections_csv["corrections_csv"]
  ingest_calcofi_bird_mammal_census["ingest_calcofi_bird_mammal_census"]
  ingest_calcofi_bottle["ingest_calcofi_bottle"]
  ingest_calcofi_ctd_cast["ingest_calcofi_ctd_cast"]
  ingest_calcofi_dic["ingest_calcofi_dic"]
  ingest_calcofi_phyllosoma["ingest_calcofi_phyllosoma"]
  ingest_calcofi_phytoplankton["ingest_calcofi_phytoplankton"]
  ingest_cce_lter_euphausiids["ingest_cce_lter_euphausiids"]
  ingest_pic_zooplankton["ingest_pic_zooplankton"]
  ingest_spatial["ingest_spatial"]
  ingest_swfsc_cufes["ingest_swfsc_cufes"]
  ingest_swfsc_ichthyo["ingest_swfsc_ichthyo"]
  release_database["release_database"]
  test_release["test_release"]
  corrections_csv --> ingest_swfsc_ichthyo
  corrections_csv --> ingest_calcofi_phyllosoma
  corrections_csv --> ingest_swfsc_cufes
  corrections_csv --> ingest_calcofi_ctd_cast
  corrections_csv --> ingest_cce_lter_euphausiids
  corrections_csv --> ingest_calcofi_dic
  corrections_csv --> ingest_pic_zooplankton
  corrections_csv --> ingest_calcofi_bird_mammal_census
  corrections_csv --> ingest_calcofi_phytoplankton
  corrections_csv --> ingest_spatial
  corrections_csv --> ingest_calcofi_bottle
  ingest_calcofi_bird_mammal_census --> release_database
  ingest_calcofi_bottle --> ingest_calcofi_dic
  ingest_calcofi_bottle --> release_database
  ingest_calcofi_ctd_cast --> release_database
  ingest_calcofi_dic --> release_database
  ingest_calcofi_phyllosoma --> release_database
  ingest_calcofi_phytoplankton --> release_database
  ingest_cce_lter_euphausiids --> release_database
  ingest_pic_zooplankton --> release_database
  ingest_spatial --> release_database
  ingest_swfsc_cufes --> release_database
  ingest_swfsc_ichthyo --> ingest_calcofi_phyllosoma
  ingest_swfsc_ichthyo --> ingest_swfsc_cufes
  ingest_swfsc_ichthyo --> ingest_calcofi_ctd_cast
  ingest_swfsc_ichthyo --> ingest_cce_lter_euphausiids
  ingest_swfsc_ichthyo --> ingest_pic_zooplankton
  ingest_swfsc_ichthyo --> ingest_calcofi_bird_mammal_census
  ingest_swfsc_ichthyo --> release_database
  ingest_swfsc_ichthyo --> ingest_calcofi_phytoplankton
  ingest_swfsc_ichthyo --> ingest_calcofi_bottle
  release_database --> test_release
  classDef input  fill:#eeeeee,stroke:#999999,color:#333333;
  classDef ingest fill:#e3f2fd,stroke:#1565c0,color:#0d3c61;
  classDef rel    fill:#ef6c00,stroke:#b35100,color:#ffffff,font-weight:bold;
  classDef test   fill:#e8f4e8,stroke:#2e7d32,color:#1b5e20;
  class corrections_csv input;
  class ingest_calcofi_bird_mammal_census,ingest_calcofi_bottle,ingest_calcofi_ctd_cast,ingest_calcofi_dic,ingest_calcofi_phyllosoma,ingest_calcofi_phytoplankton,ingest_cce_lter_euphausiids,ingest_pic_zooplankton,ingest_spatial,ingest_swfsc_cufes,ingest_swfsc_ichthyo ingest;
  class release_database rel;
  class test_release test;

Figure 1: Pipeline dependency graph, auto-discovered from _targets.R: every workflow in this folder is a node and edges are dependencies. release_database (this notebook, orange) is the caboose — it runs last, after all ingests, to assemble the frozen release. Click to zoom.

0.1 Setup

Code
devtools::load_all(here::here("../calcofi4db"))
ℹ Loading calcofi4db
Code
devtools::load_all(here::here("../calcofi4r"))
ℹ Loading calcofi4r
Registered S3 method overwritten by 'quantmod':
  method            from
  as.zoo.data.frame zoo 
Code
# cleanup_gcs_obsolete(dry_run = F)
librarian::shelf(
  CalCOFI / calcofi4db,
  CalCOFI / calcofi4r,
  DBI,
  dplyr,
  DT,
  fs,
  glue,
  here,
  jsonlite,
  purrr,
  tibble,
  quiet = T
)
options(DT.options = list(scrollX = TRUE))

# release version
release_version <- format(Sys.Date(), "v%Y.%m.%d")
message(glue("Release version: {release_version}"))
Release version: v2026.06.08

0.2 Assemble from Ingest Outputs

Create VIEWs on local parquet files from each ingest (zero-copy). For tables appearing in multiple ingests, use the canonical (first) source.

Code
con_wdl <- get_duckdb_con(":memory:")
load_duckdb_extension(con_wdl, "spatial")
Loaded extension: spatial
Code
# auto-discover table registry from all ingest manifests
registry <- build_release_table_registry(here())

# use only canonical, non-supplemental tables
reg_canon <- registry |>
  filter(canonical, !supplemental)

message(glue(
  "{nrow(reg_canon)} canonical tables from ",
  "{length(unique(reg_canon$ingest))} ingests"))
44 canonical tables from 11 ingests
Code
# --- authoritative dataset metadata + ERD coloring from ingest YAML ----
# table -> provider_dataset(s) owned, from each ingest's calcofi.tables_owned
ingest_yaml   <- read_ingest_yaml(here())
table_dataset <- list()
add_owner <- function(tbl, pd) {
  if (is.null(tbl)) return(invisible())
  table_dataset[[tbl]] <<- unique(c(table_dataset[[tbl]], pd))
}
for (key in names(ingest_yaml)) {
  cc <- ingest_yaml[[key]]
  for (e in cc$tables_owned %||% list()) add_owner(e$table, key)
  for (ad in cc$additional_datasets %||% list()) {
    pd2 <- paste0(ad$provider, "_", ad$dataset)
    for (e in ad$tables_owned %||% list()) add_owner(e$table, pd2)
  }
}

# one color per dataset (from calcofi.erd.color)
dataset_colors <- lapply(ingest_yaml, function(cc) cc$erd$color)

# release-level config: neutral ERD overrides for common tables
rel_cfg           <- read_calcofi_meta(here("release_database.qmd"))
release_overrides <- rel_cfg$erd_overrides

# cross-dataset foreign keys (relationships spanning ingests) are authored in a
# reviewable CSV; intra-dataset FKs live in each ingest's relationships.json.
cross_fks_df <- readr::read_csv(
  here("metadata/relationships_cross.csv"), show_col_types = FALSE)
cross_fks <- lapply(seq_len(nrow(cross_fks_df)), function(i)
  as.list(cross_fks_df[i, c("table", "column", "ref_table", "ref_column")]))

# stroke-based color map consumed by every cc_erd() call below
color_map <- cc_erd_color_map(
  table_dataset  = table_dataset,
  dataset_colors = dataset_colors,
  overrides      = release_overrides,
  neutral        = "#dcdcdc")

# create VIEWs on local parquet for each canonical table
# _new delta tables handled separately for merging
all_geom_tables <- c("grid", "site", "segment", "casts", "ctd_cast", "_spatial")
main_tables <- reg_canon |> filter(!grepl("_new$", table))
new_tables  <- registry |> filter(grepl("_new$", table))

load_stats <- purrr::map_dfr(
  split(main_tables, seq_len(nrow(main_tables))),
  function(row) {
    load_prior_tables(
      con         = con_wdl,
      parquet_dir = row$parquet_dir,
      tables      = row$table,
      geom_tables = all_geom_tables,
      as_view     = TRUE
    )
  })
Loaded bird_mammal_transect: 60715 rows (VIEW)
Loaded bird_mammal_observation: 82418 rows (VIEW)
Loaded bird_mammal_species: 200 rows (VIEW)
Loaded bird_mammal_behavior: 4 rows (VIEW)
Loaded dataset: 7 rows (VIEW)
Loaded casts: 35644 rows (VIEW) (GEOMETRY)
Loaded bottle: 895371 rows (VIEW)
Loaded bottle_measurement: 11135600 rows (VIEW)
Loaded cast_condition: 235513 rows (VIEW)
Loaded measurement_type: 106 rows (VIEW)
Loaded ctd_cast: 5550014 rows (VIEW) (GEOMETRY)
Loaded ctd_thin: 5551551 rows (partitioned) (VIEW)
Loaded ctd_summary: 108390249 rows (partitioned) (VIEW)
Loaded dic_sample: 4391 rows (VIEW)
Loaded dic_measurement: 16391 rows (VIEW)
Loaded dic_summary: 15786 rows (VIEW)
Loaded phyllosoma_tow: 1859 rows (VIEW)
Loaded phyllosoma_measurement: 22308 rows (VIEW)
Loaded phyto_sample: 409 rows (VIEW)
Loaded phyto_measurement: 159804 rows (VIEW)
Loaded phyto_taxon: 399 rows (VIEW)
Loaded region: 4 rows (VIEW)
Loaded euphausiids_tow: 10150 rows (VIEW)
Loaded euphausiids_measurement: 10150 rows (VIEW)
Loaded euphausiids_summary: 10145 rows (VIEW)
Loaded zooplankton_tow: 99530 rows (VIEW)
Loaded _spatial_attr: 40298 rows (VIEW)
Loaded _spatial: 3373 rows (VIEW) (GEOMETRY)
Loaded cufes_sample: 49572 rows (VIEW)
Loaded cufes_measurement: 284097 rows (VIEW)
Loaded cruise: 691 rows (VIEW)
Loaded grid: 218 rows (VIEW) (GEOMETRY)
Loaded ichthyo: 852228 rows (VIEW)
Loaded invert: 9223 rows (VIEW)
Loaded lookup: 26 rows (VIEW)
Loaded net: 76512 rows (VIEW)
Loaded segment: 60413 rows (VIEW) (GEOMETRY)
Loaded ship: 48 rows (VIEW)
Loaded site: 61104 rows (VIEW) (GEOMETRY)
Loaded species: 1167 rows (VIEW)
Loaded taxa_rank: 41 rows (VIEW)
Loaded taxon: 3385 rows (VIEW)
Loaded tow: 75506 rows (VIEW)
Code
# merge {table}_new additions into their base tables
# driven by calcofi.modifies in YAML frontmatter
if (nrow(new_tables) > 0) {
  # group _new tables by their base table
  base_names <- unique(sub("_new$", "", new_tables$table))

  for (base_tbl in base_names) {
    delta_rows <- new_tables |> filter(table == paste0(base_tbl, "_new"))

    # replace VIEW with TABLE for this base table (so we can INSERT)
    base_src <- main_tables |> filter(table == base_tbl)
    if (nrow(base_src) > 0) {
      dbExecute(con_wdl, glue("DROP VIEW IF EXISTS {base_tbl}"))
      load_prior_tables(
        con = con_wdl, parquet_dir = base_src$parquet_dir[1],
        tables = base_tbl, geom_tables = all_geom_tables)

      # get PK column for dedup
      pk_col <- dbGetQuery(con_wdl, glue(
        "SELECT column_name FROM information_schema.columns
         WHERE table_name = '{base_tbl}'
         ORDER BY ordinal_position LIMIT 1"))$column_name

      for (j in seq_len(nrow(delta_rows))) {
        dr <- delta_rows[j, ]
        pq_path <- file.path(dr$parquet_dir, paste0(base_tbl, "_new.parquet"))
        if (file.exists(pq_path)) {
          dbExecute(con_wdl, glue(
            "INSERT INTO {base_tbl}
             SELECT * FROM read_parquet('{pq_path}')
             WHERE {pk_col} NOT IN (SELECT {pk_col} FROM {base_tbl})"))
          n_new <- dbGetQuery(con_wdl, glue(
            "SELECT COUNT(*) AS n FROM read_parquet('{pq_path}')"))$n
          message(glue("Merged {n_new} {base_tbl} addition(s) from {dr$ingest}"))
        }
      }
    }
  }
}
Loaded ship: 48 rows
Merged 1 ship addition(s) from ingest_calcofi_bottle
Code
load_stats |>
  datatable(caption = "Assembled tables (VIEWs on local parquet)")

0.3 Scan Manifests for Mismatches

Code
# scan all ingest manifests for unresolved mismatches
all_manifests <- list.files(
  "data/parquet", "manifest.json",
  recursive = TRUE, full.names = TRUE)

all_mismatches <- purrr::compact(lapply(all_manifests, function(mf) {
  m <- jsonlite::read_json(mf)
  if (is.null(m$mismatches)) return(NULL)
  dataset <- basename(dirname(mf))
  purrr::imap_dfr(m$mismatches, function(items, category) {
    if (length(items) == 0) return(NULL)
    purrr::map_dfr(items, function(x) {
      # replace NULL values with NA so as_tibble works
      x[vapply(x, is.null, logical(1))] <- NA
      as_tibble(x)
    }) |>
      mutate(dataset = dataset, category = category, .before = 1)
  })
}))

if (length(all_mismatches) > 0) {
  d_mismatches <- bind_rows(all_mismatches)
  message(glue("{nrow(d_mismatches)} unresolved mismatch(es) across manifests"))
  d_mismatches |>
    datatable(caption = "Unresolved mismatches (from manifest.json)")
} else {
  message("No unresolved mismatches found across manifests")
}
5 unresolved mismatch(es) across manifests

0.4 Validate

Cross-dataset validation to ensure data integrity before freezing.

Code
# grid_key integrity: casts.grid_key should all be in grid.grid_key
tbls <- DBI::dbListTables(con_wdl)

if (all(c("casts", "grid") %in% tbls)) {
  # use information_schema to check columns (avoids GEOMETRY type issues)
  casts_cols_wdl <- dbGetQuery(
    con_wdl,
    "SELECT column_name FROM information_schema.columns
     WHERE table_name = 'casts'"
  )$column_name
  grid_cols_wdl <- dbGetQuery(
    con_wdl,
    "SELECT column_name FROM information_schema.columns
     WHERE table_name = 'grid'"
  )$column_name
  if ("grid_key" %in% casts_cols_wdl && "grid_key" %in% grid_cols_wdl) {
    grid_orphans <- dbGetQuery(
      con_wdl,
      "SELECT COUNT(*) AS n FROM casts c
       WHERE c.grid_key IS NOT NULL
         AND c.grid_key NOT IN (SELECT grid_key FROM grid)"
    )$n
    message(glue("Grid key orphans in casts: {grid_orphans}"))
    # Grid key orphans in casts: 0
  }
}
Grid key orphans in casts: 0
Code
# ship PK uniqueness
if ("ship" %in% tbls) {
  ship_dups <- dbGetQuery(
    con_wdl,
    "SELECT ship_key, COUNT(*) AS n FROM ship
     GROUP BY ship_key HAVING COUNT(*) > 1"
  )
  if (nrow(ship_dups) > 0) {
    warning(glue("Duplicate ship_key values: {nrow(ship_dups)}"))
  } else {
    message("ship_key: all unique")
  }
}
ship_key: all unique
Code
# ship_key: all unique

# cruise PK uniqueness
if ("cruise" %in% tbls) {
  cruise_dups <- dbGetQuery(
    con_wdl,
    "SELECT cruise_key, COUNT(*) AS n FROM cruise
     GROUP BY cruise_key HAVING COUNT(*) > 1"
  )
  if (nrow(cruise_dups) > 0) {
    warning(glue("Duplicate cruise_key values: {nrow(cruise_dups)}"))
  } else {
    message("cruise_key: all unique")
  }
}
cruise_key: all unique
Code
# cruise_key: all unique

# cruise bridge coverage
if ("casts" %in% tbls) {
  bridge_stats <- dbGetQuery(
    con_wdl,
    "SELECT
      COUNT(*) AS total_casts,
      SUM(CASE WHEN ship_key IS NOT NULL THEN 1 ELSE 0 END) AS with_ship_key,
      SUM(CASE WHEN cruise_key IS NOT NULL THEN 1 ELSE 0 END) AS with_cruise_key
    FROM casts"
  )
  bridge_stats |> datatable(caption = "Cruise bridge coverage")
}
Code
# cruise_key format validation (YYYY-MM-NODC)
if ("cruise" %in% tbls) {
  bad_ck <- dbGetQuery(
    con_wdl,
    "SELECT cruise_key FROM cruise
     WHERE cruise_key IS NOT NULL
       AND NOT regexp_matches(cruise_key, '^\\d{4}-\\d{2}-.+$')"
  )
  if (nrow(bad_ck) > 0) {
    warning(glue("cruise_key format violations: {nrow(bad_ck)} rows"))
  } else {
    message("cruise_key: all match YYYY-MM-NODC format")
  }
}
Warning: cruise_key format violations: 1 rows
Code
# Warning message: cruise_key format violations: 1 rows
# cruise_key: 2019-07-

# site_key format validation (NNN.N NNN.N)
for (tbl_name in intersect(c("site", "casts", "ctd_cast"), tbls)) {
  tbl_cols <- dbGetQuery(
    con_wdl,
    glue(
      "SELECT column_name FROM information_schema.columns
     WHERE table_name = '{tbl_name}'"
    )
  )$column_name
  if ("site_key" %in% tbl_cols) {
    bad_sk <- dbGetQuery(
      con_wdl,
      glue(
        "SELECT COUNT(*) AS n FROM {tbl_name}
         WHERE site_key IS NOT NULL
           AND NOT regexp_matches(site_key, '^\\d{{3}}\\.\\d \\d{{3}}\\.\\d$')"
      )
    )$n
    if (bad_sk > 0) {
      warning(glue("site_key format violations in {tbl_name}: {bad_sk} rows"))
    } else {
      message(glue("site_key in {tbl_name}: all match NNN.N NNN.N format"))
    }
  }
}
Warning: site_key format violations in site: 982 rows
site_key in casts: all match NNN.N NNN.N format
Warning: site_key format violations in ctd_cast: 3211 rows
Code
# site_key in casts: all match NNN.N NNN.N format
# Warning message: site_key format violations in site: 982 rows

# cruise_summary table for Shiny app
if (all(c("cruise", "ship") %in% tbls)) {
  # detect site_key vs sta_key for backward compat with old parquets
  ctd_site_col <- ifelse(
    "site_key" %in%
      dbGetQuery(
        con_wdl,
        "SELECT column_name FROM information_schema.columns
       WHERE table_name = 'ctd_cast'"
      )$column_name,
    "site_key",
    "sta_key"
  )

  dbExecute(
    con_wdl,
    glue(
      "
    CREATE OR REPLACE TABLE cruise_summary AS
    SELECT
      cr.cruise_key,
      EXTRACT(YEAR FROM cr.date_ym)::INTEGER  AS year,
      EXTRACT(MONTH FROM cr.date_ym)::INTEGER AS month,
      s.ship_name,
      s.ship_nodc,
      COALESCE((SELECT COUNT(DISTINCT site_key) FROM site
                WHERE cruise_key = cr.cruise_key), 0) AS ichthyo,
      COALESCE((SELECT COUNT(DISTINCT site_key) FROM casts
                WHERE cruise_key = cr.cruise_key), 0) AS bottle,
      COALESCE((SELECT COUNT(DISTINCT {ctd_site_col}) FROM ctd_cast
                WHERE cruise_key = cr.cruise_key), 0) AS ctd_cast,
      COALESCE((SELECT COUNT(DISTINCT ds.site_key)
                FROM dic_sample ds
                JOIN casts c ON ds.cast_id = c.cast_id
                WHERE c.cruise_key = cr.cruise_key), 0) AS dic
    FROM cruise cr
    JOIN ship s ON cr.ship_key = s.ship_key
    ORDER BY year DESC, month DESC"
    )
  )
  n_cs <- dbGetQuery(con_wdl, "SELECT COUNT(*) AS n FROM cruise_summary")$n
  message(glue("Created cruise_summary table: {n_cs} rows"))
}
Created cruise_summary table: 691 rows
Code
# Created cruise_summary table: 691 rows
tbl(con_wdl, "cruise_summary") |>
  collect() |>
  datatable(caption = "cruise_summary preview")
Code
# run standard release validation (wrapped in tryCatch for GEOMETRY compat)
tryCatch(
  {
    validation <- validate_for_release(con_wdl)
    if (validation$passed) {
      message("Release validation passed!")
    } else {
      cat("Validation FAILED:\n")
      cat(paste("-", validation$errors, collapse = "\n"))
    }
  },
  error = function(e) {
    message(glue("validate_for_release skipped: {e$message}"))
  }
)
Validation FAILED:
- Table 'bird_mammal_species' has 41 NULL values in required column 'itis_id'
- Table 'bird_mammal_transect' has 60715 NULL values in required column 'cruise_key'
- Table 'bird_mammal_transect' has 16 NULL values in required column 'grid_key'
- Table 'casts' has 298 NULL values in required column 'grid_key'
- Table 'casts' has 49 NULL values in required column 'ship_key'
- Table 'casts' has 49 NULL values in required column 'cruise_key'
- Table 'cufes_sample' has 10713 NULL values in required column 'cruise_key'
- Table 'cufes_sample' has 2331 NULL values in required column 'grid_key'
- Table 'dic_measurement' has 12683 NULL values in required column 'cast_id'
- Table 'dic_measurement' has 12721 NULL values in required column 'bottle_id'
- Table 'dic_sample' has 3305 NULL values in required column 'cast_id'
- Table 'dic_sample' has 3326 NULL values in required column 'bottle_id'
- Table 'dic_summary' has 12592 NULL values in required column 'cast_id'
- Table 'dic_summary' has 12611 NULL values in required column 'bottle_id'
- Table 'euphausiids_summary' has 30 NULL values in required column 'site_key'
- Table 'euphausiids_tow' has 1834 NULL values in required column 'cruise_key'
- Table 'euphausiids_tow' has 172 NULL values in required column 'ship_key'
- Table 'euphausiids_tow' has 34 NULL values in required column 'site_key'
- Table 'euphausiids_tow' has 10 NULL values in required column 'grid_key'
- Table 'phyllosoma_tow' has 43 NULL values in required column 'ship_key'
- Table 'phyllosoma_tow' has 225 NULL values in required column 'cruise_key'
- Table 'phyllosoma_tow' has 41 NULL values in required column 'grid_key'
- Table 'phyto_sample' has 168 NULL values in required column 'cruise_key'
- Table 'phyto_taxon' has 90 NULL values in required column 'aphia_id'
- Table 'site' has 2084 NULL values in required column 'grid_key'
- Table 'species' has 182 NULL values in required column 'gbif_id'
- Table 'zooplankton_tow' has 16000 NULL values in required column 'site_key'
- Table 'zooplankton_tow' has 14210 NULL values in required column 'ship_key'
- Table 'zooplankton_tow' has 33250 NULL values in required column 'cruise_key'
- Table 'zooplankton_tow' has 1334 NULL values in required column 'grid_key'

0.5 Show Combined Schema

Code
# dir_frozen used later; define early so ERD can reference it
dir_frozen <- here(glue("data/releases/{release_version}"))
dir.create(dir_frozen, recursive = TRUE, showWarnings = FALSE)

erd <- cc_erd(con_wdl, layout = "elk")
plot(erd)

erd <- cc_erd(con_wdl, colors = color_map)
plot(erd)
Code
# exclude internal tables and views
schema_tbls <- setdiff(
  tbls,
  c("_meta", "_sp_update", "casts_derived", "ctd_cast_derived"))

# merge per-dataset relationships.json files (auto-discovered — every ingest
# writes data/parquet/{provider}_{dataset}/relationships.json, so new datasets
# are picked up without editing this list)
rels_paths <- Sys.glob(here("data/parquet/*/relationships.json"))

dir_frozen <- here(glue("data/releases/{release_version}"))
dir.create(dir_frozen, recursive = TRUE, showWarnings = FALSE)
rels_merged_path <- file.path(dir_frozen, "relationships.json")

if (length(rels_paths) > 0) {
  merge_relationships_json(rels_paths, rels_merged_path)

  # append cross-dataset FKs authored in metadata/relationships_cross.csv
  rels_merged <- jsonlite::fromJSON(
    rels_merged_path, simplifyVector = FALSE)
  rels_merged$foreign_keys <- c(
    rels_merged$foreign_keys, cross_fks)
  jsonlite::write_json(
    rels_merged, rels_merged_path,
    auto_unbox = TRUE, pretty = TRUE, null = "null")

  # emit a flat, reviewable view of every relationship (intra + cross) alongside
  # relationships.json / erd.mmd, so the cross-dataset graph is legible as a table
  g <- function(x, k) { v <- x[[k]]; if (is.null(v)) NA_character_ else as.character(v) }
  fk_df <- do.call(rbind, lapply(rels_merged$foreign_keys, function(fk)
    data.frame(
      from_table  = g(fk, "table"),     from_column = g(fk, "column"),
      to_table    = g(fk, "ref_table"), to_column   = g(fk, "ref_column"),
      stringsAsFactors = FALSE)))
  cross_keys <- paste(cross_fks_df$table, cross_fks_df$column,
                      cross_fks_df$ref_table, cross_fks_df$ref_column)
  fk_df$scope <- ifelse(
    paste(fk_df$from_table, fk_df$from_column,
          fk_df$to_table, fk_df$to_column) %in% cross_keys, "cross", "intra")
  readr::write_csv(fk_df, file.path(dir_frozen, "relationships_all.csv"))

  # validate: every cross-FK target column exists in the assembled release schema
  schema_cols <- unlist(lapply(schema_tbls, function(t)
    paste(t, DBI::dbListFields(con_wdl, t))))
  cross_targets   <- paste(cross_fks_df$ref_table, cross_fks_df$ref_column)
  missing_targets <- cross_fks_df[!(cross_targets %in% schema_cols), , drop = FALSE]
  if (nrow(missing_targets) > 0) {
    warning(glue(
      "cross-FK target(s) missing from release schema: ",
      "{paste(missing_targets$ref_table, missing_targets$ref_column, collapse = ', ')}"))
  } else {
    message(glue(
      "cross-FK check: all {nrow(cross_fks_df)} cross-dataset targets present; ",
      "wrote {nrow(fk_df)} relationships to relationships_all.csv"))
  }
}
Merged relationships.json: 37 PKs, 36 FKs from 10 files
cross-FK check: all 22 cross-dataset targets present; wrote 58 relationships to relationships_all.csv
Code
# render dataset-colored ERD (stroke outlines; cc_erd handles GEOMETRY natively)
cc_erd(
  con_wdl,
  tables    = schema_tbls,
  rels_path = rels_merged_path,
  colors    = color_map)

0.6 Create Frozen Release

Strip provenance columns and export clean parquet files for public access. See Frozen DuckLake pattern.

Code
dir_frozen <- here(glue("data/releases/{release_version}"))
dir_frozen_pq <- file.path(dir_frozen, "parquet")
dir.create(dir_frozen_pq, recursive = TRUE, showWarnings = FALSE)
message(glue("Creating frozen release: {release_version}"))
Creating frozen release: v2026.06.08
Code
# only cruise_summary is derived in this notebook — export locally
# all other tables are GCS-copied from ingest/ (including provenance columns)
derived_tables <- "cruise_summary"
if (nrow(new_tables) > 0) {
  # tables with _new additions need local merge + export
  merged_base <- unique(sub("_new$", "", new_tables$table))
  derived_tables <- c(derived_tables, merged_base)
}

# export only derived/merged tables to local parquet
export_parquet(con_wdl, "cruise_summary",
  file.path(dir_frozen_pq, "cruise_summary.parquet"), compression = "zstd")
message("Exported cruise_summary.parquet")
Exported cruise_summary.parquet
Code
# export merged tables (e.g., ship with _new additions)
for (tbl in setdiff(derived_tables, "cruise_summary")) {
  export_parquet(con_wdl, tbl,
    file.path(dir_frozen_pq, paste0(tbl, ".parquet")), compression = "zstd")
  message(glue("Exported {tbl}.parquet (merged)"))
}
Exported ship.parquet (merged)
Code
# build freeze stats from registry (auto-discovered)
# exclude _new delta tables (intermediate) and supplemental
freeze_stats <- reg_canon |>
  filter(!supplemental, !grepl("_new$", table)) |>
  select(table, rows, partitioned, gcs_prefix)

# merged tables (from _new additions) → mark as derived (gcs_prefix = NA → upload from local)
if (nrow(new_tables) > 0) {
  merged_base <- unique(sub("_new$", "", new_tables$table))
  freeze_stats <- freeze_stats |>
    mutate(gcs_prefix = if_else(table %in% merged_base, NA_character_, gcs_prefix))
}

# add derived tables (cruise_summary, etc.)
for (dt in derived_tables) {
  if (!dt %in% freeze_stats$table) {
    n <- dbGetQuery(con_wdl, glue("SELECT COUNT(*) AS n FROM {dt}"))$n
    freeze_stats <- freeze_stats |>
      bind_rows(tibble(
        table = dt, rows = n, partitioned = FALSE, gcs_prefix = NA_character_))
  }
}

freeze_stats |>
  datatable(caption = glue("Frozen release {release_version} — {nrow(freeze_stats)} tables"))

0.7 Release Notes

Code
# build release notes
tables_list <- paste0(
  "- ",
  freeze_stats$table,
  " (",
  format(freeze_stats$rows, big.mark = ","),
  " rows)"
)

release_notes <- paste0(
  "# CalCOFI Database Release ",
  release_version,
  "\n\n",
  "**Release Date**: ",
  Sys.Date(),
  "\n\n",
  "## Tables Included\n\n",
  paste(tables_list, collapse = "\n"),
  "\n\n",
  "## Total\n\n",
  "- **Tables**: ",
  nrow(freeze_stats),
  "\n",
  "- **Total Rows**: ",
  format(sum(freeze_stats$rows, na.rm = TRUE), big.mark = ","),
  "\n\n",
  "## Data Sources\n\n",
  "- `ingest_swfsc_ichthyo.qmd` - Ichthyo tables (cruise, ship, site, tow, net, species, ichthyo, grid, segment, lookup, taxon, taxa_rank)\n",
  "- `ingest_calcofi_bottle.qmd` - Bottle/cast tables (casts, bottle, bottle_measurement, cast_condition, measurement_type)\n",
  "- `ingest_calcofi_ctd-cast.qmd` - CTD tables (ctd_cast, ctd_thin, ctd_summary, measurement_type; full ctd_measurement available as supplemental)\n",
  "- `ingest_calcofi_dic.qmd` - DIC/alkalinity tables (dic_sample, dic_measurement, dic_summary, dataset)\n\n",
  "## Cross-Dataset Integration\n\n",
  "- **Ship matching**: Reconciled ship codes between bottle casts and swfsc ship reference\n",
  "- **Cruise bridge**: Derived cruise_key (YYYY-MM-NODC) for bottle casts via ship matching + datetime\n",
  "- **Taxonomy**: Standardized species with WoRMS AphiaID, ITIS TSN, GBIF backbone key\n",
  "- **Taxon hierarchy**: Built taxon + taxa_rank tables from WoRMS/ITIS classification\n\n",
  "## Access\n\n",
  "Parquet files can be queried directly from GCS:\n\n",
  "```r\n",
  "library(duckdb)\n",
  "con <- dbConnect(duckdb())\n",
  "dbExecute(con, 'INSTALL httpfs; LOAD httpfs;')\n",
  "dbGetQuery(con, \"\n",
  "  SELECT * FROM read_parquet(\n",
  "    'https://storage.googleapis.com/calcofi-db/ducklake/releases/",
  release_version,
  "/parquet/ichthyo.parquet')\n",
  "  LIMIT 10\")\n",
  "```\n\n",
  "Or use calcofi4r:\n\n",
  "```r\n",
  "library(calcofi4r)\n",
  "con <- cc_get_db(version = '",
  release_version,
  "')\n",
  "```\n"
)

writeLines(release_notes, file.path(dir_frozen, "RELEASE_NOTES.md"))
message(glue(
  "Release notes written to {file.path(dir_frozen, 'RELEASE_NOTES.md')}"
))
Release notes written to /Users/bbest/Github/CalCOFI/workflows/data/releases/v2026.06.08/RELEASE_NOTES.md
Code
cat(release_notes)

1 CalCOFI Database Release v2026.06.08

Release Date: 2026-06-08

1.1 Tables Included

  • bird_mammal_transect ( 60,715 rows)
  • bird_mammal_observation ( 82,418 rows)
  • bird_mammal_species ( 200 rows)
  • bird_mammal_behavior ( 4 rows)
  • dataset ( 7 rows)
  • casts ( 35,644 rows)
  • bottle ( 895,371 rows)
  • bottle_measurement ( 11,135,600 rows)
  • cast_condition ( 235,513 rows)
  • measurement_type ( 106 rows)
  • ctd_cast ( 5,550,014 rows)
  • ctd_thin ( 5,551,551 rows)
  • ctd_summary (108,390,249 rows)
  • dic_sample ( 4,391 rows)
  • dic_measurement ( 16,391 rows)
  • dic_summary ( 15,786 rows)
  • phyllosoma_tow ( 1,859 rows)
  • phyllosoma_measurement ( 22,308 rows)
  • phyto_sample ( 409 rows)
  • phyto_measurement ( 159,804 rows)
  • phyto_taxon ( 399 rows)
  • region ( 4 rows)
  • euphausiids_tow ( 10,150 rows)
  • euphausiids_measurement ( 10,150 rows)
  • euphausiids_summary ( 10,145 rows)
  • zooplankton_tow ( 99,530 rows)
  • _spatial_attr ( 40,298 rows)
  • _spatial ( 3,373 rows)
  • cufes_sample ( 49,572 rows)
  • cufes_measurement ( 284,097 rows)
  • cruise ( 691 rows)
  • grid ( 218 rows)
  • ichthyo ( 852,228 rows)
  • invert ( 9,223 rows)
  • lookup ( 26 rows)
  • net ( 76,512 rows)
  • segment ( 60,413 rows)
  • ship ( 48 rows)
  • site ( 61,104 rows)
  • species ( 1,167 rows)
  • taxa_rank ( 41 rows)
  • taxon ( 3,385 rows)
  • tow ( 75,506 rows)
  • cruise_summary ( 691 rows)

1.2 Total

  • Tables: 44
  • Total Rows: 133,807,311

1.3 Data Sources

  • ingest_swfsc_ichthyo.qmd - Ichthyo tables (cruise, ship, site, tow, net, species, ichthyo, grid, segment, lookup, taxon, taxa_rank)
  • ingest_calcofi_bottle.qmd - Bottle/cast tables (casts, bottle, bottle_measurement, cast_condition, measurement_type)
  • ingest_calcofi_ctd-cast.qmd - CTD tables (ctd_cast, ctd_thin, ctd_summary, measurement_type; full ctd_measurement available as supplemental)
  • ingest_calcofi_dic.qmd - DIC/alkalinity tables (dic_sample, dic_measurement, dic_summary, dataset)

1.4 Cross-Dataset Integration

  • Ship matching: Reconciled ship codes between bottle casts and swfsc ship reference
  • Cruise bridge: Derived cruise_key (YYYY-MM-NODC) for bottle casts via ship matching + datetime
  • Taxonomy: Standardized species with WoRMS AphiaID, ITIS TSN, GBIF backbone key
  • Taxon hierarchy: Built taxon + taxa_rank tables from WoRMS/ITIS classification

1.5 Access

Parquet files can be queried directly from GCS:

library(duckdb)
con <- dbConnect(duckdb())
dbExecute(con, 'INSTALL httpfs; LOAD httpfs;')
dbGetQuery(con, "
  SELECT * FROM read_parquet(
    'https://storage.googleapis.com/calcofi-db/ducklake/releases/v2026.06.08/parquet/ichthyo.parquet')
  LIMIT 10")

Or use calcofi4r:

library(calcofi4r)
con <- cc_get_db(version = 'v2026.06.08')

1.6 Upload Frozen Release to GCS

Code
gcs_bucket  <- "calcofi-db"
gcs_release <- glue("ducklake/releases/{release_version}")
gcloud      <- find_gcloud()

# 1. GCS server-side copy for ingest tables (auto-discovered from registry)
copy_rows <- freeze_stats |> filter(!is.na(gcs_prefix))
message(glue("Copying {nrow(copy_rows)} tables from ingest/ to releases/ on GCS..."))
Copying 42 tables from ingest/ to releases/ on GCS...
Code
for (i in seq_len(nrow(copy_rows))) {
  tbl  <- copy_rows$table[i]
  pfx  <- copy_rows$gcs_prefix[i]
  part <- copy_rows$partitioned[i]

  if (part) {
    # partitioned: copy directory
    src <- glue("gs://{gcs_bucket}/{pfx}/{tbl}")
    dst <- glue("gs://{gcs_bucket}/{gcs_release}/parquet/{tbl}")
    res <- system2(gcloud, c("storage", "cp", "-r",
      paste0(src, "/*"), dst), stdout = TRUE, stderr = TRUE)
  } else {
    src <- glue("gs://{gcs_bucket}/{pfx}/{tbl}.parquet")
    dst <- glue("gs://{gcs_bucket}/{gcs_release}/parquet/{tbl}.parquet")
    res <- system2(gcloud, c("storage", "cp",
      src, dst), stdout = TRUE, stderr = TRUE)
  }
  rc <- attr(res, "status") %||% 0L
  if (rc != 0) {
    stop(glue("GCS copy failed for {tbl}: {src} -> {dst}\n",
              "  exit code {rc}: {paste(res, collapse = '\n')}"))
  }
  message(glue("  {tbl}: copied from {pfx}"))
}
  bird_mammal_transect: copied from ingest/calcofi_bird_mammal_census
  bird_mammal_observation: copied from ingest/calcofi_bird_mammal_census
  bird_mammal_species: copied from ingest/calcofi_bird_mammal_census
  bird_mammal_behavior: copied from ingest/calcofi_bird_mammal_census
  dataset: copied from ingest/calcofi_bird_mammal_census
  casts: copied from ingest/calcofi_bottle
  bottle: copied from ingest/calcofi_bottle
  bottle_measurement: copied from ingest/calcofi_bottle
  cast_condition: copied from ingest/calcofi_bottle
  measurement_type: copied from ingest/calcofi_bottle
  ctd_cast: copied from ingest/calcofi_ctd-cast
  ctd_thin: copied from ingest/calcofi_ctd-cast
  ctd_summary: copied from ingest/calcofi_ctd-cast
  dic_sample: copied from ingest/calcofi_dic
  dic_measurement: copied from ingest/calcofi_dic
  dic_summary: copied from ingest/calcofi_dic
  phyllosoma_tow: copied from ingest/calcofi_phyllosoma
  phyllosoma_measurement: copied from ingest/calcofi_phyllosoma
  phyto_sample: copied from ingest/calcofi_phytoplankton
  phyto_measurement: copied from ingest/calcofi_phytoplankton
  phyto_taxon: copied from ingest/calcofi_phytoplankton
  region: copied from ingest/calcofi_phytoplankton
  euphausiids_tow: copied from ingest/cce-lter_euphausiids
  euphausiids_measurement: copied from ingest/cce-lter_euphausiids
  euphausiids_summary: copied from ingest/cce-lter_euphausiids
  zooplankton_tow: copied from ingest/pic_zooplankton
  _spatial_attr: copied from ingest/spatial
  _spatial: copied from ingest/spatial
  cufes_sample: copied from ingest/swfsc_cufes
  cufes_measurement: copied from ingest/swfsc_cufes
  cruise: copied from ingest/swfsc_ichthyo
  grid: copied from ingest/swfsc_ichthyo
  ichthyo: copied from ingest/swfsc_ichthyo
  invert: copied from ingest/swfsc_ichthyo
  lookup: copied from ingest/swfsc_ichthyo
  net: copied from ingest/swfsc_ichthyo
  segment: copied from ingest/swfsc_ichthyo
  site: copied from ingest/swfsc_ichthyo
  species: copied from ingest/swfsc_ichthyo
  taxa_rank: copied from ingest/swfsc_ichthyo
  taxon: copied from ingest/swfsc_ichthyo
  tow: copied from ingest/swfsc_ichthyo
Code
# 2. upload derived tables from local parquet (cruise_summary)
derived_local <- list.files(dir_frozen_pq, pattern = "[.]parquet$",
                            full.names = TRUE)
for (pq in derived_local) {
  tbl <- tools::file_path_sans_ext(basename(pq))
  gcs_path <- glue("gs://{gcs_bucket}/{gcs_release}/parquet/{tbl}.parquet")
  put_gcs_file(pq, gcs_path)
  message(glue("  {tbl}: uploaded (derived)"))
}
  cruise_summary: uploaded (derived)
  ship: uploaded (derived)
Code
# 3. build and upload catalog.json (needed by cc_get_db())
tables_df <- freeze_stats |>
  select(name = table, rows, partitioned)

# sum bytes of the uploaded parquet tree on GCS
du_out <- system2(
  gcloud,
  c("storage", "du", "--summarize",
    glue("gs://{gcs_bucket}/{gcs_release}/parquet/")),
  stdout = TRUE, stderr = TRUE)
total_bytes <- suppressWarnings(
  as.numeric(sub("\\s.*$", "", trimws(du_out[1]))))
if (is.na(total_bytes)) {
  warning(glue("Could not parse gcloud storage du output: {paste(du_out, collapse='; ')}"))
  total_bytes <- 0
}

catalog <- list(
  version      = release_version,
  release_date = as.character(Sys.Date()),
  total_rows   = sum(tables_df$rows, na.rm = TRUE),
  total_size   = total_bytes,
  tables       = tables_df)
catalog_path <- file.path(dir_frozen, "catalog.json")
jsonlite::write_json(catalog, catalog_path, auto_unbox = TRUE, pretty = TRUE)
put_gcs_file(catalog_path,
  glue("gs://{gcs_bucket}/{gcs_release}/catalog.json"))
gs://calcofi-db/ducklake/releases/v2026.06.08/catalog.json
Code
# upload RELEASE_NOTES.md
notes_path <- file.path(dir_frozen, "RELEASE_NOTES.md")
if (file.exists(notes_path))
  put_gcs_file(notes_path,
    glue("gs://{gcs_bucket}/{gcs_release}/RELEASE_NOTES.md"))
gs://calcofi-db/ducklake/releases/v2026.06.08/RELEASE_NOTES.md
Code
# upload relationships.json
rels_json <- file.path(dir_frozen, "relationships.json")
if (file.exists(rels_json))
  put_gcs_file(rels_json,
    glue("gs://{gcs_bucket}/{gcs_release}/relationships.json"))
gs://calcofi-db/ducklake/releases/v2026.06.08/relationships.json
Code
# build and upload metadata.json (table/column descriptions + units).
# auto-discover every ingest's metadata.json (same as rels_paths) so newly added
# datasets' tables/columns are merged in — not just a hardcoded set.
meta_paths <- Sys.glob(here("data/parquet/*/metadata.json"))

meta_json_path <- file.path(dir_frozen, "metadata.json")
if (length(meta_paths) > 0) {
  merge_metadata_json(
    paths                = meta_paths,
    output_path          = meta_json_path,
    release_version      = release_version,
    release_tables_csv   = here("metadata/release_tables.csv"),
    release_columns_csv  = here("metadata/release_columns.csv"),
    measurement_type_csv = here("metadata/measurement_type.csv"),
    ingest_yaml          = ingest_yaml,
    table_rows           = setNames(freeze_stats$rows, freeze_stats$table))

  # enrich columns with data_type from the working DuckDB (so the schema
  # site can render types without spinning up DuckDB-WASM)
  schema_cols <- DBI::dbGetQuery(con_wdl, "
    SELECT table_name, column_name, data_type
    FROM information_schema.columns
    WHERE table_schema = 'main'")
  meta <- jsonlite::read_json(meta_json_path, simplifyVector = FALSE)
  n_typed <- 0L
  for (i in seq_len(nrow(schema_cols))) {
    key <- paste0(schema_cols$table_name[i], ".", schema_cols$column_name[i])
    if (key %in% names(meta$columns)) {
      meta$columns[[key]]$data_type <- schema_cols$data_type[i]
    } else {
      meta$columns[[key]] <- list(data_type = schema_cols$data_type[i])
    }
    n_typed <- n_typed + 1L
  }
  jsonlite::write_json(meta, meta_json_path,
    auto_unbox = TRUE, pretty = TRUE, null = "null")
  message(glue("metadata.json enriched with data_type for {n_typed} columns"))

  put_gcs_file(meta_json_path,
    glue("gs://{gcs_bucket}/{gcs_release}/metadata.json"))

  # erd.mmd sidecar: Mermaid ER diagram driven by relationships.json
  rels_for_erd <- file.path(dir_frozen, "relationships.json")
  if (file.exists(rels_for_erd)) {
    erd <- cc_erd(
      con       = con_wdl,
      rels_path = rels_for_erd,
      colors    = color_map,
      view_type = "all")
    erd_path <- file.path(dir_frozen, "erd.mmd")
    writeLines(unclass(erd), erd_path)

    # validate the Mermaid parses before publishing — a malformed erd.mmd
    # (e.g. erDiagram styling unsupported by an older mermaid) would break the
    # schema site, which renders it client-side with mermaid. Validate with
    # mermaid-cli (mmdc); KEEP schema/_config.yml `mermaid_version` >= this
    # mmdc's bundled mermaid so the site accepts what passes here.
    mmdc <- Sys.which("mmdc")
    if (nzchar(mmdc)) {
      erd_svg_check <- tempfile(fileext = ".svg")
      erd_val <- suppressWarnings(system2(
        mmdc, c("-i", erd_path, "-o", erd_svg_check),
        stdout = TRUE, stderr = TRUE))
      if (!identical(attr(erd_val, "status"), NULL) &&
          !identical(attr(erd_val, "status"), 0L)) {
        stop(glue(
          "erd.mmd failed Mermaid validation; not uploading.\n",
          "{paste(erd_val, collapse = '\n')}"))
      }
      message("erd.mmd passed Mermaid validation (mmdc)")
    } else {
      warning("mmdc not found; skipping Mermaid validation of erd.mmd")
    }

    put_gcs_file(erd_path,
      glue("gs://{gcs_bucket}/{gcs_release}/erd.mmd"))
    message(glue("erd.mmd uploaded ({length(attr(erd, 'tables'))} tables)"))
  } else {
    warning("relationships.json missing; skipping erd.mmd sidecar")
  }
} else {
  warning("No per-ingest metadata.json files found; skipping release metadata.json")
}
Warning in merge_metadata_json(paths = meta_paths, output_path =
meta_json_path, : merge_metadata_json: duplicate table keys across ingests
(last-writer-wins): measurement_type, dataset, grid
Warning in merge_metadata_json(paths = meta_paths, output_path =
meta_json_path, : merge_metadata_json: 33 duplicate table.column keys across
ingests (last-writer-wins). First few: measurement_type.measurement_type,
measurement_type.description, measurement_type.units,
measurement_type.is_canonical, measurement_type._source_column
Merged metadata.json: 46 tables, 556 columns, 10 datasets, 125 measurement types
metadata.json enriched with data_type for 472 columns
erd.mmd passed Mermaid validation (mmdc)
erd.mmd uploaded (44 tables)
Code
# 4. update versions.json (latest.txt promotion is deferred to test_release.qmd)
# discover all releases from GCS and rebuild versions.json
gcs_ls <- system2(gcloud, c("storage", "ls",
  glue("gs://{gcs_bucket}/ducklake/releases/")),
  stdout = TRUE, stderr = TRUE)
release_vers <- regmatches(gcs_ls,
  regexpr("v[0-9]{4}[.][0-9]{2}[.]*[0-9]*", gcs_ls))
https_base <- glue("https://storage.googleapis.com/{gcs_bucket}/ducklake/releases")

all_versions <- purrr::compact(lapply(release_vers, function(v) {
  tryCatch({
    cat_data <- jsonlite::fromJSON(glue("{https_base}/{v}/catalog.json"))
    list(
      version      = cat_data$version,
      release_date = cat_data$release_date %||% NA_character_,
      tables       = if (is.data.frame(cat_data$tables)) nrow(cat_data$tables)
                     else length(cat_data$tables),
      total_rows   = as.integer(cat_data$total_rows %||% 0),
      size_mb      = round((cat_data$total_size %||% 0) / 1024 / 1024, 1))
  }, error = function(e) NULL)
}))
all_versions <- all_versions[order(
  sapply(all_versions, `[[`, "version"), decreasing = TRUE)]

versions_local <- tempfile(fileext = ".json")
jsonlite::write_json(list(versions = all_versions), versions_local,
  auto_unbox = TRUE, pretty = TRUE)
put_gcs_file(versions_local,
  glue("gs://{gcs_bucket}/ducklake/releases/versions.json"))
gs://calcofi-db/ducklake/releases/versions.json
Code
# the schema site (calcofi.io/schema) fetches these JSON/mmd sidecars at runtime
# and they are OVERWRITTEN in place when a release is re-run (e.g. to fix a bug).
# GCS defaults to `cache-control: public, max-age=3600`, so a corrected re-upload
# stays masked by browser/CDN caches for up to an hour. Tag the mutable sidecars
# `no-cache` (revalidate-always; cheap 304s) so a re-render is visible immediately.
sidecar_urls <- c(
  glue("gs://{gcs_bucket}/ducklake/releases/versions.json"),
  glue("gs://{gcs_bucket}/{gcs_release}/catalog.json"),
  glue("gs://{gcs_bucket}/{gcs_release}/metadata.json"),
  glue("gs://{gcs_bucket}/{gcs_release}/relationships.json"),
  glue("gs://{gcs_bucket}/{gcs_release}/erd.mmd"),
  glue("gs://{gcs_bucket}/{gcs_release}/RELEASE_NOTES.md"))
cc_res <- system2(gcloud,
  c("storage", "objects", "update", "--cache-control=no-cache", sidecar_urls),
  stdout = TRUE, stderr = TRUE)
if (!identical(attr(cc_res, "status") %||% 0L, 0L))
  warning(glue("could not set no-cache on sidecars: {paste(cc_res, collapse='; ')}"))
message("runtime sidecars tagged cache-control: no-cache")
runtime sidecars tagged cache-control: no-cache
Code
# NOTE: latest.txt is NOT updated here. Promotion is gated on the
# query-test pass in test_release.qmd, which writes latest.txt only
# when every pre-baked query in CalCOFI/query/_queries succeeds.
message(glue(
  "Release {release_version} uploaded ({length(all_versions)} versions tracked); ",
  "latest.txt promotion deferred to test_release.qmd"))
Release v2026.06.08 uploaded (12 versions tracked); latest.txt promotion deferred to test_release.qmd

1.7 Cleanup

Code
# close in-memory DuckDB connection
close_duckdb(con_wdl)
message("Assembly DuckDB connection closed")
Assembly DuckDB connection closed
Code
# summary
message(glue("\n=== Summary ==="))
=== Summary ===
Code
message(glue("Frozen release: {release_version} created at {dir_frozen}"))
Frozen release: v2026.06.08 created at /Users/bbest/Github/CalCOFI/workflows/data/releases/v2026.06.08
Code
message(glue("Tables: {nrow(freeze_stats)}"))
Tables: 44
Code
message(glue("Total rows: {format(sum(freeze_stats$rows, na.rm = TRUE), big.mark = ',')}"))
Total rows: 133,807,311
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-08
 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.5.0)
   arrow                24.0.0     2026-04-29 [1] CRAN (R 4.5.2)
   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)
   base64enc            0.1-6      2026-02-02 [1] CRAN (R 4.5.2)
   base64url            1.4        2018-05-14 [1] CRAN (R 4.5.0)
   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)
   broom                1.0.13     2026-05-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()
 P calcofi4r          * 1.3.0      2026-06-05 [?] load_all()
   callr                3.7.6      2024-03-25 [1] CRAN (R 4.5.0)
   class                7.3-23     2025-01-01 [1] CRAN (R 4.5.2)
   classInt             0.4-11     2025-01-08 [1] CRAN (R 4.5.0)
   cli                  3.6.6      2026-04-09 [1] CRAN (R 4.5.2)
   codetools            0.2-20     2024-03-31 [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)
   data.table           1.18.4     2026-05-06 [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)
   dygraphs             1.1.1.6    2018-07-11 [1] CRAN (R 4.5.0)
   e1071                1.7-17     2025-12-18 [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)
   farver               2.1.2      2024-05-13 [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)
   fuzzyjoin            0.1.8      2026-02-20 [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)
   geojsonsf            2.0.5      2025-11-26 [1] CRAN (R 4.5.2)
   ggplot2              4.0.3      2026-04-22 [1] CRAN (R 4.5.2)
   glue               * 1.8.1      2026-04-17 [1] CRAN (R 4.5.2)
   googledrive          2.1.2      2025-09-10 [1] CRAN (R 4.5.0)
   gtable               0.3.6      2024-10-25 [1] CRAN (R 4.5.0)
   here               * 1.0.2      2025-09-15 [1] CRAN (R 4.5.0)
   highcharter          0.9.5      2026-04-22 [1] CRAN (R 4.5.2)
   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)
   httr2                1.2.2      2025-12-08 [1] CRAN (R 4.5.2)
   igraph               2.3.2      2026-05-29 [1] CRAN (R 4.5.2)
   isoband              0.3.0      2025-12-07 [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)
   KernSmooth           2.23-26    2025-01-01 [1] CRAN (R 4.5.2)
   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)
   lattice              0.22-9     2026-02-09 [1] CRAN (R 4.5.2)
   lazyeval             0.2.3      2026-04-04 [1] CRAN (R 4.5.2)
   leafem               0.2.5      2025-08-28 [1] CRAN (R 4.5.0)
   leaflet              2.2.3      2025-09-04 [1] CRAN (R 4.5.0)
   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)
   mapgl                0.4.6      2026-06-05 [1] Github (bbest/mapgl@0b70b3f)
   mapview              2.11.4     2025-09-08 [1] CRAN (R 4.5.0)
   markdown             2.0        2025-03-23 [1] CRAN (R 4.5.0)
   Matrix               1.7-5      2026-03-21 [1] CRAN (R 4.5.2)
   memoise              2.0.1      2021-11-26 [1] CRAN (R 4.5.0)
   mgcv                 1.9-4      2025-11-07 [1] CRAN (R 4.5.0)
   mime                 0.13       2025-03-17 [1] CRAN (R 4.5.0)
   nlme                 3.1-169    2026-03-27 [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)
   plotly               4.12.0     2026-01-24 [1] CRAN (R 4.5.2)
   png                  0.1-9      2026-03-15 [1] CRAN (R 4.5.2)
   prettyunits          1.2.0      2023-09-24 [1] CRAN (R 4.5.0)
   processx             3.8.7      2026-04-01 [1] CRAN (R 4.5.2)
   promises             1.5.0      2025-11-01 [1] CRAN (R 4.5.0)
   proxy                0.4-29     2025-12-29 [1] CRAN (R 4.5.2)
   ps                   1.9.2      2026-03-31 [1] CRAN (R 4.5.2)
   purrr              * 1.2.2      2026-04-10 [1] CRAN (R 4.5.2)
   quantmod             0.4.28     2025-06-19 [1] CRAN (R 4.5.0)
   R6                   2.6.1      2025-02-15 [1] CRAN (R 4.5.0)
   rappdirs             0.3.4      2026-01-17 [1] CRAN (R 4.5.2)
   raster               3.6-32     2025-03-28 [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)
   rlist                0.4.6.2    2021-09-03 [1] CRAN (R 4.5.0)
   rmarkdown            2.31       2026-03-26 [1] CRAN (R 4.5.2)
   rnaturalearth        1.2.0      2026-01-19 [1] CRAN (R 4.5.2)
   rnaturalearthhires   1.0.0.9000 2025-10-02 [1] Github (ropensci/rnaturalearthhires@e4736f6)
   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)
   S7                   0.2.2      2026-04-22 [1] CRAN (R 4.5.2)
   sass                 0.4.10     2025-04-11 [1] CRAN (R 4.5.0)
   satellite            1.0.6      2025-08-21 [1] CRAN (R 4.5.0)
   scales               1.4.0      2025-04-24 [1] CRAN (R 4.5.0)
   secretbase           1.2.1      2026-03-30 [1] CRAN (R 4.5.2)
   sessioninfo          1.2.3      2025-02-05 [1] CRAN (R 4.5.0)
   sf                   1.1-1      2026-05-06 [1] CRAN (R 4.5.2)
   shiny                1.13.0     2026-02-20 [1] CRAN (R 4.5.2)
   shinyWidgets         0.9.1      2026-03-09 [1] CRAN (R 4.5.2)
   snakecase            0.11.1     2023-08-27 [1] CRAN (R 4.5.0)
   sp                   2.2-1      2026-02-13 [1] CRAN (R 4.5.2)
   stars                0.7-2      2026-04-03 [1] CRAN (R 4.5.2)
   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)
   targets            * 1.12.0     2026-02-09 [1] CRAN (R 4.5.2)
   terra                1.9-27     2026-05-10 [1] CRAN (R 4.5.2)
   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)
   TTR                  0.24.4     2023-11-28 [1] CRAN (R 4.5.0)
   tzdb                 0.5.0      2025-03-15 [1] CRAN (R 4.5.0)
   units                1.0-1      2026-03-11 [1] CRAN (R 4.5.2)
   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)
   viridisLite          0.4.3      2026-02-04 [1] CRAN (R 4.5.2)
   visNetwork           2.1.4      2025-09-04 [1] CRAN (R 4.5.0)
   vroom                1.7.1      2026-03-31 [1] CRAN (R 4.5.2)
   withr                3.0.2      2024-10-28 [1] CRAN (R 4.5.0)
   xfun                 0.58       2026-06-01 [1] CRAN (R 4.5.2)
   xtable               1.8-8      2026-02-22 [1] CRAN (R 4.5.2)
   xts                  0.14.2     2026-02-28 [1] CRAN (R 4.5.2)
   yaml                 2.3.12     2025-12-10 [1] CRAN (R 4.5.2)
   zoo                  1.8-15     2025-12-15 [1] CRAN (R 4.5.2)

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

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

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