kvgit 0.1.8__tar.gz → 0.1.9__tar.gz
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.
- {kvgit-0.1.8 → kvgit-0.1.9}/PKG-INFO +1 -1
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/indexeddb.py +7 -3
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/kv.py +175 -24
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.1.8 → kvgit-0.1.9}/pyproject.toml +1 -1
- {kvgit-0.1.8 → kvgit-0.1.9}/LICENSE +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/README.md +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/__init__.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/content_types.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/encoding.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/errors.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/base.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/composite.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/disk.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/memory.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/namespaced.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/py.typed +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/staged.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/store.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/base.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/gp.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/setup.cfg +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_content_types.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_namespaced.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_staged.py +0 -0
- {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_store_factory.py +0 -0
|
@@ -124,10 +124,14 @@ def _to_bytes(js_value) -> bytes | None:
|
|
|
124
124
|
"""
|
|
125
125
|
if js_value is None or js_value is undefined:
|
|
126
126
|
return None
|
|
127
|
-
|
|
127
|
+
try:
|
|
128
|
+
from js import Uint8Array # type: ignore[import-not-found]
|
|
128
129
|
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
arr = Uint8Array.new(js_value)
|
|
131
|
+
return arr.to_py().tobytes()
|
|
132
|
+
except Exception:
|
|
133
|
+
# Corrupted or unexpected JS value — treat as missing
|
|
134
|
+
return None
|
|
131
135
|
|
|
132
136
|
|
|
133
137
|
class IndexedDB(KVStore):
|
|
@@ -14,6 +14,7 @@ from .merge import MergeResolution
|
|
|
14
14
|
PARENT_COMMIT = "__parent_commit__%s"
|
|
15
15
|
COMMIT_KEYSET = "__commit_keyset__%s"
|
|
16
16
|
BRANCH_HEAD = "__branch_head__%s"
|
|
17
|
+
BRANCH_HEAD_PREV = "__branch_head_prev__%s"
|
|
17
18
|
META_KEY = "__meta__%s"
|
|
18
19
|
TOTAL_VAR_SIZE_KEY = "__total_var_size__%s"
|
|
19
20
|
INFO_KEY = "__info__%s"
|
|
@@ -41,6 +42,145 @@ def content_hash(
|
|
|
41
42
|
return h.hexdigest()[:40]
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
logger = logging.getLogger("kvgit")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _safe_from_bytes(raw: bytes):
|
|
49
|
+
"""Like from_bytes but returns None on any decode/parse error."""
|
|
50
|
+
try:
|
|
51
|
+
return from_bytes(raw)
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_head(store: KVStore, branch: str, *, repair: bool = True) -> str | None:
|
|
57
|
+
"""Resolve a branch HEAD, falling back to prev HEAD or commit scan.
|
|
58
|
+
|
|
59
|
+
When *repair* is True (default), a corrupt HEAD is automatically
|
|
60
|
+
healed by writing the recovered commit hash back to the store.
|
|
61
|
+
Pass ``repair=False`` for side-effect-free reads (e.g. properties).
|
|
62
|
+
|
|
63
|
+
Returns a valid commit hash, or None if unrecoverable.
|
|
64
|
+
"""
|
|
65
|
+
# 1. Try current HEAD
|
|
66
|
+
head_bytes = store.get(BRANCH_HEAD % branch)
|
|
67
|
+
if head_bytes is not None:
|
|
68
|
+
commit_hash = _safe_from_bytes(head_bytes)
|
|
69
|
+
if (
|
|
70
|
+
isinstance(commit_hash, str)
|
|
71
|
+
and store.get(COMMIT_KEYSET % commit_hash) is not None
|
|
72
|
+
):
|
|
73
|
+
return commit_hash
|
|
74
|
+
|
|
75
|
+
# 2. Try previous HEAD (backup written before each CAS)
|
|
76
|
+
prev_bytes = store.get(BRANCH_HEAD_PREV % branch)
|
|
77
|
+
if prev_bytes is not None:
|
|
78
|
+
commit_hash = _safe_from_bytes(prev_bytes)
|
|
79
|
+
if (
|
|
80
|
+
isinstance(commit_hash, str)
|
|
81
|
+
and store.get(COMMIT_KEYSET % commit_hash) is not None
|
|
82
|
+
):
|
|
83
|
+
logger.warning(
|
|
84
|
+
"Branch '%s': HEAD corrupt, recovered from prev HEAD", branch
|
|
85
|
+
)
|
|
86
|
+
if repair:
|
|
87
|
+
store.set(BRANCH_HEAD % branch, to_bytes(commit_hash))
|
|
88
|
+
return commit_hash
|
|
89
|
+
|
|
90
|
+
# 3. HEAD existed but is corrupt and no prev — scan for best commit
|
|
91
|
+
if head_bytes is not None:
|
|
92
|
+
commit_hash = _scan_for_best_commit(store, branch)
|
|
93
|
+
if commit_hash is not None:
|
|
94
|
+
logger.warning(
|
|
95
|
+
"Branch '%s': HEAD corrupt, recovered via commit scan", branch
|
|
96
|
+
)
|
|
97
|
+
if repair:
|
|
98
|
+
store.set(BRANCH_HEAD % branch, to_bytes(commit_hash))
|
|
99
|
+
return commit_hash
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _scan_for_best_commit(store: KVStore, branch: str) -> str | None:
|
|
105
|
+
"""Scan the store for the best valid commit for a corrupt branch.
|
|
106
|
+
|
|
107
|
+
Finds all valid commits, excludes those reachable from healthy branches,
|
|
108
|
+
and returns the most recent tip.
|
|
109
|
+
"""
|
|
110
|
+
# Collect all valid commits with timestamps
|
|
111
|
+
ks_prefix = COMMIT_KEYSET.replace("%s", "")
|
|
112
|
+
meta_prefix = META_KEY.replace("%s", "")
|
|
113
|
+
all_commits: dict[str, float] = {}
|
|
114
|
+
for key in store.keys():
|
|
115
|
+
if not isinstance(key, str) or not key.startswith(ks_prefix):
|
|
116
|
+
continue
|
|
117
|
+
h = key[len(ks_prefix) :]
|
|
118
|
+
if not h:
|
|
119
|
+
continue
|
|
120
|
+
meta_bytes = store.get(meta_prefix + h)
|
|
121
|
+
ts = 0.0
|
|
122
|
+
if meta_bytes is not None:
|
|
123
|
+
try:
|
|
124
|
+
meta = json.loads(meta_bytes)
|
|
125
|
+
ts = max((e.get("created_at", 0) for e in meta.values()), default=0)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
all_commits[h] = ts
|
|
129
|
+
|
|
130
|
+
if not all_commits:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Exclude commits reachable from healthy branches
|
|
134
|
+
claimed: set[str] = set()
|
|
135
|
+
head_prefix = BRANCH_HEAD.replace("%s", "")
|
|
136
|
+
for key in store.keys():
|
|
137
|
+
if not isinstance(key, str) or not key.startswith(head_prefix):
|
|
138
|
+
continue
|
|
139
|
+
other = key[len(head_prefix) :]
|
|
140
|
+
if other == branch or not other:
|
|
141
|
+
continue
|
|
142
|
+
hb = store.get(key)
|
|
143
|
+
if hb is None:
|
|
144
|
+
continue
|
|
145
|
+
h = _safe_from_bytes(hb)
|
|
146
|
+
if not isinstance(h, str) or store.get(COMMIT_KEYSET % h) is None:
|
|
147
|
+
continue
|
|
148
|
+
# Walk parent chain
|
|
149
|
+
stack = [h]
|
|
150
|
+
while stack:
|
|
151
|
+
c = stack.pop()
|
|
152
|
+
if c in claimed:
|
|
153
|
+
continue
|
|
154
|
+
claimed.add(c)
|
|
155
|
+
pb = store.get(PARENT_COMMIT % c)
|
|
156
|
+
if pb is not None:
|
|
157
|
+
parsed = _safe_from_bytes(pb)
|
|
158
|
+
if isinstance(parsed, str):
|
|
159
|
+
stack.append(parsed)
|
|
160
|
+
elif isinstance(parsed, list):
|
|
161
|
+
stack.extend(p for p in parsed if isinstance(p, str))
|
|
162
|
+
|
|
163
|
+
candidates = {h for h in all_commits if h not in claimed}
|
|
164
|
+
if not candidates:
|
|
165
|
+
candidates = set(all_commits)
|
|
166
|
+
|
|
167
|
+
# Find tips (not a parent of any other candidate)
|
|
168
|
+
all_parents: set[str] = set()
|
|
169
|
+
for h in candidates:
|
|
170
|
+
pb = store.get(PARENT_COMMIT % h)
|
|
171
|
+
if pb is not None:
|
|
172
|
+
parsed = _safe_from_bytes(pb)
|
|
173
|
+
if isinstance(parsed, str):
|
|
174
|
+
all_parents.add(parsed)
|
|
175
|
+
elif isinstance(parsed, list):
|
|
176
|
+
all_parents.update(p for p in parsed if isinstance(p, str))
|
|
177
|
+
tips = candidates - all_parents
|
|
178
|
+
if not tips:
|
|
179
|
+
tips = candidates
|
|
180
|
+
|
|
181
|
+
return max(tips, key=lambda h: all_commits.get(h, 0))
|
|
182
|
+
|
|
183
|
+
|
|
44
184
|
class VersionedKV(VersionedBase):
|
|
45
185
|
"""A commit log over a KV store.
|
|
46
186
|
|
|
@@ -63,10 +203,10 @@ class VersionedKV(VersionedBase):
|
|
|
63
203
|
self.store = store
|
|
64
204
|
|
|
65
205
|
if commit_hash is None:
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
206
|
+
commit_hash = _resolve_head(store, branch)
|
|
207
|
+
if commit_hash is None and store.get(BRANCH_HEAD % branch) is not None:
|
|
208
|
+
raise ValueError(f"Branch '{branch}' HEAD is corrupt and unrecoverable")
|
|
209
|
+
if commit_hash is None:
|
|
70
210
|
# Create initial empty commit
|
|
71
211
|
commit_hash = content_hash((), {}, {})
|
|
72
212
|
initial = {
|
|
@@ -107,10 +247,7 @@ class VersionedKV(VersionedBase):
|
|
|
107
247
|
@property
|
|
108
248
|
def latest_head(self) -> str | None:
|
|
109
249
|
"""Read HEAD directly from the KV store (reflects other writers)."""
|
|
110
|
-
|
|
111
|
-
if head_bytes is not None:
|
|
112
|
-
return from_bytes(head_bytes)
|
|
113
|
-
return None
|
|
250
|
+
return _resolve_head(self.store, self._branch, repair=False)
|
|
114
251
|
|
|
115
252
|
# -- Read operations --
|
|
116
253
|
|
|
@@ -297,8 +434,14 @@ class VersionedKV(VersionedBase):
|
|
|
297
434
|
return merge_hash
|
|
298
435
|
|
|
299
436
|
def _cas_head(self, expected: str, new_head: str) -> bool:
|
|
300
|
-
"""Atomically advance branch HEAD via KVStore CAS.
|
|
437
|
+
"""Atomically advance branch HEAD via KVStore CAS.
|
|
438
|
+
|
|
439
|
+
Saves the current HEAD as prev HEAD before advancing, so a
|
|
440
|
+
corrupt write can be recovered from.
|
|
441
|
+
"""
|
|
301
442
|
branch_key = BRANCH_HEAD % self._branch
|
|
443
|
+
prev_key = BRANCH_HEAD_PREV % self._branch
|
|
444
|
+
self.store.set(prev_key, to_bytes(expected))
|
|
302
445
|
return self.store.cas(
|
|
303
446
|
branch_key, to_bytes(new_head), expected=to_bytes(expected)
|
|
304
447
|
)
|
|
@@ -367,10 +510,10 @@ class VersionedKV(VersionedBase):
|
|
|
367
510
|
|
|
368
511
|
def refresh(self) -> None:
|
|
369
512
|
"""Reload state from HEAD."""
|
|
370
|
-
|
|
371
|
-
if
|
|
513
|
+
commit_hash = _resolve_head(self.store, self._branch)
|
|
514
|
+
if commit_hash is None:
|
|
372
515
|
raise ValueError("No HEAD commit found for branch %s" % self._branch)
|
|
373
|
-
self._load_commit(
|
|
516
|
+
self._load_commit(commit_hash, update_base=True)
|
|
374
517
|
|
|
375
518
|
def checkout(
|
|
376
519
|
self, commit_hash: str, *, branch: str | None = None
|
|
@@ -409,23 +552,25 @@ class VersionedKV(VersionedBase):
|
|
|
409
552
|
|
|
410
553
|
def switch_branch(self, name: str) -> None:
|
|
411
554
|
"""Switch this instance to a different branch in-place."""
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
555
|
+
commit_hash = _resolve_head(self.store, name)
|
|
556
|
+
if commit_hash is None:
|
|
557
|
+
if self.store.get(BRANCH_HEAD % name) is not None:
|
|
558
|
+
raise ValueError(f"Branch '{name}' HEAD is corrupt and unrecoverable")
|
|
415
559
|
raise ValueError(f"Branch '{name}' does not exist")
|
|
416
560
|
self._branch = name
|
|
417
|
-
self._load_commit(
|
|
561
|
+
self._load_commit(commit_hash, update_base=True)
|
|
418
562
|
|
|
419
563
|
def peek(self, key: str, *, branch: str) -> bytes | None:
|
|
420
564
|
"""Read a key from another branch's HEAD without switching."""
|
|
421
|
-
|
|
422
|
-
if
|
|
565
|
+
commit_hash = _resolve_head(self.store, branch)
|
|
566
|
+
if commit_hash is None:
|
|
423
567
|
return None
|
|
424
|
-
commit_hash = from_bytes(head_bytes)
|
|
425
568
|
keyset_bytes = self.store.get(COMMIT_KEYSET % commit_hash)
|
|
426
569
|
if keyset_bytes is None:
|
|
427
570
|
return None
|
|
428
|
-
keyset =
|
|
571
|
+
keyset = _safe_from_bytes(keyset_bytes)
|
|
572
|
+
if not isinstance(keyset, dict):
|
|
573
|
+
return None
|
|
429
574
|
content_hash_ = keyset.get(key)
|
|
430
575
|
if content_hash_ is None:
|
|
431
576
|
return None
|
|
@@ -435,7 +580,13 @@ class VersionedKV(VersionedBase):
|
|
|
435
580
|
"""Reset HEAD to a specific commit."""
|
|
436
581
|
if self.store.get(COMMIT_KEYSET % commit_hash) is None:
|
|
437
582
|
return False
|
|
438
|
-
|
|
583
|
+
branch_key = BRANCH_HEAD % self._branch
|
|
584
|
+
prev_key = BRANCH_HEAD_PREV % self._branch
|
|
585
|
+
# Save current HEAD as prev before overwriting
|
|
586
|
+
current = self.store.get(branch_key)
|
|
587
|
+
if current is not None:
|
|
588
|
+
self.store.set(prev_key, current)
|
|
589
|
+
self.store.set(branch_key, to_bytes(commit_hash))
|
|
439
590
|
self._load_commit(commit_hash, update_base=True)
|
|
440
591
|
return True
|
|
441
592
|
|
|
@@ -489,10 +640,10 @@ class VersionedKV(VersionedBase):
|
|
|
489
640
|
prefix = BRANCH_HEAD.replace("%s", "")
|
|
490
641
|
for key in self.store.keys():
|
|
491
642
|
if isinstance(key, str) and key.startswith(prefix):
|
|
492
|
-
|
|
493
|
-
|
|
643
|
+
branch_name = key[len(prefix) :]
|
|
644
|
+
branch_head = _resolve_head(self.store, branch_name)
|
|
645
|
+
if branch_head is None:
|
|
494
646
|
continue
|
|
495
|
-
branch_head = from_bytes(head_bytes)
|
|
496
647
|
for commit in self.history(commit_hash=branch_head, all_parents=True):
|
|
497
648
|
if commit not in reachable:
|
|
498
649
|
reachable.add(commit)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.9"
|
|
8
8
|
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
9
|
authors = [{ name = "ashenfad" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|