fabrica

hanna/fabrica

feat(store): scaffold git-LFS object persistence

8e8a081 · hanna committed on 2026-07-26

Add the storage.lfs_dir config directory and the lfs_objects table
(migration 0013) tracking which sha256-addressed objects each repo has
stored, with store queries: lfs_object_exists / _size / _add / repo_bytes.
The object bytes themselves land on disk under lfs_dir in a later change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +113 −0UnifiedSplit
crates/config/src/lib.rs +1 −0
@@ -183,6 +183,7 @@ fn validate(config: &Config) -> Result<Vec<String>, ConfigError> {
183183 // Storage directories must exist or be creatable.
184184 check_dir_creatable("storage.data_dir", &config.storage.data_dir, &mut errors);
185185 check_dir_creatable("storage.repo_dir", &config.storage.repo_dir, &mut errors);
186+ check_dir_creatable("storage.lfs_dir", &config.storage.lfs_dir, &mut errors);
186187
187188 // SMTP delivery needs a host and a from address.
188189 if config.mail.backend == MailBackend::Smtp {
crates/config/src/types.rs +3 −0
@@ -278,6 +278,8 @@ pub struct Storage {
278278 pub data_dir: PathBuf,
279279 /// Directory holding the bare repositories, sharded by id prefix.
280280 pub repo_dir: PathBuf,
281+ /// Directory holding git-LFS objects, content-addressed by their sha256 oid.
282+ pub lfs_dir: PathBuf,
281283 }
282284
283285 impl Default for Storage {
@@ -285,6 +287,7 @@ impl Default for Storage {
285287 Self {
286288 data_dir: PathBuf::from("/var/lib/fabrica"),
287289 repo_dir: PathBuf::from("/var/lib/fabrica/repos"),
290+ lfs_dir: PathBuf::from("/var/lib/fabrica/lfs"),
288291 }
289292 }
290293 }
crates/store/src/lfs.rs +87 −0
@@ -0,0 +1,87 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Git-LFS object bookkeeping: which content-addressed objects (by sha256 oid)
6+//! each repository has stored. The bytes themselves live on disk under
7+//! `storage.lfs_dir`; this table answers "does this repo have this object?" for
8+//! the batch API and access control.
9+
10+use sqlx::Row;
11+
12+use crate::{Store, StoreError, new_id, now_ms};
13+
14+impl Store {
15+ /// Whether `repo_id` has an LFS object with this `oid` recorded.
16+ ///
17+ /// # Errors
18+ ///
19+ /// Returns [`StoreError::Query`] on a database failure.
20+ pub async fn lfs_object_exists(&self, repo_id: &str, oid: &str) -> Result<bool, StoreError> {
21+ let row = sqlx::query("SELECT 1 AS n FROM lfs_objects WHERE repo_id = $1 AND oid = $2")
22+ .bind(repo_id)
23+ .bind(oid)
24+ .fetch_optional(&self.pool)
25+ .await?;
26+ Ok(row.is_some())
27+ }
28+
29+ /// The stored size (bytes) of an LFS object in `repo_id`, if present.
30+ ///
31+ /// # Errors
32+ ///
33+ /// Returns [`StoreError::Query`] on a database failure.
34+ pub async fn lfs_object_size(
35+ &self,
36+ repo_id: &str,
37+ oid: &str,
38+ ) -> Result<Option<i64>, StoreError> {
39+ let row = sqlx::query("SELECT size AS n FROM lfs_objects WHERE repo_id = $1 AND oid = $2")
40+ .bind(repo_id)
41+ .bind(oid)
42+ .fetch_optional(&self.pool)
43+ .await?;
44+ Ok(row.map(|r| r.try_get("n")).transpose()?)
45+ }
46+
47+ /// Record that `repo_id` holds an LFS object (`oid`, `size`). Idempotent: a
48+ /// repeat upload of the same object is a no-op.
49+ ///
50+ /// # Errors
51+ ///
52+ /// Returns [`StoreError::Query`] on a database failure.
53+ pub async fn lfs_object_add(
54+ &self,
55+ repo_id: &str,
56+ oid: &str,
57+ size: i64,
58+ ) -> Result<(), StoreError> {
59+ sqlx::query(
60+ "INSERT INTO lfs_objects (id, repo_id, oid, size, created_at) \
61+ VALUES ($1, $2, $3, $4, $5) ON CONFLICT (repo_id, oid) DO NOTHING",
62+ )
63+ .bind(new_id())
64+ .bind(repo_id)
65+ .bind(oid)
66+ .bind(size)
67+ .bind(now_ms())
68+ .execute(&self.pool)
69+ .await?;
70+ Ok(())
71+ }
72+
73+ /// The total bytes of LFS objects tracked for `repo_id`.
74+ ///
75+ /// # Errors
76+ ///
77+ /// Returns [`StoreError::Query`] on a database failure.
78+ pub async fn lfs_repo_bytes(&self, repo_id: &str) -> Result<i64, StoreError> {
79+ let n: i64 =
80+ sqlx::query("SELECT COALESCE(SUM(size), 0) AS n FROM lfs_objects WHERE repo_id = $1")
81+ .bind(repo_id)
82+ .fetch_one(&self.pool)
83+ .await?
84+ .try_get("n")?;
85+ Ok(n)
86+ }
87+}
crates/store/src/lib.rs +1 −0
@@ -31,6 +31,7 @@ mod invites;
3131 mod issues;
3232 mod keys;
3333 mod labels;
34+mod lfs;
3435 mod repos;
3536 mod sessions;
3637 mod tokens;
fabrica.example.toml +1 −0
@@ -41,6 +41,7 @@ clone_port = 22
4141 [storage]
4242 data_dir = "/var/lib/fabrica" # secrets, host key, SQLite db, scratch
4343 repo_dir = "/var/lib/fabrica/repos" # bare repos, sharded by id prefix
44+lfs_dir = "/var/lib/fabrica/lfs" # git-LFS objects, content-addressed by sha256
4445
4546 [database]
4647 url = "sqlite:///var/lib/fabrica/fabrica.db"
migrations/postgres/0013_lfs.sql +10 −0
@@ -0,0 +1,10 @@
1+-- Git LFS: track which content-addressed objects (by sha256 oid) each repo has
2+-- stored. The bytes live on disk under storage.lfs_dir, sharded by oid prefix.
3+CREATE TABLE lfs_objects (
4+ id TEXT PRIMARY KEY,
5+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6+ oid TEXT NOT NULL,
7+ size BIGINT NOT NULL,
8+ created_at BIGINT NOT NULL
9+);
10+CREATE UNIQUE INDEX idx_lfs_objects_repo_oid ON lfs_objects (repo_id, oid);
migrations/sqlite/0013_lfs.sql +10 −0
@@ -0,0 +1,10 @@
1+-- Git LFS: track which content-addressed objects (by sha256 oid) each repo has
2+-- stored. The bytes live on disk under storage.lfs_dir, sharded by oid prefix.
3+CREATE TABLE lfs_objects (
4+ id TEXT PRIMARY KEY,
5+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6+ oid TEXT NOT NULL,
7+ size BIGINT NOT NULL,
8+ created_at BIGINT NOT NULL
9+);
10+CREATE UNIQUE INDEX idx_lfs_objects_repo_oid ON lfs_objects (repo_id, oid);