Core Contract v2 Design
Relevant source files
The following files were used as context for generating this wiki page:
The Core Contract v2 Design represents a major architectural shift in the better-tables project, primarily introduced in the 0.6.0 release. The design focuses on moving away from a stringly-typed ecosystem toward a fully typed column registry and implementing complex boolean logic through recursive filter groups (AND/OR). This overhaul replaces the previous per-table shell with an app-level instance pattern, facilitating better developer experience and more robust data operations.
This design addresses two primary goals: enabling AND/OR nested queries and providing a strictly typed adapter contract that links column IDs to their respective row and value types. These changes impact several core systems, including the filter manager, URL serialization, UI components, and all database adapters.
Sources: plans/006-core-contract-v2-design.md:19-32, plans/011-table-definition-dx-design.md:38-48
Recursive Filter Tree Architecture
A central component of v2 is the transition from flat filter arrays to a recursive tree structure. This allows for complex query logic that was previously impossible.
FilterNode and FilterGroupNode
The logic is built around the FilterNode type, which is a discriminated union of a standard FilterState (a leaf node) and a FilterGroupNode (a branch node). A FilterGroupNode contains a logic operator (and | or) and an array of children FilterNodes.
The diagram above illustrates the recursive nature of the filter tree where a group node can contain other group nodes or leaf states.
Sources: plans/006-core-contract-v2-design.md:86-94, plans/015-filternode-core-serialization.md:75-84
Validation and Normalization
To ensure system stability and performance, the contract enforces specific validation semantics for the filter tree:
- Depth Policy: Nesting is capped at a maximum depth of 3 to prevent performance degradation in both UI rendering and SQL generation.
- Normalization Rules: Empty groups are dropped, single-child groups are unwrapped to their child node, and nodes with unknown logic are discarded.
- Adapter Capabilities: Adapters advertise their support through
supportsFilterGroupsandmaxGroupDepthmetadata fields.
Sources: plans/006-core-contract-v2-design.md:95-103, plans/015-filternode-core-serialization.md:88-93
Typed Column Registry
The v2 contract introduces a typed registry that derives from table definitions rather than relying on manual string keys. This ensures that column IDs, filter values, and row data are type-safe throughout the application lifecycle.
Registry Derivation
The registry is derived from a tuple of ColumnDefinition objects collected by the defineTable() API. It maps literal column IDs to their inferred value types.
| Component | Description |
|---|---|
| ColumnRegistry | A mapped type where keys are literal column IDs and values are inferred from the column definition. |
| TableAdapterV2 | The updated adapter interface that uses keyof TReg for column identification. |
| literal-preserving .id() | A requirement for builder methods to preserve the exact string literal of the ID for type inference. |
Sources: plans/006-core-contract-v2-design.md:120-136, plans/011-table-definition-dx-design.md:46-52
Instance vs. Per-Table Definition
The architecture moves from per-table shells to a unified betterTables() instance that carries the database schema type into every table definition.
This instance-based approach (inspired by patterns like Better-Auth) allows the schema type to flow from the database adapter into the column builders.
Sources: plans/011-table-definition-dx-design.md:65-80, plans/018-instance-api-runtime.md
Serialization and Wire Format (c2:)
The introduction of nested groups required a new URL wire format, designated as c2:. This format manages the compression and decompression of complex tree structures while maintaining backward compatibility for reading legacy flat filters.
Compression Key Map
To minimize URL length, structural keys are mapped to short codes. A critical fix in v2 ensures that the renamer only descends into structural keys, preventing collisions with user-provided data in meta or values objects.
| Original Key | Short Code | Context |
|---|---|---|
kind | k | Structural (FilterGroupNode) |
logic | l | Structural (FilterGroupNode) |
children | h | Structural (FilterGroupNode) |
columnId | c | Structural (FilterState) |
operator | o | Structural (FilterState) |
Sources: plans/015-filternode-core-serialization.md:104-110, plans/006-core-contract-v2-design.md:98-100
Wire Format Versioning
The c2: prefix indicates a v2 payload. The system follows a "write new, read both" policy:
- Write Path: Always emits
c2:formatted payloads containing the recursive tree. - Read Path: Identifies
c2:for tree parsing. If ac:prefix is detected, it falls back to parsing as a legacy flat array, treating it as an implicitANDgroup.
Sources: plans/015-filternode-core-serialization.md:120-131, plans/006-core-contract-v2-design.md:99-100
Surface Truth and Cleanup
As part of the v2 transition, implementers identified and removed "dead" surface area in the TableConfig and TableFeatures interfaces. These were fields declared in previous versions that were not actually consumed by the UI or core logic.
Removed Reserved Fields
- TableConfig:
defaultFilters,actionsConfig,exportOptions,theme,loadingState. - TableFeatures:
bulkActions,export,columnResizing,virtualScrolling,realTimeUpdates,rowExpansion.
These fields were removed to ensure that the TypeScript interfaces accurately reflect the features implemented in the 0.6.0 runtime.
Sources: plans/057-contract-surface-truth.md:46-75
Migration Summary
The shift to v2 is a breaking change. The migration path involves replacing the createColumnBuilder style with the new betterTables() / defineTable() instance API.
// 0.5 Legacy (Removed)
const cb = createColumnBuilder<User>();
const columns = [cb.text('name'), cb.number('age')];
// 0.6 flagship (v2 Contract)
export const tables = betterTables({ database: drizzle(db) });
export const usersTable = defineTable<typeof tables>()('users', (t) => ({
columns: [
t.text('name'),
t.number('age'),
],
}));
Sources: plans/019-migration-guide.md:38-50, plans/011-table-definition-dx-design.md:65-98
Core Contract v2 establishes a robust foundation for the project by unifying table definitions under a single type-safe instance and enabling complex query logic via recursive filter trees. By formalizing these contracts, the system reduces runtime errors and provides developers with superior autocomplete and type-checking capabilities.