Brainstorm initial API and modify db to be more FUSE friendly

This commit is contained in:
Andrew Mulbrook 2026-03-16 01:14:07 -05:00
parent 0a444a55c4
commit 96c9e62354
3 changed files with 183 additions and 105 deletions

View file

@ -3,58 +3,49 @@ Python API for accessing underlying SQlite3 File Database
A Sqlite3 database provides the basic underlying interface to the underlying A Sqlite3 database provides the basic underlying interface to the underlying
notes interface. The low level database is an extremely simple model using notes interface. The low level database is an extremely simple model using
similar ideas to get storage. However, UUID are utilized for persistent object similar ideas to git storage.
references.
""" """
import argparse import argparse
import sqlite3 import sqlite3
import sys import sys
import uuid as _uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .trove import NODE_ROOT_ID
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS objects ( CREATE TABLE IF NOT EXISTS objects (
uuid TEXT PRIMARY KEY, id INTEGER PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('blob', 'tree')), type TEXT NOT NULL CHECK(type IN ('blob', 'tree')),
data BLOB, data BLOB,
created TEXT NOT NULL,
modified TEXT NOT NULL modified TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS metadata ( CREATE TABLE IF NOT EXISTS metadata (
uuid TEXT NOT NULL REFERENCES objects(uuid) ON DELETE CASCADE, id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
key TEXT NOT NULL, key TEXT NOT NULL,
value BLOB, value BLOB,
PRIMARY KEY (uuid, key) PRIMARY KEY (id, key)
); );
CREATE TABLE IF NOT EXISTS labels CREATE TABLE IF NOT EXISTS labels
( (
label label TEXT PRIMARY KEY,
TEXT id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE
PRIMARY );
KEY, """
uuid
TEXT
NOT
NULL
REFERENCES
objects
(
uuid
) ON DELETE CASCADE
); \
"""
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
class TroveDB: def _initialize_db(con: sqlite3.Connection):
con.executescript(_SCHEMA)
con.commit()
class Sqlite3Trove:
def __init__(self, con: sqlite3.Connection): def __init__(self, con: sqlite3.Connection):
self._con = con self._con = con
self._con.execute("PRAGMA foreign_keys = ON") self._con.execute("PRAGMA foreign_keys = ON")
@ -65,24 +56,22 @@ class TroveDB:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@classmethod @classmethod
def init(cls, path: str | Path) -> "TroveDB": def open(cls, path: str | Path, create: bool = False) -> "Sqlite3Trove":
"""Create a new Trove database at path. Fails if file already exists."""
p = Path(path)
if p.exists():
raise FileExistsError(f"Database already exists: {p}")
con = sqlite3.connect(str(p))
con.executescript(_SCHEMA)
con.commit()
return cls(con)
@classmethod
def open(cls, path: str | Path) -> "TroveDB":
"""Open an existing Trove database.""" """Open an existing Trove database."""
p = Path(path) p = Path(path)
initialize = False
if not p.exists(): if not p.exists():
raise FileNotFoundError(f"Database not found: {p}") if not create:
raise FileNotFoundError(f"Database not found: {p}")
initialize = True
con = sqlite3.connect(str(p)) con = sqlite3.connect(str(p))
return cls(con) if initialize:
con.executescript(_SCHEMA)
con.commit()
obj = cls(con)
if initialize:
obj.write_blob(b"", NODE_ROOT_ID)
return obj
def close(self): def close(self):
self._con.close() self._con.close()
@ -97,77 +86,84 @@ class TroveDB:
# CRUD operations # CRUD operations
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def read_blob(self, uuid: str) -> bytes | None: def read_object(self, object_id: int) -> bytes | None:
"""Return raw data for a blob object, or None if not found.""" """Return raw data for a blob object, or None if not found."""
row = self._con.execute( row = self._con.execute(
"SELECT data, type FROM objects WHERE uuid = ?", (uuid,) "SELECT data, type FROM objects WHERE id = ?", (object_id,)
).fetchone() ).fetchone()
if row is None: if row is None:
return None return None
if row["type"] != "blob":
raise TypeError(f"Object {uuid} is type '{row['type']}', not 'blob'")
return bytes(row["data"]) if row["data"] is not None else b"" return bytes(row["data"]) if row["data"] is not None else b""
def read_metadata(self, uuid: str, key: str) -> bytes | None: def read_metadata(self, object_id: int, key: str) -> bytes | None:
"""Return raw metadata value for (uuid, key), or None if not found.""" """Return raw metadata value for (uuid, key), or None if not found."""
row = self._con.execute( row = self._con.execute(
"SELECT value FROM metadata WHERE uuid = ? AND key = ?", (uuid, key) "SELECT value FROM metadata WHERE id = ? AND key = ?", (object_id, key)
).fetchone() ).fetchone()
if row is None: if row is None:
return None return None
return bytes(row["value"]) if row["value"] is not None else b"" return bytes(row["value"]) if row["value"] is not None else b""
def write_blob(self, data: bytes, existing_uuid: str | None = None) -> str: def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int:
""" """
Insert or replace a blob. Returns the uuid. Insert or replace an object. Returns the id.
Pass existing_uuid to update an existing object. If object_id is None, creates a new object with auto-assigned ID.
If object_id is provided, updates or creates the object with that ID.
""" """
now = _now() modified = _now()
uid = existing_uuid or str(_uuid.uuid4()) if object_id is None:
if existing_uuid: cur = self._con.execute(
self._con.execute( "INSERT INTO objects (type, data, modified) VALUES (?, ?, ?)",
"UPDATE objects SET data = ?, modified = ? WHERE uuid = ? AND type = 'blob'", (dtype, data, modified)
(data, now, uid),
) )
self._con.commit()
return cur.lastrowid
else: else:
self._con.execute( self._con.execute(
"INSERT INTO objects (uuid, type, data, created, modified) VALUES (?, 'blob', ?, ?, ?)", "INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)",
(uid, data, now, now), (object_id, dtype, data, modified)
) )
self._con.commit() self._con.commit()
return uid return object_id
def delete_blob(self, uuid: str) -> bool: def write_blob(self, data: bytes, existing_id: int | None = None) -> int:
"""
Insert or replace a blob. Returns the id.
Pass existing_id to update an existing object.
"""
return self._write_object(data, "blob", existing_id)
def delete_object(self, object_id: int) -> bool:
""" """
Delete a blob and all its metadata rows. Delete a blob and all its metadata rows.
Returns True if an object was deleted, False if uuid not found. Returns True if an object was deleted, False if id not found.
Foreign key cascade handles the metadata rows. Foreign key cascade handles the metadata rows.
""" """
cur = self._con.execute( cur = self._con.execute(
"DELETE FROM objects WHERE uuid = ?", (uuid,) "DELETE FROM objects WHERE id = ?", (object_id,)
) )
self._con.commit() self._con.commit()
return cur.rowcount > 0 return cur.rowcount > 0
def get_label(self, label: str) -> str | None: def get_label(self, label: str) -> int | None:
""" """
Return the UUID associated with a label, or None if not found. Return the ID associated with a label, or None if not found.
""" """
row = self._con.execute( row = self._con.execute(
"SELECT uuid FROM labels WHERE label = ?", (label,) "SELECT id FROM labels WHERE label = ?", (label,)
).fetchone() ).fetchone()
if row is None: if row is None:
return None return None
return row["uuid"] return row["id"]
def set_label(self, label: str, uuid: str) -> None: def set_label(self, label: str, object_id: int) -> None:
""" """
Set a label to point to a UUID. Creates or updates the label. Set a label to point to an ID. Creates or updates the label.
The UUID must exist in the objects table. The ID must exist in the objects table.
""" """
self._con.execute( self._con.execute(
"INSERT OR REPLACE INTO labels (label, uuid) VALUES (?, ?)", "INSERT OR REPLACE INTO labels (label, id) VALUES (?, ?)",
(label, uuid), (label, object_id),
) )
self._con.commit() self._con.commit()
@ -181,14 +177,14 @@ class TroveDB:
self._con.commit() self._con.commit()
return cur.rowcount > 0 return cur.rowcount > 0
def list_labels(self) -> list[tuple[str, str]]: def list_labels(self) -> list[tuple[str, int]]:
""" """
Return all labels as a list of (label, uuid) tuples. Return all labels as a list of (label, id) tuples.
""" """
rows = self._con.execute( rows = self._con.execute(
"SELECT label, uuid FROM labels ORDER BY label" "SELECT label, id FROM labels ORDER BY label"
).fetchall() ).fetchall()
return [(row["label"], row["uuid"]) for row in rows] return [(row["label"], row["id"]) for row in rows]
def main(): def main():
@ -202,22 +198,22 @@ def main():
subparsers.add_parser("create", help="Create a new database") subparsers.add_parser("create", help="Create a new database")
# Get blob # Get blob
get_parser = subparsers.add_parser("get", help="Get a blob object by UUID") get_parser = subparsers.add_parser("get", help="Get a blob object by ID")
get_parser.add_argument("uuid", help="UUID of the blob to retrieve") get_parser.add_argument("id", type=int, help="ID of the blob to retrieve")
# Write blob # Write blob
write_parser = subparsers.add_parser("write", help="Write data to a blob") write_parser = subparsers.add_parser("write", help="Write data to a blob")
write_parser.add_argument("data", help="Data to write (as string, will be encoded as UTF-8)") write_parser.add_argument("data", help="Data to write (as string, will be encoded as UTF-8)")
write_parser.add_argument("--uuid", help="UUID of existing blob to update (optional)") write_parser.add_argument("--id", type=int, help="ID of existing blob to update (optional)")
# Delete blob # Delete blob
delete_parser = subparsers.add_parser("delete", help="Delete a blob by UUID") delete_parser = subparsers.add_parser("delete", help="Delete a blob by ID")
delete_parser.add_argument("uuid", help="UUID of the blob to delete") delete_parser.add_argument("id", type=int, help="ID of the blob to delete")
# Set label # Set label
setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to a UUID") setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to an ID")
setlabel_parser.add_argument("label", help="Label name") setlabel_parser.add_argument("label", help="Label name")
setlabel_parser.add_argument("uuid", help="UUID to associate with the label") setlabel_parser.add_argument("id", type=int, help="ID to associate with the label")
# Remove label # Remove label
rmlabel_parser = subparsers.add_parser("rmlabel", help="Delete a label") rmlabel_parser = subparsers.add_parser("rmlabel", help="Delete a label")
@ -231,59 +227,62 @@ def main():
try: try:
match args.operation: match args.operation:
case "create": case "create":
db = TroveDB.init(args.database) if Path(args.database).exists():
print(f"Database already exists: {args.database}", file=sys.stderr)
sys.exit(1)
db = Sqlite3Trove.open(args.database, create=True)
db.close() db.close()
print(f"Database created: {args.database}") print(f"Database created: {args.database}")
case "get": case "get":
db = TroveDB.open(args.database) db = Sqlite3Trove.open(args.database)
data = db.read_blob(args.uuid) data = db.read_object(args.id)
db.close() db.close()
if data is None: if data is None:
print(f"Blob not found: {args.uuid}", file=sys.stderr) print(f"Blob not found: {args.id}")
sys.exit(1) sys.exit(1)
sys.stdout.buffer.write(data) sys.stdout.buffer.write(data)
case "write": case "write":
data_bytes = args.data.encode("utf-8") data_bytes = args.data.encode("utf-8")
db = TroveDB.open(args.database) db = Sqlite3Trove.open(args.database)
uuid = db.write_blob(data_bytes, args.uuid) object_id = db.write_blob(data_bytes, args.id)
db.close() db.close()
print(uuid) print(object_id)
case "delete": case "delete":
db = TroveDB.open(args.database) db = Sqlite3Trove.open(args.database)
deleted = db.delete_blob(args.uuid) deleted = db.delete_object(args.id)
db.close() db.close()
if deleted: if deleted:
print(f"Deleted blob: {args.uuid}") print(f"Deleted blob: {args.id}")
else: else:
print(f"Blob not found: {args.uuid}", file=sys.stderr) print(f"Blob not found: {args.id}")
sys.exit(1) sys.exit(1)
case "setlabel": case "setlabel":
db = TroveDB.open(args.database) db = Sqlite3Trove.open(args.database)
db.set_label(args.label, args.uuid) db.set_label(args.label, args.id)
db.close() db.close()
print(f"Label '{args.label}' set to UUID: {args.uuid}") print(f"Label '{args.label}' set to ID: {args.id}")
case "rmlabel": case "rmlabel":
db = TroveDB.open(args.database) db = Sqlite3Trove.open(args.database)
deleted = db.delete_label(args.label) deleted = db.delete_label(args.label)
db.close() db.close()
if deleted: if deleted:
print(f"Deleted label: {args.label}") print(f"Deleted label: {args.label}")
else: else:
print(f"Label not found: {args.label}", file=sys.stderr) print(f"Label not found: {args.label}")
sys.exit(1) sys.exit(1)
case "labels": case "labels":
db = TroveDB.open(args.database) db = Sqlite3Trove.open(args.database)
labels = db.list_labels() labels = db.list_labels()
db.close() db.close()
if labels: if labels:
for label, uuid in labels: for label, id in labels:
print(f"{label}: {uuid}") print(f"{label}: {id}")
else: else:
print("No labels found.") print("No labels found.")

View file

@ -15,8 +15,8 @@ class Tree:
Initialize a Tree. If data is provided, deserialize from UTF-8 JSON bytes. Initialize a Tree. If data is provided, deserialize from UTF-8 JSON bytes.
An empty Tree is created if data is None. An empty Tree is created if data is None.
""" """
if data is None: if not data:
self._entries: dict[str, str] = {} self._entries: dict[str, int] = {}
else: else:
self._entries = json.loads(data.decode("utf-8")) self._entries = json.loads(data.decode("utf-8"))
@ -24,14 +24,18 @@ class Tree:
"""Serialize the tree to UTF-8 JSON bytes.""" """Serialize the tree to UTF-8 JSON bytes."""
return json.dumps(self._entries).encode("utf-8") return json.dumps(self._entries).encode("utf-8")
def set_entry(self, name: str, uuid: str) -> None: def set_entry(self, name: str, object_id: int) -> None:
"""Add or update an entry mapping name -> uuid.""" """Add or update an entry mapping name -> uuid."""
self._entries[name] = uuid self._entries[name] = object_id
def get_entry(self, name: str) -> int:
"""Get the uuid associated with a name, or raise KeyError if not found."""
return self._entries[name]
def rm_entry(self, name: str) -> None: def rm_entry(self, name: str) -> None:
"""Remove an entry by name. Raises KeyError if not found.""" """Remove an entry by name. Raises KeyError if not found."""
del self._entries[name] del self._entries[name]
def list(self) -> dict[str, str]: def list(self) -> dict[str, int]:
"""Return a shallow copy of all entries as {name: uuid}.""" """Return a shallow copy of all entries as {name: uuid}."""
return dict(self._entries) return dict(self._entries)

75
trovedb/trove.py Normal file
View file

@ -0,0 +1,75 @@
from typing import Protocol, runtime_checkable, Optional, Dict, List, Self
from uuid import UUID
from pathlib import PurePosixPath
NODE_ROOT_ID = 1
@runtime_checkable
class Note(Protocol):
"""
Protocol for a Note item.
Represents access to an individual note's content and metadata.
"""
@property
def object_id(self) -> int:
"""The unique identifier for this note."""
...
def get_raw_metadata(self, key: str) -> Optional[bytes]:
"""Retrieve metadata value for the given key."""
...
def set_raw_metadata(self, key: str, value: bytes) -> None:
"""Set metadata value for the given key."""
...
@runtime_checkable
class Blob(Protocol):
def read(self) -> bytes:
"""Read the raw content of the note."""
...
def write(self, data: bytes) -> None:
"""Write new content to the note."""
...
@runtime_checkable
class Tree(Protocol):
def link(self, name: str, note: Note):
"""Link name to a given note."""
...
def unlink(self, name: str):
"""Remove name from the tree."""
...
def mkdir(self, name: str) -> Self:
"""Create a new Tree with the given name."""
...
class BlobNote(Note, Blob):
"""Blob Note"""
class TreeNote(Note, Tree):
"""Tree Note"""
@runtime_checkable
class Trove(Protocol):
"""
Protocol for the Trove database API.
Provides high-level access to notes and trees.
"""
def get_raw_note(self, note: int) -> Optional[Note]:
"""Retrieve a note by a UUID"""
...
def create_blob(self, data: bytes | None = None) -> BlobNote:
"""Create a new blob node at the given path with content"""
...
def get_root(self) -> TreeNote:
"""Get Tree Node at the given path"""
...