use gwk_domain::blob::BlobAddress;
use gwk_domain::checkpoint::{CHECKPOINT_SCHEMA_VERSION, Checkpoint};
use gwk_domain::envelope::PayloadRef;
use gwk_domain::ids::{ByteCount, Seq, Timestamp};
use gwk_domain::port::BlobStore;
use gwk_domain::protocol::{ProjectionKind, ProjectionRecord};
use sha2::{Digest, Sha256};
use sqlx::{PgConnection, Row};
use crate::blob::container;
use crate::blob::store::PgBlobStore;
use crate::numeric::{from_numeric_text, to_numeric_text};
use crate::project::Refusal;
pub const RECORDS_MEDIA_TYPE: &str = "application/x-ndjson";
struct Projection {
tag: &'static str,
key: &'static str,
query: &'static str,
read: &'static str,
derived: bool,
}
const fn derived(
tag: &'static str,
key: &'static str,
query: &'static str,
read: &'static str,
) -> Projection {
Projection {
tag,
key,
query,
read,
derived: true,
}
}
const fn written_beside_the_log(
tag: &'static str,
key: &'static str,
query: &'static str,
read: &'static str,
) -> Projection {
Projection {
tag,
key,
query,
read,
derived: false,
}
}
pub fn read_query(kind: ProjectionKind) -> Option<(&'static str, &'static str)> {
PROJECTIONS
.iter()
.find(|p| p.tag == kind.as_str())
.map(|p| (p.read, p.key))
}
const PROJECTIONS: &[Projection] = &[
derived(
"attempt",
"id",
"SELECT jsonb_build_object('projection_type', 'attempt', 'attempt', to_jsonb(t))::text \
FROM gwk.attempt t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'attempt', 'attempt', to_jsonb(t))::text \
FROM gwk.attempt t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
written_beside_the_log(
"attention_item",
"id",
"SELECT jsonb_build_object('projection_type', 'attention_item', 'attention_item', \
to_jsonb(t))::text FROM gwk.attention_item t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'attention_item', 'attention_item', \
to_jsonb(t))::text FROM gwk.attention_item t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"authority_grant",
"id",
"SELECT jsonb_build_object('projection_type', 'authority_grant', 'authority_grant', \
to_jsonb(t))::text FROM gwk.authority_grant t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'authority_grant', 'authority_grant', \
to_jsonb(t))::text FROM gwk.authority_grant t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"command",
"id",
"SELECT jsonb_build_object('projection_type', 'command', 'command', to_jsonb(t))::text \
FROM gwk.command t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'command', 'command', to_jsonb(t))::text \
FROM gwk.command t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"dispatch_node",
"id",
"SELECT jsonb_build_object('projection_type', 'dispatch_node', 'dispatch_node', \
to_jsonb(t))::text FROM gwk.dispatch_node t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'dispatch_node', 'dispatch_node', \
to_jsonb(t))::text FROM gwk.dispatch_node t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"engine_session",
"id",
"SELECT jsonb_build_object('projection_type', 'engine_session', 'engine_session', \
to_jsonb(t))::text FROM gwk.engine_session t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'engine_session', 'engine_session', \
to_jsonb(t))::text FROM gwk.engine_session t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"evidence",
"id",
"SELECT jsonb_build_object('projection_type', 'evidence', 'evidence', \
to_jsonb(t) || jsonb_build_object('byte_size', t.byte_size::text))::text \
FROM gwk.evidence t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'evidence', 'evidence', \
to_jsonb(t) || jsonb_build_object('byte_size', t.byte_size::text))::text \
FROM gwk.evidence t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"gate",
"id",
"SELECT jsonb_build_object('projection_type', 'gate', 'gate', to_jsonb(t))::text \
FROM gwk.gate t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'gate', 'gate', to_jsonb(t))::text \
FROM gwk.gate t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"ingested_record",
"id",
"SELECT jsonb_build_object('projection_type', 'ingested_record', 'ingested_record', \
to_jsonb(t) || jsonb_build_object('event_seq', t.event_seq::text))::text \
FROM gwk.ingested_record t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'ingested_record', 'ingested_record', \
to_jsonb(t) || jsonb_build_object('event_seq', t.event_seq::text))::text \
FROM gwk.ingested_record t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"lease",
"id",
"SELECT jsonb_build_object('projection_type', 'lease', 'lease', \
to_jsonb(t) || jsonb_build_object('fence_token', t.fence_token::text))::text \
FROM gwk.lease t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'lease', 'lease', \
to_jsonb(t) || jsonb_build_object('fence_token', t.fence_token::text))::text \
FROM gwk.lease t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"message",
"id",
"SELECT jsonb_build_object('projection_type', 'message', 'message', to_jsonb(t))::text \
FROM gwk.message t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'message', 'message', to_jsonb(t))::text \
FROM gwk.message t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"orchestrator_checkpoint",
"orchestrator_id",
"SELECT jsonb_build_object('projection_type', 'orchestrator_checkpoint', \
'orchestrator_checkpoint', \
(to_jsonb(t) - 'updated_at') || jsonb_build_object('seq', t.seq::text))::text \
FROM gwk.orchestrator_checkpoint t ORDER BY t.orchestrator_id",
"SELECT jsonb_build_object('projection_type', 'orchestrator_checkpoint', \
'orchestrator_checkpoint', \
(to_jsonb(t) - 'updated_at') || jsonb_build_object('seq', t.seq::text))::text \
FROM gwk.orchestrator_checkpoint t \
WHERE ($1::text IS NULL OR t.orchestrator_id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.orchestrator_id = $2) \
ORDER BY t.orchestrator_id COLLATE \"C\" LIMIT $3",
),
written_beside_the_log(
"receipt",
"id",
"SELECT jsonb_build_object('projection_type', 'receipt', 'receipt', \
(to_jsonb(t) - 'from_state' - 'to_state') \
|| jsonb_strip_nulls(jsonb_build_object('from', t.from_state, 'to', t.to_state)))::text \
FROM gwk.receipt t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'receipt', 'receipt', \
(to_jsonb(t) - 'from_state' - 'to_state') \
|| jsonb_strip_nulls(jsonb_build_object('from', t.from_state, 'to', t.to_state)))::text \
FROM gwk.receipt t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"task",
"id",
"SELECT jsonb_build_object('projection_type', 'task', 'task', to_jsonb(t))::text \
FROM gwk.task t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'task', 'task', to_jsonb(t))::text \
FROM gwk.task t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
derived(
"worktree",
"id",
"SELECT jsonb_build_object('projection_type', 'worktree', 'worktree', to_jsonb(t))::text \
FROM gwk.worktree t ORDER BY t.id",
"SELECT jsonb_build_object('projection_type', 'worktree', 'worktree', to_jsonb(t))::text \
FROM gwk.worktree t \
WHERE ($1::text IS NULL OR t.id COLLATE \"C\" > $1) \
AND ($2::text IS NULL OR t.id = $2) \
ORDER BY t.id COLLATE \"C\" LIMIT $3",
),
];
pub async fn canonical_records(conn: &mut PgConnection) -> Result<Vec<u8>, Refusal> {
records(conn, false).await
}
pub async fn derived_records(conn: &mut PgConnection) -> Result<Vec<u8>, Refusal> {
records(conn, true).await
}
async fn records(conn: &mut PgConnection, derived_only: bool) -> Result<Vec<u8>, Refusal> {
let mut out = Vec::new();
for projection in PROJECTIONS {
if derived_only && !projection.derived {
continue;
}
let rows = sqlx::query(projection.query)
.fetch_all(&mut *conn)
.await
.map_err(|e| Refusal::storage(format!("read {} projections: {e}", projection.tag)))?;
for row in &rows {
let raw: String = row
.try_get(0)
.map_err(|e| Refusal::storage(format!("projection row: {e}")))?;
let record: ProjectionRecord = serde_json::from_str(&raw).map_err(|e| {
Refusal::storage(format!(
"projection row does not match the contract type: {e}"
))
})?;
serde_json::to_writer(&mut out, &record)
.map_err(|e| Refusal::storage(format!("serialize projection record: {e}")))?;
out.push(b'\n');
}
}
Ok(out)
}
pub fn projection_hash(records: &[u8]) -> String {
let digest: [u8; 32] = Sha256::digest(records).into();
container::hex_lower(&digest)
}
pub async fn snapshot(
conn: &mut PgConnection,
blobs: &PgBlobStore,
through: Seq,
created_at: &Timestamp,
) -> Result<Checkpoint, Refusal> {
let records = derived_records(conn).await?;
let hash = projection_hash(&records);
let address =
BlobAddress::from_digest(&hash).map_err(|e| Refusal::storage(format!("hash: {e}")))?;
let byte_size = ByteCount::new(records.len() as u64);
let store_blob = async {
let upload = blobs
.begin(RECORDS_MEDIA_TYPE.to_owned(), byte_size)
.await?;
for (sequence, chunk) in records
.chunks(gwk_domain::blob::BLOB_CHUNK_BYTES)
.enumerate()
{
let sequence = u32::try_from(sequence).map_err(|_| {
gwk_domain::port::BlobError::Storage("snapshot has too many chunks".to_owned())
})?;
blobs.write_chunk(&upload, sequence, chunk).await?;
}
if records.is_empty() {
blobs.write_chunk(&upload, 0, &[]).await?;
}
blobs.commit(upload, address.clone()).await
};
let (descriptor, _deduped) = store_blob
.await
.map_err(|e| Refusal::storage(format!("store checkpoint records: {e}")))?;
debug_assert_eq!(descriptor.address, address);
let checkpoint = Checkpoint {
schema_version: CHECKPOINT_SCHEMA_VERSION,
through_sequence: through,
projection_hash: hash,
records_ref: PayloadRef {
digest: address.as_str().to_owned(),
media_type: RECORDS_MEDIA_TYPE.to_owned(),
byte_size,
retention_class: None,
evidence_pin: None,
},
created_at: created_at.clone(),
};
sqlx::query(
"INSERT INTO gwk_internal.checkpoint \
(through_seq, schema_version, projection_hash, records_ref, created_at) \
VALUES ($1::numeric, $2, $3, $4, $5::timestamptz) \
ON CONFLICT (through_seq) DO NOTHING",
)
.bind(to_numeric_text(through.value()))
.bind(i64::from(checkpoint.schema_version))
.bind(&checkpoint.projection_hash)
.bind(
serde_json::to_value(&checkpoint.records_ref)
.map_err(|e| Refusal::storage(format!("serialize records_ref: {e}")))?,
)
.bind(created_at.as_str())
.execute(&mut *conn)
.await
.map_err(|e| Refusal::storage(format!("record checkpoint: {e}")))?;
sqlx::query(
"UPDATE gwk_internal.writer SET checkpoint_seq = $1::numeric, checkpoint_at = $2::timestamptz \
WHERE id = 1",
)
.bind(to_numeric_text(through.value()))
.bind(created_at.as_str())
.execute(&mut *conn)
.await
.map_err(|e| Refusal::storage(format!("advance the checkpoint barrier: {e}")))?;
Ok(checkpoint)
}
pub async fn checkpoints(conn: &mut PgConnection) -> Result<Vec<Checkpoint>, Refusal> {
let rows = sqlx::query(
"SELECT through_seq::text AS through_text, schema_version, projection_hash, records_ref, \
to_json(created_at) #>> '{}' AS created_at \
FROM gwk_internal.checkpoint ORDER BY through_seq DESC",
)
.fetch_all(conn)
.await
.map_err(|e| Refusal::storage(format!("read checkpoints: {e}")))?;
rows.iter()
.map(|row| {
let get = |name: &str| -> Result<String, Refusal> {
row.try_get(name)
.map_err(|e| Refusal::storage(format!("column {name}: {e}")))
};
let schema_version: i64 = row
.try_get("schema_version")
.map_err(|e| Refusal::storage(format!("column schema_version: {e}")))?;
let records_ref: serde_json::Value = row
.try_get("records_ref")
.map_err(|e| Refusal::storage(format!("column records_ref: {e}")))?;
Ok(Checkpoint {
schema_version: u32::try_from(schema_version)
.map_err(|e| Refusal::storage(format!("schema_version: {e}")))?,
through_sequence: Seq::new(
from_numeric_text(&get("through_text")?)
.map_err(|e| Refusal::storage(format!("column through_seq: {e}")))?,
),
projection_hash: get("projection_hash")?,
records_ref: serde_json::from_value(records_ref)
.map_err(|e| Refusal::storage(format!("column records_ref: {e}")))?,
created_at: Timestamp::new(get("created_at")?),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_projection_is_visited_exactly_once_in_a_written_down_order() {
let ordered: Vec<&str> = PROJECTIONS.iter().map(|p| p.tag).collect();
let mut tags = ordered.clone();
tags.sort_unstable();
tags.dedup();
assert_eq!(tags.len(), PROJECTIONS.len(), "a tag appears twice");
assert_eq!(ordered, tags, "the visit order must be alphabetical");
for projection in PROJECTIONS {
let tag = projection.tag;
assert!(
projection
.query
.contains(&format!("'projection_type', '{tag}', '{tag}',")),
"{tag}: the record's one field must be named for its tag"
);
assert!(
projection.query.contains(&format!(" FROM gwk.{tag} t ")),
"{tag}: the query must read the table it is tagged for"
);
assert!(
projection.query.contains(" ORDER BY "),
"{tag}: rows must be ordered"
);
}
}
#[test]
fn only_the_tables_a_replay_can_rebuild_reach_the_hash() {
let excluded: Vec<&str> = PROJECTIONS
.iter()
.filter(|p| !p.derived)
.map(|p| p.tag)
.collect();
assert_eq!(excluded, ["attention_item", "receipt"]);
assert_eq!(
PROJECTIONS.iter().filter(|p| p.derived).count(),
PROJECTIONS.len() - 2,
"everything else must be rebuildable from the log"
);
}
#[test]
fn the_hash_is_over_the_stored_bytes_and_nothing_else() {
let records = b"{\"projection_type\":\"task\"}\n".to_vec();
let hash = projection_hash(&records);
let address = BlobAddress::from_digest(&hash).expect("a legal address");
assert_eq!(address.digest_hex(), hash);
assert_eq!(
hash,
{
let digest: [u8; 32] = Sha256::digest(&records).into();
container::hex_lower(&digest)
},
"the hash must be a plain SHA-256 over the bytes"
);
assert_eq!(
projection_hash(&[]),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn a_served_row_is_the_same_row_the_hash_canonicalizes() {
for projection in PROJECTIONS {
let head = |q: &'static str| {
q.split_once(" FROM ")
.expect("every projection query selects FROM a table")
.0
.to_owned()
};
assert_eq!(
head(projection.query),
head(projection.read),
"{} builds a different record for the hash than for a read",
projection.tag
);
}
}
#[test]
fn the_cursor_key_is_the_column_the_page_was_ordered_by() {
for projection in PROJECTIONS {
let key = projection.key;
assert!(
projection
.read
.contains(&format!("ORDER BY t.{key} COLLATE")),
"{} pages by {key} but does not order by it",
projection.tag
);
assert!(
projection
.read
.contains(&format!("t.{key} COLLATE \"C\" > $1")),
"{} orders by {key} but compares the cursor against another column",
projection.tag
);
}
}
#[test]
fn every_projection_a_client_can_name_has_a_read_behind_it() {
for kind in ProjectionKind::ALL {
assert!(
read_query(*kind).is_some(),
"{} has no read query",
kind.as_str()
);
}
}
}