Document
The document contract is Forze's CRUD aggregate store: a DocumentSpec binds a read
model (and optional write models) to a logical name, and the context resolves a query
port (reads) and a command port (writes) for it. The conceptual model is in
Reading data and the
writing-operation chapter; this is the exhaustive
surface.
q = ctx.document.query(spec) # reads
c = ctx.document.command(spec) # writes
All methods are async. Naming is systematic: get*/find* return the read
model R; project* returns a JsonDict of selected fields; select* validates
into a caller-supplied return_type. The suffix sets the result container —
none → CountlessPage (no total), _page → Page (with .count), _cursor →
CursorPage (keyset), _stream → an async generator of batches. The filters,
sorts, and aggregates arguments follow the query DSL.
Spec¶
DocumentSpec[R, D, C, U] — binds the read model and optional write types to a name,
plus per-aggregate policy:
| Field | Type | Default | Meaning |
|---|---|---|---|
name |
str \| StrEnum |
required | logical name; the default namespace and route key |
read |
type[R] |
required | the read model returned from queries |
write |
DocumentWriteTypes \| None |
None |
{domain, create_cmd, update_cmd?}; omit for a read-only document (no command port) |
history_enabled |
bool |
False |
keep an audit trail of every revision |
hard_delete |
bool |
True |
False: no document operation erases a row — no kill operation is generated and the command port refuses kill/kill_many. A provisioner's drop_on_deprovision=True and raw statements are outside the port and can still delete |
materialized |
frozenset[str] |
∅ |
@computed_field names persisted as columns, so they're filterable/sortable |
read_conformity |
"strict" \| "lenient" |
"strict" |
"lenient" auto-derives lenient_read_fields from the read model (every statically-defaulted, non-identity, non-materialized field); explicit fields are added on top |
lenient_read_fields |
frozenset[str] |
∅ |
read-model fields with no backing column: dropped from the projection, hydrated from their default, removed from the filter/sort/aggregate allow-sets, and tolerated by relational startup schema checks (see below) |
write_omit_fields |
frozenset[str] |
∅ |
domain fields with no column: silently stripped from every write and hydrated from the domain default on read-back (the write-side of lenient_read_fields; explicit-only, requires write) |
default_sort |
QuerySortExpression \| None |
None |
sort applied when a caller omits sorts (required if the read model has no id) |
query_policy |
QueryFieldPolicy \| None |
None |
allow-sets restricting which fields a governed caller may filter / sort / aggregate |
filter_limits |
QueryFilterLimits \| None |
None |
bounds on the filters the document's queries accept, on every backend and on generated routes; None keeps the defaults (see Limits) |
query_params |
type[BaseModel] \| None |
None |
typed query-parameter contract, bound via with_parameters |
encryption |
FieldEncryption \| None |
None |
field-level encryption policy (share the same object with the table's SearchSpec) |
cache |
CacheSpec \| None |
None |
read-through cache for get |
sensitive |
bool |
False |
read model carries secrets; generated HTTP/MCP surfaces refuse to project it |
codecs |
DocumentCodecs \| None |
None |
codec overrides (auto-derived from the model types by default) |
guarantees |
Sequence[StorageGuarantee] |
() |
properties the store must enforce at write time (see below) |
write is a DocumentWriteTypes TypedDict — domain (the Document subclass),
create_cmd, and an optional update_cmd.
Query policy¶
QueryFieldPolicy takes three allow-sets of read-model field names: filterable,
sortable and aggregatable. Each one is None by default, which allows every read
field. The generated document list operations enforce the policy, and so does every
HTTP, MCP or agent-tool surface built on them. The query port does not, so internal code
is not restricted. A search spec has no query policy. A refused query raises
field_not_filterable, field_not_sortable or field_not_aggregatable.
On a document aggregate (agg_list), groups and measures are checked against
aggregatable, and each metric's filter against filterable. $having and sorts
name output aliases, so each alias is checked as the field it comes from, whatever it is
called:
$havingneeds that field filterable, and a sort needs it sortable.- A measure counts as its field: a
$havingon the$sumofsalaryneedssalaryfilterable. - A
$countcomes from no field, so it can always be filtered and sorted on.
Lenient read fields¶
By default every read-model field must map to a stored column, and a relational
backend fails at startup if one is missing — drift is caught at boot, not on the
first query. lenient_read_fields opts named fields out of that rule, on the read
side only:
- the field is dropped from the read projection and hydrated from its model default on every read (so it carries the same default for every row);
- it is removed from the filter / sort / aggregate allow-sets — a column that is not there cannot be queried;
- the Postgres startup schema check tolerates the missing column instead of failing (Mongo and Firestore are schemaless, so they tolerate it naturally).
Honored on Postgres, Mongo, and Firestore. Each name must be a non-computed
read-model field that carries a default (is non-required) and is not an
identity/audit field (id, rev, created_at, last_update_at) or a
materialized field. It is read-side only: if the same field is also stored on the
write/domain model over that relation, Postgres write-schema validation still
requires its column.
Use it for a field that exists in code ahead of (or independently of) the physical column — e.g. during an expand/contract migration — or a read-model display field the write model does not persist.
Instead of listing fields, set read_conformity="lenient" to auto-derive the set:
every statically-defaulted, non-identity read field that is not materialized becomes
lenient (fields with a default_factory are excluded — declare those explicitly to
accept a fresh value per row). Explicit lenient_read_fields are always added on top.
resolved_lenient_read_fields is the effective set every backend reads.
write_omit_fields is the write-side counterpart: a domain field with no column is
silently stripped from every insert/update and hydrates from the domain default on
read-back (Postgres, Mongo, and Firestore). Because the value is dropped (not persisted),
it is explicit-only — never auto-derived — requires a write spec, and each name must
be a defaulted, non-identity domain field. Use it for a domain field that is computed or
stored elsewhere, not on this table.
Storage guarantees¶
A guarantee is a property of the data that the store refuses to violate — not something the framework checks after the fact. Declare it and a backend that cannot keep it refuses at wiring:
DocumentSpec(
name="fact",
read=Fact,
write={"domain": FactDoc, "create_cmd": CreateFact},
guarantees=(
UniqueTogether(fields=("root_id",), where={"$values": {"is_current": True}}),
),
)
Two sentences carry the whole doctrine:
- A guarantee is declared, validated and never created. Nothing here issues DDL. An adapter that created an index would take a lock on a production table nobody asked it to take, and would hide a missing migration until the next deployment.
- Your migration is the thing that satisfies it. Startup checks that the mechanism is there and prints the statement that would create it.
| Member | Property | Postgres | Mongo | In-memory |
|---|---|---|---|---|
UniqueTogether(fields=…) |
at most one row per field tuple | unique index | unique index | ✅ |
UniqueTogether(fields=…, where=…) |
…among the rows the filter selects | partial unique index | partialFilterExpression |
✅ |
UniqueTogether(fields=…, skip_null=True) |
…exempting tuples holding a null | unique index (nulls distinct) | partialFilterExpression with $type |
✅ |
NonOverlapping(key=…, period=…) |
no two rows for one key hold overlapping periods | EXCLUDE USING gist |
— | ✅ |
NonOverlapping(key=…, period=…, where=…) |
…among the rows the filter selects | EXCLUDE … WHERE (…) |
— | ✅ |
SerializedBy(key=…) |
two writes for one key never run at once | pg_advisory_xact_lock |
— | ✅ |
Reference(field=…, spec=…, on_delete=…) |
a hard delete of the referenced row cascades, is refused, or clears the field | FOREIGN KEY … ON DELETE … |
— | ✅ |
Reference(fields=…, references=…, spec=…, on_delete=…) |
…through a composite key | FOREIGN KEY (…, …) REFERENCES … (…, …) |
— | ✅ |
SerializedBy is the one member about write ordering rather than about stored rows. It says
two writes for one key never run at once — the rule that otherwise lives in whichever writer
remembered to take a lock, and holds until somebody adds the next writer.
On Postgres every write takes a transaction-scoped advisory lock per owner before it writes, held
until the transaction ends — so there is nothing to migrate. A write outside a transaction runs in
one of its own, so its lock still covers it. A write that names a row by id reads the row first to
learn its owner, which is one extra query, paid only by a spec that declares this. Two
transactions that each hold the owner the other wants are a deadlock Postgres detects: one is
refused as concurrency, which is retryable, and the in-memory store refuses the same cycle the
same way. Mongo has no advisory-lock mechanism and refuses the declaration.
A datetime key field locks on the instant, not on its offset: a create written in +03:00 and an
update of the row Postgres reads back in the session's time zone take one lock. A naive datetime
is read as UTC, as Postgres stores it in a timestamptz column whatever the session's
TimeZone. An instant whose UTC spelling falls outside the years 1 to 9999 is refused as
lock_key_instant_out_of_range. On Postgres a key locks on the value as its column stores it,
writes and hold alike: in a timestamptz(3) column, 09:00:00.1234 and 09:00:00.1229 are
one owner.
Those locks add up in a long transaction. A transaction holds one for each distinct owner it writes
under until it ends, and they all live in Postgres's shared lock table. Every session shares that
table, which holds max_locks_per_transaction × (max_connections + max_prepared_transactions)
locks. A request touches a few owners; a bulk load or a seed that writes under thousands in one
transaction can fill the table and fail with out of shared memory. Commit such a load in batches,
so a transaction holds only its own batch's owners.
It serializes, it does not validate. A write takes the lock just before it writes, which
covers the write and nothing before it. A handler that reads, decides and then writes takes the
lock first with hold, inside its transaction, so it decides on a read no other writer for
that owner can change:
async with ctx.tx_ctx.scope("orders"):
lines = ctx.document.command(line_spec)
await lines.hold({"order_id": order_id}) # the lock every write for the order takes
... # read the order's lines, decide, then write: held until commit
hold writes nothing. Each argument names the fields of one declared key. A value is validated
against the field's type and constraints, so an id passed as a string locks the same owner. A
second handler for the order waits at its own hold until the first commits, and the first
handler's writes take the lock again without waiting.
hold refuses as configuration:
- a spec that declares no
SerializedBy, or an argument naming other fields; - a datetime key field: a write keys it as the database returns it, in the session's time zone;
- a key field the domain validates with a field validator of its own, or any domain with a
model validator: a write keys the value after them, and
holddoes not run them; - a key field with a serializer of its own (
PlainSerializer,WrapSerializer,@field_serializer), or any domain with a model serializer: a write can key the stored form they produce.
A call outside a transaction is refused (document_hold_outside_transaction), since its lock
would end with its statement. Mongo and Firestore keep no write lock and refuse the call.
One call takes its owners in sorted order. Two calls, or a hold and then a write for another
owner, can take owners in another order than a concurrent transaction does. Postgres refuses
that deadlock on one side as a retryable concurrency, and the in-memory store refuses the same
cycle. Pass every owner a handler needs to one hold call.
hold locks a document owner. To make an operation take turns per input instead, whatever
documents it writes, see
Serializing an operation.
The declaration itself refuses no write: where the rule can also be a hard stop, that is a second guarantee beside this one.
UniqueTogether and NonOverlapping also say when they hold: holds="always", the default,
means after every write; holds="commit" means once the transaction commits, so a transaction may
pass through a violation it resolves before committing — inserting at the top of a positioned list
while the rows it displaces still hold their positions:
from forze.application.contracts.guarantees import UniqueTogether
UniqueTogether(fields=("order_id", "position"), holds="commit")
On Postgres that is a constraint declared DEFERRABLE INITIALLY DEFERRED, and startup checks the
deferral both ways: a deferred constraint behind a guarantee that holds always is refused — the
in-memory store would refuse writes the database accepts — and so is an immediate one behind a
guarantee that holds at commit. DEFERRABLE INITIALLY IMMEDIATE counts as immediate. A
uniqueness filtered by where cannot hold at commit on Postgres, because deferral belongs to
constraints and a unique constraint cannot be partial, so wiring refuses it there. skip_null can:
a plain unique constraint already treats a tuple holding a null as distinct, so
ADD UNIQUE (…) DEFERRABLE INITIALLY DEFERRED keeps it. A filtered NonOverlapping can too,
since an EXCLUDE constraint takes both. Mongo checks every write and
refuses "commit". The in-memory store checks a "commit" guarantee when the transaction
commits — and at the write itself outside a transaction, where, as in Postgres autocommit, there
is no later moment.
Mongo refuses NonOverlapping, and will keep refusing it: non-overlap is a comparison between
two rows rather than a property of one row's fields, so unlike filtered uniqueness there is no
partial index that expresses it.
The period's start field must be non-null. A store reads a null lower bound as "in force since always" and refuses everything overlapping it, and the in-memory store has no way to say the same — so the declaration is refused rather than enforced two different ways. A null in the key conflicts with nothing, including another null, because the comparison is equality.
On Mongo the null exemption is a partialFilterExpression naming each field's stored BSON type,
not a sparse index: sparse skips a document only when every indexed field is missing and still
indexes an explicit null, so the tuples the exemption was meant to let through would collide under
it. Startup refuses a sparse index offered for a skip_null guarantee.
A Reference is declared on the referencing spec, as a foreign key is declared on the
referencing table. It says that field holds the id of a row of the document spec spec, and
what a hard delete of that row does to the rows naming it. The field must be a UUID (or
UUID | None) on the read and domain models, or it would never equal that id:
from forze.application.contracts.guarantees import Reference
Reference(field="stage_id", spec="stages", on_delete="cascade")
A composite foreign key names its columns on both sides, paired in order. field="stage_id" is
the shorthand for fields=("stage_id",), and a single field references id unless references
says otherwise:
# FOREIGN KEY (stage_id, source_id) REFERENCES stages (id, source_id) ON DELETE CASCADE
Reference(
fields=("stage_id", "source_id"),
spec="stages",
references=("id", "source_id"),
on_delete="cascade",
)
A row names a parent when every field holds the paired parent value, an aware datetime compared
as its instant; a row holding a null in any of them names nothing, as under the key's
MATCH SIMPLE. A field paired with id must be a
UUID. A field paired with another parent field must have that field's type, None aside.
Referenced fields without id must be unique on the parent, through an unfiltered
UniqueTogether over exactly them that holds always, declared or implied by its domain model;
on a tenant-scoped parent that uniqueness is per tenant, as the key beside tenant_id is. A
spec catalogue checks both once it holds both specs.
"cascade" deletes them too, and their own referencing rows in turn. "restrict" refuses the
delete with conflict while any remain. "set_null" clears every field of the reference, or
only those set_null_fields names, as SET NULL (stage_id) clears the stage and keeps the
source; each cleared field must accept None. The cleared row keeps the child's guarantees, so
one it would break refuses the delete with the write's conflict. Clearing a field that another
Reference keys on refuses it too while a row names the old value, as Postgres refuses that
update under the default ON UPDATE NO ACTION. No other ON UPDATE action is modelled: a key
declaring ON UPDATE CASCADE or SET NULL passes the change on where the in-memory store
refuses, and startup does not check it. Only kill and kill_many apply a reference: a soft
delete is an update and leaves the rows alone. On Postgres the foreign key does the work. The
in-memory store does the same inside the transaction's view, so a rollback restores the parent
and its children together.
It plans the whole cascade first, so a restrict anywhere in it refuses the delete with nothing
removed. It plans from the transaction's view, so under snapshot isolation a child another
transaction committed meanwhile survives, where Postgres's referential check would still find
it. A restrict refuses even from a row the same cascade deletes, where Postgres refuses or
allows that depending on which key was created first. Below an unscoped parent it searches
every tenant's partition of a tenant-scoped spec, as a foreign key reaches every tenant's rows.
Below a tenant-scoped parent it searches only that tenant's partition; a single-column Postgres
key would also reach another tenant's rows naming the parent, which happens only when rows
reference across tenants. It finds a spec's rows once that spec's adapter has been built
against the state, which a seed, a write or a resolved port does. Neither store evicts the rows a
cascade removes from a document cache. A spec catalogue refuses a reference to a spec it does not
hold. Mongo and Firestore cannot apply one and refuse it at wiring.
Three things happen to a declared guarantee, and a failure at any of them is loud:
-
Reconciliation, when the port is built. A backend that cannot keep the guarantee raises
preconditionwith codestorage_guarantee_unsupported, naming which part is missing — "uniqueness over a field tuple" reads differently from "uniqueness restricted to a subset of rows", and they need different fixes.check_wiringreports it like any other resolution failure, so CI sees it before production does. For a non-overlap guarantee startup checks more than the columns: the constraint's key must be exactly the declared key (an extra column weakens it — two rows would have to match on that one too before they conflict), each key element must be compared with=, the range must be built over the period's two fields in that order, and it must carry the declared bounds and the&&operator. A constraint with the right columns and=on the range refuses two identical periods and takes every other overlapping pair. -
Validation, at startup. The live catalog is checked for the index, and the refusal names the DDL. An index counts when it covers the guarantee's fields — as a set, since uniqueness over a tuple does not depend on the order an index lists it in — and when it is live and valid. How closely its restriction is compared differs by backend, and the difference is in the data rather than the effort: Mongo stores a
partialFilterExpressionas a document, so the two filters are reduced to their equality constraints and must select the same documents, and anything that does not reduce that way is refused rather than guessed at. Postgres hands back a deparsed SQL predicate, so only the columns it restricts on are checked — an index on the right column and the wrong value passes there. Neither is a proof that the mechanism matches; on Postgres it is a floor under a forgotten migration. For aReference, Postgres wants a foreign key on the referenced spec's relation pairing exactly the declared fields with the referenced ones (idfor a single field), in any column order, alone or besidetenant_id, with the declaredON DELETE. A referenced spec bound in another Postgres module is refused, since a foreign key lives in one database."restrict"takesON DELETE RESTRICTor a plainNO ACTIONkey, unless it is deferred to commit."set_null"needs the columns it clears nullable, and a key clearing exactly those: a plainON DELETE SET NULLonly when they are all the key's columns, and otherwise — always besidetenant_id, which a delete never clears —ON DELETE SET NULL (…)naming them (Postgres 15). Postgres also warns about each unique index,EXCLUDEconstraint andON DELETE CASCADEorSET NULLforeign key on the write table that no guarantee declares, other than the primary key and an index over all its columns, and suggests the declaration —fields=…, references=…for a composite foreign key,set_null_fields=…for a key clearing some of its columns. A key whoseSET NULLclearstenant_idgets the migration to a key that leaves it, then the declaration, since no declaration keeps a row moved out of its tenant. Aconflict_targetover other columns is reported too, since it refuses duplicates. Without it the in-memory store accepts writes the database refuses. An index counts as declared only when it is the one that keeps a guarantee, so a partial index beside an unfiltered declaration is still reported. A spec the framework owns, such as aforze_identityone, is not reported at all, even for an index you added yourself, because the application cannot declare anything on it. - Enforcement, at write time, by the store. A violating write raises
conflict, from every backend and from the in-memory store alike — including across two concurrent transactions, which the in-memory store rechecks at commit because a unique index is not snapshot-scoped.
Postgres counts nulls as distinct; a guarantee does not
With skip_null off, two rows sharing a tuple that holds a null conflict. An ordinary
Postgres unique index permits them, so a guarantee over a nullable column asks for
UNIQUE NULLS NOT DISTINCT and startup refuses the plain index by name. Over NOT NULL
columns the two readings cannot differ and an ordinary index is exactly the mechanism. The
same holds for a column the where rules out nulls in: a top-level $null: false, an
equality or ordered comparison, or an $in whose list holds no null.
With skip_null on, the ordinary index is the mechanism, and no predicate is needed. A
predicate dropping the rows that hold a null also counts, if it is only IS NOT NULL
conditions on fields of the tuple, joined by AND, covering each nullable field. Any other
predicate, such as code = 'x' or code IS NULL, is refused. NULLS NOT DISTINCT is
refused there too, unless such a predicate drops the null rows first, because it rejects
the rows the exemption lets through.
A guarantee with no where covers soft-deleted rows too
A soft-deleted row is still a row: its tuple stays reserved and no replacement can be created. A spec with soft deletion usually filters the deleted rows out — which also means an un-delete can conflict, and is refused.
A tenant-aware route keeps a guarantee per tenant
The in-memory store keeps one partition per tenant, so on a tenant_aware route two tenants
may share a tuple. Startup therefore accepts an index over tenant_id plus the fields, in
any order, such as UNIQUE (tenant_id, number_id), and prints that form when the index is
missing. An index over the fields alone also counts, but it is stricter: it refuses a
duplicate across tenants that the in-memory store accepts. The undeclared-index warning
leaves tenant_id out of its suggestion, since the spec cannot name it.
Guarantees the domain model implies¶
Some fields carry their own guarantee. NumberIdMixin gives a number_id that a counter hands
out once, so a spec whose write domain model uses it declares
UniqueTogether(fields=("number_id",)) by itself. You add no line to the spec. The in-memory
store refuses a duplicate number, and startup expects the unique index on number_id (per
tenant on a tenant-aware route).
The rules:
- A uniqueness you declare over the same fields replaces the implied one, whatever its
where,skip_nullorholds. The spec never carries two, so there is never a second index to migrate. - Only the write domain model counts. A read-only spec implies nothing, because nothing writes
through it and its relation may be a view. Likewise, Postgres startup checks only declared
guarantees on a route bound in
ro_documents: the implied ones bind the writer's table. spec.guaranteeslists both kinds, and every store reads it.spec.declared_guaranteesholds only what you passed asguarantees=.- The counter numbers per tenant only when its own route is
tenant_aware. Give the counter route and the document route the same tenancy. A per-tenant counter feeding a relation that tenants share hands two tenants the same number, and the guarantee refuses the second. - An implied guarantee needs a backend that keeps it, as a declared one does. The wiring
refusal marks it
(implied by the domain model)and names the opt-out. Firestore keeps no uniqueness, so wiring refuses a numbered spec there. To opt out, override the attribute on your domain model:implied_guarantees: ClassVar[StorageGuarantees] = (). Nothing then refuses a duplicate number. - A numbered domain whose read model does not store
number_idis refused when the spec is built, because a guarantee must name a stored field.
A mixin of your own can do the same: set implied_guarantees as a ClassVar holding a tuple
of guarantees (GuaranteeImplying is the protocol). Anything else, such as a bare guarantee
outside a tuple, is refused as implied_guarantees_invalid when the spec is built.
Guarantee or invariant?¶
Both can express "one current row per fact", and the question comes up immediately.
SystemInvariant |
Storage guarantee | |
|---|---|---|
| Declares | a law over a read-set, reduced to one number | a property the store refuses to violate |
| Enforced by | the framework, in or after the writing transaction | the store, at write time |
| Detects | a violation that already happened, or prevents it under isolation | prevents it, always |
| Expresses | anything SumOf / CountAll can score |
uniqueness, non-overlap |
Declaring both is legitimate where the guarantee is the prevention and the invariant is the proof — a simulation asserts the invariant over a workload the guarantee is quietly keeping.
Query port¶
Fetch one¶
| Method | Returns | On miss |
|---|---|---|
get(pk, *, owned_by=None, for_update=False, skip_cache=False) |
R |
raises not_found |
get_many(pks, *, owned_by=None, skip_cache=False) |
Sequence[R] |
raises not_found (lists missing) |
find(filters, *, for_update=False) |
R \| None |
returns None |
project(filters, fields, *, for_update=False) |
JsonDict \| None |
returns None |
select(filters, return_type, *, for_update=False) |
T \| None |
returns None |
for_update takes a RowLockMode (True / "nowait" / "skip_locked") to lock
the row inside a transaction. A locking read always goes to the database, past any read cache.
owned_by is the preferred way to say a row belongs to someone. With it, a row whose owner
field holds another value is not found — the same error as a missing row, from the same
read — so a handler cannot serve a foreign row by forgetting a check:
from forze.application.contracts.document import OwnedBy
note = await ctx.doc.query(NOTES).get(pk, owned_by=OwnedBy(field="owner_id", value=principal_id))
The owner goes into the database predicate, so a locking read never locks a foreign row; a row
served from the read cache is checked before it is returned. In get_many, a foreign id fails
the call exactly as a missing id does, with one summary that names no id — a list of only the
missing ids would tell the caller the others exist. field must be a stored, filterable UUID
field of the read model: one the model lacks, one sealed with randomized encryption, or a lenient
read field is refused as a configuration error before the read, rather than answered with a
not-found on one path and something else on the other.
Pair it with a non-disclosing posture so the not-found
also renders like a denial.
Fetch many¶
Each comes in find / project / select flavors and _many / _page /
_cursor containers. All take filters, sorts, and pagination:
| Method | Result |
|---|---|
find_many(filters=None, pagination=None, sorts=None) |
CountlessPage[R] (.hits) |
find_page(...) |
Page[R] (adds .count) |
find_cursor(filters=None, cursor=None, sorts=None) |
CursorPage[R] (keyset) |
project_many / project_page / project_cursor (fields, …) |
pages of JsonDict |
select_many / select_page / select_cursor (return_type, …) |
pages of T |
Stream & aggregate¶
| Method | Result |
|---|---|
find_stream(filters=None, *, sorts=None, chunk_size=500) |
async generator of Sequence[R] |
project_stream / select_stream |
async generators of JsonDict / T batches |
aggregate_many(aggregates, filters=None, …) |
CountlessPage[JsonDict] |
aggregate_page(aggregates, …) |
Page[JsonDict] (group count) |
select_many_aggregated / select_page_aggregated (return_type, aggregates, …) |
typed aggregate rows |
count(filters=None) |
int |
filters, sorts, and aggregates use the query DSL;
pagination is {"limit": …, "offset": …}.
Without a limit, a read returns every matching row, fetched in batches. Rows come
back in the effective sort (sorts, else default_sort, else id), with id
breaking ties when the read model has one, and each batch starts after the last row
of the one before. Some reads are paged by offset instead, still with the id
tie-breaker: a projection or returned model that leaves out a sort key, and on
MongoDB any sort other than id alone. Firestore refuses offsets, so there such a
read is one query with no limit, streamed whole. A read model without id has no
tie-breaker, so rows tied on its whole sort can still repeat or go missing across
offset batches.
The id tie-breaker shapes the index such a read wants. On MongoDB, a {key: 1}
index cannot serve the {key: 1, _id: 1} sort, which then runs in memory; add a
compound {key: 1, _id: 1} index for a large collection read this way. Firestore
breaks ties on the document name, which forze sets to the id and every index already
ends in, so a single-field index still serves a read sorted by one key; a sort led by
id keeps ordering by the stored id field.
Command port¶
Every mutating method takes return_new: bool = True — return the resulting read
model(s), or None when you don't need them back.
Create¶
| Method | Signature | Notes |
|---|---|---|
create |
create(payload, *, id=None, return_new=True) |
server-generates the PK unless id is given |
create_many |
create_many(payloads, *, return_new=True) |
batch insert |
ensure |
ensure(id, payload, *, return_new=True) |
insert-when-missing; never mutates an existing row (idempotent by PK) |
ensure_many |
ensure_many(items, *, return_new=True) |
bulk insert-when-missing (KeyedCreate) |
upsert |
upsert(id, create, update, *, return_new=True) |
insert create, else apply update (domain apply + OCC) |
upsert_many |
upsert_many(items, *, return_new=True, set_based=False) |
bulk insert-or-update (UpsertItem); set_based=True is the bulk-load path below |
A bulk load that needs no rows back can pass return_new=False, set_based=True. Postgres
then inserts the missing rows and patches the stored ones with set-based statements, reading
nothing back: a stored row takes the update as its DTO encodes it, and only a row the update
changes gets a new rev and last_update_at. The stored rows are locked in id order first,
so concurrent loads of overlapping batches mostly queue. A chunk inserts its missing rows
before it locks its stored ones, though, so loads whose chunks split differently can deadlock:
Postgres aborts one, which is retried outside a caller's transaction and is concurrency
inside one. It writes
set_based_batch_size rows per chunk (2,000 by default, PostgresDocumentConfig), not
batch_size: each chunk costs a few statements, and a statement-level trigger fires once per
statement, so a chunk per few hundred rows spends its time on statements.
Like update_matching, it skips the domain model's update, so a spec whose updates need it
refuses set_based with a configuration error (set_based_upsert_unsupported): one with
revision history, materialized fields, a SerializedBy guarantee or randomized field
encryption; a domain with update validators, invariants, domain events, or pydantic validators,
constraints or serializers of its own (model-level, or on any field; a JSON-only serializer does not count); or an update field the domain lacks, freezes, merges
(a model or mapping) or derives from the other fields. A table
keyed by more than the id (and tenant), or a column an update can write that Postgres cannot
compare, takes the usual path instead. Mongo, Firestore and the mock apply the same refusals
and then write through their usual path.
Each item says what its update writes, on every backend and with or without set_based:
UpsertItem.mirror(id, payload)updates with the create payload itself, for a mirrored source whose newer version rewrites every field the create carries, not only the update command's. A set-based upsert is judged on the fields the items' update types carry, so a mirror whose create holds a mapping, a model or a frozen field is refused.skip_none=True, onmirroror onUpsertItem(...), leaves out each top-level field the update sets toNone, so a stored value is kept instead of cleared. ANoneinside a mapping or model is not looked into, and the create is still inserted as it is.
An update writes the fields its DTO sets: a field the payload leaves unset keeps its stored
value, a mirror's too. On the domain path, a mirror whose create sets a frozen field (such as
ImportTimestamps.created_at) is refused for a stored row with "Field … is frozen".
Update¶
| Method | Signature | Notes |
|---|---|---|
update |
update(pk, rev, dto, *, return_new=True, return_diff=False, merge=True) |
optimistic — a stale rev is merged through history, or refused (revision_mismatch) without history or with merge=False (details); return_diff adds the change JsonDict, a merge patch holding the values the caller set on every backend (a StrEnum stays a member, a datetime is as given), not the store's column coercion |
update_many |
update_many(updates, *, return_new=True, return_diff=False, merge=True) |
per-row update (KeyedUpdate: id, rev, dto) with OCC |
update_matching |
update_matching(filters, dto, *, return_new=True) |
fast bulk patch by filter — no per-row OCC, no domain side effects; refuses a field set to a mapping or model, which only an update merges; not on Firestore; return_new=False → rows-updated count |
update_matching_strict |
update_matching_strict(filters, dto, *, return_new=True, chunk_size=None) |
like update_many (per-row OCC + domain apply) over a filter |
touch / touch_many |
touch(pk, *, rev=None, return_new=True) |
bump rev and last_update_at only; with rev (revs= per id on touch_many), only a document still at it |
Delete¶
kill(pk) and kill_many(pks) hard-delete — there is no soft-delete or
restore on the port (model soft-delete is a domain concern, applied via
update). On a spec declaring hard_delete=False both refuse with a
configuration error (hard_delete_forbidden), whoever calls them. Both apply every
Reference naming the spec. A row a foreign key still references is
refused with conflict, by ON DELETE RESTRICT and a plain key alike.
Command hooks¶
A plane that owns a document can wrap its command port wherever it is resolved: a provider
registered under DocumentCommandHookDepKey, routed by the document's name, receives
(ctx, port) and returns the port handed out, before any other wrap and before it is cached.
Every write through ctx.document.command(spec) passes it, on every backend; a write around the
port (SQL, another spec over the same table) does not. The identity plane registers one on the
policy principal to raise a principal's epoch on deactivation (see
Authn).
Implemented by¶
| Backend | Tenancy ceiling | Integration |
|---|---|---|
| Postgres | dedicated |
Postgres |
| Mongo | dedicated |
Mongo |
| Firestore | dedicated |
Firestore |
The in-memory mock implements the full surface, so an aggregate is testable without a backend — see Testing.