Motivation:
Triplit provides a powerful foundation for building real-time, offline-capable applications using basic data types (string, number, boolean, date, set). However, to unlock its full potential for richer collaborative experiences like those found in document editors, design tools, or applications with specialized counters or ordered lists, we need a way to extend its core data modeling capabilities.
Currently, adding new data types with custom merge logic (like CRDTs) requires modifying the core @triplit/db
, @triplit/client
, and @triplit/server-core
packages. This presents a high barrier for both the core team and the community to introduce specialized types tailored to specific application needs.
This proposal outlines a Plugin API designed to allow developers to define, implement, and register custom CRDTs or other mergeable data structures within Triplit. This would empower developers to:
Goals:
CRDTPluginInterface
) outlining the contract between Triplit core and a custom data type plugin.TState
), CRDT merge algorithm (merge
), and client-facing mutation logic (generateChangePayload
).Non-Goals (Initial Version):
Array.prototype.push
) to custom CRDT operations is complex. The initial focus is on supporting explicit API methods initiated by application code.Core Concepts & Integration Strategy:
This API leverages Triplit's existing architecture:
TState
maps onto TStoredValue
(or multiple KV pairs for DKVR), interacting via the standard KVStore
interface.schemaDefinition
function used by Schema as S
(e.g., S.MyCounter = MyCounterPlugin.schemaDefinition
).merge
function encapsulates the CRDT algorithm. Triplit's EntityStore
orchestrates fetching current state, calling plugin.merge
with states and HLC timestamps, and writing the result back atomically within a transaction. For DKVR, the merge
function receives the KVStoreTransaction
to perform multi-key operations.client.incrementCounter(...)
) which internally call plugin.generateChangePayload
to create the correct delta/state (TChangePayload
) for the transaction.TChangePayload
is stored in the outbox and transmitted. The server uses the corresponding registered plugin's merge
function to apply changes.compare
, satisfiesOperator
), which may require state deserialization.Proposed API: CRDTPluginInterface<TState, TSchemaOptions, TStoredValue, TChangePayload>
Integration Points (Revised):
plugins
array in TriplitClient
/createServer
options. Core maintains a Map<typeId, CRDTPluginInterface>
.S.<TypeName>
calls plugin.schemaDefinition
. Validation uses plugin.validateSchemaOptions
.EntityStoreKV
):
getEntity
): Checks plugin.storageStrategy
. If 'blob', uses kv.get
then plugin.decodeFromStorage
. If 'decomposed', uses kv.scan
(with prefix derived from attribute path) and passes iterator to plugin.decodeFromStorage
.applyChangesWithTimestamp
): Identifies custom type via schema. Fetches current state appropriately (get or scan). Calls plugin.merge
(passing kvTx
if 'decomposed'). Calls plugin.encodeForStorage
. Writes result back via kvTx.set
(for 'blob') or applies the DKVRMutationPayload
's sets/deletes atomically using kvTx.set/delete
(for 'decomposed').client.incrementCounter(...)
, client.listPush(...)
, etc.client.transact
: Read current state -> Call plugin.generateChangePayload
-> Call tx.update
with raw payload + internal flag _crdtUpdate: true
.tx.update
: If _crdtUpdate
flag is present, store raw payload in internal diff map.plugin.encodeForStorage
or plugin.diff
for CHANGES
message payload. Server receives payload, passes to EntityStore
which invokes plugin.merge
. Handles new message types if needed for op-based sync.compare
, satisfiesOperator
, etc.) potentially involving decodeFromStorage
. IVM integration for DKVR relies on future core work, potentially guided by isChangeRelevantToState
.Storage Strategy Implications:
encode/decode
handle single values. merge
usually doesn't need kvTx
. Efficient for small/infrequently updated CRDTs (Counters). Querying internals is slow. IVM treats it atomically.encode
returns sets/deletes. decode
handles iterators. merge
requires kvTx
. Enables granular updates/sync, potentially better for large/complex types (Lists, Text). Querying/IVM integration is significantly more complex for both the plugin author and requires core Triplit enhancements for efficiency.Example Plugin Sketch (PN-Counter using State Blob):
(Similar to previous example, demonstrating the simpler 'blob' strategy)
Benefits:
Challenges & Open Questions:
encodeForStorage
signal multi-key writes? How should decodeFromStorage
reliably receive scanned data? What KV scan options are needed?isChangeRelevantToState
be effective without deeper IVM awareness?CRDT_OP
message feasible, or are type-specific messages better? How are ops batched efficiently?merge
function (especially for DKVR reading/writing multiple keys via kvTx
) remains performant within transactions.Next Steps:
encode/decodeForStorage
contract for DKVR.