The only writing surface was `device_inventory.notes`, a single TEXT column rendered as plain preformatted text. No structure, no links, no search, and nowhere to write anything that is not about one device. This adds markdown documents, a navigable tree and a Documentation section in the left rail — the first app-level view that is not a canvas. Model — three tables. `documents` (kind: device / node / design / page / folder), `document_revisions` capped at 50 per document, and a `documents_fts` FTS5 index. A document attaches to the *inventory row*, not to a node, so one device reads the same on every canvas. Every link is SET NULL and the title is denormalized: deleting a device or a canvas must never destroy what the user wrote. Foreign keys are off in this SQLite setup, so `doc_links.unlink_documents` clears those columns by hand in the nodes, scan and designs delete paths. FTS5 is optional. The SQLite shipped in the LXC and Docker images may not carry it — `doc_search` probes once at boot and falls back to LIKE, and the search response names the engine it used so the UI can drop snippet highlighting. A device document is scaffolded once, from the scan facts: identity table, hardware, services (one section per service), network, operations, troubleshooting, dependencies, changelog. Then it belongs to the user and nothing rewrites it. The old `notes` are appended verbatim under `## Notes` behind a provenance comment, and `device_inventory.notes` is left untouched — HACS, the YAML export, MCP and canvas search still read it. Drift is surfaced, never corrected: `facts_snapshot` powers a banner, and `GET /documents/blocks` re-renders one generated block from current facts for the editor's slash commands. `review_every` plus `reviewed_at` produce a "needs attention" bucket, because homelab docs die of staleness. The Devices tree is pivoted client-side from the already-loaded inventory — zone, group, type, physical/virtual, subnet, rack, vendor, discovery source, status, tag, A-Z — so re-pivoting costs no request. The Library beside it is real folders the user makes. The Canvas pivot is scoped to the loaded canvas: `NodeData` carries no `design_id`, so naming every canvas a device appears on would be a guess. Saving is explicit, per the product rule. The dirty body is mirrored to localStorage and offered back on reopen; a draft whose base no longer matches the document is discarded rather than replayed. react-markdown is loaded behind React.lazy, so the canvas path does not pay for it: the main bundle is unchanged and the section brings its own 199 kB chunk. Raw HTML is not rendered, which removes the XSS surface and means no sanitizer. Export/import, a print view, an aggregated open-tasks view and images inside a document are deliberately not in this lot. MCP tools are lot 2. ha-relevant: yes
85 lines
3.1 KiB
Python
85 lines
3.1 KiB
Python
"""The documents DDL `create_all` cannot express.
|
|
|
|
A virtual table and three partial indexes, applied through `_try_migrate` at
|
|
boot. Both have to survive a second boot untouched, and the partial uniques are
|
|
what stop two documents claiming the same device behind the route's own check.
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from app.db.database import DOCUMENT_DDL, Base, _try_migrate
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
async def _boot(tmp_path, times: int = 1):
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
|
|
for _ in range(times):
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
for label, sql in DOCUMENT_DDL:
|
|
await _try_migrate(conn, sql, label=label)
|
|
return engine
|
|
|
|
|
|
async def _names(engine, kind: str) -> set[str]:
|
|
async with engine.begin() as conn:
|
|
rows = (
|
|
await conn.exec_driver_sql(f"SELECT name FROM sqlite_master WHERE type = '{kind}'")
|
|
).fetchall()
|
|
return {row[0] for row in rows}
|
|
|
|
|
|
async def test_the_tables_and_the_search_index_are_created(tmp_path):
|
|
engine = await _boot(tmp_path)
|
|
tables = await _names(engine, "table")
|
|
assert {"documents", "document_revisions", "documents_fts"} <= tables
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_the_partial_uniques_are_created(tmp_path):
|
|
engine = await _boot(tmp_path)
|
|
indexes = await _names(engine, "index")
|
|
assert {"ux_documents_device", "ux_documents_node", "ux_documents_design"} <= indexes
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_a_second_boot_changes_nothing(tmp_path):
|
|
engine = await _boot(tmp_path, times=3)
|
|
tables = await _names(engine, "table")
|
|
assert {"documents", "documents_fts"} <= tables
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_two_documents_cannot_claim_the_same_device(tmp_path):
|
|
engine = await _boot(tmp_path)
|
|
async with engine.begin() as conn:
|
|
insert = (
|
|
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
|
|
"frontmatter, tags, starred, created_at, updated_at) "
|
|
"VALUES (?, 'device', 'nas', ?, '', 'dev-1', 0, '{}', '[]', 0, "
|
|
"'2026-09-05', '2026-09-05')"
|
|
)
|
|
await conn.exec_driver_sql(insert, ("a", "nas"))
|
|
with pytest.raises(IntegrityError):
|
|
await conn.exec_driver_sql(insert, ("b", "nas-2"))
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_many_documents_may_have_no_device(tmp_path):
|
|
engine = await _boot(tmp_path)
|
|
async with engine.begin() as conn:
|
|
insert = (
|
|
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
|
|
"frontmatter, tags, starred, created_at, updated_at) "
|
|
"VALUES (?, 'page', 'p', ?, '', NULL, 0, '{}', '[]', 0, "
|
|
"'2026-09-05', '2026-09-05')"
|
|
)
|
|
await conn.exec_driver_sql(insert, ("a", "p1"))
|
|
await conn.exec_driver_sql(insert, ("b", "p2"))
|
|
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM documents")).scalar_one()
|
|
assert count == 2
|
|
await engine.dispose()
|