feat(points): add the point read/edit port and its Qdrant adapter
Why: - PointStorage is deliberately the two bulk operations ingestion performs. Reads, single-point edits, and keyword search have a different caller, a different failure vocabulary, and a different tenant-filter obligation, so they get their own port rather than accreting onto the ingestion one. Changes: - tenant_id is a required keyword argument on every port method, making a forgotten tenant filter a type error rather than a review question. - Reads go through scroll with a HasIdCondition, not retrieve: retrieve takes no filter and would push the tenant check into Python after Qdrant already answered -- the shape ADR-0002's isolation rule exists to prevent. - Ordered listing paginates by order_id value, not offset. Qdrant returns no page offset under order_by, and an offset cursor skips or repeats rows when a concurrent insert shifts positions underneath the reader. - Point.from_payload takes a Mapping, not a dict: dict is invariant in its value type, so the SDK's concrete vector union is not a dict[str, object]. - Request schemas forbid extra keys and omit server-owned fields, so a client sending tenant_id or version gets 422 rather than having it silently ignored. Impact: - No route uses this yet; the /v1/points surface is Phase 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
131
src/application/ports/point_repository.py
Normal file
131
src/application/ports/point_repository.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""The point read/edit port for `/v1/points` (ADR-0002, ADR-0015).
|
||||
|
||||
Separate from `PointStorage`, which stays exactly the two bulk operations
|
||||
ingestion performs. Reads, single-point edits, reordering, and keyword search
|
||||
have a different caller, a different failure vocabulary, and a different
|
||||
tenant-filter obligation, so they get their own port rather than accreting onto
|
||||
the ingestion one.
|
||||
|
||||
`tenant_id` is a required keyword argument on **every** method. That is not
|
||||
style: ADR-0002's isolation rule has to hold on every code path that touches the
|
||||
collection, and an optional tenant filter is one forgotten argument away from a
|
||||
cross-tenant read. Making it required moves that from a review question to a
|
||||
type error.
|
||||
|
||||
`src/infrastructure/qdrant/point_repository.py` is the production adapter;
|
||||
`tests.fakes.FakePointRepository` is the test double.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.application.points.point import Point
|
||||
|
||||
|
||||
class PointPage(BaseModel):
|
||||
"""One page of points plus the cursor that continues it.
|
||||
|
||||
`next_cursor` is opaque to callers and encoded by the adapter: ordered
|
||||
scrolls and keyword searches paginate by different Qdrant mechanisms, and
|
||||
neither is a plain integer offset. `None` means the listing is exhausted.
|
||||
"""
|
||||
|
||||
points: tuple[Point, ...]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class PayloadPatch(BaseModel):
|
||||
"""Set these payload fields on one point, optionally guarded by `version`.
|
||||
|
||||
When `expected_version` is set, the adapter attaches it to the operation's
|
||||
filter, so a concurrent write that has already moved the version on means
|
||||
this patch matches nothing rather than clobbering it. The guard is what
|
||||
makes a lost update impossible; detecting that it fired is the service's
|
||||
job (see `apply_patches`).
|
||||
"""
|
||||
|
||||
point_id: uuid.UUID
|
||||
payload: dict[str, object]
|
||||
expected_version: int | None = None
|
||||
|
||||
|
||||
class PointRepository(Protocol):
|
||||
async def get(
|
||||
self, *, tenant_id: uuid.UUID, point_id: uuid.UUID, with_vectors: bool = False
|
||||
) -> Point | None:
|
||||
"""One point, or `None` if it does not exist *under this tenant*.
|
||||
|
||||
The two cases are deliberately indistinguishable — the route maps both
|
||||
to `404` so a caller cannot probe for another tenant's point ids.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_many(
|
||||
self, *, tenant_id: uuid.UUID, point_ids: Sequence[uuid.UUID]
|
||||
) -> tuple[Point, ...]:
|
||||
"""The subset of `point_ids` that exists under this tenant.
|
||||
|
||||
Order is not guaranteed and missing ids are silently absent: callers are
|
||||
neighbour-relinking and batch precondition checks, both of which match
|
||||
on id rather than position.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_by_file(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
limit: int,
|
||||
cursor: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> PointPage:
|
||||
"""One file's points in `order_id` order (ADR-0008's `scroll`).
|
||||
|
||||
Scoped to a single file because the cursor is an `order_id` value, and
|
||||
`order_id` is only unique within a file.
|
||||
"""
|
||||
...
|
||||
|
||||
async def count(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
domain: str | None = None,
|
||||
file_id: uuid.UUID | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> int: ...
|
||||
|
||||
async def keyword_search(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
query: str,
|
||||
limit: int,
|
||||
cursor: str | None = None,
|
||||
domain: str | None = None,
|
||||
file_id: uuid.UUID | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> PointPage:
|
||||
"""Full-text payload match on `content`, plus structured filters.
|
||||
|
||||
Keyword matching, **not** semantic retrieval (ADR-0002). Qdrant's
|
||||
full-text index is a filter, not a scorer, so results carry no relevance
|
||||
ranking and their order is unspecified.
|
||||
"""
|
||||
...
|
||||
|
||||
async def apply_patches(self, *, tenant_id: uuid.UUID, patches: Sequence[PayloadPatch]) -> None:
|
||||
"""Apply every patch in one Qdrant `points/batch` request.
|
||||
|
||||
Qdrant has no multi-point transaction, so this is not atomic and does
|
||||
not pretend to be. ADR-0002's all-or-nothing rule is implemented one
|
||||
layer up as validate-every-precondition-then-apply; the per-patch
|
||||
`expected_version` guard here is what makes the residual window safe,
|
||||
turning a lost update into a no-op the service can detect rather than a
|
||||
silent clobber.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user