use omgbase_format::hash::{hex, sha256};
use omgbase_format::parse_markdown;
use omgbase_mutate::{ErrorCode, MutationError};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use serde_json::json;
use crate::Store;
use crate::error::Result;
use crate::graph::{adopt_phantoms, rebuild_doc_edges};
use crate::links::InboundLink;
use crate::materialize::{PendingKind, PendingRow, delete_pending, insert_pending};
use crate::mint::Mint;
use crate::mutate::known_plan;
use crate::observe::{Committed, Prepared, ingest_in, tombstone_in};
use crate::time::pool_expiry;
use crate::writers::{NewCommit, Origin, new_commit};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CommitHead<'a> {
pub origin: Origin,
pub actor: Option<&'a str>,
pub reason: Option<&'a str>,
pub clear_pending: bool,
pub ops: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DocBase {
pub doc_id: String,
pub path: String,
pub current_rev: Option<String>,
pub file_hash: Option<Vec<u8>>,
}
pub(crate) fn doc_base(conn: &Connection, doc_id: &str) -> Result<Option<DocBase>> {
Ok(conn
.query_row(
"SELECT path, current_rev, file_hash FROM docs WHERE doc_id = ?1 AND deleted_commit IS NULL",
params![doc_id],
|r| {
Ok(DocBase {
doc_id: doc_id.to_owned(),
path: r.get(0)?,
current_rev: r.get(1)?,
file_hash: r.get(2)?,
})
},
)
.optional()?)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Guard {
None,
Absent,
Doc {
base: DocBase,
on_disk: Option<String>,
},
}
pub(crate) enum Effect {
IngestKnown {
doc_id: String,
path: String,
rendered: String,
by_key: std::collections::BTreeMap<String, String>,
prior_ids: Vec<String>,
guard: Guard,
},
IngestReconciled {
prepared: Box<Prepared>,
guard: Guard,
},
MovePath {
doc_id: String,
from: String,
to: String,
current_rev: Option<String>,
inbound: Vec<InboundLink>,
},
Tombstone {
doc_id: String,
path: String,
pool: bool,
current_rev: Option<String>,
},
}
impl std::fmt::Debug for Effect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Effect::IngestKnown { doc_id, path, .. } => f
.debug_struct("IngestKnown")
.field("doc_id", doc_id)
.field("path", path)
.finish_non_exhaustive(),
Effect::IngestReconciled { prepared, guard } => f
.debug_struct("IngestReconciled")
.field("path", &prepared.path)
.field("guard", guard)
.finish_non_exhaustive(),
Effect::MovePath {
doc_id, from, to, ..
} => f
.debug_struct("MovePath")
.field("doc_id", doc_id)
.field("from", from)
.field("to", to)
.finish_non_exhaustive(),
Effect::Tombstone {
doc_id,
path,
pool,
current_rev,
} => f
.debug_struct("Tombstone")
.field("doc_id", doc_id)
.field("path", path)
.field("pool", pool)
.field("current_rev", current_rev)
.finish(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiskConflict {
pub path: String,
pub bytes: String,
}
#[derive(Debug)]
pub(crate) enum Outcome {
Committed(Vec<Committed>),
Conflict(DiskConflict),
}
impl Outcome {
pub(crate) fn committed(self) -> Result<Vec<Committed>> {
match self {
Outcome::Committed(c) => Ok(c),
Outcome::Conflict(c) => Err(crate::error::Error::Other(format!(
"commit_effects: unexpected disk conflict at {}",
c.path
))),
}
}
}
impl Store {
pub(crate) fn commit_effects(
&mut self,
repo_id: &str,
head: &CommitHead<'_>,
effects: &[Effect],
ts: &str,
) -> Result<Outcome> {
self.commit_effects_with_id(repo_id, head, effects, ts)
.map(|(outcome, _)| outcome)
}
pub(crate) fn commit_effects_with_id(
&mut self,
repo_id: &str,
head: &CommitHead<'_>,
effects: &[Effect],
ts: &str,
) -> Result<(Outcome, Option<String>)> {
if effects.is_empty() {
return Ok((Outcome::Committed(Vec::new()), None));
}
let expires = pool_expiry(ts)?;
let Store { conn, ids, .. } = self;
let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
#[cfg(test)]
if let Some(hook) = self.after_begin.as_mut() {
hook();
}
if let Some(conflict) = validate(&tx, repo_id, effects)? {
drop(tx);
return Ok((Outcome::Conflict(conflict), None));
}
let (committed, commit_id) = {
let mut mint = ids.at(&tx);
let (commit_id, _) = new_commit(
&tx,
&mut mint,
&NewCommit {
repo_id,
ts,
origin: head.origin,
actor: head.actor,
reason: head.reason,
checkpoint_id: None,
ops: head.ops,
},
)?;
let mut outcomes = Vec::with_capacity(effects.len());
for effect in effects {
outcomes.push(run_effect(
&tx, &mut mint, repo_id, head, effect, &expires, &commit_id,
)?);
}
if head.origin == Origin::Api {
record_intent(&tx, repo_id, effects, &outcomes, &commit_id)?;
}
if head.clear_pending {
for path in touched_paths(effects) {
delete_pending(&tx, repo_id, &path)?;
}
}
(
outcomes.into_iter().flatten().collect::<Vec<_>>(),
commit_id,
)
};
tx.commit()?;
Ok((Outcome::Committed(committed), Some(commit_id)))
}
}
pub(crate) fn touched_paths(effects: &[Effect]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |p: &str| {
if !out.iter().any(|q| q == p) {
out.push(p.to_owned());
}
};
for e in effects {
match e {
Effect::IngestKnown { path, guard, .. } => {
if let Guard::Doc { base, .. } = guard {
push(&base.path);
}
push(path);
}
Effect::IngestReconciled { prepared, .. } => push(&prepared.path),
Effect::MovePath { from, to, .. } => {
push(from);
push(to);
}
Effect::Tombstone { path, .. } => push(path),
}
}
out
}
fn current_bytes_hash(tx: &Connection, doc_id: &str) -> Result<Option<(Option<String>, Vec<u8>)>> {
type Row = (Option<String>, Option<Vec<u8>>, Option<Vec<u8>>);
let row: Option<Row> = tx
.query_row(
"SELECT d.current_rev, r.rendered_hash, d.file_hash
FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev
WHERE d.doc_id = ?1",
params![doc_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let Some((rev, rendered, file_hash)) = row else {
return Ok(None);
};
let hash = match rendered.or(file_hash) {
Some(h) => h,
None => match crate::read::reconstruct(tx, doc_id)? {
Some(bytes) => sha256(bytes.as_bytes()).to_vec(),
None => return Ok(None),
},
};
Ok(Some((rev, hash)))
}
fn record_intent(
tx: &Connection,
repo_id: &str,
effects: &[Effect],
outcomes: &[Option<Committed>],
commit_id: &str,
) -> Result<()> {
let moved: Option<&str> = effects.iter().find_map(|e| match e {
Effect::MovePath { doc_id, .. } => Some(doc_id.as_str()),
_ => None,
});
for (effect, outcome) in effects.iter().zip(outcomes) {
let ingested_path = match effect {
Effect::IngestKnown { path, .. } => Some(path.as_str()),
Effect::IngestReconciled { prepared, .. } => Some(prepared.path.as_str()),
_ => None,
};
let row = match (ingested_path, effect, outcome) {
(Some(path), _, Some(c)) => {
if moved == Some(c.doc_id.as_str()) {
continue; }
let rendered_hash: Vec<u8> = tx.query_row(
"SELECT rendered_hash FROM revisions WHERE rev_id = ?1",
params![c.rev_id],
|r| r.get(0),
)?;
PendingRow {
path: path.to_owned(),
kind: PendingKind::Write,
from_path: None,
doc_id: c.doc_id.clone(),
rev_id: Some(c.rev_id.clone()),
rendered_hash,
commit_id: commit_id.to_owned(),
}
}
(
_,
Effect::MovePath {
doc_id, from, to, ..
},
_,
) => {
let Some((rev_id, rendered_hash)) = current_bytes_hash(tx, doc_id)? else {
continue;
};
PendingRow {
path: to.clone(),
kind: PendingKind::Rename,
from_path: Some(from.clone()),
doc_id: doc_id.clone(),
rev_id,
rendered_hash,
commit_id: commit_id.to_owned(),
}
}
(
_,
Effect::Tombstone {
doc_id, path, pool, ..
},
_,
) => {
if *pool {
continue; }
let rendered_hash = current_bytes_hash(tx, doc_id)?
.map_or_else(|| sha256(b"").to_vec(), |(_, h)| h);
PendingRow {
path: path.clone(),
kind: PendingKind::Remove,
from_path: None,
doc_id: doc_id.clone(),
rev_id: None,
rendered_hash,
commit_id: commit_id.to_owned(),
}
}
_ => continue,
};
insert_pending(tx, repo_id, &row)?;
}
Ok(())
}
pub(crate) fn stale_plan(base: &DocBase, current: Option<&DocBase>) -> crate::error::Error {
MutationError::with_data(
ErrorCode::StalePlan,
"document changed since the plan was computed; re-plan",
json!({
"doc": base.doc_id,
"expected_revision": base.current_rev,
"current": {
"revision": current.and_then(|c| c.current_rev.clone()),
"hash": current.and_then(|c| c.file_hash.as_deref().map(hex)),
},
"retriable": true,
}),
)
.into()
}
pub(crate) fn doc_missing(doc_id: &str) -> crate::error::Error {
MutationError::with_data(
ErrorCode::DocMissing,
format!("doc {doc_id} not found"),
json!({ "doc": doc_id }),
)
.into()
}
pub(crate) fn path_taken(path: &str) -> crate::error::Error {
MutationError::new(
ErrorCode::PathTaken,
format!("a document already exists at {path}"),
)
.into()
}
fn live_doc_at(tx: &Connection, repo_id: &str, path: &str) -> Result<bool> {
Ok(tx
.query_row(
"SELECT 1 FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
params![repo_id, path],
|_| Ok(()),
)
.optional()?
.is_some())
}
fn check_doc(
tx: &Connection,
base: &DocBase,
on_disk: Option<&str>,
) -> Result<Option<DiskConflict>> {
let Some(current) = doc_base(tx, &base.doc_id)? else {
return Err(doc_missing(&base.doc_id));
};
if current != *base {
return Err(stale_plan(base, Some(¤t)));
}
if let (Some(bytes), Some(hash)) = (on_disk, ¤t.file_hash) {
if sha256(bytes.as_bytes())[..] != hash[..] {
return Ok(Some(DiskConflict {
path: base.path.clone(),
bytes: bytes.to_owned(),
}));
}
}
Ok(None)
}
fn check_guard(
tx: &Connection,
repo_id: &str,
path: &str,
guard: &Guard,
) -> Result<Option<DiskConflict>> {
match guard {
Guard::None => Ok(None),
Guard::Absent => {
if live_doc_at(tx, repo_id, path)? {
return Err(path_taken(path));
}
Ok(None)
}
Guard::Doc { base, on_disk } => check_doc(tx, base, on_disk.as_deref()),
}
}
fn validate(tx: &Connection, repo_id: &str, effects: &[Effect]) -> Result<Option<DiskConflict>> {
for effect in effects {
match effect {
Effect::IngestKnown { path, guard, .. } => {
if let Some(c) = check_guard(tx, repo_id, path, guard)? {
return Ok(Some(c));
}
}
Effect::IngestReconciled { prepared, guard } => {
if let Some(c) = check_guard(tx, repo_id, &prepared.path, guard)? {
return Ok(Some(c));
}
}
Effect::MovePath {
doc_id,
from,
to,
current_rev,
..
} => {
let Some(current) = doc_base(tx, doc_id)? else {
return Err(doc_missing(doc_id));
};
if current.path != *from || current.current_rev != *current_rev {
let base = DocBase {
doc_id: doc_id.clone(),
path: from.clone(),
current_rev: current_rev.clone(),
file_hash: current.file_hash.clone(),
};
return Err(stale_plan(&base, Some(¤t)));
}
if live_doc_at(tx, repo_id, to)? {
return Err(path_taken(to));
}
}
Effect::Tombstone {
doc_id,
current_rev,
..
} => {
let Some(current) = doc_base(tx, doc_id)? else {
return Err(doc_missing(doc_id));
};
if current.current_rev != *current_rev {
let base = DocBase {
doc_id: doc_id.clone(),
path: current.path.clone(),
current_rev: current_rev.clone(),
file_hash: current.file_hash.clone(),
};
return Err(stale_plan(&base, Some(¤t)));
}
}
}
}
Ok(None)
}
fn run_effect(
tx: &Connection,
mint: &mut Mint<'_>,
repo_id: &str,
head: &CommitHead<'_>,
effect: &Effect,
expires: &str,
commit_id: &str,
) -> Result<Option<Committed>> {
match effect {
Effect::IngestKnown {
doc_id,
path,
rendered,
by_key,
prior_ids,
..
} => {
let tree = parse_markdown(rendered);
let prior = if prior_ids.is_empty() {
std::collections::HashMap::new()
} else {
crate::mutate::prior_rows(tx, doc_id)?
};
let plan = known_plan(
&tree,
path,
rendered,
by_key,
prior_ids,
&prior,
mint,
head.origin,
head.actor,
head.reason,
)?;
Ok(Some(ingest_in(
tx, mint, repo_id, &plan, expires, commit_id,
)?))
}
Effect::IngestReconciled { prepared, .. } => {
let plan = prepared.plan(mint, head.origin, head.actor, head.reason, None)?;
Ok(Some(ingest_in(
tx, mint, repo_id, &plan, expires, commit_id,
)?))
}
Effect::MovePath {
doc_id,
from,
to,
current_rev,
inbound,
} => {
move_path_in(tx, doc_id, from, to, current_rev.as_deref(), inbound)?;
Ok(None)
}
Effect::Tombstone { doc_id, pool, .. } => {
tombstone_in(tx, doc_id, commit_id, pool.then_some(expires))?;
Ok(None)
}
}
}
fn move_path_in(
tx: &Connection,
doc_id: &str,
from: &str,
to: &str,
current_rev: Option<&str>,
inbound: &[InboundLink],
) -> Result<()> {
tx.execute(
"UPDATE docs SET path = ?1 WHERE doc_id = ?2",
params![to, doc_id],
)?;
tx.execute(
"UPDATE revisions SET path = ?1 WHERE doc_id = ?2 AND rev_id = ?3",
params![to, doc_id, current_rev],
)?;
let phantom = format!("phantom:{from}");
let mut affected: Vec<String> = Vec::new();
{
let mut stmt = tx.prepare(
"SELECT DISTINCT src_doc FROM edges WHERE dst_node = ?1 AND to_commit IS NULL AND src_doc != ?1",
)?;
let it = stmt.query_map(params![doc_id], |r| r.get::<_, String>(0))?;
for src in it {
let src = src?;
if !affected.contains(&src) {
affected.push(src);
}
}
}
tx.execute(
"UPDATE edges SET dst_node = ?1 WHERE dst_node = ?2 AND to_commit IS NULL AND src_doc != ?2",
params![phantom, doc_id],
)?;
for l in inbound {
let Some(block) = &l.block else {
continue;
};
if l.doc != doc_id {
continue;
}
tx.execute(
"UPDATE edges SET dst_node = ?1 WHERE dst_node = ?2 AND to_commit IS NULL AND src_doc = ?2 AND src_block = ?3 AND anchor IS ?4",
params![phantom, doc_id, block, l.anchor],
)?;
if !affected.iter().any(|d| d == doc_id) {
affected.push(doc_id.to_owned());
}
}
for d in &affected {
rebuild_doc_edges(tx, d)?;
}
adopt_phantoms(tx, to, doc_id)
}