macrame/error.rs
1use thiserror::Error;
2
3/// The two intervals of a [`DbError::OverlappingInterval`], boxed out of the
4/// error enum (D-075).
5///
6/// Both are reported because neither alone identifies the conflict: the caller
7/// knows what they asserted and not what it collided with, and a message naming
8/// only the other interval reads as though the assertion were the innocent one.
9#[derive(Debug, Clone, PartialEq, Eq)]
10#[non_exhaustive]
11pub struct Overlap {
12 pub source_id: String,
13 pub target_id: String,
14 pub edge_type: String,
15 /// The interval the caller asserted.
16 pub valid_from: String,
17 pub valid_to: String,
18 /// The interval it collides with — see [`Overlap::within_batch`] for where
19 /// that one is, because it is not always in the database.
20 pub existing_from: String,
21 pub existing_to: String,
22 /// Whether the collision is with another edge in the *same call* (0.13.7,
23 /// D-180).
24 ///
25 /// Two guards raise this one error. `reject_overlapping_interval` compares
26 /// the assertion against committed rows; `reject_overlaps_within` compares
27 /// a batch against itself, before the transaction opens, and nothing it
28 /// names is in the database — the batch is refused whole, so nothing it
29 /// names ever will be. A caller told an edge "already holds" an interval
30 /// goes looking for a row that is not there.
31 pub within_batch: bool,
32}
33
34impl Overlap {
35 /// The collision, as the edge it is about and the two intervals.
36 ///
37 /// Grouped rather than taken as eight strings, because the grouping is the
38 /// struct's actual shape — D-075 boxed it out of the enum precisely
39 /// because it is two intervals — and eight positional `String`s is a
40 /// signature in which `existing_from` and `valid_from` can be swapped
41 /// without the compiler noticing. Added in 0.15.13 with
42 /// `#[non_exhaustive]` (W15.3, [D-255]): the crate raises this error and no
43 /// caller has to build one, but our own binding builds a sample of every
44 /// variant to check its error mapping, and a type nothing outside the crate
45 /// can construct is a type nothing outside the crate can test against.
46 ///
47 /// [D-255]: ../docs/architecture/s13-decision-register.md#d-255
48 pub fn new(
49 source_id: impl Into<String>,
50 target_id: impl Into<String>,
51 edge_type: impl Into<String>,
52 asserted: (impl Into<String>, impl Into<String>),
53 existing: (impl Into<String>, impl Into<String>),
54 within_batch: bool,
55 ) -> Self {
56 Self {
57 source_id: source_id.into(),
58 target_id: target_id.into(),
59 edge_type: edge_type.into(),
60 valid_from: asserted.0.into(),
61 valid_to: asserted.1.into(),
62 existing_from: existing.0.into(),
63 existing_to: existing.1.into(),
64 within_batch,
65 }
66 }
67
68 /// The message's closing clause: where the second interval came from.
69 ///
70 /// A method rather than two `#[error]` strings, because one variant gets
71 /// one format string, and rather than a `String` because this is on a
72 /// `Display` path.
73 pub fn provenance(&self) -> &'static str {
74 if self.within_batch {
75 "this same batch also asserts"
76 } else {
77 "is already recorded"
78 }
79 }
80}
81
82/// Which instants a traversal stated, for the one error that has to name them
83/// (0.13.10, W7.7, D-183).
84///
85/// Three cases and never zero. [`DbError::AttributeModeUnstated`] exists
86/// *because* an instant was set, so a fourth case carrying neither would be a
87/// state no construction site can reach — [D-177]'s objection to a `Result`
88/// that cannot fail, in a different shape. [`Self::new`] returns an `Option`
89/// and the `None` is the ordinary live traversal, resolved before any error
90/// exists.
91///
92/// [D-177]: ../docs/architecture/s13-decision-register.md#d-177
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum StatedInstants {
96 /// `as_of_valid` alone — *what was true then*.
97 Valid(String),
98 /// `as_of_recorded` alone — *what we believed then*.
99 Recorded(String),
100 /// Both, which is the bitemporal cell: *what did we believe at `recorded`
101 /// about what was true at `valid`*.
102 Both {
103 /// The valid-time instant.
104 valid: String,
105 /// The transaction-time instant.
106 recorded: String,
107 },
108}
109
110impl StatedInstants {
111 /// `None` when neither axis was set, which is not an error and not this
112 /// type's business to describe.
113 pub fn new(valid: Option<&str>, recorded: Option<&str>) -> Option<Self> {
114 match (valid, recorded) {
115 (Some(v), Some(r)) => Some(Self::Both {
116 valid: v.to_string(),
117 recorded: r.to_string(),
118 }),
119 (Some(v), None) => Some(Self::Valid(v.to_string())),
120 (None, Some(r)) => Some(Self::Recorded(r.to_string())),
121 (None, None) => None,
122 }
123 }
124
125 /// The valid-time instant, if this traversal stated one.
126 pub fn valid(&self) -> Option<&str> {
127 match self {
128 Self::Valid(v) | Self::Both { valid: v, .. } => Some(v),
129 Self::Recorded(_) => None,
130 }
131 }
132
133 /// The transaction-time instant, if this traversal stated one.
134 pub fn recorded(&self) -> Option<&str> {
135 match self {
136 Self::Recorded(r) | Self::Both { recorded: r, .. } => Some(r),
137 Self::Valid(_) => None,
138 }
139 }
140}
141
142/// Rendered as the **calls that produce them**, which is the whole point.
143///
144/// A message reading `as_of(2020-06-01)` names a method that has not existed
145/// since 0.12.17 ([D-174](../docs/architecture/s13-decision-register.md#d-174)),
146/// so a caller who goes looking for it finds nothing — and, worse, is not told
147/// which of the two axes the instant they set landed on. `as_of_valid(…)` and
148/// `as_of_recorded(…)` are what a caller typed and what a caller can change.
149impl std::fmt::Display for StatedInstants {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 match self {
152 Self::Valid(v) => write!(f, "as_of_valid({v})"),
153 Self::Recorded(r) => write!(f, "as_of_recorded({r})"),
154 Self::Both { valid, recorded } => {
155 write!(f, "as_of_valid({valid}) with as_of_recorded({recorded})")
156 }
157 }
158 }
159}
160
161/// Central error type for the Macrame bitemporal ledger database.
162#[derive(Debug, Error)]
163#[non_exhaustive]
164pub enum DbError {
165 #[error("engine: {0}")]
166 Engine(#[from] libsql::Error),
167
168 #[error("migration to v{to} failed: {reason}")]
169 Migration { to: u32, reason: String },
170
171 #[error("invalid edge type {0} (must match [A-Z0-9]+)")]
172 InvalidEdgeType(String),
173
174 // NOTE: the spec (§7) names these fields `source` / `target`. `source` is a
175 // reserved field name for thiserror (it is inferred as the error source and
176 // requires `std::error::Error`), so the schema column names are used instead.
177 #[error(
178 "{source_id} -> {target_id} ({edge_type}) already has an open interval; retire it first"
179 )]
180 SingleOpenViolation {
181 source_id: String,
182 target_id: String,
183 edge_type: String,
184 },
185
186 #[error("node {0} not found")]
187 NotFound(String),
188
189 #[error("embedding dim {got}, expected {expected} for model {model}")]
190 DimMismatch {
191 got: usize,
192 expected: usize,
193 model: String,
194 },
195
196 /// A model name is spliced into DDL and queries as a table identifier, and
197 /// identifiers cannot be bound as parameters. Validating the name is what
198 /// makes that splice safe, so an invalid one is refused rather than escaped.
199 #[error("invalid embedding model name {0:?}: expected [a-z][a-z0-9_]* up to 48 characters")]
200 InvalidModelName(String),
201
202 #[error("embedding model {model} is not registered (no {table} table)")]
203 ModelNotRegistered { model: String, table: String },
204
205 /// `branches` is append-only under two unconditional triggers, so a name is
206 /// written once and can never be corrected. The rule is deliberately not
207 /// [`Self::InvalidModelName`]'s — a `branch_id` is always a bound value and
208 /// never a spliced identifier, so what is refused here is the pair of names
209 /// that read as one another rather than the ones that would break SQL.
210 #[error(
211 "invalid branch id {0:?}: expected 1-128 characters, \
212 no control characters, no leading or trailing whitespace"
213 )]
214 InvalidBranchId(String),
215
216 /// Named rather than left to the foreign key, because the caller asked
217 /// about a branch and a constraint violation would answer about a column.
218 #[error("branch {0} is not registered")]
219 UnknownBranch(String),
220
221 /// Refused rather than ignored, which is the interesting half: an
222 /// `INSERT OR IGNORE` would return a handle to a lineage with a *different*
223 /// parent and fork point than the caller asked for, which is D-069's
224 /// right-looking answer to a question nobody asked.
225 #[error("branch {0} already exists")]
226 BranchExists(String),
227
228 /// [`crate::Database::archive_branch`] refused (0.14.13, §15.4, D-230).
229 ///
230 /// Four conditions share one variant because they share one answer: the
231 /// lineage stays, and the caller has to change something about the ledger
232 /// before asking again. The `reason` says which — the trunk, a lineage with
233 /// descendants, or a lineage whose concepts another lineage's hot edges
234 /// still name.
235 ///
236 /// The fourth condition, a name that is not registered, is deliberately
237 /// **not** here: it is [`Self::UnknownBranch`], the same answer every other
238 /// branch-taking surface gives, because a typo should read the same way
239 /// wherever it is made.
240 #[error("branch {branch} cannot be archived: {reason}")]
241 BranchNotArchivable { branch: String, reason: String },
242
243 /// A rehydrate needs a lineage [`crate::Database::archive_branch`] forgot
244 /// (0.15.11, W15.1, review C-3, [D-230]).
245 ///
246 /// The two halves were built two waves apart and neither knew about the
247 /// other. `archive_branch` moves a lineage's `branches` row into the cold
248 /// file; [`crate::Database::rehydrate`] reinstates a concept row still
249 /// carrying the `branch_id` it was minted on. `concepts.branch_id`
250 /// references `branches(branch_id)` and foreign keys are on, so the insert
251 /// cannot land — and until this variant existed it did not land as
252 /// [`ErrorKind::Engine`], reading `FOREIGN KEY constraint failed`. That
253 /// message names neither the lineage, nor the concept, nor the remedy, and
254 /// attributes the refusal to `concepts` when the missing row is a branch's.
255 ///
256 /// # The other half of D-230's bargain
257 ///
258 /// The alternative was to reinstate the `branches` row inside the same
259 /// session, which would make the rehydrate succeed. It is refused because
260 /// [D-230]'s stance is that an archived lineage is *forgotten*: bringing
261 /// the name back here would register a lineage with none of its links —
262 /// known again, and answering differently than it did before it left,
263 /// without the caller having asked for either.
264 ///
265 /// The remedy is therefore the caller's, and the cold file holds what it
266 /// needs: `cold.branches` keeps the row's `parent_id`, `forked_at` and the
267 /// instant it was archived, so [`crate::Database::fork`] can re-register
268 /// the lineage on its original terms, after which the rehydrate succeeds.
269 /// Doing that deliberately is the difference this variant exists to make.
270 ///
271 /// [D-230]: ../../docs/architecture/s13-decision-register.md#d-230
272 #[error(
273 "concept {concept} was minted on branch {branch}, which the ledger has \
274 forgotten; re-register the lineage before rehydrating the concept"
275 )]
276 BranchArchived { branch: String, concept: String },
277
278 /// The cross-row half of the fork-point invariant, which no `CHECK` can see
279 /// (§15.2): fork points must not decrease down a root path.
280 ///
281 /// A branch cut *before* its parent was is a branch that inherits nothing
282 /// whatever from the parent it names — every row that parent wrote falls
283 /// past the child's cutoff — so its `parent_id` and its visible history say
284 /// different things, silently.
285 ///
286 /// Compared against the parent's `forked_at` and not its `created_at`,
287 /// which the schema comment originally called for: `created_at` on the
288 /// trunk is stamped from `SystemTime::now()` during migration, before the
289 /// database's clock exists, so it is not on the same timeline as anything
290 /// else. See [`Database::fork`](crate::Database::fork).
291 #[error(
292 "branch {branch} would fork from {parent} at {forked_at}, \
293 before {parent} itself was cut at {parent_forked_at}"
294 )]
295 ForkPrecedesParent {
296 branch: String,
297 parent: String,
298 forked_at: String,
299 parent_forked_at: String,
300 },
301
302 /// A branch tried to restate a concept another lineage already holds
303 /// (§15.2, v12, D-225).
304 ///
305 /// # A guard that existed for three releases with nothing able to fire it
306 ///
307 /// `trg_concepts_cross_lineage` has been in the schema since v12 and
308 /// [`AbortKind::CrossLineage`] has recognised it since, but [`classify`]
309 /// had no arm for that kind, so it fell through to
310 /// [`DbError::Engine`] — the opaque variant every other guard exists to
311 /// avoid. Nothing was wrong with that until 0.14.8, because until 0.14.8
312 /// no write in this crate could name a lineage and no caller could reach
313 /// the trigger. It is the same shape D-224 found in a comment and D-223
314 /// found in a filter: **machinery written for an unbuilt caller is
315 /// exercised by nothing**, so a gap in it is invisible in a green suite.
316 ///
317 /// `held_by` is read back on the error path rather than parsed out of the
318 /// abort message, for [`RecordedAtRegression`](Self::RecordedAtRegression)'s
319 /// reason: the trigger cannot put it in the text, and the database knows it.
320 #[error(
321 "concept {id} belongs to lineage {held_by} and {attempted} may not \
322 restate it; a branch inherits concepts"
323 )]
324 CrossLineage {
325 id: String,
326 held_by: String,
327 attempted: String,
328 },
329
330 /// A write reached a [`BranchView`](crate::branch::BranchView) carrying a
331 /// different lineage's name (§15.4, 0.14.9, D-226).
332 ///
333 /// # Why this is refused rather than overwritten
334 ///
335 /// The view exists to spare a caller from threading a `BranchId` through
336 /// every call, so the obvious reading is that it should simply *stamp* its
337 /// own lineage on whatever it is handed. That is right for an assertion
338 /// that names none — which is the shape a caller building through the view
339 /// produces, and the one this does not refuse. It is wrong for an assertion
340 /// that names a **different** lineage, because that assertion is evidence
341 /// the caller believed something about where the write was going, and
342 /// silently relabelling it discards the belief instead of contradicting it.
343 ///
344 /// The failure this catches is holding two views and passing one's
345 /// assertion to the other, which nothing in the type system prevents:
346 /// `BranchView` is `Clone` and both views have the same methods, so the
347 /// mistake reads correctly at the call site and produces rows on the wrong
348 /// lineage. On a ledger where a lineage is what a belief *means*, that is
349 /// not a misfiled row — it is an assertion attributed to the wrong belief.
350 #[error("view of branch {view} was handed a write naming {named}")]
351 BranchMismatch {
352 /// The lineage the view carries.
353 view: String,
354 /// The lineage the assertion named.
355 named: String,
356 },
357
358 #[error("subgraph exceeds budget ({n} > {budget})")]
359 SubgraphTooLarge { n: usize, budget: usize },
360
361 /// Dijkstra and A* settle a node permanently the first time they pop it,
362 /// which is only sound when no later edge can reduce the distance — that is,
363 /// when weights are non-negative. `links.weight` is a bare `REAL NOT NULL`
364 /// with no CHECK, so the guarantee has to be established at load time. The
365 /// alternative is a shortest-path result that is quietly just a path.
366 #[error("edge {source_id} -> {target_id} has weight {weight}, which shortest-path analytics cannot use")]
367 NegativeEdgeWeight {
368 source_id: String,
369 target_id: String,
370 weight: f64,
371 },
372
373 #[error("replay corrupt at seq {seq}: {reason}")]
374 ReplayCorrupt { seq: i64, reason: String },
375
376 /// A snapshot this build cannot read. Distinct from [`Self::ReplayCorrupt`]
377 /// on purpose: corruption is a fault to report, an incompatible snapshot is
378 /// the ordinary consequence of an upgrade, and the correct response is to
379 /// discard the file and fold from the log instead (D-043).
380 #[error("snapshot {path} is not readable by this build: {reason}")]
381 SnapshotIncompatible { path: String, reason: String },
382
383 /// A snapshot that is damaged rather than foreign (0.13.12, W8.2, D-185).
384 ///
385 /// The third case in the same family, and it needed its own name for the
386 /// reason [D-069] gives: an error that names the wrong subject sends a
387 /// caller to fix the wrong thing.
388 ///
389 /// * [`Self::SnapshotIncompatible`] — *a different build wrote this*.
390 /// Ordinary after an upgrade.
391 /// * [`Self::ReplayCorrupt`] — **the ledger is damaged.** The log is the
392 /// only authority in this system and this is the worst thing it can say.
393 /// * This — *the cache is damaged.* The ledger is untouched. Deleting the
394 /// file restores correctness and costs a slower reconstruction, because
395 /// [Doctrine VI] makes a snapshot derivative and disposable.
396 ///
397 /// Every failure of `load_snapshot` used to be `ReplayCorrupt { seq: 0 }`,
398 /// which claimed the ledger was damaged and carried a sequence number that
399 /// cannot exist — `AUTOINCREMENT` starts at 1. That is the same placeholder
400 /// [D-069] removed from `InvalidTimestamp`, left in place here because
401 /// nothing had cause to look at it.
402 ///
403 /// It carries the path and not a `seq`, because a snapshot is identified by
404 /// its file. The reason names the check that failed, and the checks are
405 /// ordered so that the earliest possible one fires: declared length, then
406 /// checksum, then the decompressed size against what the header declared.
407 ///
408 /// [D-069]: ../docs/architecture/s13-decision-register.md#d-069
409 /// [Doctrine VI]: ../docs/architecture/s0-s3-foundations.md#doctrine-vi
410 #[error("snapshot {path} is damaged: {reason}")]
411 SnapshotCorrupt { path: String, reason: String },
412
413 /// A snapshot that could not be **written** (0.14.23, W12.23, C-2, [D-240]).
414 ///
415 /// The fourth case in the family above and the last one missing, which is
416 /// why it is worth saying what the other three had in common: each names
417 /// the subject a caller has to go and fix. Every failure inside
418 /// `save_snapshot` — the directory, the serialization, the compression, the
419 /// temp file, the write, the flush, the rename, and the directory flush
420 /// after it — used to be [`Self::ReplayCorrupt`], which says **the ledger
421 /// is damaged**, the worst thing this system can say. A full disk said it.
422 ///
423 /// * [`Self::SnapshotIncompatible`] — *a different build wrote this*.
424 /// * [`Self::SnapshotCorrupt`] — *the cache is damaged*, delete the file.
425 /// * [`Self::ReplayCorrupt`] — **the ledger is damaged**.
426 /// * This — *the cache could not be written*. **Nothing is damaged and
427 /// nothing is lost**: [Doctrine VI] makes a snapshot derivative, so the
428 /// next start folds from the previous anchor and the whole cost is a
429 /// slower start. The subject is the filesystem.
430 ///
431 /// The read half of this correction shipped at 0.13.12: `load_snapshot`
432 /// stopped answering `ReplayCorrupt { seq: 0 }` for a damaged file, for
433 /// exactly this reason ([D-185](../docs/architecture/s13-decision-register.md#d-185)).
434 /// The write half kept it for ten releases.
435 ///
436 /// **One variant covers the directory flush as well**, and that is
437 /// [D-186](../docs/architecture/s13-decision-register.md#d-186)'s decision
438 /// rather than a simplification here: a failed `sync_directory` leaves the
439 /// snapshot at its final name and readable, unable only to promise the name
440 /// survives a power loss, and D-186 already placed it in "the same class the
441 /// file's own `sync_all` failure already returns". `reason` names which step
442 /// failed.
443 ///
444 /// [D-240]: ../docs/architecture/s13-decision-register.md#d-240
445 /// [Doctrine VI]: ../docs/architecture/s0-s3-foundations.md#doctrine-vi
446 #[error("snapshot {path} could not be saved: {reason}")]
447 SnapshotWriteFailed { path: String, reason: String },
448
449 #[error("payload v{got} unsupported (max {max})")]
450 PayloadVersion { got: u8, max: u8 },
451
452 #[error("physical delete blocked outside archive session ({table})")]
453 ArchiveViolation { table: String },
454
455 /// The archive-session marker exists as **committed** state (0.10.0, W2).
456 ///
457 /// [`ArchiveViolation`] is this guard working. This variant is the guard
458 /// having been silently switched off: while
459 /// `macrame_archive_session` is present, `trg_concepts_guard_delete`,
460 /// `trg_links_guard_delete` and `trg_txlog_guard_delete` all evaluate their
461 /// `WHEN` to false and permit the deletes they exist to refuse, and
462 /// `trg_concepts_log_insert` writes no `transaction_log` row for a concept
463 /// insert. [Doctrine IV] and [Doctrine V] are both suspended, with no error
464 /// and no counter — which is why the condition needs a name of its own.
465 ///
466 /// **It cannot be produced by an archive session, crashed or otherwise.**
467 /// `archive()` and `archive_windowed()` create and drop the marker inside
468 /// the same transaction that does the work, so a commit drops it and a
469 /// rollback discards it; and the check that raises this error —
470 /// `verify` in `src/schema/migrations.rs`, which is private, hence the file
471 /// reference rather than a link — reads committed state, so it cannot see an
472 /// in-flight session. Reaching this
473 /// error therefore means something wrote the table outside the write actor
474 /// — the raw-writer case §4.7 concedes exists.
475 ///
476 /// Not a [`Migration`] error: the schema is intact. What is wrong is the
477 /// database's *contents*, and saying "your schema is wrong" would send the
478 /// reader to the migration ladder for a fault a `DROP TABLE` fixes.
479 ///
480 /// [Doctrine IV]: ../../docs/architecture/s0-s3-foundations.md#doctrine-iv
481 /// [Doctrine V]: ../../docs/architecture/s0-s3-foundations.md#doctrine-v
482 /// [`ArchiveViolation`]: DbError::ArchiveViolation
483 /// [`Migration`]: DbError::Migration
484 #[error(
485 "the archive-session marker table {marker:?} is present as committed \
486 state. While it exists, the delete guards on concepts, links and \
487 transaction_log are disarmed and concept inserts write no \
488 transaction_log row. An archive session creates and drops this table \
489 inside one transaction, so it should never be visible here — \
490 something wrote it outside the write actor. Drop it (DROP TABLE \
491 {marker}) and audit for deletions and missing log rows since it \
492 appeared"
493 )]
494 ArchiveSessionLeaked { marker: String },
495
496 /// A traversal asked about the past without saying which text it wanted
497 /// (T3.2, D-085).
498 ///
499 /// An instant on either axis fixes the *topology*. Node attributes are a
500 /// second, independent question, and the default answer —
501 /// `AttributeMode::Current` — is live text. That combination returns the
502 /// past's graph wearing the present's titles, which is a legitimate thing to
503 /// want and a terrible thing to get by accident.
504 ///
505 /// It used to be a `tracing::warn!`, which is invisible in any application
506 /// that has not configured a subscriber. This is the same statement as a
507 /// value the caller cannot miss.
508 ///
509 /// Fix by stating the mode: `.attribute_mode(AttributeMode::AtTime)` for the
510 /// past's text, or `.attribute_mode(AttributeMode::Current)` to affirm that
511 /// live text is what was meant.
512 ///
513 /// # It carries [`StatedInstants`] rather than one string (0.13.10, W7.7, D-183)
514 ///
515 /// The field was `as_of: String` and the message rendered it as
516 /// `as_of(…)` — a method removed in 0.12.17 when
517 /// [D-174](../docs/architecture/s13-decision-register.md#d-174) split the
518 /// axes. Both instants collapsed into it through an `.or()`, so a caller who
519 /// set `as_of_recorded` was told about `as_of`, a caller who set both was
520 /// told about one of them, and neither was told which clock they had asked
521 /// about. Naming the axis is the whole remedy this error offers.
522 #[error(
523 "traversal {instants} did not state an attribute mode: that topology \
524 would be returned with attributes as they are *now*. Call \
525 .attribute_mode(AttributeMode::AtTime) for attributes as believed at \
526 the stated instant, or .attribute_mode(AttributeMode::Current) to \
527 confirm live attributes are intended"
528 )]
529 AttributeModeUnstated { instants: StatedInstants },
530 /// [`crate::Database::diagnostic_conn`] could not open the file read-only
531 /// (T5.1, D-091).
532 ///
533 /// Its own variant rather than `NotFound`, which renders "node {0} not
534 /// found" — naming the wrong subject is the defect [D-069] was written to
535 /// correct, and a file is not a node.
536 ///
537 /// The case worth the sentence is a missing file:
538 /// `SQLITE_OPEN_READ_ONLY` drops `SQLITE_OPEN_CREATE` with it, so a path
539 /// that does not exist is `SQLITE_CANTOPEN` rather than a fresh empty
540 /// database. That is the right behaviour and an opaque error to receive.
541 ///
542 /// [D-069]: ../../docs/architecture/s13-decision-register.md#d-069
543 #[error("cannot open {path} read-only for diagnostics: {reason}")]
544 DiagnosticConn { path: String, reason: String },
545 /// [`crate::Database::archive_windowed`] was given a window it cannot use
546 /// (T1.1, D-080).
547 ///
548 /// Carries a `reason` rather than the numbers as fields because the two
549 /// cases it covers are not the same shape — a zero-length window never
550 /// advances at all, while a merely narrow one produces a session count that
551 /// has to be quoted against the limit to mean anything. A caller reading
552 /// this needs the sentence, not the struct.
553 ///
554 /// It is an error rather than a silent clamp on purpose. Rounding a
555 /// one-second window up to something workable would archive over boundaries
556 /// the caller did not choose, and the caller cannot see that it happened.
557 #[error("archive window {window:?} is unusable: {reason}")]
558 ArchiveWindow {
559 window: std::time::Duration,
560 reason: String,
561 },
562
563 /// A search asked for decay without saying what age is measured from
564 /// (0.13.20, W9.5, D-193).
565 ///
566 /// Decay ranks a hit by how old the thing it matched is, and *old* is only
567 /// meaningful relative to an instant. The crate does not read a wall clock
568 /// on a read path — that is what makes the suite's `FakeClock` able to
569 /// pin these answers at all — so the instant has to be stated, and the one
570 /// to state is the one the search is already bounded by.
571 ///
572 /// Refusing rather than defaulting to *now*: a default here would silently
573 /// make every decayed search a search about the present, which is exactly
574 /// the class of quiet substitution F-35 and
575 /// [D-175](../docs/architecture/s13-decision-register.md#d-175) were about.
576 #[error(
577 "a half-life was given without a valid-time instant to measure age from: decay ranks a hit by how old what it matched is, and \"old\" is relative to the instant the search reads at. State it — `as_of_valid(t)` on the same search — or drop the half-life"
578 )]
579 HalfLifeWithoutInstant,
580
581 /// A read named a transaction-time instant the hot log can no longer answer
582 /// for (0.13.2, W7.1, D-174; extended 0.13.16, W9.1, D-189).
583 ///
584 /// A transaction-time read folds `transaction_log`, and
585 /// [`crate::Database::archive`] removes superseded rows from it. Once
586 /// anything has been archived, an instant below the cutoff is not *before
587 /// history*, it is *history that is in the other file* — and these readers
588 /// take a connection, not an archive path, so they cannot go and get it.
589 ///
590 /// **Two surfaces fold the log and both raise this.**
591 /// [`crate::graph::TraversalBuilder::as_of_recorded`] folds it for topology;
592 /// [`crate::temporal::hydrate_attributes`] folds it for the text under
593 /// [`crate::graph::AttributeMode::AtTime`]. The second was added in 0.13.16
594 /// (W9.1), where it had been returning a quietly shorter `Vec` — §3.2 of the
595 /// review, and the same silence in the same wave as the first.
596 ///
597 /// **Scoped to the instants that were actually lost (0.15.4, W14.2,
598 /// D-246).** The test was `hot_log_is_intact` — whether anything was *ever*
599 /// removed — with the instant discarded, so the first archive session a
600 /// deployment ran took `as_of_recorded` and `AttributeMode::AtTime` away for
601 /// its whole history rather than for the archived part, `as_of_recorded(now)`
602 /// included. That was justified on the ground that the archive cutoff is not
603 /// recorded hot-side (D-132's refused marker), which is true and does not
604 /// imply it: the cutoff is not needed. `LOG_ARCHIVABLE` requires a later row
605 /// at the same entity, so the newest row per entity is never archivable, and
606 /// an instant at or after the newest *surviving* stamp is answered entirely
607 /// by rows that are still hot. Below that stamp the refusal stands, and the
608 /// reason it stands is unchanged: answering from a partial fold returns
609 /// *nearly* the right topology, and on a ledger that is the worst failure
610 /// available.
611 ///
612 /// [`crate::temporal::reconstruct`] takes the archive path and answers the
613 /// same question, which is why the message names it.
614 #[error(
615 "transaction-time instant {ts} cannot be answered from the hot log: rows \
616 have been archived out of it and this read has no archive path. Use \
617 macrame::temporal::reconstruct(conn, ts, archive_path, snapshots_dir), \
618 which does"
619 )]
620 RecordedInstantUnreachable { ts: String },
621
622 /// A timestamp that is not in canonical form (§4.1, D-029).
623 ///
624 /// **Distinct from [`Self::ReplayCorrupt`], which is what this used to be
625 /// (Wave 4.5).** `timestamp::normalize` and `timestamp::parse` reported bad
626 /// *caller input* as `ReplayCorrupt { seq: 0 }` — a claim that the ledger is
627 /// damaged, carrying a sequence number that cannot exist because
628 /// `AUTOINCREMENT` starts at 1. The same mistake as defect J: an error that
629 /// names the wrong subject sends a caller to fix the wrong thing.
630 ///
631 /// The value is reported rather than the provenance, because one function
632 /// serves both directions — a caller passing `2026-01-01T00:00:00Z` and a
633 /// stored `recorded_at` that will not parse produce the same complaint about
634 /// the same string. `SystemClock::new` is where the second case is
635 /// interpreted, and it already logs and floors to the wall clock (D-027).
636 #[error("timestamp {value:?} is not canonical: {reason}")]
637 InvalidTimestamp { value: String, reason: String },
638
639 /// An identifier the crate's own encodings cannot represent (D-061).
640 ///
641 /// Distinct from [`Self::NotFound`], and the distinction is defect J: this
642 /// id was refused, not looked up. `validate_id` used to return `NotFound`
643 /// here, which tells a caller the thing is missing and invites them to
644 /// create it — with the same id, which will be refused again.
645 #[error("invalid identifier {id:?}: {reason}")]
646 InvalidId { id: String, reason: String },
647
648 /// Two valid-time intervals for one relationship claim the same instant.
649 ///
650 /// Distinct from [`Self::SingleOpenViolation`], which is the storage layer's
651 /// guard and covers only the *open* sentinel. This is the general case, and
652 /// it is refused at the API rather than by a trigger (D-060): raw SQL against
653 /// the same file can still write an overlap, and §4.2 says so.
654 ///
655 /// The consequence of allowing one is not an error later but a wrong answer:
656 /// `query_as_of_edges` at an instant inside both returns the relationship
657 /// twice, and every weighted algorithm downstream double-counts that edge.
658 ///
659 /// **Boxed, and it is the only variant that is (D-075).** Seven `String`s is
660 /// 168 bytes, which made `DbError` — and therefore every `Result` in the
661 /// crate, on the `Ok` path too — larger than `clippy::result_large_err`'s
662 /// threshold the moment D-060 added it. The other variants are well under.
663 /// Boxing the rarest one keeps the whole error small rather than trimming
664 /// what a caller is told; `matches!(err, OverlappingInterval { .. })` is
665 /// unaffected, which is how every call site uses it.
666 #[error(
667 "edge {} -> {} ({}): the asserted [{}, {}) overlaps [{}, {}), which {}",
668 .overlap.source_id, .overlap.target_id, .overlap.edge_type,
669 .overlap.valid_from, .overlap.valid_to,
670 .overlap.existing_from, .overlap.existing_to,
671 .overlap.provenance()
672 )]
673 OverlappingInterval { overlap: Box<Overlap> },
674
675 #[error("links_current drift detected: {n} intervals diverge")]
676 CurrentDrift { n: usize },
677
678 #[error("rebuild verification failed: {n} intervals still diverge")]
679 RebuildFailed { n: usize },
680 /// A chunked shadow rebuild was abandoned rather than committed (T1.2, D-082).
681 ///
682 /// Distinct from [`Self::RebuildFailed`], and the distinction is the whole
683 /// point: `RebuildFailed` means the repair ran and did not repair, which is
684 /// a reason to distrust the ledger. This means the repair **did not run** —
685 /// something invalidated the work in progress and it was discarded before it
686 /// could be swapped in. `links_current` is untouched and whatever was true
687 /// of it before is still true. The action is to retry.
688 #[error("chunked rebuild abandoned: {reason}")]
689 RebuildInterrupted { reason: String },
690
691 // -- 0.4.5: writer-actor containment --
692 #[error("write actor is not running (reopen the Database)")]
693 WriterUnavailable,
694
695 #[error("write actor dropped the response channel mid-request")]
696 WriterDroppedResponder,
697
698 /// The actor's task did not join cleanly at [`crate::Database::close`].
699 ///
700 /// Distinct from [`Self::WriterUnavailable`], which means the channel is
701 /// gone while the handle is still in use. This is the shutdown path telling
702 /// a caller that the write actor panicked — which `close()` used to swallow,
703 /// so a database whose write path had died closed "successfully" (Wave 4.2).
704 #[error("write actor did not shut down cleanly: {0}")]
705 WriterStopped(String),
706
707 // -- 0.5.0: concept integrity --
708 #[error("recorded_at must advance on concept update (got {got}, had {had})")]
709 RecordedAtRegression { got: String, had: String },
710
711 /// The stored transaction-time floor is in the future (0.13.5, W7.4, §3.4).
712 ///
713 /// The clock is raised to `MAX(recorded_at)` at open so that stamps stay
714 /// strictly increasing across restarts. That makes a single row from the
715 /// future — a skewed host, a bad import, a fixture that escaped — this
716 /// process's floor, and every stamp it issues lands at or after it. Those
717 /// rows are then written, so the next open reads the same floor back: the
718 /// damage is permanent, and it spreads.
719 ///
720 /// Refused at open rather than absorbed, which is where the crate can still
721 /// tell the difference between a stamp it wrote and one it did not.
722 /// `macrame::FutureStampPolicy` widens or waives the bound; waiving it
723 /// opens the file to be *read*, and does not repair it.
724 // The message names the *knob* rather than the Rust spelling of it,
725 // because it crosses to Python verbatim and a caller there cannot write a
726 // `Tuning` literal. `future_stamps` and `allow` are the two words that mean
727 // the same thing on both surfaces.
728 #[error(
729 "the newest recorded_at in this database is {stamp}, past the limit \
730 {limit}. The clock floor is taken from it, so opening would stamp \
731 every later write at or after it — permanently, since the next open \
732 reads those rows back. Set the future_stamps policy to allow to open \
733 it and inspect it; that inherits the floor rather than repairing it"
734 )]
735 FutureRecordedAt { stamp: String, limit: String },
736
737 /// A chunked bulk write stopped because its caller asked it to (0.13.8,
738 /// W7.6, [D-181]).
739 ///
740 /// Not a failure of the ledger, and the only [`DbError`] a caller can
741 /// *cause on purpose*. Nothing is rolled back: the chunks that committed
742 /// before the token was seen are committed, which is the same per-chunk
743 /// boundary [`crate::Database::bulk_import`] already documents. How many
744 /// rows those were is on [`BulkInterrupted::written`], the error this
745 /// arrives inside.
746 ///
747 /// It carries no count of its own precisely so that there is one place to
748 /// read the count from, whether the stop was a cancellation or a
749 /// constraint.
750 ///
751 /// [D-181]: ../../docs/architecture/s13-decision-register.md#d-181
752 #[error("the bulk write was cancelled between chunks")]
753 BulkCancelled,
754}
755
756/// What kind of failure a [`DbError`] is, as a value (0.14.25, §14.1 C-3,
757/// [D-242]).
758///
759/// # Why this exists
760///
761/// [`DbError`] is `#[non_exhaustive]` ([D-207]), so a downstream `match` needs
762/// a wildcard arm and can never be checked for completeness by the compiler.
763/// That was a deliberate trade — a ledger that will certainly add error
764/// variants after 1.0 cannot make each addition a major version — and its
765/// price was paid by callers, who lost the one guarantee that told them they
766/// had considered everything.
767///
768/// This buys part of it back, and the part it buys back is **inside this
769/// crate**: [`DbError::kind`] is one exhaustive match with no wildcard, so a
770/// variant added without a classification does not compile. The decision moves
771/// to the person adding the variant, at the line that needs it, which is
772/// exactly what [`crate::DbError`]'s binding lost in 0.13.34.
773///
774/// # The taxonomy is not new
775///
776/// These twelve names are the hierarchy the Python bindings have published
777/// since they existed — seven groups a caller can catch as a set, and five
778/// failures that belong to no group. Inventing a second, Rust-only taxonomy
779/// here would be [D-227]'s finding again: *a surface that spells its own
780/// version of a shared thing misses every repair made to the shared thing,
781/// quietly*. `binding_parity_tests` pins the two spellings together.
782///
783/// # What it is not
784///
785/// It is not a replacement for matching on [`DbError`] itself. A caller who
786/// needs the `path` a snapshot failed to write still matches the variant; this
787/// answers the coarser question — *whose problem is this, and can I retry it* —
788/// and, being `Copy + Eq + Hash`, answers it somewhere a [`DbError`] cannot go:
789/// a metrics key, a log field, a counter.
790///
791/// It is `#[non_exhaustive]` for the same reason [`DbError`] is. An exhaustive
792/// `ErrorKind` would give downstream its compile-time completeness back, and
793/// would do it by making a genuinely new *category* of failure a major
794/// version — which is the trap [D-207] rejected by name, one level up: the
795/// category would then not get added, and the ledger would report the wrong
796/// kind rather than a new one.
797///
798/// [D-207]: ../../docs/architecture/s13-decision-register.md#d-207
799/// [D-227]: ../../docs/architecture/s13-decision-register.md#d-227
800/// [D-242]: ../../docs/architecture/s13-decision-register.md#d-242
801#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
802#[non_exhaustive]
803pub enum ErrorKind {
804 /// The ledger's own invariants: overlap, drift, a leaked archive session,
805 /// a rebuild that failed or was interrupted. Something is wrong with the
806 /// data or with a repair of it, and no retry fixes it.
807 Integrity,
808 /// The caller's input was refused before anything was attempted.
809 Validation,
810 /// Embeddings and the model registry.
811 Vector,
812 /// Time, snapshots and the archive — the bitemporal machinery.
813 Temporal,
814 /// The write actor could not take, keep, or answer the request.
815 Writer,
816 /// A bound the caller set, or one the crate sets on the caller's behalf.
817 Budget,
818 /// Lineage: a branch that does not exist, cannot be forked, or may not be
819 /// named from where the caller is standing.
820 Branch,
821 /// A chunked bulk write stopped between chunks. Distinct from every other
822 /// kind because **part of it landed** — see [`BulkInterrupted`].
823 Cancelled,
824 /// The read-only diagnostic connection.
825 Diagnostic,
826 /// libSQL itself, passed through.
827 Engine,
828 /// Schema migration.
829 Migration,
830 /// The thing asked for is not there.
831 NotFound,
832}
833
834impl ErrorKind {
835 /// A stable name, for logs and metrics labels.
836 ///
837 /// Stable in the sense that matters for a label: these strings are part of
838 /// the public surface from 1.0 and will not be re-spelled. New kinds may
839 /// appear, which is what `#[non_exhaustive]` says.
840 pub fn as_str(self) -> &'static str {
841 match self {
842 Self::Integrity => "integrity",
843 Self::Validation => "validation",
844 Self::Vector => "vector",
845 Self::Temporal => "temporal",
846 Self::Writer => "writer",
847 Self::Budget => "budget",
848 Self::Branch => "branch",
849 Self::Cancelled => "cancelled",
850 Self::Diagnostic => "diagnostic",
851 Self::Engine => "engine",
852 Self::Migration => "migration",
853 Self::NotFound => "not_found",
854 }
855 }
856}
857
858impl std::fmt::Display for ErrorKind {
859 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860 f.write_str(self.as_str())
861 }
862}
863
864impl DbError {
865 /// This error's [`ErrorKind`].
866 ///
867 /// **The match below has no wildcard arm, and that is the whole point**
868 /// ([D-242], §14.1 C-3). `DbError` is `#[non_exhaustive]`, so no `match`
869 /// outside this crate can be checked for completeness — but inside it, the
870 /// compiler still checks. A variant added without a line here fails to
871 /// build, which is the guarantee [D-207] traded away for the binding and
872 /// could not get back there.
873 ///
874 /// [D-207]: ../../docs/architecture/s13-decision-register.md#d-207
875 /// [D-242]: ../../docs/architecture/s13-decision-register.md#d-242
876 pub fn kind(&self) -> ErrorKind {
877 match self {
878 Self::Engine(_) => ErrorKind::Engine,
879 Self::Migration { .. } => ErrorKind::Migration,
880 Self::NotFound { .. } => ErrorKind::NotFound,
881 Self::DiagnosticConn { .. } => ErrorKind::Diagnostic,
882 Self::BulkCancelled => ErrorKind::Cancelled,
883
884 Self::ArchiveSessionLeaked { .. }
885 | Self::CurrentDrift { .. }
886 | Self::FutureRecordedAt { .. }
887 | Self::NegativeEdgeWeight { .. }
888 | Self::OverlappingInterval { .. }
889 | Self::RebuildFailed { .. }
890 | Self::RebuildInterrupted { .. }
891 | Self::RecordedAtRegression { .. }
892 | Self::SingleOpenViolation { .. } => ErrorKind::Integrity,
893
894 Self::AttributeModeUnstated { .. }
895 | Self::HalfLifeWithoutInstant
896 | Self::InvalidBranchId { .. }
897 | Self::InvalidEdgeType { .. }
898 | Self::InvalidId { .. }
899 | Self::InvalidModelName { .. }
900 | Self::InvalidTimestamp { .. } => ErrorKind::Validation,
901
902 Self::DimMismatch { .. } | Self::ModelNotRegistered { .. } => ErrorKind::Vector,
903
904 Self::ArchiveViolation { .. }
905 | Self::ArchiveWindow { .. }
906 | Self::PayloadVersion { .. }
907 | Self::RecordedInstantUnreachable { .. }
908 | Self::ReplayCorrupt { .. }
909 | Self::SnapshotCorrupt { .. }
910 | Self::SnapshotIncompatible { .. }
911 | Self::SnapshotWriteFailed { .. } => ErrorKind::Temporal,
912
913 Self::WriterDroppedResponder
914 | Self::WriterStopped { .. }
915 | Self::WriterUnavailable { .. } => ErrorKind::Writer,
916
917 Self::SubgraphTooLarge { .. } => ErrorKind::Budget,
918
919 Self::BranchArchived { .. }
920 | Self::BranchExists { .. }
921 | Self::BranchMismatch { .. }
922 | Self::BranchNotArchivable { .. }
923 | Self::CrossLineage { .. }
924 | Self::ForkPrecedesParent { .. }
925 | Self::UnknownBranch { .. } => ErrorKind::Branch,
926 }
927 }
928}
929
930pub type Result<T> = std::result::Result<T, DbError>;
931
932/// A chunked bulk write that stopped partway, and how much of it landed
933/// (0.13.8, W7.6, [D-181]).
934///
935/// The four chunked paths — [`crate::Database::bulk_import`],
936/// [`write_concepts`], [`upsert_embeddings`] and
937/// [`write_analytics_annotations`] — are atomic per chunk and not overall, so a
938/// failure at row 19,000 of 20,000 leaves the first 18,000-odd rows committed.
939/// Until 0.13.8 they returned a bare [`DbError`] and the caller was told only
940/// that it failed: the count was computed, used to size the next chunk, and
941/// dropped on the floor at the `?`. A caller who then retried the whole batch
942/// re-wrote everything that had already landed, and one who skipped it lost the
943/// tail.
944///
945/// This is why those four return `Result<usize, BulkInterrupted>` rather than
946/// [`Result`]. `From<BulkInterrupted> for DbError` exists so `?` still works in
947/// a function returning [`Result`] — that conversion is how a caller says the
948/// count does not interest them, and it says so at the call site instead of
949/// silently.
950///
951/// [`write_concepts`]: crate::Database::write_concepts
952/// [`upsert_embeddings`]: crate::Database::upsert_embeddings
953/// [`write_analytics_annotations`]: crate::Database::write_analytics_annotations
954/// [D-181]: ../../docs/architecture/s13-decision-register.md#d-181
955#[derive(Debug)]
956#[non_exhaustive]
957pub struct BulkInterrupted {
958 /// Rows the chunks that finished before the stop committed, and which are
959 /// still committed. Zero is an ordinary value: the first chunk can fail.
960 pub written: usize,
961 /// Why it stopped. [`DbError::BulkCancelled`] if the caller asked;
962 /// otherwise whatever the failing chunk raised, unchanged — this is not a
963 /// new error, it is the same one with the count attached.
964 pub cause: DbError,
965}
966
967impl std::fmt::Display for BulkInterrupted {
968 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
969 write!(
970 f,
971 "{} ({} row(s) committed before the stop, and still committed)",
972 self.cause, self.written
973 )
974 }
975}
976
977impl std::error::Error for BulkInterrupted {
978 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
979 Some(&self.cause)
980 }
981}
982
983impl From<BulkInterrupted> for DbError {
984 /// Discards `written`. That is the point: a caller writing `?` into a
985 /// function returning [`Result`] has decided the partial count is not
986 /// something they will act on, and this puts that decision at the place it
987 /// is taken rather than inside the crate.
988 fn from(e: BulkInterrupted) -> Self {
989 e.cause
990 }
991}
992
993impl BulkInterrupted {
994 /// Whether the stop was the caller's own cancellation rather than a fault.
995 pub fn was_cancelled(&self) -> bool {
996 matches!(self.cause, DbError::BulkCancelled)
997 }
998}
999
1000/// What the four chunked bulk paths return (0.13.8, W7.6).
1001pub type BulkResult<T> = std::result::Result<T, BulkInterrupted>;
1002
1003/// A guard abort recognised by its message (§4.3).
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1005#[non_exhaustive]
1006pub enum AbortKind {
1007 SingleOpenInterval,
1008 RecordedAtRegression,
1009 DeleteOutsideArchive,
1010 /// A concept id already held by a different lineage (v12, §15.2, D-214).
1011 CrossLineage,
1012 /// An `UPDATE` that would move a concept between lineages (v12, D-214).
1013 BranchImmutable,
1014 /// Any write to `branches` other than an insert (v12, §15.2).
1015 BranchesFrozen,
1016 /// Not one of our guards — an ordinary engine error.
1017 NotAGuard,
1018}
1019
1020/// `SQLITE_CONSTRAINT_TRIGGER` — `SQLITE_CONSTRAINT | (7 << 8)`.
1021///
1022/// The code a `RAISE(ABORT, …)` inside a trigger body carries. Measured on
1023/// libSQL 0.9.30 rather than taken from the header: the probe
1024/// `examples/abort_code_probe.rs` fires one of the crate's own guards and
1025/// prints `SqliteFailure(1811, "…")`, against 1299 for a `NOT NULL` failure
1026/// the engine raises itself.
1027const SQLITE_CONSTRAINT_TRIGGER: std::ffi::c_int = 1811;
1028
1029/// Recognise a schema guard's `RAISE(ABORT, …)`.
1030///
1031/// **The only place in the crate that matches on engine error text**, and since
1032/// 0.15.19 the text is reached only after the *code* says a trigger raised it.
1033/// SQLite flattens every `RAISE(ABORT)` into one constraint failure, so the
1034/// message remains the only thing distinguishing "you violated the
1035/// single-open-interval rule" from "you violated the cross-lineage rule" — but
1036/// it no longer has to distinguish a guard from an unrelated failure.
1037///
1038/// # Code first, then text (0.15.19, review C-20)
1039///
1040/// `libsql::Error::SqliteFailure(1811, msg)` is what a guard produces, and
1041/// `msg` is the crate's own abort string **verbatim** — the probe above prints
1042/// it, and the "libSQL prefix" this was thought to depend on is `Display`'s,
1043/// not the message's. So two things improve at once. The needle is matched
1044/// against the engine's message field rather than against a rendered string,
1045/// which removes `Display` as a layer that can change underneath the
1046/// classification; and a non-trigger error that happens to quote one of these
1047/// strings — a caller's own text echoed back in a `NOT NULL` message, say —
1048/// can no longer be read as a guard.
1049///
1050/// The free-text arm stays as the fallback, because not every route to this
1051/// function produces `SqliteFailure`: an error wrapped or re-rendered on the
1052/// way here would otherwise degrade every guard to
1053/// [`AbortKind::NotAGuard`] at once, which is the silent failure this function
1054/// exists to concentrate rather than to cause.
1055///
1056/// The needles are the [`crate::schema::ddl`] constants spliced into the
1057/// triggers themselves, so guard and classifier cannot drift.
1058pub fn abort_kind(err: &libsql::Error) -> AbortKind {
1059 use crate::schema::ddl::{
1060 ABORT_BRANCHES_FROZEN, ABORT_BRANCH_IMMUTABLE, ABORT_CROSS_LINEAGE, ABORT_DELETE_GUARD,
1061 ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN,
1062 };
1063
1064 let text = match err {
1065 libsql::Error::SqliteFailure(code, msg) if *code == SQLITE_CONSTRAINT_TRIGGER => {
1066 msg.clone()
1067 }
1068 // Not a trigger abort by its code. Only a variant that carries no code
1069 // at all can still be one of our guards, so an engine failure with a
1070 // *different* code is refused here rather than sent to the text arm.
1071 libsql::Error::SqliteFailure(..) => return AbortKind::NotAGuard,
1072 other => other.to_string(),
1073 };
1074 if text.contains(ABORT_SINGLE_OPEN) {
1075 AbortKind::SingleOpenInterval
1076 } else if text.contains(ABORT_MONOTONIC_RA) {
1077 AbortKind::RecordedAtRegression
1078 } else if text.contains(ABORT_DELETE_GUARD) {
1079 AbortKind::DeleteOutsideArchive
1080 } else if text.contains(ABORT_CROSS_LINEAGE) {
1081 AbortKind::CrossLineage
1082 } else if text.contains(ABORT_BRANCH_IMMUTABLE) {
1083 AbortKind::BranchImmutable
1084 } else if text.contains(ABORT_BRANCHES_FROZEN) {
1085 AbortKind::BranchesFrozen
1086 } else {
1087 AbortKind::NotAGuard
1088 }
1089}
1090
1091/// What a failing statement was trying to do, so a guard abort can name it.
1092#[non_exhaustive]
1093pub enum WriteOp<'a> {
1094 Edge {
1095 source_id: &'a str,
1096 target_id: &'a str,
1097 edge_type: &'a str,
1098 },
1099 Concept {
1100 id: &'a str,
1101 recorded_at: &'a str,
1102 /// The lineage the upsert named, for `DbError::CrossLineage` (0.14.8).
1103 /// It cannot be read back after the abort, because the row it would
1104 /// have been on was never written.
1105 branch: &'a str,
1106 },
1107 Delete {
1108 table: &'a str,
1109 },
1110 /// A derived annotation (0.13.3, W7.2, [`crate::Annotation`]).
1111 ///
1112 /// The only [`WriteOp`] whose failure is not a `RAISE(ABORT)`.
1113 /// `analytics_annotations` carries no triggers at all — that is why it is
1114 /// the cheapest bulk table and why its chunk ceiling is the largest
1115 /// (D-058) — so the guard vocabulary [`abort_kind`] speaks has nothing to
1116 /// say about it. What it does carry is a foreign key onto `concepts`, and
1117 /// that is the failure a caller can actually cause.
1118 Annotation {
1119 concept_id: &'a str,
1120 },
1121}
1122
1123/// `SQLITE_CONSTRAINT_FOREIGNKEY` — `SQLITE_CONSTRAINT | (3 << 8)`.
1124///
1125/// libSQL reports statement failures through
1126/// `libsql::Error::SqliteFailure(extended_error_code(…), …)`, so this is the
1127/// *extended* code and discriminates a foreign-key failure from the CHECK,
1128/// PRIMARY KEY and NOT NULL failures that share primary code 19. Matching the
1129/// primary code would classify a malformed `computed_at` — a different bug with
1130/// a different fix — as a missing concept.
1131const SQLITE_CONSTRAINT_FOREIGNKEY: std::ffi::c_int = 787;
1132
1133/// Recognise a foreign-key failure by its result code, not by its message.
1134///
1135/// The deliberate counterpart to [`abort_kind`]. That function matches text
1136/// because it has no alternative: SQLite flattens every `RAISE(ABORT)` into one
1137/// generic constraint failure and the message is the only thing left. A foreign
1138/// key is enforced by the engine itself and carries a code of its own, so
1139/// nothing here depends on wording — an upstream message change cannot degrade
1140/// this classification, which is exactly the failure mode `abort_kind`'s
1141/// rustdoc warns about and cannot escape.
1142fn is_foreign_key_violation(err: &libsql::Error) -> bool {
1143 matches!(err, libsql::Error::SqliteFailure(code, _) if *code == SQLITE_CONSTRAINT_FOREIGNKEY)
1144}
1145
1146/// Turn an engine error into the typed error §7 specifies, where one applies.
1147///
1148/// Takes a connection because `RecordedAtRegression` reports the value it
1149/// clashed with, and the trigger does not put it in the message. One extra query
1150/// on an error path buys an error a caller can act on instead of one they have
1151/// to reproduce by hand.
1152pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
1153 match (abort_kind(&err), op) {
1154 (
1155 AbortKind::SingleOpenInterval,
1156 WriteOp::Edge {
1157 source_id,
1158 target_id,
1159 edge_type,
1160 },
1161 ) => DbError::SingleOpenViolation {
1162 source_id: source_id.to_string(),
1163 target_id: target_id.to_string(),
1164 edge_type: edge_type.to_string(),
1165 },
1166 (
1167 AbortKind::RecordedAtRegression,
1168 WriteOp::Concept {
1169 id, recorded_at, ..
1170 },
1171 ) => {
1172 let had = current_recorded_at(conn, id).await.unwrap_or_default();
1173 DbError::RecordedAtRegression {
1174 got: recorded_at.to_string(),
1175 had,
1176 }
1177 }
1178 (AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
1179 table: table.to_string(),
1180 },
1181 (AbortKind::CrossLineage, WriteOp::Concept { id, branch, .. }) => DbError::CrossLineage {
1182 id: id.to_string(),
1183 held_by: lineage_of_concept(conn, id).await,
1184 attempted: branch.to_string(),
1185 },
1186 // An annotation naming a concept that is not there. The engine says
1187 // "FOREIGN KEY constraint failed" and no more — not which row, and a
1188 // rejected chunk may hold up to `chunk_rows::ANNOTATIONS` of them. The
1189 // typed error names the concept, which is the fact the database
1190 // actually knows and the one the caller has to act on.
1191 (_, WriteOp::Annotation { concept_id }) if is_foreign_key_violation(&err) => {
1192 DbError::NotFound(concept_id.to_string())
1193 }
1194 // The same treatment for an edge, which W7.2 left out because its scope
1195 // was the annotation path (C-1, D-176). `links` declares **two** keys
1196 // into `concepts`, so unlike the annotation case the message does not
1197 // even narrow it to one column: an unqualified "FOREIGN KEY constraint
1198 // failed" is all a caller gets for a batch that may have named the
1199 // wrong source, the wrong target, or both.
1200 //
1201 // Which one is missing is a question the database can answer, so it is
1202 // asked rather than guessed. The source is reported when both are
1203 // absent — one name a caller can act on beats a compound message that
1204 // has to be parsed.
1205 (
1206 _,
1207 WriteOp::Edge {
1208 source_id,
1209 target_id,
1210 ..
1211 },
1212 ) if is_foreign_key_violation(&err) => {
1213 DbError::NotFound(missing_endpoint(conn, source_id, target_id).await)
1214 }
1215 // A guard fired for an operation it does not describe. Reporting the raw
1216 // error is honest; inventing a typed one from the wrong context is not.
1217 _ => DbError::Engine(err),
1218 }
1219}
1220
1221/// Who holds a concept id, for [`DbError::CrossLineage`].
1222///
1223/// The refused lineage is not read back — the row was never written — so it
1224/// comes from [`WriteOp::Concept`], which is the only place it survives the
1225/// abort. Falls back to `"?"` rather than guessing when the read fails, for
1226/// [`missing_endpoint`]'s reason: a classifier that can fail twice is worse than
1227/// one that answers approximately.
1228async fn lineage_of_concept(conn: &libsql::Connection, id: &str) -> String {
1229 let unknown = || "?".to_string();
1230 let Ok(mut rows) = conn
1231 .query(
1232 "SELECT branch_id FROM concepts WHERE id = ?1",
1233 libsql::params![id],
1234 )
1235 .await
1236 else {
1237 return unknown();
1238 };
1239 match rows.next().await {
1240 Ok(Some(row)) => row.get::<String>(0).unwrap_or_else(|_| unknown()),
1241 _ => unknown(),
1242 }
1243}
1244
1245/// Which endpoint of a refused edge is not in `concepts` (C-1).
1246///
1247/// One query on an error path, for the reason [`classify`]'s own rustdoc gives:
1248/// it buys an error a caller can act on instead of one they have to reproduce
1249/// by hand. Falls back to the source id if the query itself fails, because a
1250/// classifier that can fail twice is worse than one that answers approximately.
1251///
1252/// # The concepts path, and why it still needs no arm of its own
1253///
1254/// C-1 names `links` **and** `concepts`. Since v12 `concepts` carries an
1255/// outbound key — `branch_id` into `branches` (§15.2) — and **since 0.14.8 a
1256/// caller can choose what goes in it**, which is the condition this paragraph
1257/// used to say would need an arm "with a different column".
1258///
1259/// It still does not, and the reason moved rather than held: the write path
1260/// checks every lineage a write names *before* it opens the transaction
1261/// (`connection::check_lineages`), so an unregistered branch comes back as
1262/// [`DbError::UnknownBranch`] naming the branch, and the foreign key never
1263/// fires. An arm here would be a classification for a state the API cannot
1264/// reach — defect Q's shape, a typed error no code path can produce — and the
1265/// honest place for the refusal is the one that can say *branch* rather than
1266/// *constraint*.
1267async fn missing_endpoint(conn: &libsql::Connection, source_id: &str, target_id: &str) -> String {
1268 for id in [source_id, target_id] {
1269 let Ok(mut rows) = conn
1270 .query("SELECT 1 FROM concepts WHERE id = ?1", libsql::params![id])
1271 .await
1272 else {
1273 return source_id.to_string();
1274 };
1275 if !matches!(rows.next().await, Ok(Some(_))) {
1276 return id.to_string();
1277 }
1278 }
1279 source_id.to_string()
1280}
1281
1282async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
1283 conn.query(
1284 "SELECT recorded_at FROM concepts WHERE id = ?1",
1285 libsql::params![id],
1286 )
1287 .await
1288 .ok()?
1289 .next()
1290 .await
1291 .ok()??
1292 .get(0)
1293 .ok()
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 /// `DbError` stays under `clippy::result_large_err`'s 128-byte threshold.
1301 ///
1302 /// Every fallible function in the crate returns `Result<T, DbError>`, so the
1303 /// enum's size is paid on the `Ok` path too. D-060 pushed it to 168 bytes with
1304 /// one seven-`String` variant and nobody noticed until D-075 read the lint
1305 /// output; boxing that variant brought it back. This is the tripwire, because
1306 /// the failure mode is a warning in a build log rather than a broken test —
1307 /// the kind this cycle has spent its whole length finding.
1308 #[test]
1309 fn the_error_enum_stays_small_enough_to_return_by_value() {
1310 let size = std::mem::size_of::<DbError>();
1311 assert!(
1312 size <= 128,
1313 "DbError is {size} bytes. Some variant has grown past what a Result \n should carry — box it, as OverlappingInterval is boxed (D-075)."
1314 );
1315 }
1316
1317 fn sample_overlap(within_batch: bool) -> DbError {
1318 DbError::OverlappingInterval {
1319 overlap: Box::new(Overlap {
1320 source_id: "a".into(),
1321 target_id: "b".into(),
1322 edge_type: "KNOWS".into(),
1323 valid_from: "2026-03-01T00:00:00.000000Z".into(),
1324 valid_to: "2026-09-01T00:00:00.000000Z".into(),
1325 existing_from: "2026-01-01T00:00:00.000000Z".into(),
1326 existing_to: "2026-06-01T00:00:00.000000Z".into(),
1327 within_batch,
1328 }),
1329 }
1330 }
1331
1332 /// The boxed variant still reports both intervals.
1333 #[test]
1334 fn an_overlap_names_the_asserted_interval_and_the_other_one() {
1335 let msg = sample_overlap(false).to_string();
1336 assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
1337 assert!(msg.contains("asserted [2026-03-01"), "{msg}");
1338 assert!(msg.contains("[2026-01-01"), "{msg}");
1339 }
1340
1341 /// One error, two guards, and only one of them is talking about the
1342 /// database (0.13.7, D-180).
1343 ///
1344 /// `reject_overlaps_within` refuses the batch *before* the transaction
1345 /// opens, so the interval it names is not stored and will not become
1346 /// stored. Saying the edge "already holds" it sent a caller looking for a
1347 /// row that was never written.
1348 #[test]
1349 fn an_overlap_says_which_side_of_the_write_the_other_interval_is_on() {
1350 let stored = sample_overlap(false).to_string();
1351 assert!(stored.contains("is already recorded"), "{stored}");
1352 assert!(!stored.contains("batch"), "{stored}");
1353
1354 let in_batch = sample_overlap(true).to_string();
1355 assert!(
1356 in_batch.contains("this same batch also asserts"),
1357 "{in_batch}"
1358 );
1359 assert!(!in_batch.contains("recorded"), "{in_batch}");
1360 }
1361
1362 /// The count is the whole reason this type exists, so it has to be in the
1363 /// sentence a caller sees, not only in a field they have to know about
1364 /// (0.13.8, W7.6).
1365 #[test]
1366 fn a_partial_bulk_failure_says_how_much_landed() {
1367 let e = BulkInterrupted {
1368 written: 18_935,
1369 cause: DbError::NotFound("ghost".into()),
1370 };
1371 let text = e.to_string();
1372 assert!(text.contains("node ghost not found"), "{text}");
1373 assert!(text.contains("18935"), "{text}");
1374 }
1375
1376 /// Cancellation is not a fault, and the type says which it was without the
1377 /// caller matching on a variant.
1378 #[test]
1379 fn cancellation_is_distinguishable_from_a_failure() {
1380 assert!(BulkInterrupted {
1381 written: 7,
1382 cause: DbError::BulkCancelled,
1383 }
1384 .was_cancelled());
1385 assert!(!BulkInterrupted {
1386 written: 7,
1387 cause: DbError::WriterUnavailable,
1388 }
1389 .was_cancelled());
1390 }
1391
1392 /// `?` into a `Result<_, DbError>` keeps the cause and drops the count.
1393 /// Both halves of that are deliberate; this pins them.
1394 #[test]
1395 fn converting_to_a_db_error_keeps_the_cause_and_loses_the_count() {
1396 let e = BulkInterrupted {
1397 written: 400,
1398 cause: DbError::SingleOpenViolation {
1399 source_id: "a".into(),
1400 target_id: "b".into(),
1401 edge_type: "KNOWS".into(),
1402 },
1403 };
1404 let cause: DbError = e.into();
1405 assert!(matches!(cause, DbError::SingleOpenViolation { .. }));
1406 assert!(!cause.to_string().contains("400"));
1407 }
1408
1409 /// The error chain reaches the cause, so `anyhow`-style reporters print
1410 /// both lines rather than only the wrapper's.
1411 #[test]
1412 fn the_cause_is_reachable_as_an_error_source() {
1413 use std::error::Error;
1414 let e = BulkInterrupted {
1415 written: 1,
1416 cause: DbError::BulkCancelled,
1417 };
1418 assert_eq!(
1419 e.source().map(ToString::to_string).as_deref(),
1420 Some("the bulk write was cancelled between chunks")
1421 );
1422 }
1423
1424 /// The kind is a property of the variant, not of what it is carrying
1425 /// (0.14.25, C-3, [D-242]).
1426 ///
1427 /// [D-242]: ../../docs/architecture/s13-decision-register.md#d-242
1428 #[test]
1429 fn the_kind_is_the_same_whatever_the_fields_say() {
1430 let a = DbError::SnapshotWriteFailed {
1431 path: "snapshots/1.snap.zst".into(),
1432 reason: "no space left on device".into(),
1433 };
1434 let b = DbError::SnapshotWriteFailed {
1435 path: "elsewhere".into(),
1436 reason: "read-only file system".into(),
1437 };
1438 assert_eq!(a.kind(), b.kind());
1439 assert_eq!(a.kind(), ErrorKind::Temporal);
1440 }
1441
1442 /// A snapshot that could not be written and a damaged ledger are the same
1443 /// *kind*, and that is deliberate: [D-240] split them so a caller learns
1444 /// which subject is broken, and the kind answers the coarser question. The
1445 /// discriminant does not replace matching the variant, and this test is
1446 /// where that is written down rather than assumed.
1447 ///
1448 /// [D-240]: ../../docs/architecture/s13-decision-register.md#d-240
1449 #[test]
1450 fn the_kind_is_coarser_than_the_variant_and_says_so() {
1451 let unwritten = DbError::SnapshotWriteFailed {
1452 path: "p".into(),
1453 reason: "r".into(),
1454 };
1455 let damaged = DbError::ReplayCorrupt {
1456 seq: 7,
1457 reason: "r".into(),
1458 };
1459 assert_eq!(unwritten.kind(), damaged.kind());
1460 assert_ne!(unwritten.to_string(), damaged.to_string());
1461 }
1462
1463 /// Two kinds never share a name, or a metrics label collapses two
1464 /// populations into one bar.
1465 #[test]
1466 fn no_two_kinds_spell_themselves_the_same_way() {
1467 use std::collections::BTreeSet;
1468 let kinds = [
1469 ErrorKind::Integrity,
1470 ErrorKind::Validation,
1471 ErrorKind::Vector,
1472 ErrorKind::Temporal,
1473 ErrorKind::Writer,
1474 ErrorKind::Budget,
1475 ErrorKind::Branch,
1476 ErrorKind::Cancelled,
1477 ErrorKind::Diagnostic,
1478 ErrorKind::Engine,
1479 ErrorKind::Migration,
1480 ErrorKind::NotFound,
1481 ];
1482 let names: BTreeSet<&str> = kinds.iter().map(|k| k.as_str()).collect();
1483 assert_eq!(
1484 names.len(),
1485 kinds.len(),
1486 "two kinds share a label: {names:?}"
1487 );
1488 for kind in kinds {
1489 assert_eq!(kind.to_string(), kind.as_str(), "Display and as_str differ");
1490 }
1491 }
1492
1493 /// It goes where a `DbError` cannot: a key.
1494 #[test]
1495 fn a_kind_can_be_counted() {
1496 use std::collections::HashMap;
1497 let mut seen: HashMap<ErrorKind, usize> = HashMap::new();
1498 for err in [
1499 DbError::WriterStopped("a write".into()),
1500 DbError::WriterDroppedResponder,
1501 DbError::BulkCancelled,
1502 ] {
1503 *seen.entry(err.kind()).or_default() += 1;
1504 }
1505 assert_eq!(seen[&ErrorKind::Writer], 2);
1506 assert_eq!(seen[&ErrorKind::Cancelled], 1);
1507 }
1508}