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.
Files changed (35) hide show
  1. {kvgit-0.1.8 → kvgit-0.1.9}/PKG-INFO +1 -1
  2. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/indexeddb.py +7 -3
  3. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/kv.py +175 -24
  4. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/PKG-INFO +1 -1
  5. {kvgit-0.1.8 → kvgit-0.1.9}/pyproject.toml +1 -1
  6. {kvgit-0.1.8 → kvgit-0.1.9}/LICENSE +0 -0
  7. {kvgit-0.1.8 → kvgit-0.1.9}/README.md +0 -0
  8. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/__init__.py +0 -0
  9. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/content_types.py +0 -0
  10. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/encoding.py +0 -0
  11. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/errors.py +0 -0
  12. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/__init__.py +0 -0
  13. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/base.py +0 -0
  14. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/composite.py +0 -0
  15. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/disk.py +0 -0
  16. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/kv/memory.py +0 -0
  17. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/namespaced.py +0 -0
  18. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/py.typed +0 -0
  19. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/staged.py +0 -0
  20. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/store.py +0 -0
  21. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/__init__.py +0 -0
  22. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/base.py +0 -0
  23. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/gp.py +0 -0
  24. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/helpers.py +0 -0
  25. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/merge.py +0 -0
  26. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit/versioned/protocol.py +0 -0
  27. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/SOURCES.txt +0 -0
  28. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/dependency_links.txt +0 -0
  29. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/requires.txt +0 -0
  30. {kvgit-0.1.8 → kvgit-0.1.9}/kvgit.egg-info/top_level.txt +0 -0
  31. {kvgit-0.1.8 → kvgit-0.1.9}/setup.cfg +0 -0
  32. {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_content_types.py +0 -0
  33. {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_namespaced.py +0 -0
  34. {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_staged.py +0 -0
  35. {kvgit-0.1.8 → kvgit-0.1.9}/tests/test_store_factory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -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
- from js import Uint8Array # type: ignore[import-not-found]
127
+ try:
128
+ from js import Uint8Array # type: ignore[import-not-found]
128
129
 
129
- arr = Uint8Array.new(js_value)
130
- return arr.to_py().tobytes()
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
- head_bytes = store.get(BRANCH_HEAD % branch)
67
- if head_bytes is not None:
68
- commit_hash = from_bytes(head_bytes)
69
- else:
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
- head_bytes = self.store.get(BRANCH_HEAD % self._branch)
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
- head_bytes = self.store.get(BRANCH_HEAD % self._branch)
371
- if head_bytes is None:
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(from_bytes(head_bytes), update_base=True)
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
- branch_key = BRANCH_HEAD % name
413
- head_bytes = self.store.get(branch_key)
414
- if head_bytes is None:
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(from_bytes(head_bytes), update_base=True)
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
- head_bytes = self.store.get(BRANCH_HEAD % branch)
422
- if head_bytes is None:
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 = from_bytes(keyset_bytes)
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
- self.store.set(BRANCH_HEAD % self._branch, to_bytes(commit_hash))
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
- head_bytes = self.store.get(key)
493
- if head_bytes is None:
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.1.8"
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