dv-platform 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Dataset Version Management Platform - backend package."""
app/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """Application settings (env-prefixed with DV_)."""
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ model_config = SettingsConfigDict(env_prefix="DV_", env_file=".env", extra="ignore")
7
+
8
+ app_name: str = "Dataset Version Platform"
9
+ database_url: str = "sqlite:///./dv.db"
10
+ storage_backend: str = "local" # local | s3
11
+ storage_root: str = "./storage" # local backend object root
12
+ s3_endpoint: str = "http://localhost:9000"
13
+ s3_bucket: str = "datasets"
14
+ s3_access_key: str = ""
15
+ s3_secret_key: str = ""
16
+ s3_region: str = "us-east-1"
17
+ presign_ttl: int = 900 # seconds (15 min)
18
+ jwt_secret: str = "dev-secret-change-me-0123456789abcdef0123456789"
19
+ jwt_algorithm: str = "HS256"
20
+ jwt_expire_minutes: int = 10080 # 7 days
21
+ public_base_url: str = "http://localhost:8000"
22
+ web_dist: str = "" # optional: absolute path to a built web/dist to serve
23
+ first_admin_password: str = "admin123"
24
+ chunk_size: int = 8 * 1024 * 1024 # 8MB reference chunk for docs
25
+
26
+
27
+ settings = Settings()
28
+
app/db.py ADDED
@@ -0,0 +1,55 @@
1
+ """SQLAlchemy engine / session factory.
2
+
3
+ SQLite notes (development default): we enable WAL journaling plus a busy
4
+ timeout and make every transaction start with ``BEGIN IMMEDIATE``. When many
5
+ clients upload/download concurrently the metadata writes (blob registration,
6
+ commit creation, ...) are short but frequent; ``BEGIN IMMEDIATE`` serialises
7
+ writers up-front so two concurrent requests never read a stale snapshot and
8
+ then fail with "database is locked" mid-transaction.
9
+ """
10
+ from sqlalchemy import create_engine, event
11
+ from sqlalchemy.orm import declarative_base, sessionmaker
12
+
13
+ from .config import settings
14
+
15
+ connect_args = {}
16
+ if settings.database_url.startswith("sqlite"):
17
+ connect_args = {"check_same_thread": False}
18
+
19
+ engine = create_engine(settings.database_url, connect_args=connect_args, future=True)
20
+
21
+ if settings.database_url.startswith("sqlite") and ":memory:" not in settings.database_url:
22
+ @event.listens_for(engine, "connect")
23
+ def _sqlite_pragmas(dbapi_connection, _connection_record): # pragma: no cover - exercised via app
24
+ # Disable pysqlite's implicit BEGIN so SQLAlchemy controls transactions
25
+ # and we can start them with BEGIN IMMEDIATE (see recipe in SQLAlchemy docs).
26
+ dbapi_connection.isolation_level = None
27
+ cursor = dbapi_connection.cursor()
28
+ try:
29
+ cursor.execute("PRAGMA journal_mode=WAL")
30
+ cursor.execute("PRAGMA busy_timeout=30000")
31
+ cursor.execute("PRAGMA foreign_keys=ON")
32
+ finally:
33
+ cursor.close()
34
+
35
+ @event.listens_for(engine, "begin")
36
+ def _begin_immediate(conn):
37
+ # Take the write lock at transaction start instead of upgrading a
38
+ # read snapshot later (avoids SQLITE_BUSY_SNAPSHOT / lost updates).
39
+ conn.exec_driver_sql("BEGIN IMMEDIATE")
40
+
41
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
42
+ Base = declarative_base()
43
+
44
+
45
+ def get_db():
46
+ db = SessionLocal()
47
+ try:
48
+ yield db
49
+ finally:
50
+ db.close()
51
+
52
+
53
+ def init_db() -> None:
54
+ from . import models # noqa: F401 (register models)
55
+ Base.metadata.create_all(bind=engine)
app/engine.py ADDED
@@ -0,0 +1,342 @@
1
+ """Git-like version engine: blob/tree/commit, branches, tags, diff, rollback,
2
+ reference counting and dataset deletion (metadata + physical storage cleanup)."""
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime
6
+ from typing import Iterable
7
+
8
+ from fastapi import HTTPException
9
+ from sqlalchemy.orm import Session
10
+
11
+ from .models import (
12
+ AuditLog, Blob, BlobRef, Branch, Commit, Dataset, DatasetAcl, StagedUpload,
13
+ Tag, Tree, User,
14
+ )
15
+ from .storage import get_storage, object_key
16
+
17
+
18
+ # ------------------------------------------------------------------ hashing
19
+ def canonical_json(obj) -> str:
20
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
21
+
22
+
23
+ def sha256_hex(data: bytes) -> str:
24
+ return hashlib.sha256(data).hexdigest()
25
+
26
+
27
+ def hash_tree(entries: dict) -> str:
28
+ return sha256_hex(canonical_json(entries).encode("utf-8"))
29
+
30
+
31
+ def hash_commit(tree_id: str, parents: list[str], author: str, message: str, ts: str) -> str:
32
+ obj = {"tree": tree_id, "parents": parents, "author": author, "message": message, "ts": ts}
33
+ return sha256_hex(canonical_json(obj).encode("utf-8"))
34
+
35
+
36
+ def normalize_files(files: Iterable[dict]) -> dict:
37
+ """Normalize [{path,hash,size}] -> {path: {"type":"blob","hash","size"}} sorted."""
38
+ entries = {}
39
+ for f in files:
40
+ path = f["path"].replace("\\", "/").lstrip("./")
41
+ path = "/".join(p for p in path.split("/") if p not in ("", ".", ".."))
42
+ if not path:
43
+ continue
44
+ entries[path] = {"type": "blob", "hash": f["hash"], "size": int(f.get("size", 0))}
45
+ return dict(sorted(entries.items()))
46
+
47
+
48
+ def entries_from_commit(db: Session, commit: Commit) -> dict:
49
+ tree = db.query(Tree).filter(Tree.content_hash == commit.tree_id).first()
50
+ return dict(tree.payload) if tree else {}
51
+
52
+
53
+ # ------------------------------------------------------------------ refs
54
+ def resolve_ref(db: Session, dataset: Dataset, ref: str) -> Commit:
55
+ """Resolve a ref to a commit: tag / branch / 'HEAD' / commit hash / seq / vN."""
56
+ ref = (ref or "HEAD").strip()
57
+ low = ref.lower()
58
+ # tag
59
+ tag = db.query(Tag).filter(Tag.dataset_id == dataset.id, Tag.name == ref).first()
60
+ if tag:
61
+ return db.query(Commit).filter(Commit.commit_hash == tag.commit_id).first()
62
+ # branch
63
+ if low in ("head", "latest", "main"):
64
+ branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == "main").first()
65
+ if branch:
66
+ return db.query(Commit).filter(Commit.commit_hash == branch.head_commit_id).first()
67
+ branch = db.query(Branch).filter(Branch.dataset_id == dataset.id).first()
68
+ if branch:
69
+ return db.query(Commit).filter(Commit.commit_hash == branch.head_commit_id).first()
70
+ else:
71
+ branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == ref).first()
72
+ if branch:
73
+ return db.query(Commit).filter(Commit.commit_hash == branch.head_commit_id).first()
74
+ # vN / seq number
75
+ if low.startswith("v"):
76
+ num = low[1:]
77
+ if num.isdigit():
78
+ ref = num
79
+ if ref.isdigit():
80
+ c = db.query(Commit).filter(Commit.dataset_id == dataset.id, Commit.seq == int(ref)).first()
81
+ if c:
82
+ return c
83
+ # commit hash (full or prefix)
84
+ q = db.query(Commit).filter(Commit.dataset_id == dataset.id, Commit.commit_hash.startswith(ref.lower()))
85
+ if ref.lower() and q.count() > 0:
86
+ return q.first()
87
+ # latest
88
+ return db.query(Commit).filter(Commit.dataset_id == dataset.id).order_by(Commit.seq.desc()).first()
89
+
90
+
91
+ def commit_or_404(db: Session, dataset: Dataset, ref: str) -> Commit:
92
+ c = resolve_ref(db, dataset, ref)
93
+ if c is None:
94
+ raise HTTPException(404, f"Version/ref '{ref}' not found")
95
+ return c
96
+
97
+
98
+ # ------------------------------------------------------------------ commits
99
+ def create_commit(
100
+ db: Session,
101
+ dataset: Dataset,
102
+ author: User,
103
+ files: Iterable[dict],
104
+ message: str,
105
+ branch_name: str = "main",
106
+ parent_ref: str | None = None,
107
+ ) -> Commit:
108
+ entries = normalize_files(files)
109
+ # validate declared blobs are present in object storage; register if missing from DB
110
+ missing = []
111
+ for path, meta in entries.items():
112
+ blob = db.get(Blob, meta["hash"])
113
+ if blob is None:
114
+ if get_storage().exists(object_key(meta["hash"])):
115
+ db.add(Blob(
116
+ content_hash=meta["hash"], size=meta["size"],
117
+ storage_key=object_key(meta["hash"]), ref_count=0,
118
+ ))
119
+ else:
120
+ missing.append(path)
121
+ if missing:
122
+ raise HTTPException(400, f"Blobs not uploaded yet: {missing[:20]}")
123
+ db.flush()
124
+
125
+ tree_id = hash_tree(entries)
126
+ if db.query(Tree).filter(Tree.content_hash == tree_id).first() is None:
127
+ db.add(Tree(dataset_id=dataset.id, content_hash=tree_id, payload=entries))
128
+
129
+ branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == branch_name).first()
130
+ parents: list[str] = []
131
+ if parent_ref:
132
+ parent = resolve_ref(db, dataset, parent_ref)
133
+ if parent:
134
+ parents = [parent.commit_hash]
135
+ elif branch is not None:
136
+ parents = [branch.head_commit_id]
137
+
138
+ ts = datetime.utcnow().isoformat()
139
+ commit_hash = hash_commit(tree_id, parents, author.name, message, ts)
140
+ while db.query(Commit).filter(Commit.commit_hash == commit_hash).first():
141
+ ts = datetime.utcnow().isoformat() + "-" + str(len(parents))
142
+ commit_hash = hash_commit(tree_id, parents, author.name, message, ts)
143
+
144
+ last = db.query(Commit).filter(Commit.dataset_id == dataset.id).order_by(Commit.seq.desc()).first()
145
+ commit = Commit(
146
+ dataset_id=dataset.id,
147
+ commit_hash=commit_hash,
148
+ tree_id=tree_id,
149
+ parent_hashes=parents,
150
+ author_id=author.id,
151
+ message=message,
152
+ seq=(last.seq + 1 if last else 1),
153
+ )
154
+ db.add(commit)
155
+
156
+ if branch is None:
157
+ branch = Branch(dataset_id=dataset.id, name=branch_name, head_commit_id=commit_hash)
158
+ db.add(branch)
159
+ else:
160
+ branch.head_commit_id = commit_hash
161
+
162
+ # reference counting (dataset-level): +1 only for newly referenced blobs
163
+ for path, meta in entries.items():
164
+ existing = (
165
+ db.query(BlobRef)
166
+ .filter(BlobRef.dataset_id == dataset.id, BlobRef.blob_hash == meta["hash"])
167
+ .first()
168
+ )
169
+ if existing is None:
170
+ blob = db.get(Blob, meta["hash"])
171
+ if blob:
172
+ blob.ref_count += 1
173
+ db.add(BlobRef(dataset_id=dataset.id, blob_hash=meta["hash"]))
174
+ db.commit()
175
+ db.refresh(commit)
176
+ return commit
177
+
178
+
179
+ def rollback(db: Session, dataset: Dataset, author: User, target_ref: str, message: str) -> Commit:
180
+ target = commit_or_404(db, dataset, target_ref)
181
+ entries = entries_from_commit(db, target)
182
+ files = [
183
+ {"path": p, "hash": m["hash"], "size": m["size"]}
184
+ for p, m in entries.items()
185
+ ]
186
+ branch = db.query(Branch).filter(Branch.dataset_id == dataset.id, Branch.name == "main").first()
187
+ parent_ref = branch.head_commit_id if branch else None
188
+ msg = message or f"rollback to {target_ref} ({target.commit_hash[:8]})"
189
+ return create_commit(
190
+ db, dataset, author, files, msg, branch_name=branch.name if branch else "main",
191
+ parent_ref=parent_ref,
192
+ )
193
+
194
+
195
+ # ------------------------------------------------------------------ diff
196
+ def diff_commits(db: Session, base: Commit, head: Commit) -> list[dict]:
197
+ base_entries = entries_from_commit(db, base)
198
+ head_entries = entries_from_commit(db, head)
199
+ changes = []
200
+ for path in sorted(set(base_entries) | set(head_entries)):
201
+ old = base_entries.get(path)
202
+ new = head_entries.get(path)
203
+ if old is None:
204
+ changes.append({"path": path, "status": "added", "old": None, "new": new})
205
+ elif new is None:
206
+ changes.append({"path": path, "status": "removed", "old": old, "new": None})
207
+ elif old["hash"] != new["hash"]:
208
+ changes.append({"path": path, "status": "modified", "old": old, "new": new})
209
+ return changes
210
+
211
+
212
+ # ------------------------------------------------------------------ delete
213
+ def storage_info(db: Session, dataset: Dataset) -> dict:
214
+ refs = db.query(BlobRef).filter(BlobRef.dataset_id == dataset.id).all()
215
+ total_bytes = 0
216
+ shared = 0
217
+ shared_bytes = 0
218
+ for r in refs:
219
+ blob = db.get(Blob, r.blob_hash)
220
+ if blob:
221
+ total_bytes += blob.size
222
+ if blob.ref_count > 1:
223
+ shared += 1
224
+ shared_bytes += blob.size
225
+ versions = db.query(Commit).filter(Commit.dataset_id == dataset.id).count()
226
+ latest = db.query(Commit).filter(Commit.dataset_id == dataset.id).order_by(Commit.seq.desc()).first()
227
+ file_count = len(entries_from_commit(db, latest)) if latest else 0
228
+ return {
229
+ "dataset_id": dataset.id,
230
+ "versions": versions,
231
+ "file_count": file_count,
232
+ "storage_bytes": total_bytes,
233
+ "shared_blobs": shared,
234
+ "shared_bytes": shared_bytes,
235
+ "unique_blobs": len(refs),
236
+ }
237
+
238
+
239
+ def delete_dataset(
240
+ db: Session, dataset: Dataset, user: User, ip: str = ""
241
+ ) -> dict:
242
+ """Metadata deletion + reference counting + physical storage cleanup."""
243
+ info = storage_info(db, dataset)
244
+ blob_hashes = [r.blob_hash for r in db.query(BlobRef).filter(BlobRef.dataset_id == dataset.id).all()]
245
+ zero_ref: list[Blob] = []
246
+ for h in blob_hashes:
247
+ blob = db.get(Blob, h)
248
+ if blob:
249
+ blob.ref_count -= 1
250
+ if blob.ref_count <= 0:
251
+ zero_ref.append(blob)
252
+
253
+ # collect tree hashes this dataset's commits use
254
+ tree_ids = {c.tree_id for c in db.query(Commit).filter(Commit.dataset_id == dataset.id).all()}
255
+ commit_hashes = {c.commit_hash for c in db.query(Commit).filter(Commit.dataset_id == dataset.id).all()}
256
+
257
+ # delete dataset-scoped rows
258
+ db.query(Commit).filter(Commit.dataset_id == dataset.id).delete()
259
+ db.query(Branch).filter(Branch.dataset_id == dataset.id).delete()
260
+ db.query(Tag).filter(Tag.dataset_id == dataset.id).delete()
261
+ db.query(DatasetAcl).filter(DatasetAcl.dataset_id == dataset.id).delete()
262
+ db.query(BlobRef).filter(BlobRef.dataset_id == dataset.id).delete()
263
+ db.query(StagedUpload).filter(StagedUpload.dataset_id == dataset.id).delete()
264
+ ds_id = dataset.id
265
+ ds_name = f"{dataset.namespace}/{dataset.name}"
266
+ db.delete(dataset)
267
+
268
+ # remove tree rows no longer referenced by any commit (any dataset)
269
+ for tree_id in tree_ids:
270
+ still_used = db.query(Commit).filter(Commit.tree_id == tree_id).count()
271
+ if still_used == 0:
272
+ db.query(Tree).filter(Tree.content_hash == tree_id).delete()
273
+
274
+ db.commit()
275
+
276
+ # physical deletion of unreferenced blobs (after metadata tx)
277
+ released_bytes = 0
278
+ deleted_objects = []
279
+ storage = get_storage()
280
+ for blob in zero_ref:
281
+ try:
282
+ storage.delete(object_key(blob.content_hash))
283
+ released_bytes += blob.size
284
+ deleted_objects.append(blob.content_hash)
285
+ except Exception:
286
+ pass # retryable via GC job
287
+ for h in deleted_objects:
288
+ db.query(Blob).filter(Blob.content_hash == h).delete()
289
+ db.commit()
290
+
291
+ from .models import GcJob
292
+ if len(deleted_objects) != len(zero_ref):
293
+ db.add(GcJob(
294
+ dataset_id=ds_id,
295
+ type="dataset_delete",
296
+ status="pending",
297
+ total=len(zero_ref),
298
+ done=len(deleted_objects),
299
+ detail={"note": "Some objects pending retry"},
300
+ created_by=user.id,
301
+ ))
302
+ db.commit()
303
+
304
+ write_audit(
305
+ db, user, ds_id, "dataset.delete",
306
+ {
307
+ "name": ds_name,
308
+ "versions": info["versions"],
309
+ "released_bytes": released_bytes,
310
+ "objects_deleted": len(deleted_objects),
311
+ },
312
+ ip,
313
+ )
314
+ return {
315
+ "deleted": True,
316
+ "released_bytes": released_bytes,
317
+ "versions_deleted": info["versions"],
318
+ "objects_deleted": len(deleted_objects),
319
+ "shared_blobs_kept": info["shared_blobs"],
320
+ }
321
+
322
+
323
+ # ------------------------------------------------------------------ audit
324
+ def write_audit(
325
+ db: Session, user: User | None, dataset_id: int | None,
326
+ action: str, detail: dict, ip: str = "",
327
+ ) -> None:
328
+ db.add(AuditLog(
329
+ user_id=user.id if user else None,
330
+ dataset_id=dataset_id,
331
+ action=action,
332
+ detail=detail or {},
333
+ ip=ip,
334
+ ))
335
+ db.commit()
336
+
337
+
338
+ def client_ip(request) -> str:
339
+ return request.client.host if request.client else ""
340
+
341
+
342
+
app/main.py ADDED
@@ -0,0 +1,62 @@
1
+ """FastAPI application entrypoint."""
2
+ from pathlib import Path
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from .config import settings
9
+ from .db import init_db
10
+ from .routers import audit, auth, datasets, storage_objects, tokens, versions
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
13
+ # DV_WEB_DIST lets a pip-installed deployment serve a pre-built web UI from an
14
+ # arbitrary location; when unset we fall back to the in-repo build (dev).
15
+ WEB_DIST = Path(settings.web_dist).expanduser() if settings.web_dist else REPO_ROOT / "web" / "dist"
16
+
17
+ app = FastAPI(
18
+ title=settings.app_name,
19
+ version="0.1.0",
20
+ description="Git-like dataset version management platform "
21
+ "(control plane stores metadata only; data plane is object storage).",
22
+ )
23
+
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+
33
+ @app.middleware("http")
34
+ async def no_store_api(request, call_next):
35
+ response = await call_next(request)
36
+ if request.url.path.startswith("/api/"):
37
+ response.headers["Cache-Control"] = "no-store"
38
+ return response
39
+
40
+ app.include_router(auth.router)
41
+ app.include_router(tokens.router)
42
+ app.include_router(datasets.router)
43
+ app.include_router(versions.router)
44
+ app.include_router(storage_objects.router)
45
+ app.include_router(audit.router)
46
+
47
+
48
+ @app.on_event("startup")
49
+ def on_startup():
50
+ init_db()
51
+
52
+
53
+ @app.get("/api/v1/health")
54
+ def health():
55
+ return {"status": "ok", "app": settings.app_name}
56
+
57
+
58
+ # Serve the built web UI (if present) - API routes above take precedence.
59
+ if WEB_DIST.is_dir():
60
+ app.mount("/", StaticFiles(directory=str(WEB_DIST), html=True), name="web")
61
+
62
+
app/models.py ADDED
@@ -0,0 +1,170 @@
1
+ """ORM models. Only metadata is stored here - never file contents."""
2
+ from datetime import datetime
3
+
4
+ from sqlalchemy import (
5
+ JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint,
6
+ )
7
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
8
+
9
+ from .db import Base
10
+
11
+
12
+ def _now() -> datetime:
13
+ return datetime.utcnow()
14
+
15
+
16
+ class User(Base):
17
+ __tablename__ = "users"
18
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
19
+ name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
20
+ email: Mapped[str] = mapped_column(String(255), default="")
21
+ password_hash: Mapped[str] = mapped_column(String(255))
22
+ role: Mapped[str] = mapped_column(String(16), default="user") # admin | user
23
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
24
+
25
+ tokens = relationship("ApiToken", back_populates="user", cascade="all, delete-orphan")
26
+
27
+
28
+ class ApiToken(Base):
29
+ __tablename__ = "api_tokens"
30
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
31
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
32
+ name: Mapped[str] = mapped_column(String(128))
33
+ token_hash: Mapped[str] = mapped_column(String(64), unique=True)
34
+ scope: Mapped[list] = mapped_column(JSON, default=list) # ["*"] or [dataset_id,...]
35
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
36
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
37
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
38
+
39
+ user = relationship("User", back_populates="tokens")
40
+
41
+
42
+ class Dataset(Base):
43
+ __tablename__ = "datasets"
44
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
45
+ name: Mapped[str] = mapped_column(String(128), index=True)
46
+ namespace: Mapped[str] = mapped_column(String(128), default="default", index=True)
47
+ description: Mapped[str] = mapped_column(Text, default="")
48
+ visibility: Mapped[str] = mapped_column(String(16), default="private") # public|private
49
+ created_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
50
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
51
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
52
+
53
+ __table_args__ = (UniqueConstraint("namespace", "name", "deleted_at"),)
54
+
55
+ acls = relationship("DatasetAcl", back_populates="dataset", cascade="all, delete-orphan")
56
+ branches = relationship("Branch", back_populates="dataset", cascade="all, delete-orphan")
57
+ tags = relationship("Tag", back_populates="dataset", cascade="all, delete-orphan")
58
+ commits = relationship("Commit", back_populates="dataset", cascade="all, delete-orphan")
59
+
60
+
61
+ class DatasetAcl(Base):
62
+ __tablename__ = "dataset_acls"
63
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
64
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
65
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
66
+ role: Mapped[str] = mapped_column(String(16), default="reader") # owner|editor|reader
67
+
68
+ __table_args__ = (UniqueConstraint("dataset_id", "user_id"),)
69
+ dataset = relationship("Dataset", back_populates="acls")
70
+
71
+
72
+ class Tree(Base):
73
+ __tablename__ = "trees"
74
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
75
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
76
+ content_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
77
+ payload: Mapped[dict] = mapped_column(JSON) # {path: {"type","hash","size"}}
78
+
79
+
80
+ class Commit(Base):
81
+ __tablename__ = "commits"
82
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
83
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
84
+ commit_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
85
+ tree_id: Mapped[str] = mapped_column(String(64), index=True)
86
+ parent_hashes: Mapped[list] = mapped_column(JSON, default=list)
87
+ author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
88
+ message: Mapped[str] = mapped_column(Text, default="")
89
+ seq: Mapped[int] = mapped_column(Integer, default=0) # human friendly version number
90
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
91
+
92
+ dataset = relationship("Dataset", back_populates="commits")
93
+
94
+
95
+ class Blob(Base):
96
+ __tablename__ = "blobs"
97
+ content_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
98
+ size: Mapped[int] = mapped_column(Integer, default=0)
99
+ storage_key: Mapped[str] = mapped_column(String(512))
100
+ ref_count: Mapped[int] = mapped_column(Integer, default=0)
101
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
102
+
103
+
104
+ class BlobRef(Base):
105
+ """Which datasets reference a blob (dataset-level ref counting)."""
106
+ __tablename__ = "blob_refs"
107
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
108
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
109
+ blob_hash: Mapped[str] = mapped_column(ForeignKey("blobs.content_hash"), index=True)
110
+
111
+ __table_args__ = (UniqueConstraint("dataset_id", "blob_hash"),)
112
+
113
+
114
+ class Branch(Base):
115
+ __tablename__ = "branches"
116
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
117
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
118
+ name: Mapped[str] = mapped_column(String(128))
119
+ head_commit_id: Mapped[str] = mapped_column(String(64))
120
+
121
+ __table_args__ = (UniqueConstraint("dataset_id", "name"),)
122
+ dataset = relationship("Dataset", back_populates="branches")
123
+
124
+
125
+ class Tag(Base):
126
+ __tablename__ = "tags"
127
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
128
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
129
+ name: Mapped[str] = mapped_column(String(128))
130
+ commit_id: Mapped[str] = mapped_column(String(64))
131
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
132
+
133
+ __table_args__ = (UniqueConstraint("dataset_id", "name"),)
134
+ dataset = relationship("Dataset", back_populates="tags")
135
+
136
+
137
+ class StagedUpload(Base):
138
+ __tablename__ = "staged_uploads"
139
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
140
+ dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
141
+ uploader_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
142
+ blob_hashes: Mapped[list] = mapped_column(JSON, default=list)
143
+ status: Mapped[str] = mapped_column(String(16), default="pending")
144
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
145
+ expires_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
146
+
147
+
148
+ class GcJob(Base):
149
+ __tablename__ = "gc_jobs"
150
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
151
+ dataset_id: Mapped[int] = mapped_column(Integer, nullable=True)
152
+ type: Mapped[str] = mapped_column(String(32)) # dataset_delete | gc
153
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending|running|done|failed
154
+ total: Mapped[int] = mapped_column(Integer, default=0)
155
+ done: Mapped[int] = mapped_column(Integer, default=0)
156
+ detail: Mapped[dict] = mapped_column(JSON, default=dict)
157
+ created_by: Mapped[int] = mapped_column(Integer, nullable=True)
158
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
159
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
160
+
161
+
162
+ class AuditLog(Base):
163
+ __tablename__ = "audit_logs"
164
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
165
+ user_id: Mapped[int] = mapped_column(Integer, nullable=True, index=True)
166
+ dataset_id: Mapped[int] = mapped_column(Integer, nullable=True, index=True)
167
+ action: Mapped[str] = mapped_column(String(64), index=True)
168
+ detail: Mapped[dict] = mapped_column(JSON, default=dict)
169
+ ip: Mapped[str] = mapped_column(String(64), default="")
170
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=_now, index=True)
@@ -0,0 +1 @@
1
+ """API routers."""