Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/opt-in-mutations-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@tanstack/db": minor
"@tanstack/electric-db-collection": minor
"@tanstack/powersync-db-collection": minor
"@tanstack/query-db-collection": minor
"@tanstack/rxdb-db-collection": minor
"@tanstack/trailbase-db-collection": minor
---

**BREAKING CHANGE**: Mutations are now opt-in via the `mutations` plugin

Collections now require explicitly importing and passing the `mutations` plugin to enable optimistic mutation capabilities. This change enables tree-shaking to eliminate ~25% of bundle size (~20KB minified) for applications that only perform read-only queries.

## Migration Guide

### Before
```typescript
import { createCollection } from "@tanstack/db"

const collection = createCollection({
sync: { sync: () => {} },
onInsert: async (params) => { /* ... */ },
onUpdate: async (params) => { /* ... */ },
onDelete: async (params) => { /* ... */ },
})
```

### After
```typescript
import { createCollection, mutations } from "@tanstack/db"

const collection = createCollection({
mutations, // Add the mutations plugin
sync: { sync: () => {} },
onInsert: async (params) => { /* ... */ },
onUpdate: async (params) => { /* ... */ },
onDelete: async (params) => { /* ... */ },
})
```

### Read-Only Collections

If your collection only performs queries and never uses `.insert()`, `.update()`, or `.delete()`, you can now omit the `mutations` plugin entirely. This will reduce your bundle size by ~20KB (minified):

```typescript
import { createCollection } from "@tanstack/db"

const collection = createCollection({
sync: { sync: () => {} },
// No mutations plugin = smaller bundle
})
```

## Benefits

- **Smaller bundles**: 25% reduction for read-only collections (~58.5KB vs ~78.3KB minified)
- **Type safety**: TypeScript enforces that `onInsert`, `onUpdate`, and `onDelete` handlers require the `mutations` plugin
- **Runtime safety**: Attempting to call mutation methods without the plugin throws `MutationsNotEnabledError` with a clear message

## Affected Packages

All adapter packages have been updated to use the mutations plugin:
- `@tanstack/electric-db-collection`
- `@tanstack/powersync-db-collection`
- `@tanstack/query-db-collection`
- `@tanstack/rxdb-db-collection`
- `@tanstack/trailbase-db-collection`
44 changes: 33 additions & 11 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CollectionRequiresConfigError,
CollectionRequiresSyncConfigError,
MutationsNotEnabledError,
} from "../errors"
import { currentStateAsChanges } from "./change-events"

Expand All @@ -9,8 +10,8 @@ import { CollectionChangesManager } from "./changes"
import { CollectionLifecycleManager } from "./lifecycle.js"
import { CollectionSyncManager } from "./sync"
import { CollectionIndexesManager } from "./indexes"
import { CollectionMutationsManager } from "./mutations"
import { CollectionEventsManager } from "./events.js"
import type { CollectionMutationsManager } from "./mutations"
import type { CollectionSubscription } from "./subscription"
import type { AllCollectionEvents, CollectionEventHandler } from "./events.js"
import type { BaseIndex, IndexResolver } from "../indexes/base-index.js"
Expand Down Expand Up @@ -245,9 +246,11 @@ export function createCollection<

// Implementation
export function createCollection(
options: CollectionConfig<any, string | number, any, UtilsRecord> & {
schema?: StandardSchemaV1
}
options:
| (CollectionConfig<any, string | number, any, UtilsRecord> & {
schema?: StandardSchemaV1
})
| any // Use 'any' to satisfy all overloads - actual validation happens via overload signatures
): Collection<any, string | number, UtilsRecord, any, any> {
const collection = new CollectionImpl<any, string | number, any, any, any>(
options
Expand Down Expand Up @@ -283,7 +286,8 @@ export class CollectionImpl<
public _lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>
public _sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
private _indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
private _mutations: CollectionMutationsManager<
// Only instantiated when mutationPlugin is provided (for tree-shaking)
private _mutations?: CollectionMutationsManager<
TOutput,
TKey,
TUtils,
Expand Down Expand Up @@ -329,10 +333,21 @@ export class CollectionImpl<
this._events = new CollectionEventsManager()
this._indexes = new CollectionIndexesManager()
this._lifecycle = new CollectionLifecycleManager(config, this.id)
this._mutations = new CollectionMutationsManager(config, this.id)
this._state = new CollectionStateManager(config)
this._sync = new CollectionSyncManager(config, this.id)

// Only instantiate mutations module when mutations plugin is provided
// Tree-shaking works because the plugin (and mutations module) is only imported
// when the user explicitly imports mutations
if (config.mutations) {
this._mutations = config.mutations._createManager(config, this.id)
this._mutations.setDeps({
collection: this,
lifecycle: this._lifecycle,
state: this._state,
})
}

this.comparisonOpts = buildCompareOptionsFromConfig(config)

this._changes.setDeps({
Expand All @@ -355,11 +370,6 @@ export class CollectionImpl<
state: this._state,
sync: this._sync,
})
this._mutations.setDeps({
collection: this, // Required for passing to config.onInsert/onUpdate/onDelete and annotating mutations
lifecycle: this._lifecycle,
state: this._state,
})
this._state.setDeps({
collection: this, // Required for filtering events to only include this collection
lifecycle: this._lifecycle,
Expand Down Expand Up @@ -573,6 +583,9 @@ export class CollectionImpl<
type: `insert` | `update`,
key?: TKey
): TOutput | never {
if (!this._mutations) {
throw new MutationsNotEnabledError(type)
}
return this._mutations.validateData(data, type, key)
}

Expand Down Expand Up @@ -618,6 +631,9 @@ export class CollectionImpl<
* }
*/
insert = (data: TInput | Array<TInput>, config?: InsertConfig) => {
if (!this._mutations) {
throw new MutationsNotEnabledError(`insert`)
}
return this._mutations.insert(data, config)
}

Expand Down Expand Up @@ -697,6 +713,9 @@ export class CollectionImpl<
| ((draft: WritableDeep<TInput>) => void)
| ((drafts: Array<WritableDeep<TInput>>) => void)
) {
if (!this._mutations) {
throw new MutationsNotEnabledError(`update`)
}
return this._mutations.update(keys, configOrCallback, maybeCallback)
}

Expand Down Expand Up @@ -734,6 +753,9 @@ export class CollectionImpl<
keys: Array<TKey> | TKey,
config?: OperationConfig
): TransactionType<any> => {
if (!this._mutations) {
throw new MutationsNotEnabledError(`delete`)
}
return this._mutations.delete(keys, config)
}

Expand Down
88 changes: 76 additions & 12 deletions packages/db/src/collection/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,70 @@ import type {
import type { CollectionLifecycleManager } from "./lifecycle"
import type { CollectionStateManager } from "./state"

/**
* The mutations plugin interface.
* Used to enable optimistic mutations on a collection.
* Import and pass to the `mutations` option to enable insert/update/delete.
*
* @example
* ```ts
* import { createCollection, mutations } from "@tanstack/db"
*
* const collection = createCollection({
* id: "todos",
* getKey: (todo) => todo.id,
* sync: { sync: () => {} },
* mutations,
* onInsert: async ({ transaction }) => {
* await api.createTodo(transaction.mutations[0].modified)
* }
* })
* ```
*/
export interface MutationsPlugin {
readonly _brand: `mutations`
/** @internal */
readonly _createManager: <
TOutput extends object,
TKey extends string | number,
TUtils extends UtilsRecord,
TSchema extends StandardSchemaV1,
TInput extends object,
>(
config: CollectionConfig<TOutput, TKey, TSchema>,
id: string
) => CollectionMutationsManager<TOutput, TKey, TUtils, TSchema, TInput>
}

/**
* The mutations plugin. Import and pass to the `mutations` option to enable
* insert, update, and delete operations on a collection.
*
* @example
* ```ts
* import { createCollection, mutations } from "@tanstack/db"
*
* const collection = createCollection({
* id: "todos",
* getKey: (todo) => todo.id,
* sync: { sync: () => {} },
* mutations,
* onInsert: async ({ transaction }) => {
* await api.createTodo(transaction.mutations[0].modified)
* }
* })
* ```
*/
export const mutations: MutationsPlugin = {
_brand: `mutations` as const,
_createManager: (config, id) => new CollectionMutationsManager(config, id),
}

/**
* @deprecated Use `mutations` instead
*/
export const mutationPlugin = mutations

export class CollectionMutationsManager<
TOutput extends object = Record<string, unknown>,
TKey extends string | number = string | number,
Expand Down Expand Up @@ -162,7 +226,7 @@ export class CollectionMutationsManager<
}

const items = Array.isArray(data) ? data : [data]
const mutations: Array<PendingMutation<TOutput>> = []
const pendingMutations: Array<PendingMutation<TOutput>> = []
const keysInCurrentBatch = new Set<TKey>()

// Create mutations for each item
Expand Down Expand Up @@ -202,12 +266,12 @@ export class CollectionMutationsManager<
collection: this.collection,
}

mutations.push(mutation)
pendingMutations.push(mutation)
})

// If an ambient transaction exists, use it
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations)
ambientTransaction.applyMutations(pendingMutations)

state.transactions.set(ambientTransaction.id, ambientTransaction)
state.scheduleTransactionCleanup(ambientTransaction)
Expand All @@ -231,7 +295,7 @@ export class CollectionMutationsManager<
})

// Apply mutations to the new transaction
directOpTransaction.applyMutations(mutations)
directOpTransaction.applyMutations(pendingMutations)
// Errors still reject tx.isPersisted.promise; this catch only prevents global unhandled rejections
directOpTransaction.commit().catch(() => undefined)

Expand Down Expand Up @@ -309,7 +373,7 @@ export class CollectionMutationsManager<
}

// Create mutations for each object that has changes
const mutations: Array<
const pendingMutations: Array<
PendingMutation<
TOutput,
`update`,
Expand Down Expand Up @@ -386,7 +450,7 @@ export class CollectionMutationsManager<
>

// If no changes were made, return an empty transaction early
if (mutations.length === 0) {
if (pendingMutations.length === 0) {
const emptyTransaction = createTransaction({
mutationFn: async () => {},
})
Expand All @@ -399,7 +463,7 @@ export class CollectionMutationsManager<

// If an ambient transaction exists, use it
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations)
ambientTransaction.applyMutations(pendingMutations)

state.transactions.set(ambientTransaction.id, ambientTransaction)
state.scheduleTransactionCleanup(ambientTransaction)
Expand All @@ -426,7 +490,7 @@ export class CollectionMutationsManager<
})

// Apply mutations to the new transaction
directOpTransaction.applyMutations(mutations)
directOpTransaction.applyMutations(pendingMutations)
// Errors still hit tx.isPersisted.promise; avoid leaking an unhandled rejection from the fire-and-forget commit
directOpTransaction.commit().catch(() => undefined)

Expand Down Expand Up @@ -461,7 +525,7 @@ export class CollectionMutationsManager<
}

const keysArray = Array.isArray(keys) ? keys : [keys]
const mutations: Array<
const pendingMutations: Array<
PendingMutation<
TOutput,
`delete`,
Expand Down Expand Up @@ -497,12 +561,12 @@ export class CollectionMutationsManager<
collection: this.collection,
}

mutations.push(mutation)
pendingMutations.push(mutation)
}

// If an ambient transaction exists, use it
if (ambientTransaction) {
ambientTransaction.applyMutations(mutations)
ambientTransaction.applyMutations(pendingMutations)

state.transactions.set(ambientTransaction.id, ambientTransaction)
state.scheduleTransactionCleanup(ambientTransaction)
Expand All @@ -528,7 +592,7 @@ export class CollectionMutationsManager<
})

// Apply mutations to the new transaction
directOpTransaction.applyMutations(mutations)
directOpTransaction.applyMutations(pendingMutations)
// Errors still reject tx.isPersisted.promise; silence the internal commit promise to prevent test noise
directOpTransaction.commit().catch(() => undefined)

Expand Down
10 changes: 10 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ export class CollectionRequiresSyncConfigError extends CollectionConfigurationEr
}
}

export class MutationsNotEnabledError extends CollectionConfigurationError {
constructor(method: `insert` | `update` | `delete`) {
super(
`Cannot call ${method}() on a read-only collection. ` +
`Pass \`mutations\` in the collection config to enable mutations.`
)
this.name = `MutationsNotEnabledError`
}
}

export class InvalidSchemaError extends CollectionConfigurationError {
constructor() {
super(`Schema must implement the standard-schema interface`)
Expand Down
5 changes: 5 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import * as IR from "./query/ir.js"

export * from "./collection/index.js"
export {
mutations,
mutationPlugin,
type MutationsPlugin,
} from "./collection/mutations.js"
export * from "./SortedMap"
export * from "./transactions"
export * from "./types"
Expand Down
Loading
Loading