sandloom.sdk.projections¶
Query projections: the declarative Projection and today's descriptors
and helpers.
Public API 1.0 (spec §2.2, Appendix C). A name whose mechanism lands at a
later hand-off raises NotImplementedError naming it.
from sandloom.sdk.projections import <name>. Signatures in a terminal: sandloom docs <name>. Beginner names are the ones generated code and the examples use; any other name is advanced (mark its import line # sandloom: advanced <reason> in generated code and examples).
Advanced¶
| Name | Kind | Summary |
|---|---|---|
BoundContractMethod |
callable | |
ConditionalWriteStore |
class | Driver feature conditional_write (spec §2.3). |
ConsistencyToken |
class | A verified token (spec §2.3). |
delta |
function | A mapper of delta events (column increments); allowed only for a relational projection applied under the durable inbox (§6.4 item 4). |
full_state |
function | A mapper of full-state events: every owned field, replaced iff the stored version is NULL or older (convergent under any interleaving). |
make_consumer_callback |
function | Wrap a projection consumer descriptor into an event-bus callback. |
Mapper |
class | Maps an event to the projection fields its group owns. |
monotonic_delete |
async function | Persist a versioned tombstone, including when deletion arrives first. |
monotonic_update |
async function | Update every projection row matching match, guarded by source version. |
monotonic_upsert |
async function | Insert-or-update a single projection row, guarded by source version. |
Projection |
class | A declared read model (spec §6.4), registered by define_module from the module's projections submodule. |
ProjectionConsumerDescriptor |
class | A durable consumer that applies one source channel to one projection. |
ProjectionHandler |
callable | |
ProjectionRebuilder |
class | Reconstructs a projection from its source modules (idempotently). |
ProjectionRebuildResult |
class | Outcome of a rebuild run (rows written + whether the index was cleared). |
ProjectionRegistryError |
exception | A projection/consumer registration conflicts with existing state. |
ProjectionView |
class | Reads one projection (Annotated[ProjectionView[R], View(...)]). |
purge_tombstones |
async function | Purge tombstones behind the watermark that refuses older replays. |
QueryProjectionDescriptor |
class | A denormalised cross-module read model owned by one module. |
QueryProjectionRegistry |
class | Holds query-projection descriptors, consumers and rebuilders by name. |
read_token |
function | The token of header; None for a missing, malformed, expired, unknown-kid or other-tenant token (never an error). |
rebuild |
async function | Rebuild projection name from its source (§6.4): one tenant, or every tenant. Purges refuse while it runs; the purge watermark is kept.... |
SourceGroup |
class | The channels of ONE aggregate feeding a projection, with its version. |
start_projection_consumers |
async function | Subscribe every registered projection consumer to the event bus. |
tombstone |
function | A mapper of delete events: a versioned tombstone in the owning group (§6.4 item 5); reads filter it. |
View |
function | The dependency resolving a ProjectionView of projection name: |
Advanced names¶
BoundContractMethod¶
def BoundContractMethod(*args, **kwargs)
(no docstring)
ConditionalWriteStore¶
class ConditionalWriteStore(Protocol)
Driver feature conditional_write (spec §2.3).
ConditionalWriteStore.put_if_newer¶
async def put_if_newer(
self,
*,
tenant_id: uuid.UUID,
key: Mapping[str, Any],
version_column: str,
version: int,
fields: Mapping[str, Any],
insert_only: Mapping[str, Any] | None = None,
) -> bool
ConditionalWriteStore.tombstone_if_newer¶
async def tombstone_if_newer(
self,
*,
tenant_id: uuid.UUID,
key: Mapping[str, Any],
version_column: str,
version: int,
deleted_at: datetime,
) -> bool
ConsistencyToken¶
class ConsistencyToken(
kid: str,
issued_at: int,
tenant_id: UUID,
entries: tuple[TokenEntry, ...],
) -> None
A verified token (spec §2.3).
ConsistencyToken.digest¶
@staticmethod
def digest(key: Mapping[str, Any]) -> str
The digest of a projection row key, as the entries carry it.
ConsistencyToken.versions¶
def versions(self, projection: str) -> dict[EntryKey, int]
(key digest, group, aggregate digest) -> version the token
waits for in projection: one version per source group and
aggregate, never merged across groups.
ConsistencyToken.keys¶
def keys(self, projection: str) -> set[str]
The row key digests the token names in projection.
ConsistencyToken.wait_for¶
async def wait_for(
self,
projection: str,
stored_version: Callable[[str, str], Awaitable[int | None]],
*,
wait_ms: int | None = None,
refresh: Callable[[], Awaitable[None]] | None = None,
) -> bool
Poll stored_version(digest, group) (the row's value of that
group's version column) until projection reached every entry, at
most wait_ms (default PROJECTION_RYW_WAIT_MS); False when
the budget ran out (serve stale, never an error). refresh runs
before every round but the first (a caller polling cached rows
rereads them there).
delta¶
def delta(fn: Callable[[E], Mapping[str, Any]]) -> Mapper[E]
A mapper of delta events (column increments); allowed only for a relational projection applied under the durable inbox (§6.4 item 4).
full_state¶
def full_state(fn: Callable[[E], Mapping[str, Any]]) -> Mapper[E]
A mapper of full-state events: every owned field, replaced iff the stored version is NULL or older (convergent under any interleaving).
make_consumer_callback¶
def make_consumer_callback(
descriptor: ProjectionConsumerDescriptor,
storage: str,
opener: SessionOpener,
) -> Any
Wrap a projection consumer descriptor into an event-bus callback.
Delegates to the generic consumer wrapper with projection labels/metrics. Exposed separately so it can be unit-tested without a live bus.
Mapper¶
class Mapper(Protocol)
Maps an event to the projection fields its group owns.
monotonic_delete¶
async def monotonic_delete(
session: AsyncSession,
target: Any,
*,
key: dict[str, Any],
version_column: str,
incoming_version: int,
values: dict[str, Any] | None = None,
insert_only: dict[str, Any] | None = None,
deleted_column: str = 'deleted_at',
deleted_at: datetime | None = None,
) -> bool
Persist a versioned tombstone, including when deletion arrives first.
A physical DELETE discards the version and lets a delayed older upsert
resurrect the row. This helper retains it using the same tenant/version
checks as monotonic_upsert. The projection needs a nullable timestamp
deleted_column and reads must filter it to NULL. values and
insert_only must supply tenant_id and other required columns so an
absent row can be inserted as a tombstone. Only an explicitly newer event
can subsequently clear it by upserting deleted_column=None.
Tombstones must survive at least the complete replay/rebuild horizon; removing them early reintroduces resurrection. Rebuilders apply source versions through the same helper and must not infer deletion from an incomplete source listing.
monotonic_update¶
async def monotonic_update(
session: AsyncSession,
target: Any,
*,
match: dict[str, Any],
values: dict[str, Any],
version_column: str,
incoming_version: int,
) -> int
Update every projection row matching match, guarded by source version.
For denormalised fields that fan out across many rows (e.g. a project's name
copied onto every task row): updates the matched rows whose stored
version_column is strictly older than incoming_version (or NULL),
setting the new values and version. Out-of-order/duplicate project
events are no-ops. Returns the number of rows updated. Auto-scoped to the
bound tenant (if any) on tenant-owned tables — see the module docstring.
Note: this only refreshes rows that ALREADY EXIST and are COMMITTED (or
locked by this transaction). A row another writer inserted but has not
committed yet is invisible here, so a writer that reads the source over RPC
and then inserts can lose this update for good. Serialise the two writers on
a local, version-guarded snapshot row of the source (upsert it here first;
SELECT ... FOR UPDATE it in the other writer and copy its values) — see
the projecthub taskboard ProjectSnapshot.
monotonic_upsert¶
async def monotonic_upsert(
session: AsyncSession,
target: Any,
*,
key: dict[str, Any],
values: dict[str, Any],
version_column: str,
incoming_version: int,
insert_only: dict[str, Any] | None = None,
) -> bool
Insert-or-update a single projection row, guarded by source version.
Inserts the row (key + values + insert_only + the version) when
absent; on a primary-key/unique conflict it updates values and the
version ONLY when the stored version_column is strictly older than
incoming_version (or NULL). A duplicate (equal version) or an
out-of-order older event leaves the row untouched. Returns True when a
row was inserted or updated.
insert_only columns are written on INSERT but NEVER touched by the
on-conflict update — for fields this source merely SEEDS but does not own
(e.g. a task upsert seeds the denormalised project name once, but project
events, guarded by their own version, are the authority for keeping it
fresh). Without this, every task update would clobber project fields back to
whatever the seed lookup returned, rolling back fresher project data.
Projection¶
class Projection(
name: str,
*,
key: tuple[str, ...],
groups: Sequence[SourceGroup],
model: type[TenantScoped] | None = None,
store: tuple[str, str] | None = None,
insert_only: Mapping[str, Any] | None = None,
deleted_column: str = 'deleted_at',
rebuild_from: BoundContractMethod | tuple[str, str] | None = None,
lag_slo_s: float = 5.0,
at_least_once: bool = False,
) -> None
A declared read model (spec §6.4), registered by define_module
from the module's projections submodule.
model= (a relational TenantScoped table) is the supported target
in 1.0. store= (a document_store instance) is experimental in 1.0:
tested against the in-memory driver only, without production drivers.
Projection.channels¶
def channels(self) -> tuple[str, ...]
The source channels, in declaration order.
Projection.descriptor¶
def descriptor(self, owner_module: str) -> QueryProjectionDescriptor
Projection.token_entries¶
def token_entries(
self,
channel: str,
payload: Mapping[str, Any],
) -> Iterable[tuple[Mapping[str, Any], str, Any, int]]
The (row key, group, aggregate id, version) an event of
channel will produce (group: its source group's version
column): a write's consistency token (spec §6.4 item 7).
Projection.target¶
def target(self) -> ConditionalWriteStore
The store of a store= projection (conditional_write).
Projection.consumers¶
def consumers(self) -> list[ProjectionConsumerDescriptor]
Projection.rebuilder¶
def rebuilder(self) -> ProjectionRebuilderFromContract | None
Projection.apply¶
async def apply(
self,
session: Any,
group: SourceGroup,
event: Event,
mapper: Mapper[Any],
) -> bool
Apply one event to the projection in session (the consumer's unit).
ProjectionConsumerDescriptor¶
class ProjectionConsumerDescriptor(
name: str,
source_channel: str,
projection: str,
handler: ProjectionHandler,
durable: bool = True,
required: bool = True,
module: str | None = None,
group: str | None = None,
start_from: str = 'earliest',
bind_tenant_from: str | None = None,
) -> None
A durable consumer that applies one source channel to one projection.
The durable consumer-group id on the event bus is group_name
(<module>:<name> when registered through ModuleAPI; the module is
stamped by the loader): each consumer gets its OWN group, so it has its own
pending list, redelivery and dead-letter queue, independent of every other
subscriber to the channel. Keep name stable across deploys — changing it
makes the bus treat the consumer as brand new (a new group starting at
start_from, default the channel's retained backlog). group pins an
explicit group id instead.
durable (default) claims each event id in the handler's Unit of Work
(UNIQUE(consumer, event_id)) so the apply runs exactly once even though
delivery is at-least-once; idempotent handlers may set it False and rely
on the monotonic upsert alone.
required=True makes the projection subscription part of readiness. Use
False only when the owning read path explicitly tolerates a stale or
unavailable projection.
ProjectionConsumerDescriptor.group_name¶
group_name (property)
The durable consumer-group id (also the exactly-once inbox key).
ProjectionHandler¶
def ProjectionHandler(*args, **kwargs)
(no docstring)
ProjectionRebuilder¶
class ProjectionRebuilder(Protocol)
Reconstructs a projection from its source modules (idempotently).
Every method rebuilds a SCOPE and returns a
ProjectionRebuildResult. Implementations re-read source state via
public contracts/events and apply it through the same monotonic upsert the
live consumers use, so a rebuild and steady-state delivery converge to the
same rows. Rebuilds must be safe to run while consumers are live. Source
deletions require versioned tombstones (monotonic_delete), never
physical DELETEs: a stale source row observed before a concurrent delete
must not resurrect a row during the rebuild. Retain tombstones through
the whole replay/rebuild horizon and distinguish complete snapshots from
partial listings before reconciling absent entities.
ProjectionRebuilder.rebuild_all¶
async def rebuild_all(self) -> ProjectionRebuildResult
Rebuild the whole projection (every tenant).
ProjectionRebuilder.rebuild_tenant¶
async def rebuild_tenant(self, tenant_id: uuid.UUID) -> ProjectionRebuildResult
Rebuild only the rows belonging to tenant_id.
ProjectionRebuilder.rebuild_entity¶
async def rebuild_entity(self, ref: str) -> ProjectionRebuildResult
Rebuild a single entity (a stable owner_module:entity_id ref).
ProjectionRebuildResult¶
class ProjectionRebuildResult(
projection: str,
rows: int = 0,
cleared: bool = False,
details: dict[str, Any] = <factory>,
) -> None
Outcome of a rebuild run (rows written + whether the index was cleared).
ProjectionRegistryError¶
class ProjectionRegistryError(RuntimeError)
A projection/consumer registration conflicts with existing state.
ProjectionView¶
class ProjectionView(Generic)(
projection: Projection,
row: type[R],
session: Any,
*,
on_stale: Literal['serve', 'source'] = 'serve',
wait_ms: int | None = None,
token: ConsistencyToken | None = None,
response: Response | None = None,
) -> None
Reads one projection (Annotated[ProjectionView[R], View(...)]).
Rows whose tombstone is set are never returned. With a consistency token
(SANDLOOM-Consistency-Token) a read waits up to wait_ms for the rows
the caller's write produces; then it serves anyway with stale = True
and SANDLOOM-Projection-Stale: true (on_stale="serve"), or reads the
entity from its source through rebuild_from (on_stale="source",
get only). Never an error status.
ProjectionView.get¶
async def get(self, key: Mapping[str, Any]) -> R | None
ProjectionView.query¶
async def query(
self,
*,
filter: Mapping[str, Any] | None = None,
limit: int = 50,
cursor: str | None = None,
) -> Page[R]
ProjectionView.search¶
async def search(self, q: str, *, limit: int = 50) -> list[R]
purge_tombstones¶
async def purge_tombstones(name: str, *, older_than: timedelta) -> int
Purge tombstones behind the watermark that refuses older replays.
QueryProjectionDescriptor¶
class QueryProjectionDescriptor(
name: str,
owner_module: str,
version: str = 'v1',
source_events: tuple[str, ...] = (),
storage: str = 'default',
consistency: Literal['eventual'] = 'eventual',
rebuildable: bool = True,
purge: TombstonePurger | None = None,
token_entries: TokenEntries | None = None,
) -> None
A denormalised cross-module read model owned by one module.
consistency is intentionally fixed to "eventual": a projection lags
its source modules by the event delivery + apply time and must never back a
strong-consistency command invariant (validate those against the owner's
public command contract instead). rebuildable records that the
projection can be dropped and reconstructed from the source modules — the
projection is a cache, not a source of truth.
QueryProjectionRegistry¶
class QueryProjectionRegistry() -> None
Holds query-projection descriptors, consumers and rebuilders by name.
QueryProjectionRegistry.register_projection¶
def register_projection(self, descriptor: QueryProjectionDescriptor) -> None
QueryProjectionRegistry.register_consumer¶
def register_consumer(self, descriptor: ProjectionConsumerDescriptor) -> None
QueryProjectionRegistry.register_rebuilder¶
def register_rebuilder(self, projection: str, rebuilder: ProjectionRebuilder) -> None
QueryProjectionRegistry.get_projection¶
def get_projection(self, name: str) -> QueryProjectionDescriptor | None
QueryProjectionRegistry.get_consumer¶
def get_consumer(self, name: str) -> ProjectionConsumerDescriptor | None
Look a consumer up by its group id, or by its bare name.
QueryProjectionRegistry.get_rebuilder¶
def get_rebuilder(self, projection: str) -> ProjectionRebuilder | None
QueryProjectionRegistry.list_projections¶
def list_projections(self) -> list[QueryProjectionDescriptor]
QueryProjectionRegistry.list_consumers¶
def list_consumers(self) -> list[ProjectionConsumerDescriptor]
QueryProjectionRegistry.consumers_for¶
def consumers_for(self, projection: str) -> list[ProjectionConsumerDescriptor]
QueryProjectionRegistry.token_sources¶
def token_sources(self) -> dict[str, tuple[tuple[str, Any], ...]]
channel -> ((projection, token_entries), ...) for every
projection that issues consistency tokens (empty: none does).
QueryProjectionRegistry.snapshot¶
def snapshot(self) -> dict[str, dict[str, object]]
Shallow copy of the maps for a later restore.
Descriptors are frozen/immutable and rebuilders are opaque objects, so a shallow per-map copy is enough to rewind a failed module registration without leaking later mutations into the snapshot.
QueryProjectionRegistry.restore¶
def restore(self, snapshot: dict[str, dict[str, object]]) -> None
Reset the maps to a prior snapshot.
read_token¶
def read_token(header: str | None) -> ConsistencyToken | None
The token of header; None for a missing, malformed, expired,
unknown-kid or other-tenant token (never an error).
rebuild¶
async def rebuild(
name: str,
*,
tenant_id: UUID | None = None,
blue_green: bool = True,
) -> ProjectionRebuildResult
Rebuild projection name from its source (§6.4): one tenant, or
every tenant. Purges refuse while it runs; the purge watermark is kept.
Without an alias-swapping store a blue/green rebuild runs in place
(details["blue_green"] == "in_place").
SourceGroup¶
class SourceGroup(
events: Mapping[type[Event], Mapper[Any]],
aggregate_key: str,
version_field: str,
version_column: str,
owns: tuple[str, ...],
tenant_from: str = 'tenant_id',
) -> None
The channels of ONE aggregate feeding a projection, with its version.
start_projection_consumers¶
async def start_projection_consumers(
core: CoreFacade,
*,
session_opener: SessionOpener | None = None,
) -> int
Subscribe every registered projection consumer to the event bus.
Returns the number of consumers wired through the canonical supervised
runtime. Duplicate groups and missing required projections fail startup;
repeated calls do not add duplicate handlers. session_opener defaults
to unit_of_work.db_session and is injectable for tests.
tombstone¶
def tombstone(fn: Callable[[E], Mapping[str, Any]]) -> Mapper[E]
A mapper of delete events: a versioned tombstone in the owning group (§6.4 item 5); reads filter it.
View¶
def View(
name: str,
row: type[R],
*,
on_stale: Literal['serve', 'source'] = 'serve',
wait_ms: int | None = None,
) -> params.Depends
The dependency resolving a ProjectionView of projection name:
tasks: Annotated[ProjectionView[TaskRow], View("board.tasks", TaskRow)]