macrame-db 0.13.0

A Bitemporal Graph Ledger on libSQL · Embedded knowledge database
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
use thiserror::Error;

/// The two intervals of a [`DbError::OverlappingInterval`], boxed out of the
/// error enum (D-075).
///
/// Both are reported because neither alone identifies the conflict: the caller
/// knows what they asserted and not what it collided with, and a message naming
/// only the stored interval reads as though the assertion were the innocent one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Overlap {
    pub source_id: String,
    pub target_id: String,
    pub edge_type: String,
    /// The interval the caller asserted.
    pub valid_from: String,
    pub valid_to: String,
    /// The interval already stored that it collides with.
    pub existing_from: String,
    pub existing_to: String,
}

/// Central error type for the Macrame bitemporal ledger database.
#[derive(Debug, Error)]
pub enum DbError {
    #[error("engine: {0}")]
    Engine(#[from] libsql::Error),

    #[error("migration to v{to} failed: {reason}")]
    Migration { to: u32, reason: String },

    #[error("invalid edge type {0} (must match [A-Z0-9]+)")]
    InvalidEdgeType(String),

    // NOTE: the spec (§7) names these fields `source` / `target`. `source` is a
    // reserved field name for thiserror (it is inferred as the error source and
    // requires `std::error::Error`), so the schema column names are used instead.
    #[error(
        "{source_id} -> {target_id} ({edge_type}) already has an open interval; retire it first"
    )]
    SingleOpenViolation {
        source_id: String,
        target_id: String,
        edge_type: String,
    },

    #[error("node {0} not found")]
    NotFound(String),

    #[error("embedding dim {got}, expected {expected} for model {model}")]
    DimMismatch {
        got: usize,
        expected: usize,
        model: String,
    },

    /// A model name is spliced into DDL and queries as a table identifier, and
    /// identifiers cannot be bound as parameters. Validating the name is what
    /// makes that splice safe, so an invalid one is refused rather than escaped.
    #[error("invalid embedding model name {0:?}: expected [a-z][a-z0-9_]* up to 48 characters")]
    InvalidModelName(String),

    #[error("embedding model {model} is not registered (no {table} table)")]
    ModelNotRegistered { model: String, table: String },

    #[error("subgraph exceeds budget ({n} > {budget})")]
    SubgraphTooLarge { n: usize, budget: usize },

    /// Dijkstra and A* settle a node permanently the first time they pop it,
    /// which is only sound when no later edge can reduce the distance — that is,
    /// when weights are non-negative. `links.weight` is a bare `REAL NOT NULL`
    /// with no CHECK, so the guarantee has to be established at load time. The
    /// alternative is a shortest-path result that is quietly just a path.
    #[error("edge {source_id} -> {target_id} has weight {weight}, which shortest-path analytics cannot use")]
    NegativeEdgeWeight {
        source_id: String,
        target_id: String,
        weight: f64,
    },

    #[error("replay corrupt at seq {seq}: {reason}")]
    ReplayCorrupt { seq: i64, reason: String },

    /// A snapshot this build cannot read. Distinct from [`Self::ReplayCorrupt`]
    /// on purpose: corruption is a fault to report, an incompatible snapshot is
    /// the ordinary consequence of an upgrade, and the correct response is to
    /// discard the file and fold from the log instead (D-043).
    #[error("snapshot {path} is not readable by this build: {reason}")]
    SnapshotIncompatible { path: String, reason: String },

    #[error("payload v{got} unsupported (max {max})")]
    PayloadVersion { got: u8, max: u8 },

    #[error("physical delete blocked outside archive session ({table})")]
    ArchiveViolation { table: String },

    /// The archive-session marker exists as **committed** state (0.10.0, W2).
    ///
    /// [`ArchiveViolation`] is this guard working. This variant is the guard
    /// having been silently switched off: while
    /// `macrame_archive_session` is present, `trg_concepts_guard_delete`,
    /// `trg_links_guard_delete` and `trg_txlog_guard_delete` all evaluate their
    /// `WHEN` to false and permit the deletes they exist to refuse, and
    /// `trg_concepts_log_insert` writes no `transaction_log` row for a concept
    /// insert. [Doctrine IV] and [Doctrine V] are both suspended, with no error
    /// and no counter — which is why the condition needs a name of its own.
    ///
    /// **It cannot be produced by an archive session, crashed or otherwise.**
    /// `archive()` and `archive_windowed()` create and drop the marker inside
    /// the same transaction that does the work, so a commit drops it and a
    /// rollback discards it; and the check that raises this error —
    /// `verify` in `src/schema/migrations.rs`, which is private, hence the file
    /// reference rather than a link — reads committed state, so it cannot see an
    /// in-flight session. Reaching this
    /// error therefore means something wrote the table outside the write actor
    /// — the raw-writer case §4.7 concedes exists.
    ///
    /// Not a [`Migration`] error: the schema is intact. What is wrong is the
    /// database's *contents*, and saying "your schema is wrong" would send the
    /// reader to the migration ladder for a fault a `DROP TABLE` fixes.
    ///
    /// [Doctrine IV]: ../../docs/architecture/s0-s3-foundations.md#doctrine-iv
    /// [Doctrine V]: ../../docs/architecture/s0-s3-foundations.md#doctrine-v
    /// [`ArchiveViolation`]: DbError::ArchiveViolation
    /// [`Migration`]: DbError::Migration
    #[error(
        "the archive-session marker table {marker:?} is present as committed \
         state. While it exists, the delete guards on concepts, links and \
         transaction_log are disarmed and concept inserts write no \
         transaction_log row. An archive session creates and drops this table \
         inside one transaction, so it should never be visible here — \
         something wrote it outside the write actor. Drop it (DROP TABLE \
         {marker}) and audit for deletions and missing log rows since it \
         appeared"
    )]
    ArchiveSessionLeaked { marker: String },

    /// A traversal asked about the past without saying which text it wanted
    /// (T3.2, D-085).
    ///
    /// `TraversalBuilder::as_of(ts)` fixes the *topology* at `ts`. Node
    /// attributes are a second, independent question, and the default answer —
    /// `AttributeMode::Current` — is live text. That combination returns the
    /// past's graph wearing the present's titles, which is a legitimate thing to
    /// want and a terrible thing to get by accident.
    ///
    /// It used to be a `tracing::warn!`, which is invisible in any application
    /// that has not configured a subscriber. This is the same statement as a
    /// value the caller cannot miss.
    ///
    /// Fix by stating the mode: `.attribute_mode(AttributeMode::AtTime)` for the
    /// past's text, or `.attribute_mode(AttributeMode::Current)` to affirm that
    /// live text is what was meant.
    #[error(
        "traversal as_of({as_of}) did not state an attribute mode: topology at \
         {as_of} would be returned with attributes as they are *now*. Call \
         .attribute_mode(AttributeMode::AtTime) for attributes as believed at \
         {as_of}, or .attribute_mode(AttributeMode::Current) to confirm live \
         attributes are intended"
    )]
    AttributeModeUnstated { as_of: String },
    /// [`crate::Database::diagnostic_conn`] could not open the file read-only
    /// (T5.1, D-091).
    ///
    /// Its own variant rather than `NotFound`, which renders "node {0} not
    /// found" — naming the wrong subject is the defect [D-069] was written to
    /// correct, and a file is not a node.
    ///
    /// The case worth the sentence is a missing file:
    /// `SQLITE_OPEN_READ_ONLY` drops `SQLITE_OPEN_CREATE` with it, so a path
    /// that does not exist is `SQLITE_CANTOPEN` rather than a fresh empty
    /// database. That is the right behaviour and an opaque error to receive.
    ///
    /// [D-069]: ../../docs/architecture/s13-decision-register.md#d-069
    #[error("cannot open {path} read-only for diagnostics: {reason}")]
    DiagnosticConn { path: String, reason: String },
    /// [`crate::Database::archive_windowed`] was given a window it cannot use
    /// (T1.1, D-080).
    ///
    /// Carries a `reason` rather than the numbers as fields because the two
    /// cases it covers are not the same shape — a zero-length window never
    /// advances at all, while a merely narrow one produces a session count that
    /// has to be quoted against the limit to mean anything. A caller reading
    /// this needs the sentence, not the struct.
    ///
    /// It is an error rather than a silent clamp on purpose. Rounding a
    /// one-second window up to something workable would archive over boundaries
    /// the caller did not choose, and the caller cannot see that it happened.
    #[error("archive window {window:?} is unusable: {reason}")]
    ArchiveWindow {
        window: std::time::Duration,
        reason: String,
    },

    /// A timestamp that is not in canonical form (§4.1, D-029).
    ///
    /// **Distinct from [`Self::ReplayCorrupt`], which is what this used to be
    /// (Wave 4.5).** `timestamp::normalize` and `timestamp::parse` reported bad
    /// *caller input* as `ReplayCorrupt { seq: 0 }` — a claim that the ledger is
    /// damaged, carrying a sequence number that cannot exist because
    /// `AUTOINCREMENT` starts at 1. The same mistake as defect J: an error that
    /// names the wrong subject sends a caller to fix the wrong thing.
    ///
    /// The value is reported rather than the provenance, because one function
    /// serves both directions — a caller passing `2026-01-01T00:00:00Z` and a
    /// stored `recorded_at` that will not parse produce the same complaint about
    /// the same string. `SystemClock::new` is where the second case is
    /// interpreted, and it already logs and floors to the wall clock (D-027).
    #[error("timestamp {value:?} is not canonical: {reason}")]
    InvalidTimestamp { value: String, reason: String },

    /// An identifier the crate's own encodings cannot represent (D-061).
    ///
    /// Distinct from [`Self::NotFound`], and the distinction is defect J: this
    /// id was refused, not looked up. `validate_id` used to return `NotFound`
    /// here, which tells a caller the thing is missing and invites them to
    /// create it — with the same id, which will be refused again.
    #[error("invalid identifier {id:?}: {reason}")]
    InvalidId { id: String, reason: String },

    /// Two valid-time intervals for one relationship claim the same instant.
    ///
    /// Distinct from [`Self::SingleOpenViolation`], which is the storage layer's
    /// guard and covers only the *open* sentinel. This is the general case, and
    /// it is refused at the API rather than by a trigger (D-060): raw SQL against
    /// the same file can still write an overlap, and §4.2 says so.
    ///
    /// The consequence of allowing one is not an error later but a wrong answer:
    /// `query_as_of_edges` at an instant inside both returns the relationship
    /// twice, and every weighted algorithm downstream double-counts that edge.
    ///
    /// **Boxed, and it is the only variant that is (D-075).** Seven `String`s is
    /// 168 bytes, which made `DbError` — and therefore every `Result` in the
    /// crate, on the `Ok` path too — larger than `clippy::result_large_err`'s
    /// threshold the moment D-060 added it. The other variants are well under.
    /// Boxing the rarest one keeps the whole error small rather than trimming
    /// what a caller is told; `matches!(err, OverlappingInterval { .. })` is
    /// unaffected, which is how every call site uses it.
    #[error(
        "edge {} -> {} ({}) already holds [{}, {}), which overlaps the asserted [{}, {})",
        .overlap.source_id, .overlap.target_id, .overlap.edge_type,
        .overlap.existing_from, .overlap.existing_to,
        .overlap.valid_from, .overlap.valid_to
    )]
    OverlappingInterval { overlap: Box<Overlap> },

    #[error("links_current drift detected: {n} intervals diverge")]
    CurrentDrift { n: usize },

    #[error("rebuild verification failed: {n} intervals still diverge")]
    RebuildFailed { n: usize },
    /// A chunked shadow rebuild was abandoned rather than committed (T1.2, D-082).
    ///
    /// Distinct from [`Self::RebuildFailed`], and the distinction is the whole
    /// point: `RebuildFailed` means the repair ran and did not repair, which is
    /// a reason to distrust the ledger. This means the repair **did not run** —
    /// something invalidated the work in progress and it was discarded before it
    /// could be swapped in. `links_current` is untouched and whatever was true
    /// of it before is still true. The action is to retry.
    #[error("chunked rebuild abandoned: {reason}")]
    RebuildInterrupted { reason: String },

    // -- 0.4.5: writer-actor containment --
    #[error("write actor is not running (reopen the Database)")]
    WriterUnavailable,

    #[error("write actor dropped the response channel mid-request")]
    WriterDroppedResponder,

    /// The actor's task did not join cleanly at [`crate::Database::close`].
    ///
    /// Distinct from [`Self::WriterUnavailable`], which means the channel is
    /// gone while the handle is still in use. This is the shutdown path telling
    /// a caller that the write actor panicked — which `close()` used to swallow,
    /// so a database whose write path had died closed "successfully" (Wave 4.2).
    #[error("write actor did not shut down cleanly: {0}")]
    WriterStopped(String),

    // -- 0.5.0: concept integrity --
    #[error("recorded_at must advance on concept update (got {got}, had {had})")]
    RecordedAtRegression { got: String, had: String },
}

pub type Result<T> = std::result::Result<T, DbError>;

/// A guard abort recognised by its message (§4.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbortKind {
    SingleOpenInterval,
    RecordedAtRegression,
    DeleteOutsideArchive,
    /// Not one of our guards — an ordinary engine error.
    NotAGuard,
}

/// Recognise a schema guard's `RAISE(ABORT, …)` by its message.
///
/// **The only place in the crate that matches on engine error text.** SQLite
/// reports a `RAISE(ABORT)` as a generic constraint failure carrying the
/// message, so the message is the only thing distinguishing "you violated the
/// single-open-interval rule" from "the disk is full" — but matching on it
/// scattered across call sites means an upstream wording change degrades an
/// unknown number of typed errors into opaque ones, silently. Concentrated here,
/// a change breaks one function and the tests that cover it.
///
/// The needles are the [`crate::schema::ddl`] constants spliced into the
/// triggers themselves, so guard and classifier cannot drift.
pub fn abort_kind(err: &libsql::Error) -> AbortKind {
    use crate::schema::ddl::{ABORT_DELETE_GUARD, ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN};

    let text = err.to_string();
    if text.contains(ABORT_SINGLE_OPEN) {
        AbortKind::SingleOpenInterval
    } else if text.contains(ABORT_MONOTONIC_RA) {
        AbortKind::RecordedAtRegression
    } else if text.contains(ABORT_DELETE_GUARD) {
        AbortKind::DeleteOutsideArchive
    } else {
        AbortKind::NotAGuard
    }
}

/// What a failing statement was trying to do, so a guard abort can name it.
pub enum WriteOp<'a> {
    Edge {
        source_id: &'a str,
        target_id: &'a str,
        edge_type: &'a str,
    },
    Concept {
        id: &'a str,
        recorded_at: &'a str,
    },
    Delete {
        table: &'a str,
    },
}

/// Turn an engine error into the typed error §7 specifies, where one applies.
///
/// Takes a connection because `RecordedAtRegression` reports the value it
/// clashed with, and the trigger does not put it in the message. One extra query
/// on an error path buys an error a caller can act on instead of one they have
/// to reproduce by hand.
pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
    match (abort_kind(&err), op) {
        (
            AbortKind::SingleOpenInterval,
            WriteOp::Edge {
                source_id,
                target_id,
                edge_type,
            },
        ) => DbError::SingleOpenViolation {
            source_id: source_id.to_string(),
            target_id: target_id.to_string(),
            edge_type: edge_type.to_string(),
        },
        (AbortKind::RecordedAtRegression, WriteOp::Concept { id, recorded_at }) => {
            let had = current_recorded_at(conn, id).await.unwrap_or_default();
            DbError::RecordedAtRegression {
                got: recorded_at.to_string(),
                had,
            }
        }
        (AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
            table: table.to_string(),
        },
        // A guard fired for an operation it does not describe. Reporting the raw
        // error is honest; inventing a typed one from the wrong context is not.
        _ => DbError::Engine(err),
    }
}

async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
    conn.query(
        "SELECT recorded_at FROM concepts WHERE id = ?1",
        libsql::params![id],
    )
    .await
    .ok()?
    .next()
    .await
    .ok()??
    .get(0)
    .ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `DbError` stays under `clippy::result_large_err`'s 128-byte threshold.
    ///
    /// Every fallible function in the crate returns `Result<T, DbError>`, so the
    /// enum's size is paid on the `Ok` path too. D-060 pushed it to 168 bytes with
    /// one seven-`String` variant and nobody noticed until D-075 read the lint
    /// output; boxing that variant brought it back. This is the tripwire, because
    /// the failure mode is a warning in a build log rather than a broken test —
    /// the kind this cycle has spent its whole length finding.
    #[test]
    fn the_error_enum_stays_small_enough_to_return_by_value() {
        let size = std::mem::size_of::<DbError>();
        assert!(
            size <= 128,
            "DbError is {size} bytes. Some variant has grown past what a Result \n             should carry — box it, as OverlappingInterval is boxed (D-075)."
        );
    }

    /// The boxed variant still reports both intervals.
    #[test]
    fn an_overlap_names_the_asserted_interval_and_the_stored_one() {
        let err = DbError::OverlappingInterval {
            overlap: Box::new(Overlap {
                source_id: "a".into(),
                target_id: "b".into(),
                edge_type: "KNOWS".into(),
                valid_from: "2026-03-01T00:00:00.000000Z".into(),
                valid_to: "2026-09-01T00:00:00.000000Z".into(),
                existing_from: "2026-01-01T00:00:00.000000Z".into(),
                existing_to: "2026-06-01T00:00:00.000000Z".into(),
            }),
        };
        let msg = err.to_string();
        assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
        assert!(msg.contains("already holds [2026-01-01"), "{msg}");
        assert!(msg.contains("asserted [2026-03-01"), "{msg}");
    }
}