Guides

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

  1. For Go consumers, update go.mod and every TableTheory import:

    go get github.com/theory-cloud/tabletheory/v3@v3.0.0
    go mod tidy
    
    import (
        tabletheory "github.com/theory-cloud/tabletheory/v3"
        "github.com/theory-cloud/tabletheory/v3/pkg/session"
    )
    

    Use the intended v3.0.0-rc.N tag during release-candidate validation.

  2. Update authored DMS documents from dms_version: "0.1" to dms_version: "0.2".
  3. Search for high-level updates that explicitly select an omitempty field and may supply "", 0, false, an empty collection, nil, or the language-equivalent empty value.
  4. If absence is intended, no update-call change is required.
  5. If a present empty DynamoDB attribute is required, remove omitempty from the model or use the runtime’s deliberate low-level SET builder.
  6. Audit attribute_exists conditions, sparse GSIs, projections, streams, and tests that inspect raw DynamoDB item shape.
  7. Run the full TableTheory contract and the consumer’s real DynamoDB Local integration suite; Put-semantics fakes are insufficient for this migration.
  8. If a Go transaction create/save path depends on omitempty collections, confirm top-level empty maps and slices are intended to be absent; transaction marshaling now applies the same emptiness predicate as query writes.
  9. If Go models use fixed arrays, confirm consumers expect DynamoDB L encoding. Fixed arrays are now supported consistently by Create, Update, transaction writes, stream-image decoding, BatchGet, TransactGet, and update-builder ALL_NEW results rather than failing, being silently omitted, or becoming unreadable.
  10. 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 theorydb and json tags without omitempty when a nested zero value must remain present on the DynamoDB wire.
  11. 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’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.
  12. If a TypeScript updateFn callback intentionally relied on running before malformed-key validation, move that work outside the callback and validate the key first.
  13. Audit Go model-shaped transaction updates for json-tagged []byte and json.RawMessage fields, encrypted or not. Their wire type changes from B to normalized JSON S; existing reads remain tolerant of both encodings.
  14. Audit legacy Go one-argument Transaction.Update(model) callers, especially deterministic-timestamp tests and backfills. Remove expectations that caller-set updated_at is preserved. A zero-version update no longer upserts a missing or mismatched item: its failed condition aborts the whole transaction group, so call Rollback and issue an explicit create when the item is absent. Handle every Create, Update, or Delete construction error rather than ignoring it; the first error poisons the transaction, Commit returns it without submitting any queued write, and Rollback is required before the transaction can be reused.
  15. 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.