CRUD & Marshaling
The CRUD scenario is the foundation of the P0 contract: every TableTheory runtime must produce identical DynamoDB items for identical inputs and read them back identically. If Go writes a Note and Python reads it, the two runtimes see the same field values, the same attribute names, and the same DynamoDB types.
The canonical scenario lives at contract-tests/scenarios/p0/01-crud-basic.yml and is exercised by every runtime on every commit.
The contract
Given a model with pk, sk, one user attribute, and the same DMS shape across runtimes:
| Behavior | Required across Go / TS / Python |
|---|---|
| Create writes all populated attributes | yes |
Zero values without omit_empty are written |
yes |
| Attribute names match the DMS shape | yes |
| Read returns identical field values | yes |
| Update mutates only the named fields | yes |
| Delete removes the item | yes |
| Missing item on Get raises a typed not-found error, never returns silent nil/None | yes |
Go
type Note struct {
PK string `theorydb:"pk" json:"pk"`
SK string `theorydb:"sk" json:"sk"`
Body string `json:"body"`
}
// Create
db.Model(&Note{PK: "USER#1", SK: "NOTE#welcome", Body: "Hi."}).Create()
// Read
var got Note
db.Model(&Note{PK: "USER#1", SK: "NOTE#welcome"}).First(&got)
// Update specific fields
db.Model(&Note{PK: "USER#1", SK: "NOTE#welcome", Body: "Edited."}).Update("body")
// Delete
db.Model(&Note{PK: "USER#1", SK: "NOTE#welcome"}).Delete()
TypeScript
// TheorydbClient.update requires a version role on the model and the
// current version in the item payload. Most "real" TableTheory models
// declare version anyway; show it here so the CRUD flow is complete.
const Note = defineModel({
name: 'Note',
table: { name: 'notes_contract' },
keys: {
partition: { attribute: 'PK', type: 'S' },
sort: { attribute: 'SK', type: 'S' },
},
attributes: [
{ attribute: 'PK', type: 'S', roles: ['pk'] },
{ attribute: 'SK', type: 'S', roles: ['sk'] },
{ attribute: 'body', type: 'S', optional: true, omit_empty: true },
{ attribute: 'version', type: 'N', roles: ['version'] },
],
});
const db = new TheorydbClient(ddb).register(Note);
await db.create('Note', { PK: 'USER#1', SK: 'NOTE#welcome', body: 'Hi.' });
const item = await db.get('Note', { PK: 'USER#1', SK: 'NOTE#welcome' });
// Pass the current version through; the runtime asserts it matches.
await db.update(
'Note',
{ PK: 'USER#1', SK: 'NOTE#welcome', body: 'Edited.', version: item.version },
['body'],
);
await db.delete('Note', { PK: 'USER#1', SK: 'NOTE#welcome' });
Python
@dataclass(frozen=True)
class Note:
pk: str = theorydb_field(roles=["pk"])
sk: str = theorydb_field(roles=["sk"])
body: str = theorydb_field(omitempty=True)
model = ModelDefinition.from_dataclass(Note, table_name="notes_contract")
table = Table(model, client=client)
table.put(Note(pk="USER#1", sk="NOTE#welcome", body="Hi."))
note = table.get("USER#1", "NOTE#welcome")
table.update("USER#1", "NOTE#welcome", {"body": "Edited."}) # third arg is a Mapping
table.delete("USER#1", "NOTE#welcome")
Omit-empty subtlety
Without omit_empty, zero values are written: "", 0, False/false, empty slices, empty maps. With omit_empty, the attribute is omitted entirely from the DynamoDB item.
This matters because DynamoDB distinguishes “attribute present with empty string” from “attribute absent” in queries and conditional expressions. The contract pins which behavior each combination produces, and every runtime must agree.
In Go transaction puts, explicitly non-nil empty collections inside nested structs are preserved unless those fields
use omitempty.
The dedicated omit-empty scenario exercises every type variant in the matrix.
Nil and empty collections per writer path
DynamoDB has no “nil” collection: an attribute holds an L/M, holds NULL, or is absent. TableTheory’s runtimes, and Go’s two writers, store different representations for a nil collection. That split is a permanent compatibility contract: each writer path’s stored representation is frozen by the operator’s policy that TableTheory supports its consumers rather than changing stored data, no writer’s output may change, and the rows below are asserted by tests.
For a collection written without omit_empty:
| Input (logical class) | Go Create / BatchCreate / BatchWrite |
Go Transact().Put / Create |
TypeScript create / save / batchWrite / transactWrite |
Python put / save / batch_write / transact_write |
|---|---|---|---|---|
| nil list or map | NULL |
L or M |
attribute absent | NULL |
| non-nil empty list or map | L or M |
L or M |
L or M |
L or M |
nil list or map, omit_empty |
absent | absent | absent | absent |
empty list or map, omit_empty |
absent | absent | absent | absent |
false or 0 |
BOOL or N |
BOOL or N |
BOOL or N |
BOOL or N |
Three further differences belong to the same table:
- TypeScript never stores
NULLfor a missing collection. It has no nil collection, so its nearest input is an absent property, which the marshaler drops. A collection nested inside anMvalue can instead be an explicitnull, which TypeScript does store asNULL. - Python stores
NoneasNULLon every writer path, which matches Go’s put family but not Go’sTransactWrite. - Go’s
TransactWriteignores thesettag. A set-tagged slice is stored as an emptyLthere, where the put family storesNULL.
The single source of truth for these rows is contract-tests/serialization/nil-empty-collections.yml. Each runtime’s test suite loads that one file and asserts its own column against payloads captured from its real writers, and each asserts a derived row count, so a change to any writer for these classes — or a dropped row — fails the build.
Measuring what a writer will store
Because the writer paths store different representations for some inputs, the same model can be measured two ways. Each runtime exposes one item-serialization preflight that returns exactly the DynamoDB item a named writer path stores:
| Runtime | Preflight | Writer paths |
|---|---|---|
| Go | marshal.MarshalItemForWriterPath(model, metadata, path) |
marshal.ItemWriterPathPut, marshal.ItemWriterPathTransactWrite |
| TypeScript | marshalItemForWriterPath(model, item, path) |
'put', 'transact_write' |
| Python | marshal_item_for_writer_path(table, item, path) |
ItemWriterPath.PUT, ItemWriterPath.TRANSACT_WRITE |
Both paths return the same representation in TypeScript and Python, which funnel every whole item through one serializer. Go needs the parameter because its two writers really do differ. A preflight refuses a model that declares encrypted attributes, because the stored envelope depends on KMS material a static preflight cannot see. It has no side effects, contacts nothing, and changes nothing that is written.
Custom type converters
A model that registers a custom type converter is not covered by the preflight above: the converter replaces
TableTheory’s own value handling, so the preflight cannot predict what it stores. In Go a converter is registered on the
database (RegisterTypeConverter), for example for map[string]interface{}; in TypeScript and Python it is a
per-attribute converter on the attribute definition.
A read-only audit of the current consumer set (Autheory, theory-mcp-server, theorycloud-ai, FaceTheory and AppTheory at
origin/staging) found no consumer on TableTheory v5 that registers a converter for a slice, map or pointer. The only
converters registered in that set are Autheory’s two Go converters — for map[string]interface{} and for interface{},
both in pkg/autheory/repository/dynamorm_converters.go — and Autheory pins TableTheory v4. A converter is shared by
every writer path rather than being per-writer, so it has no nil/empty writer split to pin. That is why the shared table
above has no custom-converter rows.
Explicit empty updates
DMS v0.2 distinguishes an unselected field from a selected empty field:
- an unselected empty
omit_emptyfield leaves the persisted attribute unchanged; - an explicitly selected empty
omit_emptyfield removes the persisted DynamoDB attribute; - a selected field without
omit_emptystores its empty value.
This rule is identical in Go, TypeScript, and Python. Consumers that need a
present empty attribute must not mark that field omit_empty, or must use the
runtime’s deliberate low-level SET expression builder.
Related
- Struct Definition Guide — every tag and field shape
- Core Patterns — query and transaction recipes built on top of CRUD
- Contract Scenarios — full P0 specification