1use std::collections::HashSet;
11use std::fmt;
12
13use rusqlite::{Connection, params};
14
15use crate::error::{Error, Result};
16use crate::ids::IdMinter;
17
18pub const MINT_GIVE_UP_AFTER: usize = 1_000;
23
24const ID_TABLES: &[(&str, &[(&str, &str)])] = &[
29 ("d", &[("docs", "doc_id")]),
30 (
31 "b",
32 &[
33 ("blocks", "block_id"),
34 ("block_changes", "block_id"),
35 ("resurrection_pool", "block_id"),
36 ],
37 ),
38 ("c", &[("commits", "commit_id")]),
39 ("r", &[("revisions", "rev_id")]),
40 ("x", &[("external_nodes", "node_id")]),
41 ("e", &[("edges", "edge_id")]),
42 ("cp", &[("checkpoints", "id")]),
43 ("rp", &[("repos", "repo_id")]),
44 ("src", &[("sources", "source_id")]),
45];
46
47#[must_use]
50pub fn tables_for(prefix: &str) -> &'static [(&'static str, &'static str)] {
51 ID_TABLES
52 .iter()
53 .find(|(p, _)| *p == prefix)
54 .map_or(&[], |(_, tables)| tables)
55}
56
57pub fn id_in_use(conn: &Connection, prefix: &str, id: &str) -> Result<bool> {
61 for (table, column) in tables_for(prefix) {
62 let mut stmt = conn.prepare_cached(&format!(
63 "SELECT 1 FROM {table} WHERE {column} = ?1 LIMIT 1"
64 ))?;
65 if stmt.exists(params![id])? {
66 return Ok(true);
67 }
68 }
69 Ok(false)
70}
71
72pub(crate) struct IdSource {
77 inner: Box<dyn IdMinter>,
78 issued: HashSet<String>,
79}
80
81impl IdSource {
82 pub(crate) fn new(inner: Box<dyn IdMinter>) -> Self {
83 Self {
84 inner,
85 issued: HashSet::new(),
86 }
87 }
88
89 pub(crate) fn at<'a>(&'a mut self, conn: &'a Connection) -> Mint<'a> {
92 Mint {
93 conn,
94 inner: &mut *self.inner,
95 issued: &mut self.issued,
96 }
97 }
98}
99
100pub struct Mint<'a> {
102 conn: &'a Connection,
103 inner: &'a mut dyn IdMinter,
104 issued: &'a mut HashSet<String>,
105}
106
107impl fmt::Debug for Mint<'_> {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 f.debug_struct("Mint")
110 .field("issued", &self.issued.len())
111 .finish_non_exhaustive()
112 }
113}
114
115impl Mint<'_> {
116 pub fn mint(&mut self, prefix: &str) -> Result<String> {
120 for _ in 0..MINT_GIVE_UP_AFTER {
121 let candidate = self.inner.mint(prefix);
122 if self.issued.contains(&candidate) || id_in_use(self.conn, prefix, &candidate)? {
123 continue;
124 }
125 self.issued.insert(candidate.clone());
126 return Ok(candidate);
127 }
128 Err(Error::MintExhausted {
129 prefix: prefix.to_owned(),
130 rejected: MINT_GIVE_UP_AFTER,
131 })
132 }
133
134 pub fn reborrow(&mut self) -> Mint<'_> {
136 Mint {
137 conn: self.conn,
138 inner: &mut *self.inner,
139 issued: &mut *self.issued,
140 }
141 }
142
143 pub fn deferred(&mut self, prefix: &'static str) -> Deferred<'_> {
146 Deferred {
147 mint: self.reborrow(),
148 prefix,
149 error: None,
150 }
151 }
152}
153
154pub struct Deferred<'a> {
160 mint: Mint<'a>,
161 prefix: &'static str,
162 error: Option<Error>,
163}
164
165impl fmt::Debug for Deferred<'_> {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 f.debug_struct("Deferred")
168 .field("prefix", &self.prefix)
169 .field("error", &self.error)
170 .finish_non_exhaustive()
171 }
172}
173
174impl Deferred<'_> {
175 pub fn finish(self) -> Result<()> {
177 self.error.map_or(Ok(()), Err)
178 }
179}
180
181impl omgbase_reconcile::Minter for Deferred<'_> {
182 fn mint(&mut self) -> String {
183 if self.error.is_some() {
184 return String::new();
185 }
186 match self.mint.mint(self.prefix) {
187 Ok(id) => id,
188 Err(e) => {
189 self.error = Some(e);
190 String::new()
191 }
192 }
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::Store;
200 use crate::ids::{RandomMinter, RepeatingMinter, SequentialMinter, is_valid_id};
201
202 #[test]
203 fn every_prefix_with_a_table_is_checked_and_the_rest_are_not() {
204 for p in ["d", "b", "c", "r", "x", "e", "cp", "rp", "src"] {
205 assert!(!tables_for(p).is_empty(), "{p}");
206 }
207 assert!(tables_for("col").is_empty());
208 assert!(tables_for("v").is_empty());
209 assert_eq!(
210 tables_for("b").len(),
211 3,
212 "blocks, block_changes, resurrection_pool"
213 );
214 }
215
216 #[test]
217 fn every_checked_table_and_column_exists() {
218 let store = Store::open_in_memory().unwrap();
219 for (prefix, _) in ID_TABLES {
220 assert!(!id_in_use(store.conn(), prefix, "zz_0000000").unwrap());
221 }
222 }
223
224 #[test]
225 fn a_row_in_the_table_rejects_the_candidate() {
226 let mut store =
227 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
228 store
230 .conn()
231 .execute(
232 "INSERT INTO repos (repo_id, slug) VALUES ('rp_0', 'planted')",
233 [],
234 )
235 .unwrap();
236 assert!(id_in_use(store.conn(), "rp", "rp_0").unwrap());
237 assert_eq!(
238 store.mint("rp").unwrap(),
239 "rp_1",
240 "rp_0 is in use; the redraw is rp_1"
241 );
242 assert_eq!(store.mint("col").unwrap(), "col_0", "col has no table");
243 }
244
245 #[test]
246 fn a_block_id_in_history_stays_in_use_after_its_row_is_gone() {
247 let mut store =
248 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
249 let repo = store.create_repo("r").unwrap();
250 store
251 .conn()
252 .execute(
253 "INSERT INTO docs (doc_id, repo_id, path) VALUES ('d_9', ?1, 'a.md')",
254 params![repo],
255 )
256 .unwrap();
257 let (commit, _) = store
258 .new_commit(&crate::NewCommit::observed(
259 &repo,
260 "2026-09-26T00:00:00.000Z",
261 ))
262 .unwrap();
263 store
264 .conn()
265 .execute(
266 "INSERT INTO block_changes (block_id, commit_id, kind) VALUES ('b_0', ?1, 'deleted')",
267 params![commit],
268 )
269 .unwrap();
270 store
271 .conn()
272 .execute(
273 "INSERT INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
274 VALUES ('b_1', ?1, 'd_9', zeroblob(32), zeroblob(32), 'paragraph', ?2, '2026-10-26T00:00:00.000Z')",
275 params![repo, commit],
276 )
277 .unwrap();
278 assert_eq!(
279 store.mint("b").unwrap(),
280 "b_2",
281 "b_0 and b_1 live on in history"
282 );
283 }
284
285 #[test]
286 fn the_issued_set_catches_a_repeat_before_any_row_exists() {
287 let mut store =
288 Store::open_in_memory_with_minter(Box::new(RepeatingMinter::new())).unwrap();
289 assert_eq!(store.mint("b").unwrap(), "b_0");
290 assert_eq!(
291 store.mint("b").unwrap(),
292 "b_1",
293 "the second b_0 offer is rejected"
294 );
295 assert_eq!(store.mint("d").unwrap(), "d_0");
296 assert_eq!(store.create_repo("fixture").unwrap(), "rp_0");
297 }
298
299 #[test]
300 fn the_random_minter_is_checked_and_valid() {
301 let mut store = Store::open_in_memory().unwrap();
302 let ids: HashSet<String> = (0..2000).map(|_| store.mint("b").unwrap()).collect();
303 assert_eq!(ids.len(), 2000);
304 assert!(ids.iter().all(|id| is_valid_id(id, Some("b"))));
305 let _ = RandomMinter;
306 }
307
308 #[test]
309 fn a_minter_that_cannot_produce_a_fresh_id_fails_after_the_bound() {
310 let stuck = |prefix: &str| format!("{prefix}_stuck");
311 let mut store = Store::open_in_memory_with_minter(Box::new(stuck)).unwrap();
312 assert_eq!(store.mint("b").unwrap(), "b_stuck");
313 let err = store.mint("b").unwrap_err();
314 assert!(
315 matches!(&err, Error::MintExhausted { prefix, rejected } if prefix == "b" && *rejected == MINT_GIVE_UP_AFTER),
316 "{err}"
317 );
318 assert!(err.to_string().contains("1000"), "{err}");
319 }
320
321 #[test]
322 fn deferred_keeps_the_first_failure_for_finish() {
323 use omgbase_reconcile::Minter as _;
324 let stuck = |prefix: &str| format!("{prefix}_stuck");
325 let mut store = Store::open_in_memory_with_minter(Box::new(stuck)).unwrap();
326 let mut mint = store.minter();
327 let mut deferred = mint.deferred("b");
328 assert_eq!(deferred.mint(), "b_stuck");
329 assert_eq!(deferred.mint(), "", "the failure is deferred");
330 assert_eq!(deferred.mint(), "", "and nothing more is drawn");
331 assert!(deferred.finish().is_err());
332 let mut ok = store.minter();
333 let mut deferred = ok.deferred("d");
334 assert_eq!(deferred.mint(), "d_stuck");
335 assert!(deferred.finish().is_ok());
336 }
337}