Entry families and schema layout¶
Vocabulary¶
An entry family is a logical key such as StructureEntry.
A record is a durable frozen-dataclass representation; a family may have several.
Backend/View is the representation pattern: a backend owns data, and a view presents it.
A content id identifies the record’s content across stores; a SID is only a local row id.
Every database starts with a persisted, versioned layout declaration. Passing
entry_records={} says that this is a private/custom-record store with no
queryable entry families. An entry store instead maps each registered logical
family to the exact durable Record representation or representations it may
contain:
store = SqlStore(
db,
entry_records={StructureEntry: UnitcellStructureRecord},
)
Applications may keep a private entry family out of global plugin discovery.
Supply its stable persistence names and classes directly with
EntryFamilyDeclaration and EntryRecordDeclaration:
from httk.store import EntryFamilyDeclaration, EntryRecordDeclaration
private_entries = EntryFamilyDeclaration(
name="my-application-publications",
family=PublicationEntry,
records=(
EntryRecordDeclaration(
name="my-application-publication",
record=PublicationRecord,
),
),
)
store = SqlStore(db, entry_families=(private_entries,))
This is a store-local binding, not a registry operation. The store persists
the stable names and optional entry-definition IRIs but never persists or
imports arbitrary Python paths. Consequently, every reopen of a store with
application-owned declarations must supply the same entry_families value.
Omitting it raises EntryLayoutBindingError. Installed reusable modules should
continue to use registry-backed entry_records, which permits automatic
resolution on SqlStore(db).
Both arguments may be supplied together when one store combines reusable
module families with application-private families; name or class collisions
are rejected while constructing the combined layout.
A single record is queried directly. A tuple of two or more records creates
a small family dispatch table, while the representation-specific data remains
in its normalized Record tables. Saving an exact configured record (including
saving a naturally bound domain object) makes it discoverable through
fetch_entry(StructureEntry, content_id); that method returns the actual
concrete Record.
Later registry-backed SqlStore(db) calls trust the persisted declaration for
which classes and families are stored. Beyond the declaration, reopen also
verifies a per-table schema fingerprint: the resolved on-disk layout and
content identity of every declared class and its referenced classes — the
logical identity_name, dedup, indexes, links, and each field’s role, codec,
columns, child tables, identity participation, and list-vs-tuple container. A
fingerprint JSON document has a tables mapping plus entry_id_tables, the
physical backing-table names of families with an entry definition id. A
record class whose stored shape or identity changed since creation — a gained or
retyped field, a new codec, a changed index, a list↔tuple swap, an added
IdentitySkip, a changed identity_name — is rejected up front with
StorageLayoutUpgradeRequiredError, whose diff names the offending tables
({"schema": {table: {"expected", "actual"}}}) rather than failing later at use
(or, worse, silently breaking content_id deduplication). A code move or rename
is safe only when the record pins an explicit identity_name (every shipped
httk record does); without a pin the qualified class name is the content
identity, so the move changes content_id and the store correctly refuses to
open. Tables are still created lazily on the first write; reads never issue DDL.
Old, unversioned, or incompatible layouts raise
StorageLayoutUpgradeRequiredError; this redesign does not migrate old stores,
so rebuild them explicitly — with one exception below.
The internal Run.source_id field is served under its wire name _httk_source_id
on the _httk_runs entry type. That prefixing is not hand-written at the serving
edge: it is produced by EntryTypeDefinition.served_form(), the single wire-naming
authority, which prefixes the internal runs/source_id names when the definition
is served. _httk_source_id is a nullable, queryable, sortable string containing
the identifier assigned by the system that executed the run (for example, an
httk-workflow <workspace_id>:<job_id>), and it participates in the run’s content
identity. Adding it therefore changes the
core_run schema fingerprint and requires rebuilding existing stores; it is not
an additive upgrade=True change.
Applying a purely additive change with upgrade=True¶
When the only difference is additive, the reopen is applied instead of
rejected by passing SqlStore(db, ..., upgrade=True). Additive means: new
tables, plus new fields that are each non-child, non-derived, marked
IdentitySkip, and whose columns are all nullable — with every pre-existing
table attribute and field byte-identical. The IdentitySkip requirement is the
key one: a field that participates in content identity would change the
content_id of byte-identical pre-existing rows, silently diverging dedup,
dispatch, and federation identity, so such an added field is rejected (the error
names the field and tells you to mark it IdentitySkip or rebuild). Added child,
derived (stored_property), non-nullable, removed, or retyped fields, changed
table attributes, and any protocol or declaration difference all still raise;
upgrade=True never widens or drops.
The apply creates every not-yet-created declared table whole (so a pre-existing
row that references a new table no longer reads as absent), adds each new
nullable column to the tables that already exist via ALTER TABLE ... ADD COLUMN (plus any declared single-column index), then re-stamps the stored
fingerprint last. Old rows read back with the new fields as None, and their
content_id is unchanged. Every step is idempotent — already-present columns
are skipped and the index create is IF NOT EXISTS — and the re-stamp runs only
after all other verification passes, so a store interrupted mid-upgrade (SQLite
DDL escapes the open transaction) heals cleanly when you retry the same
upgrade=True open. When upgrade is left False and the difference is exactly
additive, the raised error carries a hint pointing at upgrade=True. Additive
upgrade is not offered on the ClickHouse bulk-fenced backend.
Because an added field must be IdentitySkip, it is identity-excluded metadata:
the store’s metadata-agreement check means the new field can only carry a
non-None value on content first saved after the upgrade. Re-saving content
that already exists in order to populate the new field on it raises
EntryMetadataConflictError (the existing, correct guard against silently
mutating stored metadata), so plan to backfill by rebuilding rather than by
re-saving old content.