Skip to content

Commit 9bb6e89

Browse files
authored
feat: Make mutationFn optional for read-only collections (#12)
1 parent 8eb7e9b commit 9bb6e89

File tree

6 files changed

+106
-31
lines changed

6 files changed

+106
-31
lines changed

Diff for: .changeset/cruel-ducks-fix.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tanstack/react-optimistic": patch
3+
"@tanstack/optimistic": patch
4+
---
5+
6+
make mutationFn optional for read-only collections

Diff for: packages/optimistic/src/TransactionManager.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,13 @@ export class TransactionManager<T extends object = Record<string, unknown>> {
231231
const transaction = this.getTransaction(transactionId)
232232
if (!transaction) return
233233

234+
// If no mutationFn is provided, throw an error
235+
if (!this.collection.config.mutationFn) {
236+
throw new Error(
237+
`Cannot process transaction without a mutationFn in the collection config`
238+
)
239+
}
240+
234241
this.setTransactionState(transactionId, `persisting`)
235242

236243
this.collection.config.mutationFn
@@ -243,14 +250,14 @@ export class TransactionManager<T extends object = Record<string, unknown>> {
243250
if (!tx) return
244251

245252
tx.isPersisted?.resolve(true)
246-
if (this.collection.config.mutationFn.awaitSync) {
253+
if (this.collection.config.mutationFn?.awaitSync) {
247254
this.setTransactionState(transactionId, `persisted_awaiting_sync`)
248255

249256
// Create a promise that rejects after 2 seconds
250257
const timeoutPromise = new Promise<never>((_, reject) => {
251258
setTimeout(() => {
252259
reject(new Error(`Sync operation timed out after 2 seconds`))
253-
}, this.collection.config.mutationFn.awaitSyncTimeoutMs ?? 2000)
260+
}, this.collection.config.mutationFn?.awaitSyncTimeoutMs ?? 2000)
254261
})
255262

256263
// Race the awaitSync promise against the timeout

Diff for: packages/optimistic/src/collection.ts

+27-5
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
4545
* await preloadCollection({
4646
* id: `users-${params.userId}`,
4747
* sync: { ... },
48+
* // mutationFn is optional - provide it if you need mutation capabilities
4849
* mutationFn: { ... }
4950
* });
5051
*
@@ -53,7 +54,7 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
5354
* ```
5455
*
5556
* @template T - The type of items in the collection
56-
* @param config - Configuration for the collection, including id, sync, and mutationFn
57+
* @param config - Configuration for the collection, including id, sync, and optional mutationFn
5758
* @returns Promise that resolves when the initial sync is finished
5859
*/
5960
export function preloadCollection<T extends object = Record<string, unknown>>(
@@ -182,15 +183,12 @@ export class Collection<T extends object = Record<string, unknown>> {
182183
* Creates a new Collection instance
183184
*
184185
* @param config - Configuration object for the collection
185-
* @throws Error if sync config or mutationFn is missing
186+
* @throws Error if sync config is missing
186187
*/
187188
constructor(config?: CollectionConfig<T>) {
188189
if (!config?.sync) {
189190
throw new Error(`Collection requires a sync config`)
190191
}
191-
if (!config.mutationFn as unknown) {
192-
throw new Error(`Collection requires a mutationFn`)
193-
}
194192

195193
this.transactionStore = new TransactionStore()
196194
this.transactionManager = getTransactionManager<T>(
@@ -517,6 +515,7 @@ export class Collection<T extends object = Record<string, unknown>> {
517515
* @param config - Optional configuration including metadata and custom keys
518516
* @returns A Transaction object representing the insert operation(s)
519517
* @throws {SchemaValidationError} If the data fails schema validation
518+
* @throws {Error} If mutationFn is not provided
520519
* @example
521520
* // Insert a single item
522521
* insert({ text: "Buy groceries", completed: false })
@@ -531,6 +530,13 @@ export class Collection<T extends object = Record<string, unknown>> {
531530
* insert({ text: "Buy groceries" }, { key: "grocery-task" })
532531
*/
533532
insert = (data: T | Array<T>, config?: InsertConfig) => {
533+
// Throw error if mutationFn is not provided
534+
if (!this.config.mutationFn) {
535+
throw new Error(
536+
`Cannot use mutation operators without providing a mutationFn in the collection config`
537+
)
538+
}
539+
534540
const items = Array.isArray(data) ? data : [data]
535541
const mutations: Array<PendingMutation> = []
536542

@@ -582,6 +588,7 @@ export class Collection<T extends object = Record<string, unknown>> {
582588
* @param maybeCallback - Update callback if config was provided
583589
* @returns A Transaction object representing the update operation(s)
584590
* @throws {SchemaValidationError} If the updated data fails schema validation
591+
* @throws {Error} If mutationFn is not provided
585592
* @example
586593
* // Update a single item
587594
* update(todo, (draft) => { draft.completed = true })
@@ -612,6 +619,13 @@ export class Collection<T extends object = Record<string, unknown>> {
612619
configOrCallback: ((draft: TItem | Array<TItem>) => void) | OperationConfig,
613620
maybeCallback?: (draft: TItem | Array<TItem>) => void
614621
) {
622+
// Throw error if mutationFn is not provided
623+
if (!this.config.mutationFn) {
624+
throw new Error(
625+
`Cannot use mutation operators without providing a mutationFn in the collection config`
626+
)
627+
}
628+
615629
if (typeof items === `undefined`) {
616630
throw new Error(`The first argument to update is missing`)
617631
}
@@ -702,6 +716,7 @@ export class Collection<T extends object = Record<string, unknown>> {
702716
* @param items - Single item/key or array of items/keys to delete
703717
* @param config - Optional configuration including metadata
704718
* @returns A Transaction object representing the delete operation(s)
719+
* @throws {Error} If mutationFn is not provided
705720
* @example
706721
* // Delete a single item
707722
* delete(todo)
@@ -716,6 +731,13 @@ export class Collection<T extends object = Record<string, unknown>> {
716731
items: Array<T | string> | T | string,
717732
config?: OperationConfig
718733
) => {
734+
// Throw error if mutationFn is not provided
735+
if (!this.config.mutationFn) {
736+
throw new Error(
737+
`Cannot use mutation operators without providing a mutationFn in the collection config`
738+
)
739+
}
740+
719741
const itemsArray = Array.isArray(items) ? items : [items]
720742
const mutations: Array<PendingMutation> = []
721743

Diff for: packages/optimistic/src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,6 @@ export interface InsertConfig {
138138
export interface CollectionConfig<T extends object = Record<string, unknown>> {
139139
id: string
140140
sync: SyncConfig<T>
141-
mutationFn: MutationFn<T>
141+
mutationFn?: MutationFn<T>
142142
schema?: StandardSchema<T>
143143
}

Diff for: packages/optimistic/tests/collection.test.ts

+57-23
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,63 @@ describe(`Collection`, () => {
1010
expect(() => new Collection()).toThrow(`Collection requires a sync config`)
1111
})
1212

13-
it(`should throw if there's no mutationFn`, () => {
14-
expect(
15-
() =>
16-
// @ts-expect-error mutationFn is supposed to be missing.
17-
new Collection({
18-
id: `foo`,
19-
sync: { sync: async () => {} },
20-
})
21-
).toThrow(`Collection requires a mutationFn`)
13+
it(`should allow creating a collection without a mutationFn`, () => {
14+
// This should not throw an error
15+
const collection = new Collection({
16+
id: `foo`,
17+
sync: { sync: async () => {} },
18+
})
19+
20+
// Verify that the collection was created successfully
21+
expect(collection).toBeInstanceOf(Collection)
22+
})
23+
24+
it(`should throw an error when trying to use mutation operations without a mutationFn`, async () => {
25+
// Create a collection with sync but no mutationFn
26+
const collection = new Collection<{ value: string }>({
27+
id: `no-mutation-fn`,
28+
sync: {
29+
sync: ({ begin, write, commit }) => {
30+
// Immediately execute the sync cycle
31+
begin()
32+
write({
33+
type: `insert`,
34+
key: `initial`,
35+
value: { value: `initial value` },
36+
})
37+
commit()
38+
},
39+
},
40+
})
41+
42+
// Wait for the collection to be ready
43+
await collection.stateWhenReady()
44+
45+
// Verify initial state
46+
expect(collection.state.get(`initial`)).toEqual({ value: `initial value` })
47+
48+
// Verify that insert throws an error
49+
expect(() => {
50+
collection.insert({ value: `new value` }, { key: `new-key` })
51+
}).toThrow(
52+
`Cannot use mutation operators without providing a mutationFn in the collection config`
53+
)
54+
55+
// Verify that update throws an error
56+
expect(() => {
57+
collection.update(collection.state.get(`initial`)!, (draft) => {
58+
draft.value = `updated value`
59+
})
60+
}).toThrow(
61+
`Cannot use mutation operators without providing a mutationFn in the collection config`
62+
)
63+
64+
// Verify that delete throws an error
65+
expect(() => {
66+
collection.delete(`initial`)
67+
}).toThrow(
68+
`Cannot use mutation operators without providing a mutationFn in the collection config`
69+
)
2270
})
2371

2472
it(`It shouldn't expose any state until the initial sync is finished`, () => {
@@ -333,20 +381,6 @@ describe(`Collection`, () => {
333381
expect(collection.state).toEqual(new Map([[`foo`, { value: `bar` }]]))
334382
})
335383

336-
// Skip until e2e working
337-
it(`If the mutationFn throws error, it get retried`, () => {
338-
// new collection w/ mock sync/mutation
339-
// insert
340-
// mutationFn fails the first time and then succeeds
341-
})
342-
343-
// Skip until e2e working
344-
it(`If the mutationFn throws NonRetriableError, it doesn't get retried and optimistic state is rolled back`, () => {
345-
// new collection w/ mock sync/mutation
346-
// insert
347-
// mutationFn fails w/ NonRetriableError and the check that optimistic state is rolledback.
348-
})
349-
350384
it(`should handle sparse key arrays for bulk inserts`, () => {
351385
const collection = new Collection<{ value: string }>({
352386
id: `test`,

Diff for: packages/react-optimistic/src/useCollection.ts

+6
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export function useCollection<T extends object>(
152152
* @param maybeCallback - Callback function if config was provided
153153
* @returns {Transaction} A Transaction object representing the update operation
154154
* @throws {SchemaValidationError} If the updated data fails schema validation
155+
* @throws {Error} If mutationFn is not provided in the collection config
155156
* @example
156157
* // Update a single item
157158
* update(todo, (draft) => { draft.completed = true })
@@ -173,6 +174,7 @@ export function useCollection<T extends object>(
173174
* @returns {Transaction} A Transaction object representing the insert operation
174175
* @throws {SchemaValidationError} If the data fails schema validation
175176
* @throws {Error} If more keys provided than items to insert
177+
* @throws {Error} If mutationFn is not provided in the collection config
176178
* @example
177179
* // Insert a single item
178180
* insert({ text: "Buy groceries", completed: false })
@@ -193,6 +195,7 @@ export function useCollection<T extends object>(
193195
* @param items - Item(s) to delete (must exist in collection) or their key(s)
194196
* @param config - Optional configuration including metadata
195197
* @returns {Transaction} A Transaction object representing the delete operation
198+
* @throws {Error} If mutationFn is not provided in the collection config
196199
* @example
197200
* // Delete a single item
198201
* delete(todo)
@@ -228,6 +231,7 @@ export function useCollection<T extends object, R>(
228231
* @param maybeCallback - Callback function if config was provided
229232
* @returns {Transaction} A Transaction object representing the update operation
230233
* @throws {SchemaValidationError} If the updated data fails schema validation
234+
* @throws {Error} If mutationFn is not provided in the collection config
231235
* @example
232236
* // Update a single item
233237
* update(todo, (draft) => { draft.completed = true })
@@ -249,6 +253,7 @@ export function useCollection<T extends object, R>(
249253
* @returns {Transaction} A Transaction object representing the insert operation
250254
* @throws {SchemaValidationError} If the data fails schema validation
251255
* @throws {Error} If more keys provided than items to insert
256+
* @throws {Error} If mutationFn is not provided in the collection config
252257
* @example
253258
* // Insert a single item
254259
* insert({ text: "Buy groceries", completed: false })
@@ -269,6 +274,7 @@ export function useCollection<T extends object, R>(
269274
* @param items - Item(s) to delete (must exist in collection) or their key(s)
270275
* @param config - Optional configuration including metadata
271276
* @returns {Transaction} A Transaction object representing the delete operation
277+
* @throws {Error} If mutationFn is not provided in the collection config
272278
* @example
273279
* // Delete a single item
274280
* delete(todo)

0 commit comments

Comments
 (0)