Skip to content

Add SQLite-backed registry with JSON migration support#15

Open
kdush wants to merge 9 commits into
VectifyAI:mainfrom
kdush:feat/sqlite-storage-backend
Open

Add SQLite-backed registry with JSON migration support#15
kdush wants to merge 9 commits into
VectifyAI:mainfrom
kdush:feat/sqlite-storage-backend

Conversation

@kdush

@kdush kdush commented Apr 11, 2026

Copy link
Copy Markdown

Summary

Add SQLite-backed registry as the default storage backend, with automatic JSON migration support.

Changes

  • add DbRegistry class with SQLite backend, WAL mode, and JSON migration
  • add storage_backend config option (sqlite | json)
  • update CLI commands to use get_registry() factory
  • document storage backend options and migration behavior
  • add tests for SQLite registry, migration, and backend selection

Testing

  • 25 new tests passed for SQLite backend and migration
  • all existing tests pass
  • end-to-end verified: init, add, query, status, list
  • confirmed SQLite creates hashes.db, hashes.db-wal, hashes.db-shm

Backward Compatibility

  • storage_backend: json still works for existing setups
  • automatic migration from hashes.json to hashes.db when switching to SQLite
  • hashes.json preserved after migration for safety

@kdush
kdush force-pushed the feat/sqlite-storage-backend branch from a56ee15 to 9436ad6 Compare April 11, 2026 04:50
@kdush
kdush force-pushed the feat/sqlite-storage-backend branch 2 times, most recently from 71f9b4f to 9124f51 Compare April 12, 2026 13:54
@KylinMountain

Copy link
Copy Markdown
Collaborator

Code review

Found 2 issues:

  1. run_lint was not migrated to get_registry() and still reads hashes.json directly. With SQLite as the new default backend, hashes.json is never created, so hashes is always {} and openkb lint exits with "Nothing to lint — no documents indexed yet" regardless of how many documents are actually indexed. print_list (line 602) and print_status (line 689) were migrated correctly — only this call site was missed.

OpenKB/openkb/cli.py

Lines 541 to 551 in 9124f51

# Skip lint entirely when the KB has no indexed documents
hashes_file = openkb_dir / "hashes.json"
if hashes_file.exists():
hashes = json.loads(hashes_file.read_text(encoding="utf-8"))
else:
hashes = {}
if not hashes:
click.echo("Nothing to lint — no documents indexed yet. Run `openkb add` first.")
return

  1. JSON-to-SQLite migration is not atomic. _init_db() creates and commits the empty schema file before _migrate_from_json() runs. If the process is killed (SIGKILL, OOM, power loss, disk full) between those two steps, the DB file exists on disk but is empty. On the next startup, should_migrate = migrate_from is not None and not path.exists() evaluates to False, so migration is never retried — every entry in hashes.json is silently abandoned and openkb thinks the KB has never been ingested. The JSON file is preserved on disk but never read again. Fix: do _init_db + insert in a single transaction, or use a sentinel (schema_version row, completion marker) instead of file existence to gate migration.

OpenKB/openkb/state.py

Lines 82 to 94 in 9124f51

def __init__(self, path: Path, migrate_from: Path | None = None) -> None:
"""Initialize DbRegistry.
Args:
path: Path to SQLite database file.
migrate_from: Optional path to JSON file to migrate from.
Migration only happens if DB doesn't exist yet.
"""
self._path = path
should_migrate = migrate_from is not None and not path.exists()
self._init_db()
if should_migrate:
self._migrate_from_json(migrate_from)

Coordination note (not an issue, but worth flagging): PR #30 (still open) adds get_by_path and remove_by_doc_name to HashRegistry and calls them from cli.py / converter.py. DbRegistry here does not implement either method, so whichever PR lands second will need to add them to the other backend or users on storage_backend: sqlite will hit AttributeError on re-add.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

kdush added 5 commits May 13, 2026 11:14
review 反馈:当 storage_backend 为 sqlite(默认)时,hashes.json 不存在,
导致 lint 总是误判为无文档。对齐 print_list 和 print_status 的已有实现。
- 用 schema_meta 完成标记替代 DB 文件存在性判断,避免中断后永不重试
- get_registry() 只要 hashes.json 存在即传递 migrate_from,让 DbRegistry 自主判断
- 新增 get_by_path / remove_by_doc_name 到 HashRegistry 与 DbRegistry,
  防止后续 PR VectifyAI#30 合并时 SQLite 后端出现 AttributeError
@kdush
kdush force-pushed the feat/sqlite-storage-backend branch from 9124f51 to 8cb4522 Compare May 13, 2026 03:21
@KylinMountain

Copy link
Copy Markdown
Collaborator

Code review

Found 1 issue:

  1. The two prior issues (run_lint registry usage, migration atomicity) are addressed. However, the new get_by_path / remove_by_doc_name methods that commit 8cb4522 claims as "预兼容 PR fix: hashing to avoid doc with duplicate name to collide #30 接口" implement the wrong semantics — they share method names with PR fix: hashing to avoid doc with duplicate name to collide #30 but match on different fields, so once PR fix: hashing to avoid doc with duplicate name to collide #30 lands, neither backend will work as PR fix: hashing to avoid doc with duplicate name to collide #30's callers expect.

    HashRegistry implementations:

    OpenKB/openkb/state.py

    Lines 55 to 74 in 8cb4522

    def get_by_path(self, path: str) -> dict | None:
    """Return metadata for the first entry whose 'path' or 'name' matches."""
    for metadata in self._data.values():
    if metadata.get("path") == path or metadata.get("name") == path:
    return metadata
    return None
    def remove_by_doc_name(self, doc_name: str) -> bool:
    """Remove the first entry whose metadata 'name' matches doc_name.
    Returns True if an entry was removed, False otherwise.
    """
    for file_hash, metadata in list(self._data.items()):
    if metadata.get("name") == doc_name:
    del self._data[file_hash]
    self._persist()
    return True
    return False

    DbRegistry implementations:

    OpenKB/openkb/state.py

    Lines 236 to 266 in 8cb4522

    def get_by_path(self, path: str) -> dict | None:
    """Return metadata for the first entry whose 'path' or 'name' matches."""
    with self._connect() as conn:
    rows = conn.execute("SELECT metadata_json FROM registry").fetchall()
    for (metadata_json,) in rows:
    metadata = json.loads(metadata_json)
    if metadata.get("path") == path or metadata.get("name") == path:
    return metadata
    return None
    def remove_by_doc_name(self, doc_name: str) -> bool:
    """Remove the first entry whose metadata 'name' matches doc_name.
    Returns True if an entry was removed, False otherwise.
    """
    with self._connect() as conn:
    rows = conn.execute(
    "SELECT file_hash, metadata_json FROM registry"
    ).fetchall()
    for file_hash, metadata_json in rows:
    metadata = json.loads(metadata_json)
    if metadata.get("name") == doc_name:
    conn.execute(
    "DELETE FROM registry WHERE file_hash = ?",
    (file_hash,),
    )
    return True
    return False
    @staticmethod

    Fix: align the matching keys with PR fix: hashing to avoid doc with duplicate name to collide #30get_by_path should check path, raw_path, and source_path; remove_by_doc_name should match metadata["doc_name"] and remove all matching entries (PR fix: hashing to avoid doc with duplicate name to collide #30 expects multi-delete with None return).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@KylinMountain

Copy link
Copy Markdown
Collaborator

A few smaller follow-ups from the same pass that didn't make it into the main review — none are blocking, just worth tracking:

Migration sentinel write window. _migrate_from_json and _mark_migration_complete are two separate _connect() transactions. If the process dies after the migration rows commit but before the sentinel row commits, data is safely migrated, but _is_migration_complete() returns False forever and _is_empty() returns False, so the guard not _is_migration_complete() and _is_empty() is permanently False — the sentinel is never written and every subsequent startup pays the cost of reading schema_meta + counting registry rows just to skip migration. Combining both writes into a single _connect() block closes the window.

OpenKB/openkb/state.py

Lines 100 to 116 in 8cb4522

def __init__(self, path: Path, migrate_from: Path | None = None) -> None:
"""Initialize DbRegistry.
Args:
path: Path to SQLite database file.
migrate_from: Optional path to JSON file to migrate from.
Migration is retried if not previously completed
and the registry table is empty.
"""
self._path = path
self._init_db()
if migrate_from is not None and migrate_from.exists():
if not self._is_migration_complete() and self._is_empty():
self._migrate_from_json(migrate_from)
self._mark_migration_complete()

Backend toggle silently drops JSON-only entries. If a user is on sqlite (sentinel set), edits config to json and adds documents (entries land in hashes.json only), then switches back to sqlite, _is_migration_complete() returns True from step 1's sentinel and the new JSON entries are silently ignored — never imported, no warning. Either re-import when JSON mtime exceeds the sentinel's timestamp, or warn the user when JSON has entries not present in SQLite.

Missing DbRegistry coverage for the new methods. tests/test_state.py and tests/test_db_registry.py exercise get_by_path / remove_by_doc_name against HashRegistry only (or via the wrong matching field, as in the main review). Worth adding direct DbRegistry tests that pass realistic PR #30 inputs (raw_path for the path lookup; a hash slug like report-abc12345 for removal) — those would have caught the semantic mismatch flagged in the main review.

修复 JSON 到 SQLite 迁移完成标记的原子写入
对齐 get_by_path 和 remove_by_doc_name 与 PR VectifyAI#30 的字段契约
补充 HashRegistry 与 DbRegistry 的回归测试
@kdush

kdush commented May 23, 2026

Copy link
Copy Markdown
Author

Addressed the review feedback in 3451b79.

Changes:

  • Made JSON-to-SQLite migration completion marker write atomic with the migrated rows, with rollback on failure.
  • Aligned HashRegistry and DbRegistry with PR fix: hashing to avoid doc with duplicate name to collide #30 semantics:
    • get_by_path() now checks path, raw_path, and source_path.
    • remove_by_doc_name() now matches doc_name, deletes all matching entries, and returns None.
  • Added regression coverage for both JSON and SQLite registry backends.

Validation:

  • pytest tests/test_state.py tests/test_db_registry.py tests/test_migration.py tests/test_cli.py tests/test_config_storage_backend.py tests/test_lint_cli.py → 52 passed
  • pytest → 254 passed, 1 warning
  • ruff check openkb/state.py tests/test_state.py tests/test_db_registry.py → passed

同步 origin/main 并解决 state、CLI remove 和相关测试冲突
保持 SQLite registry 默认 backend 与上游 remove 流程兼容
@kdush

kdush commented May 23, 2026

Copy link
Copy Markdown
Author

Resolved the merge conflicts with origin/main in e2260a0.

Notes:

  • Kept the SQLite registry fixes from this PR while preserving upstream remove_by_hash support.
  • Updated openkb remove to use get_registry() with the configured storage backend, matching the SQLite default backend.
  • Adjusted remove tests to read through the registry abstraction instead of assuming hashes.json when SQLite is active.

Validation:

  • pytest tests/test_state.py tests/test_db_registry.py tests/test_cli.py tests/test_remove.py → 105 passed
  • pytest → 555 passed, 5 warnings
  • ruff check openkb/state.py tests/test_state.py tests/test_db_registry.py tests/test_cli.py tests/test_remove.py → passed

PR is now mergeable on GitHub.

@kdush

kdush commented May 30, 2026

Copy link
Copy Markdown
Author

Synced to the latest main (ad7d146) — the only overlap was openkb/cli.py, which merged cleanly: this PR's run_lintget_registry() fix is preserved and now sits correctly on top of the new openkb/lint.py module from #68.

Status recap — all earlier review feedback is addressed and there are no open items on my side:

  • run_lint reads through get_registry() (no more direct hashes.json access under the SQLite default).
  • JSON→SQLite migration completion marker is written atomically with the migrated rows, with rollback on failure.
  • get_by_path checks path / raw_path / source_path; remove_by_doc_name matches doc_name, deletes all matches, returns None — aligned with fix: hashing to avoid doc with duplicate name to collide #30's semantics on both backends, with regression coverage.

Validation on the merged branch:

  • uv run pytest -q646 passed

One coordination question: how would you like to sequence this with #30, given the shared get_by_path / remove_by_doc_name contract? Happy to rebase on top of whichever lands first. Could I get a re-review when you have a moment? 🙏

@KylinMountain

Copy link
Copy Markdown
Collaborator

@kdush 建议先用cc先review 没问题了,我在帮你看哈。。。我找cc review了还是有不少问题

@cnndabbler

Copy link
Copy Markdown
Contributor

Adopted this alongside #86 (mutation locks) on current main — merges cleanly, 705 tests pass on the combined branch. The JSON→SQLite migration worked correctly on a real populated registry (165 docs migrated to hashes.db, dedup verified, old hashes.json preserved). Composes well with #86's locking. 👍

KylinMountain added a commit that referenced this pull request Jul 21, 2026
…es (#198)

* feat(api): delete a knowledge base (POST /api/v1/kb/delete + openkb delete-kb)

Physical, irreversible KB deletion with a type-the-name confirmation.

- config.delete_kb: rmtree the KB directory + unregister it from the global
  registry (known_kbs / kb_aliases / default_kb, via the new unregister_kb).
  Guards against deleting a non-KB path; tolerates a ghost registry entry
  (directory already gone) by just unregistering.
- POST /api/v1/kb/delete: confirm_name must equal kb, re-checked server-side.
  Lives in a new api_kbs_router (sibling of the config/graph/output routers) so
  api.py stays under the 800-line gate; delete_kb self-locks (config lock), no
  create_app closure needed.
- openkb delete-kb NAME: type-the-name prompt (or --yes) then delete.

Tests: endpoint (success + unregister, confirm mismatch, non-KB target) in
test_api.py; config (physical delete + unregister, refuse non-KB, ghost
tolerance) in test_config.py.

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* feat(api): delete a wiki page (POST /api/v1/page/delete) with backlink impact

Delete a concept/entity page and degrade references cleanly instead of leaving
them dangling:

- page_ops.delete_wiki_page: dry_run reports the backlink pages (whose inbound
  [[wikilinks]] would be demoted) without touching anything; execute removes the
  page under the KB ingest lock, strips its index.md entry outright
  (compiler.remove_doc_from_index — the entry is a [[link]] line), and demotes
  the now-dangling inbound [[links]] to plain text in ONLY those backlink pages
  (lint.fix_broken_links restrict_to). The page ref is "section/stem", validated
  to concepts/ or entities/ only (path-traversal-safe).
- New api_pages_router hosts POST /api/v1/page (read — relocated here from
  api.py to keep it under the 800-line gate) + POST /api/v1/page/delete.

Tests: dry-run impact + execute (page gone, inbound link demoted to text, index
entry removed, sibling kept), 404 on missing page, and rejection of unsafe /
non-editable refs.

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* feat(api): edit a wiki page (PUT /api/v1/page) + link context (POST /api/v1/page/links)

- page_ops.edit_wiki_page: replace a concept/entity page's BODY while preserving
  its code-managed OKF frontmatter (type/description/sources) verbatim; any
  frontmatter in the submitted content is dropped. Dead [[wikilinks]] the user
  typed are demoted to plain text on save (OpenKB keeps the wiki broken-link-free)
  and returned as ghosts_stripped so the UI can warn. Atomic write under the KB
  ingest lock.
- page_ops.page_link_context (POST /api/v1/page/links): outbound + inbound links
  for the edit-impact panel. Editing the body does not break either (links are
  path-based), so this is context, not a blocker — the honest impact model.
- PUT /api/v1/page added to api_pages_router.

Tests: edit preserves frontmatter + keeps a resolvable link + demotes a dead one
(reports ghosts_stripped) + replaces the body; 404 on a missing page; links
reports out/backlinks.

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* feat(web): delete a knowledge base from the settings sheet (type-name confirm)

Adds a Danger Zone section to KbSettingsSheet: a type-the-name confirmation
gates deleteKb() (POST /api/v1/kb/delete); on success KbDetail navigates back
to the KB list. New deleteKb client + kbSettings danger* keys + common cancel
(zh + en, identical key sets).

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* feat(web): in-reader page edit + delete with impact preview (F2/F3)

For an open concept/entity page, the reader header now shows Edit and Delete:
- Delete: a dry-run first (deletePage dryRun) surfaces the backlink pages whose
  [[links]] will demote to plain text; a red confirm card lists them, then the
  real delete closes the page and refreshes the inventory.
- Edit: a body textarea (frontmatter is preserved server-side), Save via
  PUT /api/v1/page, a links panel (getPageLinks: out/backlinks), a "recompile
  may overwrite" note, and a toast listing any dead links demoted to text.

Adds the deletePage/editPage/getPageLinks wiki clients and kb:pageOps.* locale
keys (zh + en, identical key sets). Build green (i18n guard + tsc + vite).

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* fix(workbench): address code-review findings (content-mgmt correctness + concurrency)

Backend (page_ops / kb_admin / lint):
- delete/edit now do their read-modify-write INSIDE the KB ingest lock and
  re-check the page exists under it: no stale backlink snapshot, no resurrecting
  a concurrently-deleted page, no 500 on a racing unlink (missing_ok=True). [#4,#5,#6]
- delete_kb serializes rmtree under the ingest lock (was fully unlocked). Moved
  delete_kb/unregister_kb into a new openkb/kb_admin.py so config.py drops back
  under the per-file line gate (751 lines). [#1,#15]
- page deletion no longer scans or rewrites lint's _EXCLUDED_FILES
  (AGENTS.md/SCHEMA.md/log.md); fix_broken_links(restrict_to) also refuses them,
  which protects `openkb remove` too. [#2]
- edit_wiki_page treats `content` as the body verbatim — no naive frontmatter
  split that silently dropped a body opening with a '---' block. [#3]
- delete uses the real on-disk stem for the exact-match index-entry removal
  (case-insensitive FS), and adds index.md to the demotion set so a [[target]]
  embedded in another entry's brief no longer dangles. [#7,#9]

API / CLI:
- delete-KB endpoint & CLI can now clean a ghost registry entry (registered name
  whose directory was removed by hand), catch delete_kb's ValueError -> 400, and
  the endpoint is typed `-> KbDeleteResponse`. [#8,#13,E5]

Frontend (KbDetail):
- Deleting a KB dispatches `openkb:reload-kbs` so the sidebar refreshes
  immediately (was stale until a manual reload) — live-test fix.
- Removed the dead `pageReloadSeq` path (editPage always returns content). [#14]

Tests: excluded-doc-untouched, '---'-body preservation, ghost-KB unregister;
non-KB-target test updated to the new resolution model. Backend 1234 passed,
mypy/ruff clean, frontend build green.

Not changed (intentional): pages_linking_to is NOT folded into build_graph (that
excludes explorations/ backlinks) [#10]; the preview+confirm delete inherently
scans per request [#11]; EDITABLE_SECTIONS stays duplicated in the frontend,
which can't import the Python constant [#12].

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* feat(workbench): allow editing summary pages (edit ⊇ delete section sets)

Summaries are compiled markdown like concepts/entities (a recompile regenerates
them), so they are now editable via PUT /api/v1/page. They remain NOT
independently deletable — a summary is removed by deleting its source document.

Split page_ops into EDITABLE_SECTIONS (concepts/entities/summaries) vs
DELETABLE_SECTIONS (concepts/entities); validate_page_ref takes the allowlist.
Frontend ReaderBody gates Edit on canEdit (incl. summaries), Delete on canDelete.

Test: summary editable (frontmatter preserved) + summary delete rejected (400).

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG

* fix(api): cross-platform delete_kb + endpoint OSError handling (xhigh review #1,#2)

- delete_kb no longer holds the ingest lock DURING rmtree (the lock file lives
  inside the KB dir; Windows cannot delete an open file — the prior review-fix
  regressed this). It now takes the lock as a BARRIER (drain + wait out any
  in-flight mutation), releases it, re-checks existence, then rmtrees. [#1]
- delete-KB endpoint maps FileNotFoundError to an idempotent success (a
  concurrent delete already removed the tree) and other OSError to a clean 500
  with a message, instead of an uncaught 500 stack trace. [#2]

Tests: endpoint OSError -> clean 500, FileNotFoundError -> 200 deleted.

Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants