diff --git a/.changeset/better-plums-thank.md b/.changeset/better-plums-thank.md new file mode 100644 index 000000000..6f92eb47f --- /dev/null +++ b/.changeset/better-plums-thank.md @@ -0,0 +1,7 @@ +--- +"@tanstack/db-collections": patch +"@tanstack/db-example-react-todo": patch +"@tanstack/db": patch +--- + +Type PendingMutation whenever possible diff --git a/examples/react/todo/src/App.tsx b/examples/react/todo/src/App.tsx index ad0b5b169..97cd7e21b 100644 --- a/examples/react/todo/src/App.tsx +++ b/examples/react/todo/src/App.tsx @@ -132,10 +132,10 @@ const createTodoCollection = (type: CollectionType) => { if (collectionsCache.has(`todo`)) { return collectionsCache.get(`todo`) } else { - let newCollection: Collection + let newCollection: Collection if (type === CollectionType.Electric) { - newCollection = createCollection( - electricCollectionOptions({ + newCollection = createCollection( + electricCollectionOptions({ id: `todos`, shapeOptions: { url: `http://localhost:3003/v1/shape`, @@ -237,7 +237,7 @@ const createConfigCollection = (type: CollectionType) => { if (collectionsCache.has(`config`)) { return collectionsCache.get(`config`) } else { - let newCollection: Collection + let newCollection: Collection if (type === CollectionType.Electric) { newCollection = createCollection( electricCollectionOptions({ diff --git a/packages/db-collections/src/electric.ts b/packages/db-collections/src/electric.ts index 005e230d7..90af0d53d 100644 --- a/packages/db-collections/src/electric.ts +++ b/packages/db-collections/src/electric.ts @@ -40,7 +40,9 @@ export interface ElectricCollectionConfig> { * @param params Object containing transaction and mutation information * @returns Promise resolving to an object with txid */ - onInsert?: (params: MutationFnParams) => Promise<{ txid: string } | undefined> + onInsert?: ( + params: MutationFnParams + ) => Promise<{ txid: string } | undefined> /** * Optional asynchronous handler function called before an update operation @@ -48,7 +50,9 @@ export interface ElectricCollectionConfig> { * @param params Object containing transaction and mutation information * @returns Promise resolving to an object with txid */ - onUpdate?: (params: MutationFnParams) => Promise<{ txid: string } | undefined> + onUpdate?: ( + params: MutationFnParams + ) => Promise<{ txid: string } | undefined> /** * Optional asynchronous handler function called before a delete operation @@ -56,10 +60,12 @@ export interface ElectricCollectionConfig> { * @param params Object containing transaction and mutation information * @returns Promise resolving to an object with txid */ - onDelete?: (params: MutationFnParams) => Promise<{ txid: string } | undefined> + onDelete?: ( + params: MutationFnParams + ) => Promise<{ txid: string } | undefined> } -function isUpToDateMessage = Row>( +function isUpToDateMessage>( message: Message ): message is ControlMessage & { up_to_date: true } { return isControlMessage(message) && message.headers.control === `up-to-date` @@ -133,7 +139,7 @@ export function electricCollectionOptions>( // Create wrapper handlers for direct persistence operations that handle txid awaiting const wrappedOnInsert = config.onInsert - ? async (params: MutationFnParams) => { + ? async (params: MutationFnParams) => { const handlerResult = (await config.onInsert!(params)) ?? {} const txid = (handlerResult as { txid?: string }).txid @@ -149,7 +155,7 @@ export function electricCollectionOptions>( : undefined const wrappedOnUpdate = config.onUpdate - ? async (params: MutationFnParams) => { + ? async (params: MutationFnParams) => { const handlerResult = await config.onUpdate!(params) const txid = (handlerResult as { txid?: string }).txid @@ -165,7 +171,7 @@ export function electricCollectionOptions>( : undefined const wrappedOnDelete = config.onDelete - ? async (params: MutationFnParams) => { + ? async (params: MutationFnParams) => { const handlerResult = await config.onDelete!(params) const txid = (handlerResult as { txid?: string }).txid diff --git a/packages/db-collections/src/query.ts b/packages/db-collections/src/query.ts index ce45353a9..a45e5388b 100644 --- a/packages/db-collections/src/query.ts +++ b/packages/db-collections/src/query.ts @@ -284,7 +284,7 @@ export function queryCollectionOptions< // Create wrapper handlers for direct persistence operations that handle refetching const wrappedOnInsert = onInsert - ? async (params: MutationFnParams) => { + ? async (params: MutationFnParams) => { const handlerResult = (await onInsert(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -298,7 +298,7 @@ export function queryCollectionOptions< : undefined const wrappedOnUpdate = onUpdate - ? async (params: MutationFnParams) => { + ? async (params: MutationFnParams) => { const handlerResult = (await onUpdate(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false @@ -312,7 +312,7 @@ export function queryCollectionOptions< : undefined const wrappedOnDelete = onDelete - ? async (params: MutationFnParams) => { + ? async (params: MutationFnParams) => { const handlerResult = (await onDelete(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false diff --git a/packages/db-collections/tests/electric.test.ts b/packages/db-collections/tests/electric.test.ts index 425f22fe8..a599ce0e6 100644 --- a/packages/db-collections/tests/electric.test.ts +++ b/packages/db-collections/tests/electric.test.ts @@ -7,6 +7,7 @@ import type { MutationFnParams, PendingMutation, Transaction, + TransactionWithMutations, } from "@tanstack/db" import type { Message, Row } from "@electric-sql/client" @@ -415,8 +416,11 @@ describe(`Electric Integration`, () => { it(`should throw an error if handler doesn't return a txid`, async () => { // Create a mock transaction for testing - const mockTransaction = { id: `test-transaction` } as Transaction - const mockParams: MutationFnParams = { transaction: mockTransaction } + const mockTransaction = { + id: `test-transaction`, + mutations: [], + } as unknown as TransactionWithMutations + const mockParams: MutationFnParams = { transaction: mockTransaction } // Create a handler that doesn't return a txid const onInsert = vi.fn().mockResolvedValue({}) @@ -488,7 +492,7 @@ describe(`Electric Integration`, () => { } // Create a mutation function for the transaction - const mutationFn = vi.fn(async (params: MutationFnParams) => { + const mutationFn = vi.fn(async (params: MutationFnParams) => { const txid = await fakeBackend.persist(params.transaction.mutations) // Simulate server sending sync message after a delay @@ -500,7 +504,7 @@ describe(`Electric Integration`, () => { }) // Create direct persistence handler that returns the txid - const onInsert = vi.fn(async (params: MutationFnParams) => { + const onInsert = vi.fn(async (params: MutationFnParams) => { return { txid: await mutationFn(params) } }) diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 9d1f124a3..bc4f221df 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -201,7 +201,7 @@ export class CollectionImpl> { * This is populated by createCollection */ public utils: Record = {} - public transactions: Store> + public transactions: Store>> public optimisticOperations: Derived>> public derivedState: Derived> public derivedArray: Derived> @@ -250,7 +250,7 @@ export class CollectionImpl> { } this.transactions = new Store( - new SortedMap( + new SortedMap>( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() ) ) @@ -269,7 +269,7 @@ export class CollectionImpl> { const message: OptimisticChangeMessage = { type: mutation.type, key: mutation.key, - value: mutation.modified as T, + value: mutation.modified, isActive, } if ( @@ -684,8 +684,8 @@ export class CollectionImpl> { const mutation: PendingMutation = { mutationId: crypto.randomUUID(), original: {}, - modified: validatedData as Record, - changes: validatedData as Record, + modified: validatedData, + changes: validatedData, key, metadata: config?.metadata as unknown, syncMetadata: this.config.sync.getSyncMetadata?.() || {}, @@ -710,7 +710,7 @@ export class CollectionImpl> { return ambientTransaction } else { // Create a new transaction with a mutation function that calls the onInsert handler - const directOpTransaction = new Transaction({ + const directOpTransaction = new Transaction({ mutationFn: async (params) => { // Call the onInsert handler with the transaction return this.config.onInsert!(params) @@ -906,10 +906,10 @@ export class CollectionImpl> { // No need to check for onUpdate handler here as we've already checked at the beginning // Create a new transaction with a mutation function that calls the onUpdate handler - const directOpTransaction = new Transaction({ - mutationFn: async (transaction) => { + const directOpTransaction = new Transaction({ + mutationFn: async (params) => { // Call the onUpdate handler with the transaction - return this.config.onUpdate!(transaction) + return this.config.onUpdate!(params) }, }) @@ -944,7 +944,7 @@ export class CollectionImpl> { delete = ( ids: Array | string, config?: OperationConfig - ): TransactionType => { + ): TransactionType => { const ambientTransaction = getActiveTransaction() // If no ambient transaction exists, check for an onDelete handler early @@ -962,9 +962,9 @@ export class CollectionImpl> { for (const id of idsArray) { const mutation: PendingMutation = { mutationId: crypto.randomUUID(), - original: (this.state.get(id) || {}) as Record, - modified: (this.state.get(id) || {}) as Record, - changes: (this.state.get(id) || {}) as Record, + original: this.state.get(id) || {}, + modified: this.state.get(id)!, + changes: this.state.get(id) || {}, key: id, metadata: config?.metadata as unknown, syncMetadata: (this.syncedMetadata.state.get(id) || {}) as Record< @@ -993,11 +993,11 @@ export class CollectionImpl> { } // Create a new transaction with a mutation function that calls the onDelete handler - const directOpTransaction = new Transaction({ + const directOpTransaction = new Transaction({ autoCommit: true, - mutationFn: async (transaction) => { + mutationFn: async (params) => { // Call the onDelete handler with the transaction - return this.config.onDelete!(transaction) + return this.config.onDelete!(params) }, }) diff --git a/packages/db/src/transactions.ts b/packages/db/src/transactions.ts index ccf628a67..9e3e1c600 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -1,6 +1,7 @@ import { createDeferred } from "./deferred" import type { Deferred } from "./deferred" import type { + MutationFn, PendingMutation, TransactionConfig, TransactionState, @@ -24,8 +25,8 @@ function generateUUID() { }) } -const transactions: Array = [] -let transactionStack: Array = [] +const transactions: Array> = [] +let transactionStack: Array> = [] export function createTransaction(config: TransactionConfig): Transaction { if (typeof config.mutationFn === `undefined`) { @@ -51,27 +52,27 @@ export function getActiveTransaction(): Transaction | undefined { } } -function registerTransaction(tx: Transaction) { +function registerTransaction(tx: Transaction) { transactionStack.push(tx) } -function unregisterTransaction(tx: Transaction) { +function unregisterTransaction(tx: Transaction) { transactionStack = transactionStack.filter((t) => t.id !== tx.id) } -function removeFromPendingList(tx: Transaction) { +function removeFromPendingList(tx: Transaction) { const index = transactions.findIndex((t) => t.id === tx.id) if (index !== -1) { transactions.splice(index, 1) } } -export class Transaction { +export class Transaction> { public id: string public state: TransactionState - public mutationFn - public mutations: Array> - public isPersisted: Deferred + public mutationFn: MutationFn + public mutations: Array> + public isPersisted: Deferred> public autoCommit: boolean public createdAt: Date public metadata: Record @@ -80,12 +81,12 @@ export class Transaction { error: Error } - constructor(config: TransactionConfig) { + constructor(config: TransactionConfig) { this.id = config.id! this.mutationFn = config.mutationFn this.state = `pending` this.mutations = [] - this.isPersisted = createDeferred() + this.isPersisted = createDeferred>() this.autoCommit = config.autoCommit ?? true this.createdAt = new Date() this.metadata = config.metadata ?? {} @@ -99,7 +100,7 @@ export class Transaction { } } - mutate(callback: () => void): Transaction { + mutate(callback: () => void): Transaction { if (this.state !== `pending`) { throw `You can no longer call .mutate() as the transaction is no longer pending` } @@ -134,7 +135,7 @@ export class Transaction { } } - rollback(config?: { isSecondaryRollback?: boolean }): Transaction { + rollback(config?: { isSecondaryRollback?: boolean }): Transaction { const isSecondaryRollback = config?.isSecondaryRollback ?? false if (this.state === `completed`) { throw `You can no longer call .rollback() as the transaction is already completed` @@ -173,7 +174,7 @@ export class Transaction { } } - async commit(): Promise { + async commit(): Promise> { if (this.state !== `pending`) { throw `You can no longer call .commit() as the transaction is no longer pending` } @@ -189,10 +190,11 @@ export class Transaction { // Run mutationFn try { // At this point we know there's at least one mutation - // Use type assertion to tell TypeScript about this guarantee - const transactionWithMutations = - this as unknown as TransactionWithMutations - await this.mutationFn({ transaction: transactionWithMutations }) + // We've already verified mutations is non-empty, so this cast is safe + // Use a direct type assertion instead of object spreading to preserve the original type + await this.mutationFn({ + transaction: this as unknown as TransactionWithMutations, + }) this.setState(`completed`) this.touchCollection() diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6cc5a188f..e90d41a66 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -21,9 +21,9 @@ export type UtilsRecord = Record */ export interface PendingMutation> { mutationId: string - original: Record - modified: Record - changes: Record + original: Partial + modified: T + changes: Partial key: any type: OperationType metadata: unknown @@ -36,11 +36,18 @@ export interface PendingMutation> { /** * Configuration options for creating a new transaction */ -export type MutationFnParams = { - transaction: Transaction +export type MutationFnParams> = { + transaction: TransactionWithMutations } -export type MutationFn = (params: MutationFnParams) => Promise +export type MutationFn> = ( + params: MutationFnParams +) => Promise + +/** + * Represents a non-empty array (at least one element) + */ +export type NonEmptyArray = [T, ...Array] /** * Utility type for a Transaction with at least one mutation @@ -48,16 +55,16 @@ export type MutationFn = (params: MutationFnParams) => Promise */ export type TransactionWithMutations< T extends object = Record, -> = Transaction & { - mutations: [PendingMutation, ...Array>] +> = Transaction & { + mutations: NonEmptyArray> } -export interface TransactionConfig { +export interface TransactionConfig> { /** Unique identifier for the transaction */ id?: string /* If the transaction should autocommit after a mutate call or should commit be called explicitly */ autoCommit?: boolean - mutationFn: MutationFn + mutationFn: MutationFn /** Custom metadata to associate with the transaction */ metadata?: Record } @@ -155,19 +162,19 @@ export interface CollectionConfig> { * @param params Object containing transaction and mutation information * @returns Promise resolving to any value */ - onInsert?: MutationFn + onInsert?: MutationFn /** * Optional asynchronous handler function called before an update operation * @param params Object containing transaction and mutation information * @returns Promise resolving to any value */ - onUpdate?: MutationFn + onUpdate?: MutationFn /** * Optional asynchronous handler function called before a delete operation * @param params Object containing transaction and mutation information * @returns Promise resolving to any value */ - onDelete?: MutationFn + onDelete?: MutationFn } export type ChangesPayload> = Array<