TableTheory v3 migration
TableTheory v3 adopts DMS v0.2. The breaking contract change is how
high-level partial updates handle an explicitly selected empty field marked
omit_empty / omitempty. The Go runtime also follows Go semantic import
versioning and moves from github.com/theory-cloud/tabletheory/v2 to
github.com/theory-cloud/tabletheory/v3.
Changed behavior
| Update shape | v2 behavior | v3 / DMS v0.2 behavior |
|---|---|---|
Empty omitempty field not selected |
No mutation in Go sparse Update(); not representable in TS/Python explicit update collections |
No mutation |
Empty omitempty field explicitly selected |
Go/Python stored the empty value; TypeScript removed the attribute | All runtimes remove the attribute |
Empty field without omitempty selected |
Stored | Stored |
Version conditions, lifecycle timestamps, and optimistic-lock increments still apply when the selected field is removed.
DMS M values use carrier-size emptiness across runtimes: only a carrier with
zero entries is empty. A map/object such as {"source": ""} remains present
because it has an entry. For fixed-shape Go structs and Python dataclasses,
declared fields are entries, so a record with declared fields remains present
even when every field has its zero value.
Arrays/lists also use length semantics. In particular, a fixed-length Go array
is non-empty whenever its declared length is greater than zero, even when every
element is zero. Fixed Go arrays, including fixed byte arrays, serialize as
DynamoDB L; []byte retains its established B encoding. Runtime-native
timestamps retain their zero/invalid rules.
Go’s no-argument Update() keeps its v2 sparse-selection behavior. A zero-valued
omitempty struct or fixed array is treated as unselected and cannot overwrite
persisted data. Naming that field in Update("Field") explicitly selects it;
the DMS carrier-size/length rules then apply. For record-shaped M values,
tagged child fields without omitempty remain present even when their values
are zero.
Model-shaped transactional updates now follow the same lifecycle contract as
non-transactional updates in all three runtimes: a successful explicit-field
transaction update advances the library-owned updated_at attribute and
preserves created_at. Go, TypeScript, and Python model-shaped transaction
updates now reject explicit selection of created_at, updated_at, or the
version attribute with their invalid-model validation errors. These
model-shaped paths neither increment version nor add a version condition;
versioned transactional writes must use Go’s UpdateWithBuilder, TypeScript’s
updateFn or raw updateExpression, or Python’s TransactPut of the item with
an explicitly incremented version plus a condition_expression on the current
version. Python has no partial-update version path on the transactional surface.
TypeScript also
marshals an updateFn action’s key before invoking the callback, so a malformed
key cannot run caller side effects before the key error is returned.
The F6 JSON normalization parity fix changes the transactional wire encoding
for Go json-tagged []byte and json.RawMessage fields, whether encrypted or
not: their DynamoDB attribute type changes from B to normalized JSON S.
Reads through setJSONStringCarrierValue / attributeValueToJSONBytes tolerate
both encodings, so existing items remain readable. Other json-tagged carrier
types are unaffected. This is an intentional convergence with the query path.
Go legacy implicit transaction updates after v3.0.0
TableTheory v3.0.1 changes the legacy Go one-argument
(*transaction.Transaction).Update(model) whole-model surface. This path
selects fields implicitly; it is distinct from the explicit
TransactionBuilder.Update(model, fields) surface described above.
The implicit field selection excludes all five library-managed fields: PK,
SK, created_at, updated_at, and version. Caller-set lifecycle values do
not produce a validation error on this legacy surface; they are silently
ignored. A caller-set created_at is excluded so the stored value is preserved,
while a caller-set updated_at is overridden by the library timestamp.
Lifecycle-field rejection remains limited to explicit named-field transaction
updates in Go, TypeScript, and Python.
This is a behavior change from v3.0.0 and every v2.x release, where a non-empty
caller-set updated_at on the legacy one-argument path was preserved instead
of refreshed. In v3.0.1 the library always writes its timestamp when the model
declares an updated_at field. A non-zero version still adds the expected
version condition and a managed increment. As of v3.0.2, the corrected legacy
path also adds both when the version is zero. This closes a legacy-only parity
defect: v3.0.1 skipped locking and incrementing zero while the explicit Go
query path and the TypeScript and Python runtimes already locked zero, as documented in
Optimistic Locking.
This changes the v3.0.1 zero-version behavior: an update against a missing or
mismatched item no longer upserts it. Its version condition fails and aborts
every other write in the transaction group. Call Rollback, then use an
explicit create when the item is absent.
Deletes retain their established behavior: the legacy transactional
Transaction.Delete(model) surface and the explicit query Delete() surface
attach an optimistic-lock condition only when the model version is non-zero.
A key-only delete of a versioned model is therefore unconditioned by design.
Separately, v3.0.1 fixed an overlapping-document-path ValidationException
that broke versioned legacy Update(model) calls in v3.0.0 and v2.x.
Also as of v3.0.2, an ignored legacy Create, Update, or Delete
construction error poisons the transaction. Commit returns the stored first
error and submits no queued writes. Call Rollback before reusing that
transaction for another operation.
If implicit selection and the qualifying managed assignments produce no
assignment, Update(model) returns no non-key fields to update and queues no
write. In earlier releases, an all-managed-field shape could instead commit by
silently writing the caller’s created_at, clobbering the stored creation
timestamp. This change affects deterministic-timestamp tests and backfills:
neither should depend on a caller-populated lifecycle value winning on the
legacy whole-model path.
Duplicate database attribute mappings in v3.0.2
As of v3.0.2, TableTheory rejects duplicate database attribute mappings when a model is parsed or registered. This includes Go struct embedding shapes where an outer tagged field shadows a promoted embedded tagged field, or two sibling mixins promote the same tagged Go field name at equal depth, with mappings to the same DynamoDB attribute. Models accepted by v3.0.1 can therefore fail registration after upgrading. In these Go embedding shapes, TableTheory may otherwise persist and read a different field than the one the consumer’s Go code addresses.
For duplicate DynamoDB attribute mappings on fields with different Go field
names, give one field a distinct attribute tag (theorydb:"attr:<name>" in Go,
or the runtime’s database-name setting) or exclude one mapping.
For same-name fields involving Go embedding, rename the Go field name on
one declaration so both fields remain independently addressable. When the
database attribute is derived from the Go field name, this also makes the
attribute mappings distinct. When both declarations explicitly pin the same
attribute, also give one field a distinct attribute tag or exclude one mapping.
An embedded field can be excluded with theorydb:"-"; this requires editing
the shared mixin or embedded struct, which may not be yours to edit.
Renaming only the dynamo or theorydb attribute tag does not resolve this
shadowing shape because TableTheory metadata is also keyed by Go field name.
Consumer migration
-
For Go consumers, update
go.modand every TableTheory import:go get github.com/theory-cloud/tabletheory/v3@v3.0.0 go mod tidyimport ( tabletheory "github.com/theory-cloud/tabletheory/v3" "github.com/theory-cloud/tabletheory/v3/pkg/session" )Use the intended
v3.0.0-rc.Ntag during release-candidate validation. - Update authored DMS documents from
dms_version: "0.1"todms_version: "0.2". - Search for high-level updates that explicitly select an
omitemptyfield and may supply"",0,false, an empty collection,nil, or the language-equivalent empty value. - If absence is intended, no update-call change is required.
- If a present empty DynamoDB attribute is required, remove
omitemptyfrom the model or use the runtime’s deliberate low-levelSETbuilder. - Audit
attribute_existsconditions, sparse GSIs, projections, streams, and tests that inspect raw DynamoDB item shape. - Run the full TableTheory contract and the consumer’s real DynamoDB Local integration suite; Put-semantics fakes are insufficient for this migration.
- If a Go transaction create/save path depends on
omitemptycollections, confirm top-level empty maps and slices are intended to be absent; transaction marshaling now applies the same emptiness predicate as query writes. - If Go models use fixed arrays, confirm consumers expect DynamoDB
Lencoding. Fixed arrays are now supported consistently by Create, Update, transaction writes, stream-image decoding, BatchGet, TransactGet, and update-builderALL_NEWresults rather than failing, being silently omitted, or becoming unreadable. - Audit nested Go struct maps for untagged zero-valued fields. The optimized
marshaler now omits those fields instead of persisting their zero values.
Add explicit matching
theorydbandjsontags withoutomitemptywhen a nested zero value must remain present on the DynamoDB wire. - Audit Go, TypeScript, and Python model-shaped transaction field lists for
created_at,updated_at, or the version attribute and remove them; the runtime owns those values. Move version increments and conditions to Go’sUpdateWithBuilder, TypeScript’supdateFnor rawupdateExpression, or Python’sTransactPutof the item with an explicitly incremented version plus acondition_expressionon the current version. Python has no partial-update version path on the transactional surface. - If a TypeScript
updateFncallback intentionally relied on running before malformed-key validation, move that work outside the callback and validate the key first. - Audit Go model-shaped transaction updates for
json-tagged[]byteandjson.RawMessagefields, encrypted or not. Their wire type changes fromBto normalized JSONS; existing reads remain tolerant of both encodings. - Audit legacy Go one-argument
Transaction.Update(model)callers, especially deterministic-timestamp tests and backfills. Remove expectations that caller-setupdated_atis preserved. A zero-version update no longer upserts a missing or mismatched item: its failed condition aborts the whole transaction group, so callRollbackand issue an explicit create when the item is absent. Handle everyCreate,Update, orDeleteconstruction error rather than ignoring it; the first error poisons the transaction,Commitreturns it without submitting any queued write, andRollbackis required before the transaction can be reused. - Audit models for fields that map to the same DynamoDB attribute, including same-name tagged fields involving Go struct embedding; previously accepted models may now fail registration. For different Go field names, rename or exclude one attribute mapping. For the Go embedding shape, rename one Go field or exclude the embedded field; changing only its attribute tag does not resolve the collision.
Legacy Go index-role tags such as theorydb:"gsi:Name:pk" and
theorydb:"gsi:Name:sk" remain protected from update assignment. Exact token
parsing prevents unrelated attribute names containing pk or sk from being
misclassified.
Runtime examples
Go
// DMS v0.2: REMOVE nickname because it is selected, empty, and omitempty.
err := db.Model(&user).Update("Nickname")
TypeScript
await db.update(
'User',
{ PK: user.PK, SK: user.SK, nickname: '', version: user.version },
['nickname'],
);
await db.transactWrite([
{
kind: 'update',
model: 'User',
item: { PK: user.PK, SK: user.SK, nickname: '' },
fields: ['nickname'],
},
]);
The model-based transaction action applies the same rule: because nickname
is explicitly selected, empty, and marked omit_empty, it emits REMOVE.
Fields omitted from fields are untouched. Transaction updateFn and raw
updateExpression actions remain caller-controlled low-level surfaces and do
not infer this SET/REMOVE choice.
Python
table.update(
user.pk,
user.sk,
{"nickname": ""},
expected_version=user.version,
)
Persisted-data impact
Items written before migration remain readable. No bulk rewrite is required. The behavioral change occurs on future explicitly selected empty updates, which now remove the attribute. Consumers may choose a separate backfill if they need historical empty attributes normalized to absence, but TableTheory does not require or perform that migration automatically.
Release-lane transition
The source tree adopts the /v3 Go module path before release-please advances
the single manifest from the latest v2 stable baseline. During that bounded
state, scripts/verify-go-semantic-import-version.sh accepts exactly a
one-major advance on topic branches and integration/promotion PRs only when
this migration guide and a non-empty Unreleased Breaking Changes section
exist.
main never accepts the pending mismatch. A premain push in this state is
fenced to two non-publishing contexts: the prerelease-PR workflow may generate
the v3 RC PR, while the prerelease publication workflow must skip release
creation. The generated v3 release PR must advance the manifest to v3 before
any prerelease artifact can be published, at which point the module and
manifest majors match strictly again. No manifest, tag, or package version is
hand-edited to create this transition.
Until a v3 artifact exists, a CLI built directly from this source tree defaults
new scaffolds to the latest published v2 release. Release workflows override
that default with the artifact’s own tag through linker flags, so a published
v3 CLI scaffolds its matching v3 runtime by default. --runtime-version
remains available for an explicit pin.
Coordination
AppTheory, FaceTheory, KnowledgeTheory, Autheory, theory-mcp-server, Pay Theory, and external consumers must audit their explicit empty updates before adopting v3. The TableTheory maintainer coordinates those downstream migrations; this repository does not modify consumer repositories.