Release CalCOFI Database

Published

2026-04-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
    subgraph ingest["Ingest Notebooks"]
        i1["ichthyo"]
        i2["bottle"]
        i3["ctd-cast"]
        i4["dic"]
        i5["inverts"]
        i6["spatial"]
    end
    subgraph assembly["Assembly (this notebook)"]
        a["Load all parquet<br/>into in-memory DuckDB"]
    end
    subgraph release["Frozen Release"]
        f["Clean parquet<br/>+ manifest.json"]
    end
    i1 --> a
    i2 --> a
    i3 --> a
    i4 --> a
    i5 --> a
    i6 --> a
    a --> f

    style ingest fill:#e3f2fd,stroke:#1565c0
    style assembly fill:#fff3e0,stroke:#ef6c00
    style release fill:#e8f4e8,stroke:#2e7d32

Figure 1: Pipeline: ingest notebooks produce parquet → release caboose assembles frozen release

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.04.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"))
29 canonical tables from 5 ingests
Code
# 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 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: 104 rows (VIEW)
Loaded dataset: 5 rows (VIEW)
Loaded ctd_cast: 6065096 rows (VIEW) (GEOMETRY)
Loaded ctd_measurement: 233008709 rows (partitioned) (VIEW)
Loaded ctd_summary: 104828768 rows (partitioned) (VIEW)
Loaded dic_sample: 4391 rows (VIEW)
Loaded dic_measurement: 16391 rows (VIEW)
Loaded dic_summary: 15786 rows (VIEW)
Loaded _spatial_attr: 40298 rows (VIEW)
Loaded _spatial: 3373 rows (VIEW) (GEOMETRY)
Loaded cruise: 691 rows (VIEW)
Loaded grid: 218 rows (VIEW) (GEOMETRY)
Loaded ichthyo: 841417 rows (VIEW)
Loaded invert: 11815 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: 1150 rows (VIEW)
Loaded taxa_rank: 41 rows (VIEW)
Loaded taxon: 3359 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
Merged 1 ship addition(s) from ingest_calcofi_ctd_cast
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: 4548 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 '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 '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 'site' has 2084 NULL values in required column 'grid_key'
- Table 'species' has 4 NULL values in required column 'worms_id'
- Table 'species' has 175 NULL values in required column 'gbif_id'

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 = list(
    lightblue = c("cruise", "ship", "site", "tow", "net"),
    lightyellow = c("ichthyo", "species", "lookup", "taxon", "taxa_rank"),
    lightgreen = c("grid", "segment"),
    pink = c(
      "casts",
      "bottle",
      "bottle_measurement",
      "cast_condition",
      "measurement_type"
    ),
    lavender = c("ctd_cast", "ctd_measurement", "ctd_summary"),
    lightsalmon = c("dic_sample", "dic_measurement", "dic_summary"),
    white = c("dataset")
  )
)
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
rels_paths <- c(
  here("data/parquet/swfsc_ichthyo/relationships.json"),
  here("data/parquet/calcofi_bottle/relationships.json"),
  here("data/parquet/calcofi_ctd-cast/relationships.json"),
  here("data/parquet/calcofi_dic/relationships.json")
)
rels_paths <- rels_paths[file.exists(rels_paths)]

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)

  # add cross-dataset FKs (bottle casts → ichthyo cruise/ship/grid)
  rels_merged <- jsonlite::fromJSON(
    rels_merged_path, simplifyVector = FALSE)
  cross_fks <- list(
    list(table = "casts", column = "cruise_key",
         ref_table = "cruise", ref_column = "cruise_key"),
    list(table = "casts", column = "ship_key",
         ref_table = "ship", ref_column = "ship_key"),
    list(table = "casts", column = "grid_key",
         ref_table = "grid", ref_column = "grid_key"))
  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")
}
Merged relationships.json: 19 PKs, 15 FKs from 4 files
Code
# render color-coded ERD (cc_erd handles GEOMETRY columns natively)
cc_erd(
  con_wdl,
  tables   = schema_tbls,
  rels_path = rels_merged_path,
  colors = list(
    lightblue   = c("cruise", "ship", "site", "tow", "net"),
    lightyellow = c("ichthyo", "species", "lookup",
                    "taxon", "taxa_rank"),
    lightgreen  = c("grid", "segment"),
    pink        = c("casts", "bottle", "bottle_measurement",
                    "cast_condition", "measurement_type"),
    lavender    = c("ctd_cast", "ctd_measurement", "ctd_summary"),
    lightsalmon = c("dic_sample", "dic_measurement",
                    "dic_summary"),
    lightcyan   = c("_spatial", "_spatial_attr"),
    white       = "dataset"))

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.04.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_measurement, ctd_summary, measurement_type)\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.04.08/RELEASE_NOTES.md
Code
cat(release_notes)

1 CalCOFI Database Release v2026.04.08

Release Date: 2026-04-08

1.1 Tables Included

  • casts ( 35,644 rows)
  • bottle ( 895,371 rows)
  • bottle_measurement ( 11,135,600 rows)
  • cast_condition ( 235,513 rows)
  • measurement_type ( 104 rows)
  • dataset ( 5 rows)
  • ctd_cast ( 6,065,096 rows)
  • ctd_measurement (233,008,709 rows)
  • ctd_summary (104,828,768 rows)
  • dic_sample ( 4,391 rows)
  • dic_measurement ( 16,391 rows)
  • dic_summary ( 15,786 rows)
  • _spatial_attr ( 40,298 rows)
  • _spatial ( 3,373 rows)
  • cruise ( 691 rows)
  • grid ( 218 rows)
  • ichthyo ( 841,417 rows)
  • invert ( 11,815 rows)
  • lookup ( 26 rows)
  • net ( 76,512 rows)
  • segment ( 60,413 rows)
  • ship ( 48 rows)
  • site ( 61,104 rows)
  • species ( 1,150 rows)
  • taxa_rank ( 41 rows)
  • taxon ( 3,359 rows)
  • tow ( 75,506 rows)
  • cruise_summary ( 691 rows)

1.2 Total

  • Tables: 28
  • Total Rows: 357,418,040

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_measurement, ctd_summary, measurement_type)
  • 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.04.08/parquet/ichthyo.parquet')
  LIMIT 10")

Or use calcofi4r:

library(calcofi4r)
con <- cc_get_db(version = 'v2026.04.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 26 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}"))
}
  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
  dataset: copied from ingest/calcofi_bottle
  ctd_cast: copied from ingest/calcofi_ctd-cast
  ctd_measurement: 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
  _spatial_attr: copied from ingest/spatial
  _spatial: copied from ingest/spatial
  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)

catalog <- list(
  version      = release_version,
  release_date = as.character(Sys.Date()),
  total_rows   = sum(tables_df$rows, na.rm = TRUE),
  total_size   = 0,
  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.04.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.04.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.04.08/relationships.json
Code
# 4. update versions.json and latest.txt
# 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
# update latest.txt
latest_local <- tempfile()
writeLines(release_version, latest_local)
put_gcs_file(latest_local,
  glue("gs://{gcs_bucket}/ducklake/releases/latest.txt"))
gs://calcofi-db/ducklake/releases/latest.txt
Code
message(glue(
  "Release {release_version} published ({length(all_versions)} versions tracked)"))
Release v2026.04.08 published (6 versions tracked)

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.04.08 created at /Users/bbest/Github/CalCOFI/workflows/data/releases/v2026.04.08
Code
message(glue("Tables: {nrow(freeze_stats)}"))
Tables: 28
Code
message(glue("Total rows: {format(sum(freeze_stats$rows, na.rm = TRUE), big.mark = ',')}"))
Total rows: 357,418,040
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-04-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                23.0.1.2   2026-03-25 [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)
    bit                  4.6.0      2025-03-06 [1] CRAN (R 4.5.0)
    bit64                4.6.0-1    2025-01-16 [1] CRAN (R 4.5.0)
    blob                 1.3.0      2026-01-14 [1] CRAN (R 4.5.2)
    broom                1.0.12     2026-01-27 [1] CRAN (R 4.5.2)
    bslib                0.10.0     2026-01-26 [1] CRAN (R 4.5.2)
    cachem               1.1.0      2024-05-16 [1] CRAN (R 4.5.0)
 VP calcofi4db         * 2.6.1      2026-04-07 [?] load_all() (on disk 2.6.0)
 P  calcofi4r          * 1.1.10     2026-04-08 [?] Github (calcofi/calcofi4r@9e0c86d)
    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.5      2025-04-23 [1] CRAN (R 4.5.0)
    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.0.0      2025-08-19 [1] CRAN (R 4.5.0)
    data.table           1.18.2.1   2026-01-27 [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.11     2024-02-02 [1] CRAN (R 4.5.0)
    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.1      2026-03-05 [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.1      2026-03-26 [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.0.1      2026-03-24 [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.2      2026-02-03 [1] CRAN (R 4.5.2)
    glue               * 1.8.0      2024-09-30 [1] CRAN (R 4.5.0)
    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.4      2022-01-03 [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)
    httr2                1.2.2      2025-12-08 [1] CRAN (R 4.5.2)
    igraph               2.2.2      2026-02-12 [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.5.9000 2026-03-31 [1] Github (bbest/mapgl@74df3b1)
    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)
    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)
    purrr              * 1.2.1      2026-01-09 [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      2026-01-10 [1] CRAN (R 4.5.2)
    readr                2.2.0      2026-02-19 [1] CRAN (R 4.5.2)
    rlang                1.1.7      2026-01-09 [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.1      2025-11-14 [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)
    sessioninfo          1.2.3      2025-02-05 [1] CRAN (R 4.5.0)
    sf                   1.1-0      2026-02-24 [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)
    terra                1.9-11     2026-03-26 [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.0.1      2025-10-10 [1] CRAN (R 4.5.0)
    vctrs                0.7.2      2026-03-21 [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)
    withr                3.0.2      2024-10-28 [1] CRAN (R 4.5.0)
    xfun                 0.57       2026-03-20 [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.
 V ── Loaded and on-disk version mismatch.
 P ── Loaded and on-disk path mismatch.

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