Phase 10: Rich catalog v2 — metadata as product
- DatasetManifest expanded: description, owner, sensitivity, columns,
lineage, freshness contract, tags, row_count
- All new fields use #[serde(default)] for backward compatibility
- PII auto-detection: scans column names for email, phone, SSN, salary,
address, DOB, medical terms — flags as PII/PHI/Financial
- Column-level metadata: name, type, sensitivity, is_pii flag
- Lineage tracking: source_system, source_file, ingest_job, timestamp
- Ingest pipeline auto-populates: PII scan, column meta, lineage, row count
- PATCH /catalog/datasets/by-name/{name}/metadata — update metadata
- Catalog responses now include all rich fields
- 25 unit tests passing (5 new PII detection tests)
Per ADR-013: datasets without metadata become mystery files.
This makes every ingested file self-describing from day one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bf7cf96911
commit
9e53caaec3
@ -1,4 +1,7 @@
|
|||||||
use shared::types::{DatasetId, DatasetManifest, ObjectRef, SchemaFingerprint};
|
use shared::types::{
|
||||||
|
DatasetId, DatasetManifest, ObjectRef, SchemaFingerprint,
|
||||||
|
ColumnMeta, Lineage, FreshnessContract, Sensitivity,
|
||||||
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@ -6,6 +9,19 @@ use tokio::sync::RwLock;
|
|||||||
use storaged::ops;
|
use storaged::ops;
|
||||||
use object_store::ObjectStore;
|
use object_store::ObjectStore;
|
||||||
|
|
||||||
|
/// Partial metadata update — only set fields are applied.
|
||||||
|
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||||
|
pub struct MetadataUpdate {
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub sensitivity: Option<Sensitivity>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub columns: Option<Vec<ColumnMeta>>,
|
||||||
|
pub lineage: Option<Lineage>,
|
||||||
|
pub freshness: Option<FreshnessContract>,
|
||||||
|
pub row_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
const MANIFEST_PREFIX: &str = "_catalog/manifests";
|
const MANIFEST_PREFIX: &str = "_catalog/manifests";
|
||||||
|
|
||||||
/// In-memory dataset registry backed by manifest persistence in object storage.
|
/// In-memory dataset registry backed by manifest persistence in object storage.
|
||||||
@ -54,6 +70,14 @@ impl Registry {
|
|||||||
objects,
|
objects,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
description: String::new(),
|
||||||
|
owner: String::new(),
|
||||||
|
sensitivity: None,
|
||||||
|
columns: vec![],
|
||||||
|
lineage: None,
|
||||||
|
freshness: None,
|
||||||
|
tags: vec![],
|
||||||
|
row_count: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write-ahead: persist before in-memory update
|
// Write-ahead: persist before in-memory update
|
||||||
@ -68,6 +92,36 @@ impl Registry {
|
|||||||
Ok(manifest)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update metadata on an existing dataset (owner, description, tags, sensitivity, etc.)
|
||||||
|
pub async fn update_metadata(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
updates: MetadataUpdate,
|
||||||
|
) -> Result<DatasetManifest, String> {
|
||||||
|
let mut datasets = self.datasets.write().await;
|
||||||
|
let manifest = datasets.values_mut()
|
||||||
|
.find(|d| d.name == name)
|
||||||
|
.ok_or_else(|| format!("dataset not found: {name}"))?;
|
||||||
|
|
||||||
|
if let Some(desc) = updates.description { manifest.description = desc; }
|
||||||
|
if let Some(owner) = updates.owner { manifest.owner = owner; }
|
||||||
|
if let Some(sens) = updates.sensitivity { manifest.sensitivity = Some(sens); }
|
||||||
|
if let Some(tags) = updates.tags { manifest.tags = tags; }
|
||||||
|
if let Some(cols) = updates.columns { manifest.columns = cols; }
|
||||||
|
if let Some(lineage) = updates.lineage { manifest.lineage = Some(lineage); }
|
||||||
|
if let Some(freshness) = updates.freshness { manifest.freshness = Some(freshness); }
|
||||||
|
if let Some(count) = updates.row_count { manifest.row_count = Some(count); }
|
||||||
|
manifest.updated_at = chrono::Utc::now();
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
let manifest_key = format!("{MANIFEST_PREFIX}/{}.json", manifest.id);
|
||||||
|
let json = serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())?;
|
||||||
|
ops::put(&self.store, &manifest_key, json.into()).await?;
|
||||||
|
|
||||||
|
let result = manifest.clone();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a dataset by ID.
|
/// Get a dataset by ID.
|
||||||
pub async fn get(&self, id: &DatasetId) -> Option<DatasetManifest> {
|
pub async fn get(&self, id: &DatasetId) -> Option<DatasetManifest> {
|
||||||
let datasets = self.datasets.read().await;
|
let datasets = self.datasets.read().await;
|
||||||
|
|||||||
@ -18,6 +18,7 @@ pub fn router(registry: Registry) -> Router {
|
|||||||
.route("/datasets", get(list_datasets))
|
.route("/datasets", get(list_datasets))
|
||||||
.route("/datasets/{id}", get(get_dataset))
|
.route("/datasets/{id}", get(get_dataset))
|
||||||
.route("/datasets/by-name/{name}", get(get_dataset_by_name))
|
.route("/datasets/by-name/{name}", get(get_dataset_by_name))
|
||||||
|
.route("/datasets/by-name/{name}/metadata", post(update_metadata))
|
||||||
.with_state(registry)
|
.with_state(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +48,15 @@ struct DatasetResponse {
|
|||||||
objects: Vec<ObjectRefResponse>,
|
objects: Vec<ObjectRefResponse>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
|
// Rich metadata
|
||||||
|
description: String,
|
||||||
|
owner: String,
|
||||||
|
sensitivity: Option<shared::types::Sensitivity>,
|
||||||
|
columns: Vec<shared::types::ColumnMeta>,
|
||||||
|
lineage: Option<shared::types::Lineage>,
|
||||||
|
freshness: Option<shared::types::FreshnessContract>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
row_count: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@ -71,6 +81,14 @@ impl From<&shared::types::DatasetManifest> for DatasetResponse {
|
|||||||
}).collect(),
|
}).collect(),
|
||||||
created_at: m.created_at.to_rfc3339(),
|
created_at: m.created_at.to_rfc3339(),
|
||||||
updated_at: m.updated_at.to_rfc3339(),
|
updated_at: m.updated_at.to_rfc3339(),
|
||||||
|
description: m.description.clone(),
|
||||||
|
owner: m.owner.clone(),
|
||||||
|
sensitivity: m.sensitivity.clone(),
|
||||||
|
columns: m.columns.clone(),
|
||||||
|
lineage: m.lineage.clone(),
|
||||||
|
freshness: m.freshness.clone(),
|
||||||
|
tags: m.tags.clone(),
|
||||||
|
row_count: m.row_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,3 +141,14 @@ async fn get_dataset_by_name(
|
|||||||
None => Err((StatusCode::NOT_FOUND, format!("dataset not found: {name}"))),
|
None => Err((StatusCode::NOT_FOUND, format!("dataset not found: {name}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_metadata(
|
||||||
|
State(registry): State<Registry>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(updates): Json<crate::registry::MetadataUpdate>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match registry.update_metadata(&name, updates).await {
|
||||||
|
Ok(manifest) => Ok(Json(DatasetResponse::from(&manifest))),
|
||||||
|
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -94,7 +94,7 @@ pub async fn ingest_file(
|
|||||||
ops::put(store, &storage_key, parquet_bytes).await?;
|
ops::put(store, &storage_key, parquet_bytes).await?;
|
||||||
tracing::info!("stored {} as {} ({} bytes)", filename, storage_key, parquet_size);
|
tracing::info!("stored {} as {} ({} bytes)", filename, storage_key, parquet_size);
|
||||||
|
|
||||||
// 7. Register in catalog
|
// 7. Register in catalog with rich metadata
|
||||||
let schema_fp = fingerprint_schema(&schema);
|
let schema_fp = fingerprint_schema(&schema);
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let obj_ref = ObjectRef {
|
let obj_ref = ObjectRef {
|
||||||
@ -104,13 +104,49 @@ pub async fn ingest_file(
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use content hash as fingerprint for dedup tracking
|
// Register base dataset
|
||||||
registry.register(
|
registry.register(
|
||||||
safe_name.clone(),
|
safe_name.clone(),
|
||||||
SchemaFingerprint(hash.clone()),
|
SchemaFingerprint(hash.clone()),
|
||||||
vec![obj_ref],
|
vec![obj_ref],
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
|
// Auto-populate metadata: PII detection, column info, lineage, row count
|
||||||
|
let col_names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
|
||||||
|
let dataset_sensitivity = shared::pii::detect_dataset_sensitivity(&col_names);
|
||||||
|
|
||||||
|
let column_meta: Vec<shared::types::ColumnMeta> = schema.fields().iter().map(|f| {
|
||||||
|
let sens = shared::pii::detect_sensitivity(f.name());
|
||||||
|
shared::types::ColumnMeta {
|
||||||
|
name: f.name().clone(),
|
||||||
|
data_type: f.data_type().to_string(),
|
||||||
|
sensitivity: sens.clone(),
|
||||||
|
description: String::new(),
|
||||||
|
is_pii: matches!(sens, Some(shared::types::Sensitivity::Pii)),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let lineage = shared::types::Lineage {
|
||||||
|
source_system: format!("{:?}", file_type).to_lowercase(),
|
||||||
|
source_file: filename.to_string(),
|
||||||
|
ingest_job: format!("ingest-{}", now.timestamp_millis()),
|
||||||
|
ingest_timestamp: now,
|
||||||
|
parent_datasets: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let pii_count = column_meta.iter().filter(|c| c.is_pii).count();
|
||||||
|
if pii_count > 0 {
|
||||||
|
tracing::info!("auto-detected {} PII columns in '{}'", pii_count, safe_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = registry.update_metadata(&safe_name, catalogd::registry::MetadataUpdate {
|
||||||
|
sensitivity: dataset_sensitivity,
|
||||||
|
columns: Some(column_meta),
|
||||||
|
lineage: Some(lineage),
|
||||||
|
row_count: Some(total_rows as u64),
|
||||||
|
..Default::default()
|
||||||
|
}).await;
|
||||||
|
|
||||||
Ok(IngestResult {
|
Ok(IngestResult {
|
||||||
dataset_name: safe_name,
|
dataset_name: safe_name,
|
||||||
file_type: format!("{:?}", file_type),
|
file_type: format!("{:?}", file_type),
|
||||||
|
|||||||
@ -2,3 +2,4 @@ pub mod types;
|
|||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod arrow_helpers;
|
pub mod arrow_helpers;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod pii;
|
||||||
|
|||||||
116
crates/shared/src/pii.rs
Normal file
116
crates/shared/src/pii.rs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/// Auto-detect PII columns by name patterns.
|
||||||
|
/// Conservative: flags likely PII, doesn't miss obvious cases.
|
||||||
|
|
||||||
|
use crate::types::Sensitivity;
|
||||||
|
|
||||||
|
/// Check if a column name suggests PII content.
|
||||||
|
pub fn detect_sensitivity(column_name: &str) -> Option<Sensitivity> {
|
||||||
|
let lower = column_name.to_lowercase();
|
||||||
|
|
||||||
|
// Direct PII identifiers
|
||||||
|
if matches!(lower.as_str(),
|
||||||
|
"ssn" | "social_security" | "social_security_number" |
|
||||||
|
"sin" | "national_id" | "passport" | "passport_number" |
|
||||||
|
"drivers_license" | "driver_license" | "dl_number"
|
||||||
|
) {
|
||||||
|
return Some(Sensitivity::Pii);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names
|
||||||
|
if lower.contains("first_name") || lower.contains("last_name") ||
|
||||||
|
lower.contains("full_name") || lower.contains("middle_name") ||
|
||||||
|
lower == "name" || lower == "fname" || lower == "lname" {
|
||||||
|
return Some(Sensitivity::Pii);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact info
|
||||||
|
if lower.contains("email") || lower.contains("e_mail") ||
|
||||||
|
lower.contains("phone") || lower.contains("mobile") || lower.contains("cell") ||
|
||||||
|
lower.contains("fax") || lower == "tel" || lower == "telephone" {
|
||||||
|
return Some(Sensitivity::Pii);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address
|
||||||
|
if lower.contains("address") || lower.contains("street") ||
|
||||||
|
(lower.contains("zip") && !lower.contains("unzip")) ||
|
||||||
|
lower == "postal_code" || lower == "postcode" {
|
||||||
|
return Some(Sensitivity::Pii);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Financial
|
||||||
|
if lower.contains("salary") || lower.contains("wage") ||
|
||||||
|
lower.contains("pay_rate") || lower.contains("bill_rate") ||
|
||||||
|
lower.contains("compensation") || lower.contains("revenue") ||
|
||||||
|
lower.contains("bank_account") || lower.contains("routing_number") ||
|
||||||
|
lower.contains("credit_card") {
|
||||||
|
return Some(Sensitivity::Financial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health
|
||||||
|
if lower.contains("diagnosis") || lower.contains("medication") ||
|
||||||
|
lower.contains("medical") || lower.contains("health") ||
|
||||||
|
lower.contains("patient_id") || lower.contains("mrn") {
|
||||||
|
return Some(Sensitivity::Phi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date of birth
|
||||||
|
if lower == "dob" || lower == "date_of_birth" || lower == "birthdate" || lower == "birth_date" {
|
||||||
|
return Some(Sensitivity::Pii);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify all columns and return the highest sensitivity found.
|
||||||
|
pub fn detect_dataset_sensitivity(column_names: &[&str]) -> Option<Sensitivity> {
|
||||||
|
let mut highest: Option<Sensitivity> = None;
|
||||||
|
|
||||||
|
for name in column_names {
|
||||||
|
if let Some(sens) = detect_sensitivity(name) {
|
||||||
|
highest = Some(match (&highest, &sens) {
|
||||||
|
(None, s) => s.clone(),
|
||||||
|
(Some(Sensitivity::Public), s) => s.clone(),
|
||||||
|
(Some(Sensitivity::Internal), s) if !matches!(s, Sensitivity::Public | Sensitivity::Internal) => s.clone(),
|
||||||
|
(Some(Sensitivity::Financial), Sensitivity::Pii | Sensitivity::Phi) => sens.clone(),
|
||||||
|
(Some(Sensitivity::Pii), Sensitivity::Phi) => Sensitivity::Phi,
|
||||||
|
(existing, _) => existing.clone().unwrap(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_email_as_pii() {
|
||||||
|
assert_eq!(detect_sensitivity("email"), Some(Sensitivity::Pii));
|
||||||
|
assert_eq!(detect_sensitivity("contact_email"), Some(Sensitivity::Pii));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_salary_as_financial() {
|
||||||
|
assert_eq!(detect_sensitivity("salary"), Some(Sensitivity::Financial));
|
||||||
|
assert_eq!(detect_sensitivity("bill_rate"), Some(Sensitivity::Financial));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_ssn() {
|
||||||
|
assert_eq!(detect_sensitivity("ssn"), Some(Sensitivity::Pii));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_sensitive_returns_none() {
|
||||||
|
assert_eq!(detect_sensitivity("status"), None);
|
||||||
|
assert_eq!(detect_sensitivity("created_at"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dataset_sensitivity_picks_highest() {
|
||||||
|
let cols = vec!["id", "name", "email", "status"];
|
||||||
|
assert_eq!(detect_dataset_sensitivity(&cols), Some(Sensitivity::Pii));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,7 +30,50 @@ pub struct ObjectRef {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub struct SchemaFingerprint(pub String);
|
pub struct SchemaFingerprint(pub String);
|
||||||
|
|
||||||
/// Dataset manifest — the catalog's view of a dataset.
|
/// Sensitivity classification for a field or dataset.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Sensitivity {
|
||||||
|
Public, // anyone can see
|
||||||
|
Internal, // employees only
|
||||||
|
Pii, // personally identifiable (name, email, phone, SSN)
|
||||||
|
Phi, // protected health info (HIPAA)
|
||||||
|
Financial, // compensation, billing, revenue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column-level metadata.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ColumnMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub data_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sensitivity: Option<Sensitivity>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_pii: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data lineage — where this dataset came from.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Lineage {
|
||||||
|
pub source_system: String, // "csv_upload", "mysql_import", "api_ingest"
|
||||||
|
pub source_file: String, // original filename or URI
|
||||||
|
pub ingest_job: String, // job ID that created this dataset
|
||||||
|
pub ingest_timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parent_datasets: Vec<String>, // derived from these datasets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freshness contract — how often this data should be updated.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FreshnessContract {
|
||||||
|
pub expected_frequency: String, // "daily", "weekly", "hourly", "manual"
|
||||||
|
pub stale_after_hours: u32, // alert if not updated within this many hours
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dataset manifest — the catalog's complete view of a dataset.
|
||||||
|
/// All new fields use #[serde(default)] for backward compatibility with existing manifests.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DatasetManifest {
|
pub struct DatasetManifest {
|
||||||
pub id: DatasetId,
|
pub id: DatasetId,
|
||||||
@ -39,4 +82,38 @@ pub struct DatasetManifest {
|
|||||||
pub objects: Vec<ObjectRef>,
|
pub objects: Vec<ObjectRef>,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
|
||||||
|
// --- Rich metadata (Phase 10) ---
|
||||||
|
|
||||||
|
/// Human-readable description (auto-generated on ingest or manual)
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
|
||||||
|
/// Who owns this dataset
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner: String,
|
||||||
|
|
||||||
|
/// Overall sensitivity classification
|
||||||
|
#[serde(default)]
|
||||||
|
pub sensitivity: Option<Sensitivity>,
|
||||||
|
|
||||||
|
/// Column-level metadata including sensitivity and descriptions
|
||||||
|
#[serde(default)]
|
||||||
|
pub columns: Vec<ColumnMeta>,
|
||||||
|
|
||||||
|
/// Where this data came from
|
||||||
|
#[serde(default)]
|
||||||
|
pub lineage: Option<Lineage>,
|
||||||
|
|
||||||
|
/// How fresh the data should be
|
||||||
|
#[serde(default)]
|
||||||
|
pub freshness: Option<FreshnessContract>,
|
||||||
|
|
||||||
|
/// Free-form tags for discovery
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
|
||||||
|
/// Row count (updated on ingest/compact)
|
||||||
|
#[serde(default)]
|
||||||
|
pub row_count: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user