gemel 0.11.1

Evidence-native version control for agentic software development: canonical object encoding, content-addressed identity, immutable object store, change workflow, Git-carried exchange rollups, network transports, and CLI.
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
429
430
431
432
433
434
435
436
437
438
439
//! The disposable derived index (STORAGE.md ยง5).
//!
//! A SQLite database (`index/gemel.db`, WAL mode) accelerates queries. It is
//! **never** the source of truth: canonical objects + refs are. Corruption is
//! repaired by rebuild, never by history loss. The index schema version is
//! recorded in `meta`; a stale index is flagged and rebuilt by
//! `fsck --rebuild-index`.

use crate::decode::decode_object;
use crate::gid::Gid;
use crate::hash::gid_from_envelope;
use crate::store::objects;
use crate::store::refs;
use crate::store::{Error, Repo};
use crate::value::{Body, Object, Value};
use rusqlite::{params, Connection, OptionalExtension};
use std::path::{Path, PathBuf};

/// The derived-index schema version.
pub const INDEX_SCHEMA_VERSION: i64 = 1;

const META_SCHEMA_KEY: &str = "schema_version";
const META_STALE_KEY: &str = "stale";

/// The on-disk index database path.
pub fn db_path(meta: &Path) -> PathBuf {
    meta.join("index").join("gemel.db")
}

fn open_conn(repo: &Repo) -> Result<Connection, Error> {
    let path = db_path(repo.meta_dir());
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let conn = Connection::open(&path).map_err(|e| Error::Index(e.to_string()))?;
    conn.pragma_update(None, "journal_mode", "WAL")
        .map_err(|e| Error::Index(e.to_string()))?;
    conn.pragma_update(None, "busy_timeout", 5000)
        .map_err(|e| Error::Index(e.to_string()))?;
    ensure_schema(&conn)?;
    // Version check: a freshly created database records the schema and starts
    // clean; a pre-existing database with an older/missing schema version is
    // flagged stale (same connection; `fsck --rebuild-index` restores). The
    // stored version is TEXT (exact integer projection), so read it as a
    // string and parse.
    let stored_text: Option<String> = conn
        .query_row(
            "SELECT value FROM meta WHERE key = ?1",
            params![META_SCHEMA_KEY],
            |row| row.get(0),
        )
        .optional()
        .map_err(|e| Error::Index(e.to_string()))?;
    let stored = stored_text.as_deref().and_then(|s| s.parse::<i64>().ok());
    match stored {
        None => {
            conn.execute(
                "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
                params![META_SCHEMA_KEY, INDEX_SCHEMA_VERSION.to_string()],
            )
            .map_err(|e| Error::Index(e.to_string()))?;
            conn.execute(
                "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
                params![META_STALE_KEY, "0"],
            )
            .map_err(|e| Error::Index(e.to_string()))?;
        }
        Some(v) if v != INDEX_SCHEMA_VERSION => {
            conn.execute(
                "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
                params![META_STALE_KEY, "1"],
            )
            .map_err(|e| Error::Index(e.to_string()))?;
        }
        Some(_) => {}
    }
    Ok(conn)
}

fn ensure_schema(conn: &Connection) -> Result<(), Error> {
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS objects(
             id TEXT PRIMARY KEY,
             family INTEGER,
             schemever INTEGER,
             size INTEGER
         );
         CREATE TABLE IF NOT EXISTS edges(
             from_id TEXT,
             to_id TEXT,
             kind TEXT,
             ordinal INTEGER
         );
         CREATE INDEX IF NOT EXISTS edges_from ON edges(from_id);
         CREATE INDEX IF NOT EXISTS edges_to ON edges(to_id);
         CREATE TABLE IF NOT EXISTS refs(
             name TEXT PRIMARY KEY,
             gid TEXT
         );
         CREATE TABLE IF NOT EXISTS subjects(
             gid TEXT,
             subject TEXT,
             kind TEXT
         );
         CREATE TABLE IF NOT EXISTS claim_index(
             claim TEXT,
             subject TEXT
         );
         CREATE TABLE IF NOT EXISTS meta(
             key TEXT PRIMARY KEY,
             value TEXT
         );",
    )
    .map_err(|e| Error::Index(e.to_string()))?;
    Ok(())
}

/// Best-effort index update after an object insert (failures mark stale).
pub fn note_insert(repo: &Repo, obj: &Object, id: Gid, size: u64) -> Result<(), Error> {
    let conn = open_conn(repo)?;
    conn.execute(
        "INSERT OR IGNORE INTO objects(id, family, schemever, size) VALUES (?1, ?2, ?3, ?4)",
        params![
            id.to_string(),
            obj.family.code() as i64,
            obj.schemever as i64,
            size as i64
        ],
    )
    .map_err(|e| Error::Index(e.to_string()))?;
    conn.execute(
        "DELETE FROM edges WHERE from_id = ?1",
        params![id.to_string()],
    )
    .map_err(|e| Error::Index(e.to_string()))?;
    for (kind, to, ordinal) in edges_of(obj) {
        conn.execute(
            "INSERT INTO edges(from_id, to_id, kind, ordinal) VALUES (?1, ?2, ?3, ?4)",
            params![id.to_string(), to.to_string(), kind, ordinal as i64],
        )
        .map_err(|e| Error::Index(e.to_string()))?;
    }
    Ok(())
}

/// Best-effort index update after a ref transaction.
pub fn note_refs(repo: &Repo, txn: &refs::RefTransaction) -> Result<(), Error> {
    let conn = open_conn(repo)?;
    for op in &txn.ops {
        match op.new {
            Some(gid) => {
                conn.execute(
                    "INSERT OR REPLACE INTO refs(name, gid) VALUES (?1, ?2)",
                    params![op.name, gid.to_string()],
                )
                .map_err(|e| Error::Index(e.to_string()))?;
            }
            None => {
                conn.execute("DELETE FROM refs WHERE name = ?1", params![op.name])
                    .map_err(|e| Error::Index(e.to_string()))?;
            }
        }
    }
    Ok(())
}

/// Marks the index stale (best-effort; never fails the caller).
pub fn mark_stale(repo: &Repo) {
    let _ = (|| -> Result<(), Error> {
        let conn = open_conn(repo)?;
        conn.execute(
            "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
            params![META_STALE_KEY, "1"],
        )
        .map_err(|e| Error::Index(e.to_string()))?;
        Ok(())
    })();
}

/// Opens a connection for read-only queries (index may be stale; callers
/// should treat results as derived acceleration).
pub fn open_for_query(repo: &Repo) -> Result<Connection, Error> {
    open_conn(repo)
}

/// Whether the index is flagged stale.
pub fn is_stale(repo: &Repo) -> Result<bool, Error> {
    let conn = open_conn(repo)?;
    let v: Option<String> = conn
        .query_row(
            "SELECT value FROM meta WHERE key = ?1",
            params![META_STALE_KEY],
            |row| row.get(0),
        )
        .optional()
        .map_err(|e| Error::Index(e.to_string()))?;
    Ok(v.as_deref() == Some("1"))
}

/// Whether the index is safe to consult as an accelerator: not flagged
/// stale, and with object/ref counts matching the canonical store. The
/// index must never change semantic answers โ€” it only accelerates them
/// (INVARIANTS DER-01). A deleted-then-recreated index fails the count
/// parity check and forces the canonical slow path.
pub fn is_fresh(repo: &Repo) -> bool {
    if is_stale(repo).unwrap_or(true) {
        return false;
    }
    let indexed = match open_for_query(repo) {
        Ok(conn) => {
            let objects: rusqlite::Result<i64> =
                conn.query_row("SELECT COUNT(*) FROM objects", [], |row| row.get(0));
            let refs: rusqlite::Result<i64> =
                conn.query_row("SELECT COUNT(*) FROM refs", [], |row| row.get(0));
            (objects.ok(), refs.ok())
        }
        Err(_) => return false,
    };
    let disk_objects = objects::scan(repo.meta_dir()).map(|v| v.len()).unwrap_or(0) as i64;
    let disk_refs = refs::all(repo.meta_dir()).map(|v| v.len()).unwrap_or(0) as i64;
    match indexed {
        (Some(o), Some(r)) => o == disk_objects && r == disk_refs,
        _ => false,
    }
}

/// A canonical object scan: every object on disk, decoded, in deterministic
/// order (shard, then file name). Corrupt or unreadable objects are skipped
/// (fsck reports them; a derived query must not fail on them โ€” it answers
/// from what it can verify).
pub fn scan_canonical(repo: &Repo) -> Vec<(Gid, crate::value::Object)> {
    let limits = repo.limits();
    let mut out = Vec::new();
    let mut paths = match objects::scan(repo.meta_dir()) {
        Ok(p) => p,
        Err(_) => return out,
    };
    paths.sort();
    for path in paths {
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let obj = match decode_object(&bytes, &limits) {
            Ok(o) => o,
            Err(_) => continue,
        };
        let id = match gid_from_envelope(&bytes) {
            Some(id) => id,
            None => continue,
        };
        if id.digest() != &crate::hash::object_id_bytes(&bytes) {
            continue;
        }
        out.push((id, obj));
    }
    out.sort_by_key(|a| a.0.to_string());
    out
}

/// The ref mirror from the index.
pub fn refs_mirror(repo: &Repo) -> Result<Vec<(String, Gid)>, Error> {
    let conn = open_conn(repo)?;
    let mut stmt = conn
        .prepare("SELECT name, gid FROM refs ORDER BY name")
        .map_err(|e| Error::Index(e.to_string()))?;
    let rows = stmt
        .query_map([], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })
        .map_err(|e| Error::Index(e.to_string()))?;
    let mut out = Vec::new();
    for r in rows {
        let (name, gid_text) = r.map_err(|e| Error::Index(e.to_string()))?;
        let gid = gid_text
            .parse::<Gid>()
            .map_err(|e| Error::Index(e.to_string()))?;
        out.push((name, gid));
    }
    Ok(out)
}

/// Indexed objects as (id text, family code).
pub fn indexed_objects(repo: &Repo) -> Result<Vec<(String, i64)>, Error> {
    let conn = open_conn(repo)?;
    let mut stmt = conn
        .prepare("SELECT id, family FROM objects ORDER BY id")
        .map_err(|e| Error::Index(e.to_string()))?;
    let rows = stmt
        .query_map([], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
        })
        .map_err(|e| Error::Index(e.to_string()))?;
    let mut out = Vec::new();
    for r in rows {
        out.push(r.map_err(|e| Error::Index(e.to_string()))?);
    }
    Ok(out)
}

/// Extracts the graph edges of an object: (kind, target, ordinal) per GID
/// value, using schema field names as kinds (OBJECT_MODEL.md ยง7.1).
pub fn edges_of(obj: &Object) -> Vec<(String, Gid, usize)> {
    let mut out = Vec::new();
    let schema = crate::spec::schema_for(obj.family);
    if let Body::Fields(fields) = &obj.body {
        for field in fields {
            let kind = schema.field(field.tag).map(|s| s.name).unwrap_or("?");
            collect_edges(&field.value, kind, &mut out);
        }
    }
    out
}

fn collect_edges(value: &Value, kind: &str, out: &mut Vec<(String, Gid, usize)>) {
    match value {
        Value::Gid(g) => out.push((kind.to_string(), *g, 0)),
        Value::Array(items) => {
            for (i, item) in items.iter().enumerate() {
                match item {
                    Value::Gid(g) => out.push((kind.to_string(), *g, i)),
                    Value::Record(fields) => {
                        for f in fields {
                            collect_edges(&f.value, kind, out);
                        }
                    }
                    other => collect_edges(other, kind, out),
                }
            }
        }
        Value::Record(fields) => {
            for f in fields {
                collect_edges(&f.value, kind, out);
            }
        }
        _ => {}
    }
}

/// Rebuilds the index from canonical objects and refs (caller holds the
/// writer lock). Writes to a fresh database and renames it into place.
pub fn rebuild(repo: &Repo) -> Result<(), Error> {
    let target = db_path(repo.meta_dir());
    let tmp = target.with_extension("db.rebuild");
    let _ = std::fs::remove_file(&tmp);

    let conn = Connection::open(&tmp).map_err(|e| Error::Index(e.to_string()))?;
    conn.pragma_update(None, "journal_mode", "OFF")
        .map_err(|e| Error::Index(e.to_string()))?;
    ensure_schema(&conn)?;

    let limits = repo.limits();
    let mut indexed = 0usize;
    for path in objects::scan(repo.meta_dir())? {
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let obj = match decode_object(&bytes, &limits) {
            Ok(o) => o,
            Err(_) => continue,
        };
        let id = match gid_from_envelope(&bytes) {
            Some(id) => id,
            None => continue,
        };
        if id.digest() != &crate::hash::object_id_bytes(&bytes) {
            continue;
        }
        conn.execute(
            "INSERT OR IGNORE INTO objects(id, family, schemever, size) VALUES (?1, ?2, ?3, ?4)",
            params![
                id.to_string(),
                obj.family.code() as i64,
                obj.schemever as i64,
                bytes.len() as i64
            ],
        )
        .map_err(|e| Error::Index(e.to_string()))?;
        for (kind, to, ordinal) in edges_of(&obj) {
            conn.execute(
                "INSERT INTO edges(from_id, to_id, kind, ordinal) VALUES (?1, ?2, ?3, ?4)",
                params![id.to_string(), to.to_string(), kind, ordinal as i64],
            )
            .map_err(|e| Error::Index(e.to_string()))?;
        }
        indexed += 1;
    }

    for (name, gid) in refs::all(repo.meta_dir())? {
        conn.execute(
            "INSERT OR REPLACE INTO refs(name, gid) VALUES (?1, ?2)",
            params![name, gid.to_string()],
        )
        .map_err(|e| Error::Index(e.to_string()))?;
    }

    conn.execute(
        "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
        params![META_SCHEMA_KEY, INDEX_SCHEMA_VERSION.to_string()],
    )
    .map_err(|e| Error::Index(e.to_string()))?;
    conn.execute(
        "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
        params![META_STALE_KEY, "0"],
    )
    .map_err(|e| Error::Index(e.to_string()))?;
    drop(conn);

    // Publish the rebuilt database atomically. The previous database and its
    // WAL sidecars must be removed first: a leftover `-wal`/`-shm` from an
    // earlier incarnation would be recovered into the fresh file (SQLite
    // validates the WAL against the database header and would reject it,
    // leaving the index unreadable).
    let _ = std::fs::remove_file(&target);
    for ext in ["-wal", "-shm"] {
        let side = format!("{}{}", target.display(), ext);
        let _ = std::fs::remove_file(&side);
    }
    std::fs::rename(&tmp, &target).map_err(|e| Error::Index(e.to_string()))?;
    let _ = indexed;
    Ok(())
}

/// Removes the index database entirely (used by fsck --repair fallback).
pub fn remove(repo: &Repo) -> Result<(), Error> {
    let path = db_path(repo.meta_dir());
    match std::fs::remove_file(&path) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => return Err(e.into()),
    }
    // WAL sidecars.
    for ext in ["-wal", "-shm"] {
        let side = format!("{}{}", path.display(), ext);
        let _ = std::fs::remove_file(&side);
    }
    Ok(())
}