Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ local DB A --SQLite backup--> snapshot + manifest --transport--> local staging/r
+----------------------- local transaction <--------------- local DB B
```

Only snapshots cross node boundaries. A live database, WAL or SHM file is never
opened through the transport.
Only snapshots cross node boundaries. Before publication, every backup is
switched to SQLite's `DELETE` journal mode and checked for adjacent sidecars.
A live database, WAL, SHM, rollback-journal or other unmanifested SQLite file is
never opened through or left in the transport.

## Components

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- Close every backup into `DELETE` journal mode before publication and remove the
complete temporary SQLite artifact family on backup, redaction, credential-scan,
verification, or manifest failure. WAL source databases can no longer leave
unmanifested `-wal`, `-shm`, `-journal`, or similar sidecars in transit.
- Add `SyncConfig.from_bytes(payload, source_path=...)` so callers can hash and
parse the exact same config bytes. `from_file()` delegates to this parser and
keeps identical source-relative path semantics.

## 0.2.0 — 2026-07-29

- **Dokumentation, SEO & Discoverability**: `llms.txt` Verifikations-Timestamp auf 2026-07-30 aktualisiert, GFM LLM Note Callout (`> [!NOTE]`) in `README.md` & `README_de.md` integriert, 19/19 Pytest-Tests verifiziert [G 2026-07-30].
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[![License](https://img.shields.io/github/license/dev-bricks/sqlite-transit-sync)](LICENSE)
[![Python Version](https://img.shields.io/badge/python->=3.10-blue.svg)](https://www.python.org/)
[![Architecture](https://img.shields.io/badge/architecture-local--first-success.svg)](#part-of-the-ellmos-stack-family)
[![Tests](https://img.shields.io/badge/tests-19%2F19%20passed-brightgreen.svg)](#tests)
[![Tests](https://img.shields.io/badge/tests-26%2F26%20passed-brightgreen.svg)](#tests)
[![llms.txt](https://img.shields.io/badge/llms.txt-available-informational.svg)](llms.txt)

> [!NOTE]
Expand Down Expand Up @@ -64,6 +64,8 @@ application-selectable merge policies.

- consistent online snapshots through SQLite's backup API;
- atomic publication using a temporary file and `os.replace`;
- closed, rollback-journal snapshots with fail-closed cleanup of every temporary
SQLite sidecar before publication;
- SHA-256 manifest and `PRAGMA quick_check` verification;
- per-node local pull state and idempotent replay;
- row-level last-write-wins per primary key for timestamped tables;
Expand Down Expand Up @@ -127,6 +129,22 @@ secrets, local tables or migrations belong to an application.
Relative paths are resolved from the config file. The live database must never
be located inside the transit directory.

Applications that need an audit hash for exactly the parsed configuration can
read the bytes once and use the same source-relative parser without a second
file read:

```python
import hashlib
from pathlib import Path

from sqlite_transit_sync import SyncConfig

config_path = Path("node.json").resolve()
payload = config_path.read_bytes()
config = SyncConfig.from_bytes(payload, source_path=config_path)
config_sha256 = hashlib.sha256(payload).hexdigest()
```

### All keys and their defaults

| Key | Default | Meaning |
Expand Down
7 changes: 6 additions & 1 deletion README_de.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![License](https://img.shields.io/github/license/dev-bricks/sqlite-transit-sync)](LICENSE)
[![Python Version](https://img.shields.io/badge/python->=3.10-blue.svg)](https://www.python.org/)
[![Architecture](https://img.shields.io/badge/architecture-local--first-success.svg)](#teil-der-ellmos-stack-familie)
[![Tests](https://img.shields.io/badge/tests-19%2F19%20passed-brightgreen.svg)](#kurzstart)
[![Tests](https://img.shields.io/badge/tests-26%2F26%20passed-brightgreen.svg)](#kurzstart)
[![llms.txt](https://img.shields.io/badge/llms.txt-available-informational.svg)](llms.txt)

> [!NOTE]
Expand Down Expand Up @@ -62,6 +62,8 @@ mit anwendungsspezifischen Merge-Policies.

- konsistente Snapshots über die SQLite-Backup-API;
- atomare Veröffentlichung und SHA-256-Manifest;
- geschlossene Snapshots im Rollback-Journal-Modus und fail-closed Bereinigung
aller temporären SQLite-Sidecars vor der Veröffentlichung;
- `PRAGMA quick_check` vor Verarbeitung;
- lokaler Pull-Zustand je Quellknoten und idempotente Wiederholung;
- Last-Write-Wins pro Primärschlüssel für Tabellen mit Zeitstempel;
Expand Down Expand Up @@ -136,6 +138,9 @@ Ausfall einzelner Server über mehrere dauerhaft betriebene Knoten überstehen m
## Wichtige Grenzen

- Aktive Datenbanken bleiben immer lokal und liegen niemals im Transportordner.
- Vor der Veröffentlichung wird jeder Snapshot geschlossen in den
Rollback-Journal-Modus überführt; WAL-, SHM-, Journal- und sonstige
unverzeichnete SQLite-Sidecars bleiben nicht im Transport zurück.
- SHA-256 erkennt Beschädigung, authentifiziert aber keinen feindlichen Transport.
- Die Standardregel synchronisiert keine Löschungen und setzt vergleichbare
Zeitstempel voraus.
Expand Down
5 changes: 3 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@

## System Overview

`sqlite-transit-sync` is a Python 3.10+ zero-dependency library and CLI for synchronizing local SQLite databases across untrusted or asynchronous file transports (shared folders, object-store mounts, removable drives, or sync-master yards). Verified 19/19 passing test suite on 2026-07-30.
`sqlite-transit-sync` is a Python 3.10+ zero-dependency library and CLI for synchronizing local SQLite databases across untrusted or asynchronous file transports (shared folders, object-store mounts, removable drives, or sync-master yards). Verified 26/26 passing test suite on 2026-07-30.

It avoids live SQLite database locking over file sync by using:
1. Online consistent snapshots via SQLite Backup API (`sqlite3.Connection.backup`).
2. Atomic snapshot publication using temporary files and `os.replace`.
2. Closed rollback-journal snapshots with fail-closed temporary sidecar cleanup,
followed by atomic publication using `os.replace`.
3. Pre-merge integrity checks via `PRAGMA quick_check` and SHA-256 manifest verification.
4. Deterministic, transactional row-level merge policies (Last-Write-Wins per PK for timestamped tables, shared-column merge for schema drift).

Expand Down
110 changes: 95 additions & 15 deletions sqlite_transit_sync/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,43 @@ def _atomic_json_write(path: Path, payload: dict[str, Any]) -> None:
pass


def _sqlite_artifact_paths(path: Path) -> tuple[Path, ...]:
sidecars = tuple(sorted(path.parent.glob(f"{path.name}-*")))
return (path, *sidecars)


def _remove_sqlite_artifacts(path: Path) -> None:
failures: list[tuple[Path, OSError]] = []
for candidate in _sqlite_artifact_paths(path):
try:
candidate.unlink(missing_ok=True)
except OSError as error:
failures.append((candidate, error))
if failures:
details = "; ".join(f"{candidate}: {error}" for candidate, error in failures)
raise SyncError(f"Could not clean temporary SQLite artifacts: {details}")


def _assert_no_sqlite_sidecars(path: Path) -> None:
leftovers = _sqlite_artifact_paths(path)[1:]
if leftovers:
names = ", ".join(candidate.name for candidate in leftovers)
raise SyncError(f"Unclosed SQLite snapshot has sidecars: {names}")


def _remove_manifest_artifacts(path: Path) -> None:
candidates = (path, *sorted(path.parent.glob(f".{path.name}.*.tmp")))
failures: list[tuple[Path, OSError]] = []
for candidate in candidates:
try:
candidate.unlink(missing_ok=True)
except OSError as error:
failures.append((candidate, error))
if failures:
details = "; ".join(f"{candidate}: {error}" for candidate, error in failures)
raise SyncError(f"Could not clean snapshot manifest artifacts: {details}")


@dataclass(slots=True)
class SyncConfig:
"""Portable configuration for one node participating in a transit."""
Expand Down Expand Up @@ -144,7 +181,13 @@ def __post_init__(self) -> None:
@classmethod
def from_file(cls, path: str | Path) -> "SyncConfig":
config_path = Path(path).expanduser().resolve()
raw = json.loads(config_path.read_text(encoding="utf-8"))
return cls.from_bytes(config_path.read_bytes(), source_path=config_path)

@classmethod
def from_bytes(cls, payload: bytes, *, source_path: str | Path) -> "SyncConfig":
"""Parse exact config bytes using paths relative to their source file."""
config_path = Path(source_path).expanduser().resolve()
raw = json.loads(payload.decode("utf-8"))
base = config_path.parent

def resolve(value: str) -> Path:
Expand Down Expand Up @@ -420,6 +463,21 @@ def _redact_snapshot(self, path: Path) -> list[str]:
connection.close()
return redacted

@staticmethod
def _close_snapshot(path: Path) -> None:
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(str(path))
result = connection.execute("PRAGMA journal_mode=DELETE").fetchone()
except sqlite3.Error as error:
raise SyncError(f"Could not close SQLite snapshot {path}: {error}") from error
finally:
if connection is not None:
connection.close()
if not result or str(result[0]).lower() != "delete":
raise SyncError(f"Could not switch SQLite snapshot to DELETE journal mode: {path}")
_assert_no_sqlite_sidecars(path)

def _scan_snapshot_for_secrets(self, path: Path) -> list[str]:
"""Report credential-looking values in snapshot content.

Expand Down Expand Up @@ -513,21 +571,30 @@ def push(self) -> Snapshot:
final_path = self.config.transit / self._snapshot_name(created_at)
partial_path = final_path.with_name(f".{final_path.name}.partial")
manifest_path = final_path.with_suffix(final_path.suffix + ".json")
partial_path.unlink(missing_ok=True)
_remove_sqlite_artifacts(partial_path)
source: sqlite3.Connection | None = None
destination: sqlite3.Connection | None = None
try:
source = sqlite3.connect(str(self.config.database))
destination = sqlite3.connect(str(partial_path))
with destination:
source.backup(destination)
finally:
if destination is not None:
destination.close()
if source is not None:
source.close()
try:
source = sqlite3.connect(str(self.config.database))
destination = sqlite3.connect(str(partial_path))
with destination:
source.backup(destination)
finally:
if destination is not None:
destination.close()
if source is not None:
source.close()
except Exception as error:
try:
_remove_sqlite_artifacts(partial_path)
except SyncError as cleanup_error:
raise cleanup_error from error
raise

published_snapshot = False
try:
self._close_snapshot(partial_path)
redacted_tables = self._redact_snapshot(partial_path)
leaks = self._scan_snapshot_for_secrets(partial_path)
if leaks:
Expand All @@ -539,11 +606,19 @@ def push(self) -> Snapshot:
"this is a false positive. The value itself is not shown on purpose."
)
self._quick_check(partial_path)
_assert_no_sqlite_sidecars(partial_path)
digest = _sha256(partial_path)
size = partial_path.stat().st_size
os.replace(partial_path, final_path)
except Exception:
partial_path.unlink(missing_ok=True)
published_snapshot = True
_assert_no_sqlite_sidecars(final_path)
except Exception as error:
try:
_remove_sqlite_artifacts(partial_path)
if published_snapshot:
_remove_sqlite_artifacts(final_path)
except SyncError as cleanup_error:
raise cleanup_error from error
raise
manifest = {
"protocol": PROTOCOL_VERSION,
Expand All @@ -557,8 +632,13 @@ def push(self) -> Snapshot:
}
try:
_atomic_json_write(manifest_path, manifest)
except Exception:
final_path.unlink(missing_ok=True)
except Exception as error:
try:
_remove_manifest_artifacts(manifest_path)
_remove_sqlite_artifacts(final_path)
_remove_sqlite_artifacts(partial_path)
except SyncError as cleanup_error:
raise cleanup_error from error
raise
return Snapshot(
path=final_path,
Expand Down
Loading
Loading