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 |
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 |
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) |
write is a DocumentWriteTypes TypedDict — domain (the Document subclass),
create_cmd, and an optional update_cmd.
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.
Query port¶
Fetch one¶
| Method | Returns | On miss |
|---|---|---|
get(pk, *, for_update=False, skip_cache=False) |
R |
raises not_found |
get_many(pks, *, 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.
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": …}.
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) |
bulk insert-or-update (UpsertItem) |
Update¶
| Method | Signature | Notes |
|---|---|---|
update |
update(pk, rev, dto, *, return_new=True, return_diff=False) |
optimistic — a stale rev raises conflict; return_diff adds the change JsonDict |
update_many |
update_many(updates, *, return_new=True, return_diff=False) |
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; 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, *, return_new=True) |
bump last_update_at only |
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).
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.