Skip to content

qmd_py.store

The service-layer facade: collection/context CRUD, content-addressed ingest, document retrieval (get/multi-get/ls), and the ACL choke points every one of those goes through.

store

Service/CRUD layer - the single facade the CLI, MCP server, and any future REST API all call through (fixes the TS reference implementation's inconsistency #3, where the SDK's searchLex/searchVector silently skipped behavior the CLI's search/vsearch commands did: one code path per operation here, not two that can drift apart).

Every function that touches a specific collection's data resolves the Collection row and calls can_access() (see auth.py) before proceeding - mocked to always allow today, but the choke point is real from day one.

This was one 1000-line module; it is now a package split by responsibility. The whole public API is re-exported here, so from qmd_py.store import ... keeps working exactly as before and no caller needed changing. Submodules are layered to avoid cycles:

_common  -> (nothing in-package)
cleanup  -> _common
documents-> _common
collection, context, indexing, retrieval -> the above

DEFAULT_MULTI_GET_MAX_BYTES module-attribute

DEFAULT_MULTI_GET_MAX_BYTES = 65536

The TS reference's real default - its own --help text says "10KB", but the code's actual default is 64KB (one of the plan's flagged inconsistencies to fix, not reproduce).

CollectionNotFoundError

Bases: Exception

No collection of that name is visible to the caller.

Also what a non-owner gets for a collection that does exist, since lookups prefilter on ownership in SQL before can_access() is consulted - see auth.py.

PermissionDeniedError

Bases: Exception

The collection was found, but can_access() refused the operation.

Unreachable while can_access() is mocked to always allow; the call sites raise it so a real check needs no new error handling.

CollectionListRow dataclass

CollectionListRow(
    name: str,
    path: str,
    pattern: str,
    doc_count: int,
    active_count: int,
    last_modified: datetime | None,
    include_by_default: bool,
)

One row of list_collections(), with its document statistics.

Attributes:

  • name (str) –

    Collection name, unique per owner.

  • path (str) –

    Filesystem path the collection indexes.

  • pattern (str) –

    Glob used when walking that path.

  • doc_count (int) –

    Active documents. Currently identical to active_count - the distinction is reserved for when inactive (deactivated but not yet reclaimed) rows are counted separately.

  • active_count (int) –

    Documents currently on disk and indexed.

  • last_modified (datetime | None) –

    Newest modified_at among the active documents, or None for an empty collection.

  • include_by_default (bool) –

    Whether unscoped queries search this collection.

RemoveCollectionResult dataclass

RemoveCollectionResult(
    deleted_docs: int, cleaned_hashes: int
)

What remove_collection() reclaimed.

Attributes:

  • deleted_docs (int) –

    Document rows removed, active and inactive alike.

  • cleaned_hashes (int) –

    Content rows dropped because no document anywhere still referenced them. Lower than deleted_docs whenever another collection shares the same file content, since content is addressed by hash and shared across collections.

CollectionMissingContext dataclass

CollectionMissingContext(
    name: str, path: str, doc_count: int
)

A collection with no context set at all.

Attributes:

  • name (str) –

    Collection name.

  • path (str) –

    Its indexed filesystem path.

  • doc_count (int) –

    Active documents in it - a rough measure of how much search quality is being left on the table.

ContextRow dataclass

ContextRow(collection: str, path: str, context: str)

One stored context entry, flattened for listing.

Attributes:

  • collection (str) –

    Owning collection's name.

  • path (str) –

    The prefix this context applies to; "" means the whole collection.

  • context (str) –

    The prose itself.

ReindexResult dataclass

ReindexResult(
    indexed: int,
    updated: int,
    unchanged: int,
    removed: int,
    orphaned_cleaned: int,
    skipped_oversize: int = 0,
)

Summary counts from one reindex_collection() pass.

Informational only - the CLI prints them - so they are not relied on for correctness anywhere. Files skipped as unreadable or blank appear in no bucket at all, so the four document counts need not sum to the number of files on disk.

Attributes:

  • indexed (int) –

    Files that had no document row and got one.

  • updated (int) –

    Existing documents whose content or title changed. Also counts a file reappearing after deactivation, and a title-only change with an unchanged body hash.

  • unchanged (int) –

    Active documents whose hash and title both matched, so nothing was written.

  • removed (int) –

    Documents deactivated because their file is no longer on disk. Deactivated, not deleted - marq cleanup reclaims them.

  • orphaned_cleaned (int) –

    Content rows dropped because no document, active or inactive, still referenced them.

  • skipped_oversize (int) –

    Files skipped for exceeding MAX_INDEXABLE_BYTES - the one skip reason that gets its own visible count, because silently dropping a legitimate (if huge) file from the index would otherwise look like a search bug.

CollectionStatus dataclass

CollectionStatus(
    name: str,
    path: str,
    pattern: str,
    doc_count: int,
    last_updated: datetime | None,
)

Per-collection line of get_status().

Attributes:

  • name (str) –

    Collection name.

  • path (str) –

    Indexed filesystem path.

  • pattern (str) –

    Glob used to walk it.

  • doc_count (int) –

    Active documents.

  • last_updated (datetime | None) –

    Newest document mtime, or None when empty.

DocumentDetail dataclass

DocumentDetail(
    filepath: str,
    display_path: str,
    title: str,
    context: str | None,
    hash: str,
    docid: str,
    collection_name: str,
    modified_at: datetime,
    body_length: int,
    body: str,
)

One resolved document, body included.

Carries the path in three forms because different surfaces want different ones - MCP resources use the URI, CLI output the readable form, glob matching all three.

Attributes:

  • filepath (str) –

    Virtual URI, marq://<collection>/<path>.

  • display_path (str) –

    <collection>/<path> - what CLI output prints.

  • title (str) –

    Extracted heading, or the filename stem as fallback.

  • context (str | None) –

    Hierarchical context for this path (global first, then each matching prefix, most general first), or None if none applies.

  • hash (str) –

    Full SHA-256 of the body.

  • docid (str) –

    First 6 chars of hash - what #abc123 lookups use. Short enough to collide in principle; resolution is deterministic.

  • collection_name (str) –

    Owning collection.

  • modified_at (datetime) –

    Source file's mtime at index time, not the row's.

  • body_length (int) –

    len(body) in characters, not bytes.

  • body (str) –

    Full document text.

DocumentNotFound dataclass

DocumentNotFound(query: str, similar_files: list[str])

Returned instead of raising when a lookup finds nothing.

A miss is an ordinary outcome here - the caller is usually a person mistyping a path - so it is a value to render, not an exception.

Attributes:

  • query (str) –

    The lookup string as given, so the caller can echo it back.

  • similar_files (list[str]) –

    Up to five paths within Levenshtein distance 5, for a "did you mean" hint. Empty when the query looked like a docid, since edit distance over hex is meaningless.

FileRow dataclass

FileRow(
    path: str, title: str, modified_at: datetime, size: int
)

One row of list_files() - metadata only, no body.

Attributes:

  • path (str) –

    Path relative to the collection root.

  • title (str) –

    Extracted heading or filename stem.

  • modified_at (datetime) –

    Source file's mtime as recorded at index time.

  • size (int) –

    Body length in characters, computed in SQL.

GlobMatch dataclass

GlobMatch(
    filepath: str, display_path: str, body_length: int
)

One glob hit, without its body.

Attributes:

  • filepath (str) –

    Virtual URI, marq://<collection>/<path>.

  • display_path (str) –

    Bare relative path, not collection-prefixed - ambiguous across collections, but kept for parity with the TS reference's own convention.

  • body_length (int) –

    Character count, fetched with length() in SQL so the body itself is never loaded.

MultiGetFile dataclass

MultiGetFile(
    filepath: str,
    display_path: str,
    title: str,
    body: str,
    context: str | None,
    skipped: bool,
    docid: str | None = None,
    skip_reason: str | None = None,
)

One document from a multi_get() batch, possibly skipped.

Attributes:

  • filepath (str) –

    Virtual URI, marq://<collection>/<path>.

  • display_path (str) –

    <collection>/<path>.

  • title (str) –

    Extracted heading or filename stem.

  • body (str) –

    Document text, already truncated and line-numbered per the call's arguments. Empty string when skipped.

  • context (str | None) –

    Hierarchical context for this path, or None.

  • skipped (bool) –

    True when the file exceeded max_bytes and was not read.

  • docid (str | None) –

    Six-char hash prefix, for a follow-up get.

  • skip_reason (str | None) –

    Human-readable explanation, set only when skipped.

StatusInfo dataclass

StatusInfo(
    total_documents: int,
    collections: list[CollectionStatus],
)

Index summary behind marq status.

Attributes:

  • total_documents (int) –

    Active documents across every readable collection.

  • collections (list[CollectionStatus]) –

    Per-collection detail, ordered by name.

add_line_numbers

add_line_numbers(text: str, start_line: int = 1) -> str

Prefix each line with N:.

Parameters:

  • start_line (int, default: 1 ) –

    Number for the first line, so a slice taken from partway through a document still reports true line numbers.

Returns:

  • str

    The text with every line prefixed. A trailing newline yields a

  • str

    numbered empty final line, since the split is unconditional.

Source code in src/qmd_py/store/_common.py
def add_line_numbers(text: str, start_line: int = 1) -> str:
    """Prefix each line with `N: `.

    Args:
        start_line: Number for the first line, so a slice taken from
            partway through a document still reports true line numbers.

    Returns:
        The text with every line prefixed. A trailing newline yields a
        numbered empty final line, since the split is unconditional.
    """
    lines = text.split("\n")
    return "\n".join(f"{start_line + i}: {line}" for i, line in enumerate(lines))

extract_title

extract_title(content: str, filename: str) -> str

Port of the TS reference's extractTitle(): first #/## markdown heading (skipping a generic "Notes" heading in favor of the next one, a quirk carried over from the TS version's own note-taking-app interop), #+TITLE:/org heading for .org files, else the filename without its extension.

Source code in src/qmd_py/store/_common.py
def extract_title(content: str, filename: str) -> str:
    """Port of the TS reference's `extractTitle()`: first `#`/`##` markdown
    heading (skipping a generic "Notes" heading in favor of the next one,
    a quirk carried over from the TS version's own note-taking-app
    interop), `#+TITLE:`/org heading for `.org` files, else the filename
    without its extension."""
    ext = filename[filename.rfind(".") :].lower() if "." in filename else ""

    if ext == ".md":
        match = _MD_HEADING.search(content)
        if match:
            title = match.group(1).strip()
            if title in ("\U0001f4dd Notes", "Notes"):
                next_match = re.search(r"^##\s+(.+)$", content, re.MULTILINE)
                if next_match:
                    return next_match.group(1).strip()
            return title
    elif ext == ".org":
        prop_match = _ORG_TITLE_PROP.search(content)
        if prop_match:
            return prop_match.group(1).strip()
        heading_match = _ORG_HEADING.search(content)
        if heading_match:
            return heading_match.group(1).strip()

    stem = re.sub(r"\.[^.]+$", "", filename)
    return stem.rsplit("/", 1)[-1] or filename

hash_content

hash_content(content: str) -> str

SHA256 hex digest - matches the TS reference's hashContent() exactly (content-addressable hashes must agree byte-for-byte with the TS side for the Phase 3 parity check to mean anything).

Sync: pure CPU, no I/O to await. It was async only because the TS original returns a Promise.

Source code in src/qmd_py/store/_common.py
def hash_content(content: str) -> str:
    """SHA256 hex digest - matches the TS reference's `hashContent()`
    exactly (content-addressable hashes must agree byte-for-byte with the
    TS side for the Phase 3 parity check to mean anything).

    Sync: pure CPU, no I/O to await. It was `async` only because the TS
    original returns a Promise.
    """
    return hashlib.sha256(content.encode()).hexdigest()

utcnow

utcnow() -> datetime

Timezone-aware current time.

Returns:

  • datetime

    datetime.now(UTC) - aware, not naive, since every timestamp

  • datetime

    column is TIMESTAMP WITH TIME ZONE and psycopg rejects naive

  • datetime

    values for those.

Source code in src/qmd_py/store/_common.py
def utcnow() -> datetime:
    """Timezone-aware current time.

    Returns:
        `datetime.now(UTC)` - aware, not naive, since every timestamp
        column is `TIMESTAMP WITH TIME ZONE` and psycopg rejects naive
        values for those.
    """
    return datetime.now(UTC)

cleanup_orphaned_content async

cleanup_orphaned_content(session: AsyncSession) -> int

A hash is only truly orphaned once NO document row - active or inactive - references it: document.hash is a NOT NULL FK with no ON DELETE CASCADE, so Postgres blocks deleting a content row an inactive document still points to, even though search/retrieval never reads inactive documents. Filtering this query to only active documents (as an earlier version did) raised a FK violation the first time an inactive document was the last remaining reference. Run delete_inactive_documents first (see the cleanup command) to actually reclaim content orphaned only by now-abandoned inactive rows.

Returns:

  • int

    Number of content rows deleted.

Source code in src/qmd_py/store/cleanup.py
async def cleanup_orphaned_content(session: AsyncSession) -> int:
    """A hash is only truly orphaned once NO document row - active or
    inactive - references it: `document.hash` is a NOT NULL FK with no
    `ON DELETE CASCADE`, so Postgres blocks deleting a content row an
    *inactive* document still points to, even though search/retrieval
    never reads inactive documents. Filtering this query to only active
    documents (as an earlier version did) raised a FK violation the first
    time an inactive document was the last remaining reference. Run
    `delete_inactive_documents` first (see the `cleanup` command) to
    actually reclaim content orphaned only by now-abandoned inactive
    rows.

    Returns:
        Number of content rows deleted.
    """
    result = await session.execute(
        delete(Content).where(~col(Content.hash).in_(select(col(Document.hash))))
    )
    await session.flush()
    return affected_rows(result)

delete_inactive_documents async

delete_inactive_documents(
    session: AsyncSession, user: CurrentUser
) -> int

Hard-delete deactivated document rows across a user's collections.

Removes the rows only; their content is cleanup_orphaned_content's job, and has to run after this to reclaim bodies whose last remaining reference was one of these inactive rows.

Deliberately irreversible: once these are gone, a file that reappears is indexed as a new document rather than reactivating its old row.

Returns:

  • int

    Number of document rows deleted; 0 when the user has no

  • int

    accessible collections.

Source code in src/qmd_py/store/cleanup.py
async def delete_inactive_documents(session: AsyncSession, user: CurrentUser) -> int:
    """Hard-delete deactivated document rows across a user's collections.

    Removes the rows only; their content is `cleanup_orphaned_content`'s
    job, and has to run *after* this to reclaim bodies whose last
    remaining reference was one of these inactive rows.

    Deliberately irreversible: once these are gone, a file that reappears
    is indexed as a new document rather than reactivating its old row.

    Returns:
        Number of document rows deleted; 0 when the user has no
        accessible collections.
    """
    collection_ids = await resolve_collection_ids(session, user, None)
    if not collection_ids:
        return 0
    result = await session.execute(
        delete(Document).where(
            col(Document.collection_id).in_(collection_ids), ~col(Document.active)
        )
    )
    await session.flush()
    return affected_rows(result)

delete_llm_cache async

delete_llm_cache(session: AsyncSession) -> int

Empty the cached-LLM-response table.

Global, not per user: the cache is keyed by prompt, so there is nothing user-scoped to preserve.

Returns:

  • int

    Number of cached responses deleted.

Source code in src/qmd_py/store/cleanup.py
async def delete_llm_cache(session: AsyncSession) -> int:
    """Empty the cached-LLM-response table.

    Global, not per user: the cache is keyed by prompt, so there is
    nothing user-scoped to preserve.

    Returns:
        Number of cached responses deleted.
    """
    result = await session.execute(delete(LlmCache))
    await session.flush()
    return affected_rows(result)

add_collection async

add_collection(
    session: AsyncSession,
    user: CurrentUser,
    name: str,
    path: str,
    pattern: str = "**/*.md",
    ignore: list[str] | None = None,
) -> Collection

Register a new collection. Does not index it.

Indexing is a separate step: call reindex_collection() afterwards (that is what marq collection add does), so a caller can create and populate a collection in one transaction.

Parameters:

  • name (str) –

    Unique per owner, not globally - two users may each have a collection called notes.

  • path (str) –

    Absolute filesystem path to walk. Not validated here; a path that doesn't exist simply yields no files at reindex.

  • pattern (str, default: '**/*.md' ) –

    Glob relative to path. Brace groups are expanded ({src,docs}/**/*.{md,py}), which plain glob cannot do.

  • ignore (list[str] | None, default: None ) –

    Extra glob patterns to skip, on top of the always-excluded hidden files and node_modules/.git/dist-style directories.

Returns:

  • Collection

    The persisted row, with id populated by the flush.

Raises:

  • IntegrityError

    This owner already has a collection of that name (UNIQUE(owner_user_id, name)). Raised on flush, so the caller must roll back before reusing the session.

Source code in src/qmd_py/store/collection.py
async def add_collection(
    session: AsyncSession,
    user: CurrentUser,
    name: str,
    path: str,
    pattern: str = "**/*.md",
    ignore: list[str] | None = None,
) -> Collection:
    """Register a new collection. Does not index it.

    Indexing is a separate step: call `reindex_collection()` afterwards
    (that is what `marq collection add` does), so a caller can create and
    populate a collection in one transaction.

    Args:
        name: Unique per owner, not globally - two users may each have a
            collection called `notes`.
        path: Absolute filesystem path to walk. Not validated here; a
            path that doesn't exist simply yields no files at reindex.
        pattern: Glob relative to `path`. Brace groups are expanded
            (`{src,docs}/**/*.{md,py}`), which plain `glob` cannot do.
        ignore: Extra glob patterns to skip, on top of the always-excluded
            hidden files and `node_modules`/`.git`/`dist`-style directories.

    Returns:
        The persisted row, with `id` populated by the flush.

    Raises:
        sqlalchemy.exc.IntegrityError: This owner already has a collection
            of that name (`UNIQUE(owner_user_id, name)`). Raised on flush,
            so the caller must roll back before reusing the session.
    """
    collection = Collection(
        owner_user_id=user.id, name=name, path=path, pattern=pattern, ignore_patterns=ignore
    )
    session.add(collection)
    await session.flush()
    return collection

get_collection async

get_collection(
    session: AsyncSession, user: CurrentUser, name: str
) -> Collection

Fetch one collection by name, gated for read access.

Returns:

  • Collection

    The ORM row, so callers can read path/pattern/update_command

  • Collection

    directly.

Raises:

Source code in src/qmd_py/store/collection.py
async def get_collection(session: AsyncSession, user: CurrentUser, name: str) -> Collection:
    """Fetch one collection by name, gated for read access.

    Returns:
        The ORM row, so callers can read `path`/`pattern`/`update_command`
        directly.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `read` on it.
    """
    return await _resolve_owned_collection(session, user, name, "read")

list_collections async

list_collections(
    session: AsyncSession, user: CurrentUser
) -> list[CollectionListRow]

Every collection user can read, with per-collection statistics.

Today that is all of them, since can_access() is mocked True - but the filter is real and applied per row, not skipped.

Returns:

Note

Runs one statistics query per collection (an N+1). Fine at the scale this runs at - a handful of collections per user - but the counts would need folding into a single grouped query before that stopped being true.

Source code in src/qmd_py/store/collection.py
async def list_collections(session: AsyncSession, user: CurrentUser) -> list[CollectionListRow]:
    """Every collection `user` can read, with per-collection statistics.

    Today that is all of them, since `can_access()` is mocked True - but
    the filter is real and applied per row, not skipped.

    Returns:
        Rows ordered by collection name.

    Note:
        Runs one statistics query per collection (an N+1). Fine at the
        scale this runs at - a handful of collections per user - but the
        counts would need folding into a single grouped query before that
        stopped being true.
    """
    result = await session.execute(select(Collection).order_by(col(Collection.name)))
    rows = []
    for collection in result.scalars():
        if not await can_read(user, collection):
            continue
        stats = await session.execute(
            select(
                func.count(col(Document.id)),
                func.max(col(Document.modified_at)),
            ).where(col(Document.collection_id) == collection.id, col(Document.active))
        )
        active_count, last_modified = stats.one()
        rows.append(
            CollectionListRow(
                name=collection.name,
                path=collection.path,
                pattern=collection.pattern,
                doc_count=active_count or 0,
                active_count=active_count or 0,
                last_modified=last_modified,
                include_by_default=collection.include_by_default,
            )
        )
    return rows

remove_collection async

remove_collection(
    session: AsyncSession, user: CurrentUser, name: str
) -> RemoveCollectionResult

Delete a collection, its documents, contexts and grants.

Deletes the contexts and grants before the collection row - collectioncontext_collection_id_fkey/ collectiongrant_collection_id_fkey have no ON DELETE CASCADE, so Postgres blocks the collection delete while either still references it (caught live: removing a collection with any per-path context still set raised a raw FK violation instead of succeeding).

Returns:

Raises:

  • CollectionNotFoundError

    No collection of that name owned by user. Note this is also what a non-owner gets, because the lookup prefilters on ownership in SQL - see auth.py.

  • PermissionDeniedError

    can_access() refused admin on it.

Source code in src/qmd_py/store/collection.py
async def remove_collection(
    session: AsyncSession, user: CurrentUser, name: str
) -> RemoveCollectionResult:
    """Delete a collection, its documents, contexts and grants.

    Deletes the contexts and grants *before* the collection row -
    `collectioncontext_collection_id_fkey`/
    `collectiongrant_collection_id_fkey` have no `ON DELETE CASCADE`, so
    Postgres blocks the collection delete while either still references it
    (caught live: removing a collection with any per-path context still
    set raised a raw FK violation instead of succeeding).

    Returns:
        Counts of what was removed; see `RemoveCollectionResult`.

    Raises:
        CollectionNotFoundError: No collection of that name owned by
            `user`. Note this is also what a *non-owner* gets, because the
            lookup prefilters on ownership in SQL - see auth.py.
        PermissionDeniedError: `can_access()` refused `admin` on it.
    """
    collection = await _resolve_owned_collection(session, user, name, "admin")
    deleted = await session.execute(
        delete(Document).where(col(Document.collection_id) == collection.id)
    )
    await session.execute(
        delete(CollectionContext).where(col(CollectionContext.collection_id) == collection.id)
    )
    await session.execute(
        delete(CollectionGrant).where(col(CollectionGrant.collection_id) == collection.id)
    )
    await session.delete(collection)
    await session.flush()
    cleaned = await cleanup_orphaned_content(session)
    return RemoveCollectionResult(
        deleted_docs=affected_rows(deleted),
        cleaned_hashes=cleaned,
    )

rename_collection async

rename_collection(
    session: AsyncSession,
    user: CurrentUser,
    old_name: str,
    new_name: str,
) -> None

Rename a collection in place, keeping its documents and contexts.

Raises:

  • CollectionNotFoundError

    No collection named old_name owned by user.

  • PermissionDeniedError

    can_access() refused admin on it.

  • IntegrityError

    new_name is already taken by another of this owner's collections. Raised on flush, not checked up front.

Source code in src/qmd_py/store/collection.py
async def rename_collection(
    session: AsyncSession, user: CurrentUser, old_name: str, new_name: str
) -> None:
    """Rename a collection in place, keeping its documents and contexts.

    Raises:
        CollectionNotFoundError: No collection named `old_name` owned by
            `user`.
        PermissionDeniedError: `can_access()` refused `admin` on it.
        sqlalchemy.exc.IntegrityError: `new_name` is already taken by
            another of this owner's collections. Raised on flush, not
            checked up front.
    """
    collection = await _resolve_owned_collection(session, user, old_name, "admin")
    collection.name = new_name
    session.add(collection)
    await session.flush()

set_include_by_default async

set_include_by_default(
    session: AsyncSession,
    user: CurrentUser,
    name: str,
    include: bool,
) -> None

Include or exclude this collection from unscoped queries.

Backs marq collection include/exclude. Excluding removes it from the default scope only: an explicit -c <name> still searches it.

Note

Excluding every collection leaves the default scope empty, and an empty scope matches nothing rather than everything. That direction was once inverted - see _filter_by_collections in cli/commands/read.py.

Parameters:

  • include (bool) –

    True to put it back in the default scope, False to drop it.

Raises:

Source code in src/qmd_py/store/collection.py
async def set_include_by_default(
    session: AsyncSession, user: CurrentUser, name: str, include: bool
) -> None:
    """Include or exclude this collection from unscoped queries.

    Backs `marq collection include/exclude`. Excluding removes it from the
    default scope only: an explicit `-c <name>` still searches it.

    Note:
        Excluding *every* collection leaves the default scope empty, and an
        empty scope matches nothing rather than everything. That direction
        was once inverted - see `_filter_by_collections` in
        cli/commands/read.py.

    Args:
        include: True to put it back in the default scope, False to drop it.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `admin` on it.
    """
    collection = await _resolve_owned_collection(session, user, name, "admin")
    collection.include_by_default = include
    session.add(collection)
    await session.flush()

set_update_command async

set_update_command(
    session: AsyncSession,
    user: CurrentUser,
    name: str,
    command: str | None,
) -> None

Set the shell command run before this collection is re-indexed.

Used for collections backed by something that has to be refreshed first - a git pull, an export script - so marq update can bring the source up to date before walking it.

Parameters:

  • command (str | None) –

    Shell command to run from the collection's path, or None to clear it.

Raises:

Source code in src/qmd_py/store/collection.py
async def set_update_command(
    session: AsyncSession, user: CurrentUser, name: str, command: str | None
) -> None:
    """Set the shell command run before this collection is re-indexed.

    Used for collections backed by something that has to be refreshed
    first - a `git pull`, an export script - so `marq update` can bring
    the source up to date before walking it.

    Args:
        command: Shell command to run from the collection's path, or None
            to clear it.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `admin` on it.
    """
    collection = await _resolve_owned_collection(session, user, name, "admin")
    collection.update_command = command
    session.add(collection)
    await session.flush()

add_context async

add_context(
    session: AsyncSession,
    user: CurrentUser,
    collection_name: str,
    path_prefix: str,
    text: str,
) -> None

Attach context prose to a collection, or to a path within it.

Upserts: setting context for a prefix that already has one replaces the text rather than failing or accumulating.

Contexts nest. A document inherits every context whose prefix it starts under, joined most-general-first, so a root context and a journal/ context both reach journal/entry.md.

Parameters:

  • path_prefix (str) –

    Path within the collection, or "" for the whole collection. Matched as a path prefix, not a glob.

  • text (str) –

    The prose to attach. Search results carry it alongside the body so an agent knows what kind of document it is reading.

Raises:

Source code in src/qmd_py/store/context.py
async def add_context(
    session: AsyncSession, user: CurrentUser, collection_name: str, path_prefix: str, text: str
) -> None:
    """Attach context prose to a collection, or to a path within it.

    Upserts: setting context for a prefix that already has one replaces
    the text rather than failing or accumulating.

    Contexts nest. A document inherits every context whose prefix it
    starts under, joined most-general-first, so a root context and a
    `journal/` context both reach `journal/entry.md`.

    Args:
        path_prefix: Path within the collection, or `""` for the whole
            collection. Matched as a path prefix, not a glob.
        text: The prose to attach. Search results carry it alongside the
            body so an agent knows what kind of document it is reading.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `write` on it.
    """
    collection = await _resolve_owned_collection(session, user, collection_name, "write")
    stmt = (
        pg_insert(CollectionContext)
        .values(collection_id=collection.id, path_prefix=path_prefix, context=text)
        .on_conflict_do_update(
            index_elements=["collection_id", "path_prefix"], set_={"context": text}
        )
    )
    await session.execute(stmt)
    await session.flush()

context_check async

context_check(
    session: AsyncSession, user: CurrentUser
) -> tuple[
    list[CollectionMissingContext], dict[str, list[str]]
]

Port of the TS reference's getCollectionsWithoutContext + getTopLevelPathsWithoutContext, combined into the one context check command they jointly back (which the TS CLI never actually wired up despite CLAUDE.md documenting it - see the qmd-py plan's list of fixed TS inconsistencies).

Two different gaps are reported, which is why the return value is a pair: a collection with no context at all, and a collection that has some context but leaves whole top-level directories uncovered. A collection with a root ("") context is never reported for the second, since that covers everything beneath it.

Returns:

  • list[CollectionMissingContext]

    A (collections, paths) pair. collections lists collections

  • dict[str, list[str]]

    with no context, sorted by name. paths maps a collection name to

  • tuple[list[CollectionMissingContext], dict[str, list[str]]]

    its uncovered top-level directories, sorted, and omits collections

  • tuple[list[CollectionMissingContext], dict[str, list[str]]]

    with nothing missing - so an empty dict means fully covered.

Note

ACL (see auth.py): owner-prefiltered in SQL rather than gated through can_access() - same widening needed as list_contexts when grants go live.

Runs two to three queries per collection (an N+1), which is fine for an interactive advisory command.

Source code in src/qmd_py/store/context.py
async def context_check(
    session: AsyncSession, user: CurrentUser
) -> tuple[list[CollectionMissingContext], dict[str, list[str]]]:
    """Port of the TS reference's `getCollectionsWithoutContext` +
    `getTopLevelPathsWithoutContext`, combined into the one `context check`
    command they jointly back (which the TS CLI never actually wired up
    despite CLAUDE.md documenting it - see the qmd-py plan's list of fixed
    TS inconsistencies).

    Two different gaps are reported, which is why the return value is a
    pair: a collection with *no* context at all, and a collection that has
    some context but leaves whole top-level directories uncovered. A
    collection with a root (`""`) context is never reported for the
    second, since that covers everything beneath it.

    Returns:
        A `(collections, paths)` pair. `collections` lists collections
        with no context, sorted by name. `paths` maps a collection name to
        its uncovered top-level directories, sorted, and omits collections
        with nothing missing - so an empty dict means fully covered.

    Note:
        ACL (see auth.py): owner-prefiltered in SQL rather than gated
        through `can_access()` - same widening needed as `list_contexts`
        when grants go live.

        Runs two to three queries per collection (an N+1), which is fine
        for an interactive advisory command.
    """
    collections_result = await session.execute(
        select(Collection).where(col(Collection.owner_user_id) == user.id)
    )
    collections = list(collections_result.scalars())

    missing_context: list[CollectionMissingContext] = []
    missing_paths: dict[str, list[str]] = {}

    for collection in collections:
        contexts_result = await session.execute(
            select(CollectionContext).where(col(CollectionContext.collection_id) == collection.id)
        )
        contexts = list(contexts_result.scalars())

        if not contexts:
            count_result = await session.execute(
                select(func.count(col(Document.id))).where(
                    col(Document.collection_id) == collection.id, col(Document.active)
                )
            )
            missing_context.append(
                CollectionMissingContext(
                    name=collection.name,
                    path=collection.path,
                    doc_count=count_result.scalar_one(),
                )
            )
            continue

        prefixes = {c.path_prefix for c in contexts}
        if "" in prefixes:
            continue  # root context covers the whole collection

        paths_result = await session.execute(
            select(col(Document.path)).where(
                col(Document.collection_id) == collection.id, col(Document.active)
            )
        )
        top_level_dirs = {
            parts[0]
            for (path,) in paths_result.all()
            if len(parts := [p for p in path.split("/") if p]) > 1
        }
        missing = sorted(
            d
            for d in top_level_dirs
            if not any(p == d or d.startswith(p + "/") for p in prefixes)
        )
        if missing:
            missing_paths[collection.name] = missing

    missing_context.sort(key=lambda c: c.name)
    return missing_context, missing_paths

get_global_context async

get_global_context(
    session: AsyncSession, user: CurrentUser
) -> str | None

Return this user's global context.

Returns:

  • str | None

    The prose, or None when unset - which is also what a user row that

  • str | None

    somehow doesn't exist yields, since the caller has no use for the

  • str | None

    distinction.

Source code in src/qmd_py/store/context.py
async def get_global_context(session: AsyncSession, user: CurrentUser) -> str | None:
    """Return this user's global context.

    Returns:
        The prose, or None when unset - which is also what a user row that
        somehow doesn't exist yields, since the caller has no use for the
        distinction.
    """
    result = await session.execute(select(col(User.global_context)).where(col(User.id) == user.id))
    return result.scalar_one_or_none()

list_contexts async

list_contexts(
    session: AsyncSession, user: CurrentUser
) -> list[ContextRow]

Every context this user has set, across all their collections.

Returns:

  • list[ContextRow]

    Rows ordered by collection name, then path prefix - so a

  • list[ContextRow]

    collection's root context sorts before its sub-path ones.

Note

ACL (see auth.py): owner-prefiltered in SQL rather than gated through can_access(), so a granted-but-not-owned collection's contexts stay hidden until this query is widened.

Source code in src/qmd_py/store/context.py
async def list_contexts(session: AsyncSession, user: CurrentUser) -> list[ContextRow]:
    """Every context this user has set, across all their collections.

    Returns:
        Rows ordered by collection name, then path prefix - so a
        collection's root context sorts before its sub-path ones.

    Note:
        ACL (see auth.py): owner-prefiltered in SQL rather than gated
        through `can_access()`, so a granted-but-not-owned collection's
        contexts stay hidden until this query is widened.
    """
    result = await session.execute(
        select(
            col(Collection.name), col(CollectionContext.path_prefix), col(CollectionContext.context)
        )
        .join(Collection, col(CollectionContext.collection_id) == Collection.id)
        .where(col(Collection.owner_user_id) == user.id)
        .order_by(col(Collection.name), col(CollectionContext.path_prefix))
    )
    return [ContextRow(collection=r[0], path=r[1], context=r[2]) for r in result.all()]

remove_context async

remove_context(
    session: AsyncSession,
    user: CurrentUser,
    collection_name: str,
    path_prefix: str,
) -> bool

Delete the context set for one exact path prefix.

Parameters:

  • path_prefix (str) –

    Must match what was stored exactly - this deletes one row, it does not clear a subtree.

Returns:

  • bool

    True if a row was deleted, False if there was nothing set for that

  • bool

    prefix. Removing a context that isn't there is not an error.

Raises:

Source code in src/qmd_py/store/context.py
async def remove_context(
    session: AsyncSession, user: CurrentUser, collection_name: str, path_prefix: str
) -> bool:
    """Delete the context set for one exact path prefix.

    Args:
        path_prefix: Must match what was stored exactly - this deletes one
            row, it does not clear a subtree.

    Returns:
        True if a row was deleted, False if there was nothing set for that
        prefix. Removing a context that isn't there is not an error.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `write` on it.
    """
    collection = await _resolve_owned_collection(session, user, collection_name, "write")
    result = await session.execute(
        delete(CollectionContext).where(
            col(CollectionContext.collection_id) == collection.id,
            col(CollectionContext.path_prefix) == path_prefix,
        )
    )
    await session.flush()
    return affected_rows(result) > 0

set_global_context async

set_global_context(
    session: AsyncSession,
    user: CurrentUser,
    text: str | None,
) -> None

Set the context that applies across all of this user's collections.

The TS reference's context add /. User-scoped here, not a single system-wide value (see User.global_context in db/models.py). It is prepended before any per-path context, so it reads as the most general statement about everything this user has indexed.

Parameters:

  • text (str | None) –

    The prose, or None to clear it.

Source code in src/qmd_py/store/context.py
async def set_global_context(session: AsyncSession, user: CurrentUser, text: str | None) -> None:
    """Set the context that applies across all of this user's collections.

    The TS reference's `context add /`. User-scoped here, not a single
    system-wide value (see `User.global_context` in db/models.py). It is
    prepended before any per-path context, so it reads as the most general
    statement about everything this user has indexed.

    Args:
        text: The prose, or None to clear it.
    """
    result = await session.execute(select(User).where(col(User.id) == user.id))
    user_row = result.scalar_one()
    user_row.global_context = text
    session.add(user_row)
    await session.flush()

deactivate_document async

deactivate_document(
    session: AsyncSession, collection_id: int, path: str
) -> None

Mark a document inactive, keeping the row.

Soft delete: search and retrieval ignore inactive documents, but the row survives so a returning file can reactivate it rather than colliding with UNIQUE(collection_id, path). marq cleanup is what hard-deletes them.

A path with no row is a no-op, not an error.

Source code in src/qmd_py/store/documents.py
async def deactivate_document(session: AsyncSession, collection_id: int, path: str) -> None:
    """Mark a document inactive, keeping the row.

    Soft delete: search and retrieval ignore inactive documents, but the
    row survives so a returning file can reactivate it rather than
    colliding with `UNIQUE(collection_id, path)`. `marq cleanup` is what
    hard-deletes them.

    A path with no row is a no-op, not an error.
    """
    await session.execute(
        update(Document)
        .where(col(Document.collection_id) == collection_id, col(Document.path) == path)
        .values(active=False)
    )
    await session.flush()

find_active_document async

find_active_document(
    session: AsyncSession, collection_id: int, path: str
) -> Document | None

Find the live document at a path, ignoring deactivated rows.

Returns:

  • Document | None

    The document, or None if there is none or it has been

  • Document | None

    deactivated. Use find_document_by_path() when a deactivated row

  • Document | None

    still matters.

Source code in src/qmd_py/store/documents.py
async def find_active_document(
    session: AsyncSession, collection_id: int, path: str
) -> Document | None:
    """Find the live document at a path, ignoring deactivated rows.

    Returns:
        The document, or None if there is none or it has been
        deactivated. Use `find_document_by_path()` when a deactivated row
        still matters.
    """
    result = await session.execute(
        select(Document).where(
            col(Document.collection_id) == collection_id,
            col(Document.path) == path,
            col(Document.active),
        )
    )
    return result.scalar_one_or_none()

find_document_by_path async

find_document_by_path(
    session: AsyncSession, collection_id: int, path: str
) -> Document | None

Find the document at a path whether or not it is active.

reindex_collection needs this rather than find_active_document, to reactivate a deactivated document whose file reappears instead of violating Document's table-wide UNIQUE(collection_id, path) constraint by inserting a second row.

Returns:

  • Document | None

    The document, active or not, or None if the path was never

  • Document | None

    indexed.

Source code in src/qmd_py/store/documents.py
async def find_document_by_path(
    session: AsyncSession, collection_id: int, path: str
) -> Document | None:
    """Find the document at a path whether or not it is active.

    `reindex_collection` needs this rather than `find_active_document`, to
    reactivate a deactivated document whose file reappears instead of
    violating `Document`'s table-wide `UNIQUE(collection_id, path)`
    constraint by inserting a second row.

    Returns:
        The document, active or not, or None if the path was never
        indexed.
    """
    result = await session.execute(
        select(Document).where(
            col(Document.collection_id) == collection_id, col(Document.path) == path
        )
    )
    return result.scalar_one_or_none()

get_active_document_paths async

get_active_document_paths(
    session: AsyncSession, collection_id: int
) -> list[str]

List the paths currently indexed and active in a collection.

Returns:

  • list[str]

    Paths relative to the collection root, unordered. reindex_collection

  • list[str]

    diffs this against what it found on disk to decide what to

  • list[str]

    deactivate.

Source code in src/qmd_py/store/documents.py
async def get_active_document_paths(session: AsyncSession, collection_id: int) -> list[str]:
    """List the paths currently indexed and active in a collection.

    Returns:
        Paths relative to the collection root, unordered. `reindex_collection`
        diffs this against what it found on disk to decide what to
        deactivate.
    """
    result = await session.execute(
        select(col(Document.path)).where(
            col(Document.collection_id) == collection_id, col(Document.active)
        )
    )
    return [p for (p,) in result.all()]

insert_content async

insert_content(
    session: AsyncSession, hash_: str, doc: str
) -> None

Store a body under its content hash, if not already present.

Idempotent by design: content is addressed by hash and shared across every collection, so re-indexing an unchanged file - or the same file in a second worktree - inserts nothing and costs nothing.

Parameters:

  • hash_ (str) –

    SHA-256 hex digest of doc, from hash_content(). Not recomputed or verified here.

  • doc (str) –

    The full body text.

Source code in src/qmd_py/store/documents.py
async def insert_content(session: AsyncSession, hash_: str, doc: str) -> None:
    """Store a body under its content hash, if not already present.

    Idempotent by design: content is addressed by hash and shared across
    every collection, so re-indexing an unchanged file - or the same file
    in a second worktree - inserts nothing and costs nothing.

    Args:
        hash_: SHA-256 hex digest of `doc`, from `hash_content()`. Not
            recomputed or verified here.
        doc: The full body text.
    """
    stmt = (
        pg_insert(Content)
        .values(hash=hash_, doc=doc)
        .on_conflict_do_nothing(index_elements=["hash"])
    )
    await session.execute(stmt)

insert_document async

insert_document(
    session: AsyncSession,
    collection_id: int,
    path: str,
    title: str,
    hash_: str,
    created_at: datetime,
    modified_at: datetime,
) -> Document

Create a document row and build its search vector.

Assumes the content row already exists - call insert_content() first, since document.hash is a NOT NULL foreign key.

Parameters:

  • path (str) –

    Path relative to the collection root.

  • hash_ (str) –

    Content hash linking to the stored body.

  • created_at (datetime) –

    Usually the file's mtime rather than now, so the value survives a reindex from a fresh clone.

  • modified_at (datetime) –

    The file's mtime.

Returns:

  • Document

    The persisted row, with id populated by the flush.

Raises:

  • IntegrityError

    A row already exists for this (collection_id, path), active or not, or hash_ has no content row.

Source code in src/qmd_py/store/documents.py
async def insert_document(
    session: AsyncSession,
    collection_id: int,
    path: str,
    title: str,
    hash_: str,
    created_at: datetime,
    modified_at: datetime,
) -> Document:
    """Create a document row and build its search vector.

    Assumes the content row already exists - call `insert_content()`
    first, since `document.hash` is a NOT NULL foreign key.

    Args:
        path: Path relative to the collection root.
        hash_: Content hash linking to the stored body.
        created_at: Usually the file's mtime rather than now, so the value
            survives a reindex from a fresh clone.
        modified_at: The file's mtime.

    Returns:
        The persisted row, with `id` populated by the flush.

    Raises:
        sqlalchemy.exc.IntegrityError: A row already exists for this
            `(collection_id, path)`, active or not, or `hash_` has no
            content row.
    """
    document = Document(
        collection_id=collection_id,
        path=path,
        title=title,
        hash=hash_,
        created_at=created_at,
        modified_at=modified_at,
    )
    session.add(document)
    await session.flush()
    await update_document_search_vector(session, document.id)
    return document

update_document async

update_document(
    session: AsyncSession,
    document: Document,
    title: str,
    hash_: str,
    modified_at: datetime,
) -> None

Point a document at new content and rebuild its search vector.

Reactivates it (active=True) unconditionally: every caller reaches this because a real file on disk currently maps to this (collection_id, path), including a file that reappeared after being deactivated - switching git branches back and forth over the same indexed working tree does exactly that. Document has a table-wide UNIQUE(collection_id, path) with no partial/active condition, so there can only ever be one row per path regardless of active status; reactivating it is the only option, inserting a second is not.

Parameters:

  • document (Document) –

    The row to update, already loaded in this session.

  • hash_ (str) –

    New content hash. Its content row must exist.

  • modified_at (datetime) –

    The file's current mtime.

Source code in src/qmd_py/store/documents.py
async def update_document(
    session: AsyncSession, document: Document, title: str, hash_: str, modified_at: datetime
) -> None:
    """Point a document at new content and rebuild its search vector.

    Reactivates it (`active=True`) unconditionally: every caller reaches
    this because a real file on disk currently maps to this
    `(collection_id, path)`, including a file that reappeared after being
    deactivated - switching git branches back and forth over the same
    indexed working tree does exactly that. `Document` has a table-wide
    `UNIQUE(collection_id, path)` with no partial/active condition, so
    there can only ever be one row per path regardless of active status;
    reactivating it is the only option, inserting a second is not.

    Args:
        document: The row to update, already loaded in this session.
        hash_: New content hash. Its content row must exist.
        modified_at: The file's current mtime.
    """
    document.title = title
    document.hash = hash_
    document.modified_at = modified_at
    document.active = True
    session.add(document)
    await session.flush()
    await update_document_search_vector(session, document.id)

reindex_collection async

reindex_collection(
    session: AsyncSession, user: CurrentUser, name: str
) -> ReindexResult

Walk a collection's filesystem path, syncing document/content to match what's on disk - backs collection add and update.

Disk is the source of truth for one direction only: a file that has vanished deactivates its document rather than deleting it, so the row survives to be reactivated if the file comes back (switching git branches over an indexed working tree does exactly that). Document has a table-wide UNIQUE(collection_id, path) with no active/inactive carve-out, so reactivating the existing row is the only option - inserting a second one is impossible.

Simplification vs. the TS reference: a title-only change (same content hash) counts as updated rather than getting its own bucket.

A skipped file never aborts the run. Unreadable or non-UTF-8 files are skipped with a WARNING; whitespace-only files with a DEBUG line (an empty __init__.py is a normal state, not a degrade - anything louder would break the healthy-run-is-silent contract on every reindex of an ordinary code collection); files excluded by the collection's pattern or ignore rules, silently. One case is counted as well as logged: files over MAX_INDEXABLE_BYTES (see skipped_oversize). A previously indexed file that has since crossed the cap is treated like any other skipped file - its document is deactivated.

Parameters:

  • name (str) –

    Collection name, resolved against user's own collections.

Returns:

Raises:

Note

Reads every matched file and hashes it on each run - there is no mtime shortcut. Content addressing makes that cheap in storage (an unchanged file writes nothing) but not in I/O, so the cost scales with total collection size, not with how much changed.

Source code in src/qmd_py/store/indexing.py
async def reindex_collection(
    session: AsyncSession, user: CurrentUser, name: str
) -> ReindexResult:
    """Walk a collection's filesystem path, syncing `document`/`content`
    to match what's on disk - backs `collection add` and `update`.

    Disk is the source of truth for one direction only: a file that has
    vanished deactivates its document rather than deleting it, so the row
    survives to be reactivated if the file comes back (switching git
    branches over an indexed working tree does exactly that). `Document`
    has a table-wide `UNIQUE(collection_id, path)` with no active/inactive
    carve-out, so reactivating the existing row is the only option -
    inserting a second one is impossible.

    Simplification vs. the TS reference: a title-only change (same content
    hash) counts as `updated` rather than getting its own bucket.

    A skipped file never aborts the run. Unreadable or non-UTF-8 files
    are skipped with a WARNING; whitespace-only files with a DEBUG line
    (an empty `__init__.py` is a normal state, not a degrade - anything
    louder would break the healthy-run-is-silent contract on every
    reindex of an ordinary code collection); files excluded by the
    collection's pattern or ignore rules, silently. One case is *counted*
    as well as logged: files over `MAX_INDEXABLE_BYTES` (see
    `skipped_oversize`). A previously indexed file that has since crossed
    the cap is treated like any other skipped file - its document is
    deactivated.

    Args:
        name: Collection name, resolved against `user`'s own collections.

    Returns:
        Per-bucket counts; see `ReindexResult`.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `write` on it.

    Note:
        Reads every matched file and hashes it on each run - there is no
        mtime shortcut. Content addressing makes that cheap in storage (an
        unchanged file writes nothing) but not in I/O, so the cost scales
        with total collection size, not with how much changed.
    """
    with log_duration(logger, f"reindex {name}") as timing:
        result = await _reindex_collection_impl(session, user, name)
        # Inside the block: log_duration emits the line on exit, so
        # fields added afterwards would never reach it.
        timing.update(
            {
                "indexed": result.indexed,
                "updated": result.updated,
                "unchanged": result.unchanged,
                "removed": result.removed,
                "skipped_oversize": result.skipped_oversize,
            }
        )
    return result

find_document async

find_document(
    session: AsyncSession,
    user: CurrentUser,
    filename: str,
    collection_name: str | None = None,
) -> DocumentDetail | DocumentNotFound

Resolve one document by docid, virtual path, or bare path.

Tries, in order: docid (#abc123 or bare hex, matched as a hash prefix), exact virtual path (marq://collection/path), exact bare path, then a suffix match against the full virtual path - so both sample/src/foo.py and a partial src/foo.py resolve. Port of the TS reference's findDocument.

Parameters:

  • filename (str) –

    Docid or path, in any of the forms above.

  • collection_name (str | None, default: None ) –

    Restrict to one collection. None searches every collection the user can read.

Returns:

Note

A 6-char docid can front two documents. Which one wins is arbitrary but stable: _active_document_refs is ordered, so repeated lookups agree rather than depending on row order.

Source code in src/qmd_py/store/retrieval.py
async def find_document(
    session: AsyncSession, user: CurrentUser, filename: str, collection_name: str | None = None
) -> DocumentDetail | DocumentNotFound:
    """Resolve one document by docid, virtual path, or bare path.

    Tries, in order: docid (`#abc123` or bare hex, matched as a hash
    prefix), exact virtual path (`marq://collection/path`), exact bare
    path, then a suffix match against the full virtual path - so both
    `sample/src/foo.py` and a partial `src/foo.py` resolve. Port of the TS
    reference's `findDocument`.

    Args:
        filename: Docid or path, in any of the forms above.
        collection_name: Restrict to one collection. None searches every
            collection the user can read.

    Returns:
        A `DocumentDetail` with the body, or a `DocumentNotFound` carrying
        near-miss suggestions. Never raises for a miss.

    Note:
        A 6-char docid can front two documents. Which one wins is
        arbitrary but stable: `_active_document_refs` is ordered, so
        repeated lookups agree rather than depending on row order.
    """
    collection_ids = await resolve_collection_ids(session, user, collection_name)
    if not collection_ids:
        return DocumentNotFound(query=filename, similar_files=[])

    refs = await _active_document_refs(session, collection_ids)

    stripped = filename[1:] if filename.startswith("#") else filename
    if _looks_like_docid(stripped):
        lowered = stripped.lower()
        for document, coll_name in refs:
            if document.hash.lower().startswith(lowered):
                return await _build_document_detail(session, user, document, coll_name)
        return DocumentNotFound(query=filename, similar_files=[])

    found = _match_named_document(refs, filename)
    if found is not None:
        document, coll_name = found
        return await _build_document_detail(session, user, document, coll_name)

    similar = _find_similar_paths(filename, [d.path for d, _ in refs])
    return DocumentNotFound(query=filename, similar_files=similar)

get_status async

get_status(
    session: AsyncSession, user: CurrentUser
) -> StatusInfo

Backs status - reuses list_collections's per-collection stats rather than recomputing them. Deliberately narrower than the TS reference's own status command for now (no MCP-daemon liveness, no AST-chunking availability, no embedding-completeness/vector-index- health checks) - those need Phase 9's MCP server and Phase 5's embedding pipeline wired into the CLI first.

Returns:

  • StatusInfo

    Totals and per-collection detail; see StatusInfo. Counts only

  • StatusInfo

    collections the user can read, so it is empty rather than an error

  • StatusInfo

    for a user with none.

Source code in src/qmd_py/store/retrieval.py
async def get_status(session: AsyncSession, user: CurrentUser) -> StatusInfo:
    """Backs `status` - reuses `list_collections`'s per-collection stats
    rather than recomputing them. Deliberately narrower than the TS
    reference's own `status` command for now (no MCP-daemon liveness, no
    AST-chunking availability, no embedding-completeness/vector-index-
    health checks) - those need Phase 9's MCP server and Phase 5's
    embedding pipeline wired into the CLI first.

    Returns:
        Totals and per-collection detail; see `StatusInfo`. Counts only
        collections the user can read, so it is empty rather than an error
        for a user with none.
    """
    collections = await list_collections(session, user)
    return StatusInfo(
        total_documents=sum(c.active_count for c in collections),
        collections=[
            CollectionStatus(
                name=c.name,
                path=c.path,
                pattern=c.pattern,
                doc_count=c.active_count,
                last_updated=c.last_modified,
            )
            for c in collections
        ],
    )

list_files async

list_files(
    session: AsyncSession,
    user: CurrentUser,
    collection_name: str,
    path_prefix: str | None = None,
) -> list[FileRow]

List one collection's active files, optionally under a sub-path.

Backs marq ls <collection>[/path].

Parameters:

  • path_prefix (str | None, default: None ) –

    Restrict to paths starting with this string. Matched as a literal prefix (LIKE wildcards %/_ are escaped), not as a glob, so * has no special meaning either.

Returns:

  • list[FileRow]

    Rows ordered by path. Empty for a collection with no matches -

  • list[FileRow]

    only an unknown collection raises.

Raises:

Source code in src/qmd_py/store/retrieval.py
async def list_files(
    session: AsyncSession, user: CurrentUser, collection_name: str, path_prefix: str | None = None
) -> list[FileRow]:
    """List one collection's active files, optionally under a sub-path.

    Backs `marq ls <collection>[/path]`.

    Args:
        path_prefix: Restrict to paths starting with this string. Matched
            as a literal prefix (LIKE wildcards `%`/`_` are escaped), not
            as a glob, so `*` has no special meaning either.

    Returns:
        Rows ordered by path. Empty for a collection with no matches -
        only an unknown collection raises.

    Raises:
        CollectionNotFoundError: No collection of that name owned by `user`.
        PermissionDeniedError: `can_access()` refused `read` on it.
    """
    collection = await _resolve_owned_collection(session, user, collection_name, "read")
    stmt = (
        select(
            col(Document.path),
            col(Document.title),
            col(Document.modified_at),
            func.length(col(Content.doc)),
        )
        .join(Content, col(Content.hash) == col(Document.hash))
        .where(col(Document.collection_id) == collection.id, col(Document.active))
    )
    if path_prefix:
        stmt = stmt.where(col(Document.path).startswith(path_prefix, autoescape=True))
    stmt = stmt.order_by(col(Document.path))
    rows = await session.execute(stmt)
    return [FileRow(path=p, title=t, modified_at=m, size=s) for p, t, m, s in rows]

match_files_by_glob async

match_files_by_glob(
    session: AsyncSession, user: CurrentUser, pattern: str
) -> list[GlobMatch]

Glob-match against three forms of every active, accessible document's path (virtual marq://collection/path, bare path, and collection/path) - matches if any form matches, same as the TS reference's matchFilesByGlob. display_path is the bare relative path (not collection-prefixed), matching that function's own (slightly ambiguous across collections) convention.

Source code in src/qmd_py/store/retrieval.py
async def match_files_by_glob(
    session: AsyncSession, user: CurrentUser, pattern: str
) -> list[GlobMatch]:
    """Glob-match against three forms of every active, accessible
    document's path (virtual `marq://collection/path`, bare path, and
    `collection/path`) - matches if any form matches, same as the TS
    reference's `matchFilesByGlob`. `display_path` is the bare relative
    path (not collection-prefixed), matching that function's own
    (slightly ambiguous across collections) convention."""
    collection_ids = await resolve_collection_ids(session, user, None)
    refs = await _active_document_refs(session, collection_ids)
    matches = []
    for document, coll_name in refs:
        if _matches_pattern(document, coll_name, pattern):
            body_length = await _document_body_length(session, document.hash)
            matches.append(
                GlobMatch(
                    filepath=f"marq://{coll_name}/{document.path}",
                    display_path=document.path,
                    body_length=body_length,
                )
            )
    return matches

multi_get async

multi_get(
    session: AsyncSession,
    user: CurrentUser,
    pattern: str,
    max_lines: int | None = None,
    max_bytes: int = DEFAULT_MULTI_GET_MAX_BYTES,
    line_numbers: bool = True,
) -> list[MultiGetFile]

Fetch multiple documents by glob pattern or comma-separated list - port of the TS reference's multiGet (src/cli/qmd.ts).

The two forms are mutually exclusive, not combinable: pattern counts as a comma-separated list only if it holds no glob metacharacter at all. So "a.md,b*.md" is treated as one glob containing a literal comma and matches nothing, rather than as two patterns. That's the TS reference's behavior, kept deliberately for parity - split such a request into separate calls.

Oversized files are reported rather than dropped: the entry comes back with skipped=True and a skip_reason, so a caller can tell "too large, fetch it singly" from "no such file".

Parameters:

  • pattern (str) –

    Glob, or a comma-separated list of paths/docids.

  • max_lines (int | None, default: None ) –

    Truncate each body to this many lines, appending a note about how many were omitted. None keeps the whole body.

  • max_bytes (int, default: DEFAULT_MULTI_GET_MAX_BYTES ) –

    Skip any document longer than this rather than returning it. Compared against the character length.

  • line_numbers (bool, default: True ) –

    Prefix each body line with N:.

Returns:

  • list[MultiGetFile]

    One entry per match, in the order the documents were scanned.

  • list[MultiGetFile]

    Empty when nothing matched - a miss is not an error here.

Source code in src/qmd_py/store/retrieval.py
async def multi_get(
    session: AsyncSession,
    user: CurrentUser,
    pattern: str,
    max_lines: int | None = None,
    max_bytes: int = DEFAULT_MULTI_GET_MAX_BYTES,
    line_numbers: bool = True,
) -> list[MultiGetFile]:
    """Fetch multiple documents by glob pattern or comma-separated list -
    port of the TS reference's `multiGet` (src/cli/qmd.ts).

    The two forms are mutually exclusive, not combinable: `pattern` counts
    as a comma-separated list only if it holds no glob metacharacter at
    all. So `"a.md,b*.md"` is treated as one glob containing a literal
    comma and matches nothing, rather than as two patterns. That's the TS
    reference's behavior, kept deliberately for parity - split such a
    request into separate calls.

    Oversized files are reported rather than dropped: the entry comes back
    with `skipped=True` and a `skip_reason`, so a caller can tell "too
    large, fetch it singly" from "no such file".

    Args:
        pattern: Glob, or a comma-separated list of paths/docids.
        max_lines: Truncate each body to this many lines, appending a
            note about how many were omitted. None keeps the whole body.
        max_bytes: Skip any document longer than this rather than
            returning it. Compared against the character length.
        line_numbers: Prefix each body line with `N: `.

    Returns:
        One entry per match, in the order the documents were scanned.
        Empty when nothing matched - a miss is not an error here.
    """
    collection_ids = await resolve_collection_ids(session, user, None)
    if not collection_ids:
        return []

    refs = await _active_document_refs(session, collection_ids)
    is_comma_separated = "," in pattern and not any(c in pattern for c in "*?{[")

    matched: list[tuple[Document, str]] = []
    if is_comma_separated:
        for name in (n.strip() for n in pattern.split(",") if n.strip()):
            found = _match_named_document(refs, name)
            if found is not None:
                matched.append(found)
    else:
        matched = [
            (document, coll_name)
            for document, coll_name in refs
            if _matches_pattern(document, coll_name, pattern)
        ]

    results: list[MultiGetFile] = []
    for document, coll_name in matched:
        filepath = f"marq://{coll_name}/{document.path}"
        display_path = f"{coll_name}/{document.path}"
        docid = get_docid(document.hash)
        context = await get_context_for_path(session, user, document.collection_id, document.path)

        body_length = await _document_body_length(session, document.hash)
        if body_length > max_bytes:
            results.append(
                MultiGetFile(
                    filepath=filepath,
                    display_path=display_path,
                    title=document.title,
                    body="",
                    context=context,
                    skipped=True,
                    docid=docid,
                    skip_reason=(
                        f"File too large ({body_length // 1024}KB > {max_bytes // 1024}KB). "
                        f"Use 'marq get {display_path}' to retrieve."
                    ),
                )
            )
            continue

        body = await _document_body(session, document.hash)
        if max_lines is not None:
            lines = body.split("\n")
            if len(lines) > max_lines:
                omitted = len(lines) - max_lines
                body = "\n".join(lines[:max_lines]) + f"\n\n[... truncated {omitted} more lines]"
        if line_numbers:
            body = add_line_numbers(body)

        results.append(
            MultiGetFile(
                filepath=filepath,
                display_path=display_path,
                title=document.title,
                body=body,
                context=context,
                skipped=False,
                docid=docid,
            )
        )

    return results