Skip to content

Commit 5c538cf

Browse files
authored
Type PendingMutation whenever possible (#163)
1 parent bf7698e commit 5c538cf

File tree

8 files changed

+91
-65
lines changed

8 files changed

+91
-65
lines changed

.changeset/better-plums-thank.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tanstack/db-collections": patch
3+
"@tanstack/db-example-react-todo": patch
4+
"@tanstack/db": patch
5+
---
6+
7+
Type PendingMutation whenever possible

examples/react/todo/src/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,10 @@ const createTodoCollection = (type: CollectionType) => {
132132
if (collectionsCache.has(`todo`)) {
133133
return collectionsCache.get(`todo`)
134134
} else {
135-
let newCollection: Collection
135+
let newCollection: Collection<UpdateTodo>
136136
if (type === CollectionType.Electric) {
137-
newCollection = createCollection<UpdateTodo>(
138-
electricCollectionOptions({
137+
newCollection = createCollection(
138+
electricCollectionOptions<UpdateTodo>({
139139
id: `todos`,
140140
shapeOptions: {
141141
url: `http://localhost:3003/v1/shape`,
@@ -237,7 +237,7 @@ const createConfigCollection = (type: CollectionType) => {
237237
if (collectionsCache.has(`config`)) {
238238
return collectionsCache.get(`config`)
239239
} else {
240-
let newCollection: Collection
240+
let newCollection: Collection<UpdateConfig>
241241
if (type === CollectionType.Electric) {
242242
newCollection = createCollection(
243243
electricCollectionOptions({

packages/db-collections/src/electric.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,32 @@ export interface ElectricCollectionConfig<T extends Row<unknown>> {
4040
* @param params Object containing transaction and mutation information
4141
* @returns Promise resolving to an object with txid
4242
*/
43-
onInsert?: (params: MutationFnParams) => Promise<{ txid: string } | undefined>
43+
onInsert?: (
44+
params: MutationFnParams<T>
45+
) => Promise<{ txid: string } | undefined>
4446

4547
/**
4648
* Optional asynchronous handler function called before an update operation
4749
* Must return an object containing a txid string
4850
* @param params Object containing transaction and mutation information
4951
* @returns Promise resolving to an object with txid
5052
*/
51-
onUpdate?: (params: MutationFnParams) => Promise<{ txid: string } | undefined>
53+
onUpdate?: (
54+
params: MutationFnParams<T>
55+
) => Promise<{ txid: string } | undefined>
5256

5357
/**
5458
* Optional asynchronous handler function called before a delete operation
5559
* Must return an object containing a txid string
5660
* @param params Object containing transaction and mutation information
5761
* @returns Promise resolving to an object with txid
5862
*/
59-
onDelete?: (params: MutationFnParams) => Promise<{ txid: string } | undefined>
63+
onDelete?: (
64+
params: MutationFnParams<T>
65+
) => Promise<{ txid: string } | undefined>
6066
}
6167

62-
function isUpToDateMessage<T extends Row<unknown> = Row>(
68+
function isUpToDateMessage<T extends Row<unknown>>(
6369
message: Message<T>
6470
): message is ControlMessage & { up_to_date: true } {
6571
return isControlMessage(message) && message.headers.control === `up-to-date`
@@ -133,7 +139,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
133139

134140
// Create wrapper handlers for direct persistence operations that handle txid awaiting
135141
const wrappedOnInsert = config.onInsert
136-
? async (params: MutationFnParams) => {
142+
? async (params: MutationFnParams<T>) => {
137143
const handlerResult = (await config.onInsert!(params)) ?? {}
138144
const txid = (handlerResult as { txid?: string }).txid
139145

@@ -149,7 +155,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
149155
: undefined
150156

151157
const wrappedOnUpdate = config.onUpdate
152-
? async (params: MutationFnParams) => {
158+
? async (params: MutationFnParams<T>) => {
153159
const handlerResult = await config.onUpdate!(params)
154160
const txid = (handlerResult as { txid?: string }).txid
155161

@@ -165,7 +171,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
165171
: undefined
166172

167173
const wrappedOnDelete = config.onDelete
168-
? async (params: MutationFnParams) => {
174+
? async (params: MutationFnParams<T>) => {
169175
const handlerResult = await config.onDelete!(params)
170176
const txid = (handlerResult as { txid?: string }).txid
171177

packages/db-collections/src/query.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ export function queryCollectionOptions<
284284

285285
// Create wrapper handlers for direct persistence operations that handle refetching
286286
const wrappedOnInsert = onInsert
287-
? async (params: MutationFnParams) => {
287+
? async (params: MutationFnParams<TItem>) => {
288288
const handlerResult = (await onInsert(params)) ?? {}
289289
const shouldRefetch =
290290
(handlerResult as { refetch?: boolean }).refetch !== false
@@ -298,7 +298,7 @@ export function queryCollectionOptions<
298298
: undefined
299299

300300
const wrappedOnUpdate = onUpdate
301-
? async (params: MutationFnParams) => {
301+
? async (params: MutationFnParams<TItem>) => {
302302
const handlerResult = (await onUpdate(params)) ?? {}
303303
const shouldRefetch =
304304
(handlerResult as { refetch?: boolean }).refetch !== false
@@ -312,7 +312,7 @@ export function queryCollectionOptions<
312312
: undefined
313313

314314
const wrappedOnDelete = onDelete
315-
? async (params: MutationFnParams) => {
315+
? async (params: MutationFnParams<TItem>) => {
316316
const handlerResult = (await onDelete(params)) ?? {}
317317
const shouldRefetch =
318318
(handlerResult as { refetch?: boolean }).refetch !== false

packages/db-collections/tests/electric.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
MutationFnParams,
88
PendingMutation,
99
Transaction,
10+
TransactionWithMutations,
1011
} from "@tanstack/db"
1112
import type { Message, Row } from "@electric-sql/client"
1213

@@ -415,8 +416,11 @@ describe(`Electric Integration`, () => {
415416

416417
it(`should throw an error if handler doesn't return a txid`, async () => {
417418
// Create a mock transaction for testing
418-
const mockTransaction = { id: `test-transaction` } as Transaction
419-
const mockParams: MutationFnParams = { transaction: mockTransaction }
419+
const mockTransaction = {
420+
id: `test-transaction`,
421+
mutations: [],
422+
} as unknown as TransactionWithMutations<Row>
423+
const mockParams: MutationFnParams<Row> = { transaction: mockTransaction }
420424

421425
// Create a handler that doesn't return a txid
422426
const onInsert = vi.fn().mockResolvedValue({})
@@ -488,7 +492,7 @@ describe(`Electric Integration`, () => {
488492
}
489493

490494
// Create a mutation function for the transaction
491-
const mutationFn = vi.fn(async (params: MutationFnParams) => {
495+
const mutationFn = vi.fn(async (params: MutationFnParams<Row>) => {
492496
const txid = await fakeBackend.persist(params.transaction.mutations)
493497

494498
// Simulate server sending sync message after a delay
@@ -500,7 +504,7 @@ describe(`Electric Integration`, () => {
500504
})
501505

502506
// Create direct persistence handler that returns the txid
503-
const onInsert = vi.fn(async (params: MutationFnParams) => {
507+
const onInsert = vi.fn(async (params: MutationFnParams<Row>) => {
504508
return { txid: await mutationFn(params) }
505509
})
506510

packages/db/src/collection.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
201201
* This is populated by createCollection
202202
*/
203203
public utils: Record<string, Fn> = {}
204-
public transactions: Store<SortedMap<string, TransactionType>>
204+
public transactions: Store<SortedMap<string, TransactionType<any>>>
205205
public optimisticOperations: Derived<Array<OptimisticChangeMessage<T>>>
206206
public derivedState: Derived<Map<string, T>>
207207
public derivedArray: Derived<Array<T>>
@@ -250,7 +250,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
250250
}
251251

252252
this.transactions = new Store(
253-
new SortedMap<string, TransactionType>(
253+
new SortedMap<string, TransactionType<any>>(
254254
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
255255
)
256256
)
@@ -269,7 +269,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
269269
const message: OptimisticChangeMessage<T> = {
270270
type: mutation.type,
271271
key: mutation.key,
272-
value: mutation.modified as T,
272+
value: mutation.modified,
273273
isActive,
274274
}
275275
if (
@@ -684,8 +684,8 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
684684
const mutation: PendingMutation<T> = {
685685
mutationId: crypto.randomUUID(),
686686
original: {},
687-
modified: validatedData as Record<string, unknown>,
688-
changes: validatedData as Record<string, unknown>,
687+
modified: validatedData,
688+
changes: validatedData,
689689
key,
690690
metadata: config?.metadata as unknown,
691691
syncMetadata: this.config.sync.getSyncMetadata?.() || {},
@@ -710,7 +710,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
710710
return ambientTransaction
711711
} else {
712712
// Create a new transaction with a mutation function that calls the onInsert handler
713-
const directOpTransaction = new Transaction({
713+
const directOpTransaction = new Transaction<T>({
714714
mutationFn: async (params) => {
715715
// Call the onInsert handler with the transaction
716716
return this.config.onInsert!(params)
@@ -906,10 +906,10 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
906906
// No need to check for onUpdate handler here as we've already checked at the beginning
907907

908908
// Create a new transaction with a mutation function that calls the onUpdate handler
909-
const directOpTransaction = new Transaction({
910-
mutationFn: async (transaction) => {
909+
const directOpTransaction = new Transaction<T>({
910+
mutationFn: async (params) => {
911911
// Call the onUpdate handler with the transaction
912-
return this.config.onUpdate!(transaction)
912+
return this.config.onUpdate!(params)
913913
},
914914
})
915915

@@ -944,7 +944,7 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
944944
delete = (
945945
ids: Array<string> | string,
946946
config?: OperationConfig
947-
): TransactionType => {
947+
): TransactionType<any> => {
948948
const ambientTransaction = getActiveTransaction()
949949

950950
// If no ambient transaction exists, check for an onDelete handler early
@@ -962,9 +962,9 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
962962
for (const id of idsArray) {
963963
const mutation: PendingMutation<T> = {
964964
mutationId: crypto.randomUUID(),
965-
original: (this.state.get(id) || {}) as Record<string, unknown>,
966-
modified: (this.state.get(id) || {}) as Record<string, unknown>,
967-
changes: (this.state.get(id) || {}) as Record<string, unknown>,
965+
original: this.state.get(id) || {},
966+
modified: this.state.get(id)!,
967+
changes: this.state.get(id) || {},
968968
key: id,
969969
metadata: config?.metadata as unknown,
970970
syncMetadata: (this.syncedMetadata.state.get(id) || {}) as Record<
@@ -993,11 +993,11 @@ export class CollectionImpl<T extends object = Record<string, unknown>> {
993993
}
994994

995995
// Create a new transaction with a mutation function that calls the onDelete handler
996-
const directOpTransaction = new Transaction({
996+
const directOpTransaction = new Transaction<T>({
997997
autoCommit: true,
998-
mutationFn: async (transaction) => {
998+
mutationFn: async (params) => {
999999
// Call the onDelete handler with the transaction
1000-
return this.config.onDelete!(transaction)
1000+
return this.config.onDelete!(params)
10011001
},
10021002
})
10031003

packages/db/src/transactions.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createDeferred } from "./deferred"
22
import type { Deferred } from "./deferred"
33
import type {
4+
MutationFn,
45
PendingMutation,
56
TransactionConfig,
67
TransactionState,
@@ -24,8 +25,8 @@ function generateUUID() {
2425
})
2526
}
2627

27-
const transactions: Array<Transaction> = []
28-
let transactionStack: Array<Transaction> = []
28+
const transactions: Array<Transaction<any>> = []
29+
let transactionStack: Array<Transaction<any>> = []
2930

3031
export function createTransaction(config: TransactionConfig): Transaction {
3132
if (typeof config.mutationFn === `undefined`) {
@@ -51,27 +52,27 @@ export function getActiveTransaction(): Transaction | undefined {
5152
}
5253
}
5354

54-
function registerTransaction(tx: Transaction) {
55+
function registerTransaction(tx: Transaction<any>) {
5556
transactionStack.push(tx)
5657
}
5758

58-
function unregisterTransaction(tx: Transaction) {
59+
function unregisterTransaction(tx: Transaction<any>) {
5960
transactionStack = transactionStack.filter((t) => t.id !== tx.id)
6061
}
6162

62-
function removeFromPendingList(tx: Transaction) {
63+
function removeFromPendingList(tx: Transaction<any>) {
6364
const index = transactions.findIndex((t) => t.id === tx.id)
6465
if (index !== -1) {
6566
transactions.splice(index, 1)
6667
}
6768
}
6869

69-
export class Transaction {
70+
export class Transaction<T extends object = Record<string, unknown>> {
7071
public id: string
7172
public state: TransactionState
72-
public mutationFn
73-
public mutations: Array<PendingMutation<any>>
74-
public isPersisted: Deferred<Transaction>
73+
public mutationFn: MutationFn<T>
74+
public mutations: Array<PendingMutation<T>>
75+
public isPersisted: Deferred<Transaction<T>>
7576
public autoCommit: boolean
7677
public createdAt: Date
7778
public metadata: Record<string, unknown>
@@ -80,12 +81,12 @@ export class Transaction {
8081
error: Error
8182
}
8283

83-
constructor(config: TransactionConfig) {
84+
constructor(config: TransactionConfig<T>) {
8485
this.id = config.id!
8586
this.mutationFn = config.mutationFn
8687
this.state = `pending`
8788
this.mutations = []
88-
this.isPersisted = createDeferred()
89+
this.isPersisted = createDeferred<Transaction<T>>()
8990
this.autoCommit = config.autoCommit ?? true
9091
this.createdAt = new Date()
9192
this.metadata = config.metadata ?? {}
@@ -99,7 +100,7 @@ export class Transaction {
99100
}
100101
}
101102

102-
mutate(callback: () => void): Transaction {
103+
mutate(callback: () => void): Transaction<T> {
103104
if (this.state !== `pending`) {
104105
throw `You can no longer call .mutate() as the transaction is no longer pending`
105106
}
@@ -134,7 +135,7 @@ export class Transaction {
134135
}
135136
}
136137

137-
rollback(config?: { isSecondaryRollback?: boolean }): Transaction {
138+
rollback(config?: { isSecondaryRollback?: boolean }): Transaction<T> {
138139
const isSecondaryRollback = config?.isSecondaryRollback ?? false
139140
if (this.state === `completed`) {
140141
throw `You can no longer call .rollback() as the transaction is already completed`
@@ -173,7 +174,7 @@ export class Transaction {
173174
}
174175
}
175176

176-
async commit(): Promise<Transaction> {
177+
async commit(): Promise<Transaction<T>> {
177178
if (this.state !== `pending`) {
178179
throw `You can no longer call .commit() as the transaction is no longer pending`
179180
}
@@ -189,10 +190,11 @@ export class Transaction {
189190
// Run mutationFn
190191
try {
191192
// At this point we know there's at least one mutation
192-
// Use type assertion to tell TypeScript about this guarantee
193-
const transactionWithMutations =
194-
this as unknown as TransactionWithMutations
195-
await this.mutationFn({ transaction: transactionWithMutations })
193+
// We've already verified mutations is non-empty, so this cast is safe
194+
// Use a direct type assertion instead of object spreading to preserve the original type
195+
await this.mutationFn({
196+
transaction: this as unknown as TransactionWithMutations<T>,
197+
})
196198

197199
this.setState(`completed`)
198200
this.touchCollection()

0 commit comments

Comments
 (0)