From 63980d538f580380c08852ce07ef3f9413ff0b33 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 5 May 2025 14:53:51 -0600 Subject: [PATCH 01/34] New transaction types & remove mutationFn from collection.ts --- packages/optimistic/src/collection.ts | 39 +------- packages/optimistic/src/types.ts | 45 +++++++-- packages/optimistic/tests/collection.test.ts | 94 +++++++++---------- .../tests/query/function-integration.test.ts | 2 +- .../optimistic/tests/query/functions.test.ts | 2 +- .../tests/transaction-types.test.ts | 94 +++++++++++++++++++ 6 files changed, 186 insertions(+), 90 deletions(-) create mode 100644 packages/optimistic/tests/transaction-types.test.ts diff --git a/packages/optimistic/src/collection.ts b/packages/optimistic/src/collection.ts index 0c38007d4..236788439 100644 --- a/packages/optimistic/src/collection.ts +++ b/packages/optimistic/src/collection.ts @@ -43,15 +43,6 @@ interface PendingSyncedTransaction> { * await preloadCollection({ * id: `users-${params.userId}`, * sync: { ... }, - * // mutationFn is optional - provide it if you need mutation capabilities - * mutationFn: async (params: { - * transaction: Transaction - * collection: Collection> - * }) => { - * // Implement your mutation (and syncing) logic here - * // Return a promise that resolves when the mutation and syncing is complete. - * // When this function returns, the optimistic mutations are dropped. - * } * }); * * return null; @@ -59,7 +50,7 @@ interface PendingSyncedTransaction> { * ``` * * @template T - The type of items in the collection - * @param config - Configuration for the collection, including id, sync, and optional mutationFn + * @param config - Configuration for the collection, including id and sync * @returns Promise that resolves when the initial sync is finished */ export function preloadCollection>( @@ -89,7 +80,6 @@ export function preloadCollection>( new Collection({ id: config.id, sync: config.sync, - mutationFn: config.mutationFn, schema: config.schema, }) ) @@ -534,7 +524,6 @@ export class Collection> { * @param config - Optional configuration including metadata and custom keys * @returns A Transaction object representing the insert operation(s) * @throws {SchemaValidationError} If the data fails schema validation - * @throws {Error} If mutationFn is not provided * @example * // Insert a single item * insert({ text: "Buy groceries", completed: false }) @@ -549,13 +538,6 @@ export class Collection> { * insert({ text: "Buy groceries" }, { key: "grocery-task" }) */ insert = (data: T | Array, config?: InsertConfig) => { - // Throw error if mutationFn is not provided - if (!this.config.mutationFn) { - throw new Error( - `Cannot use mutation operators without providing a mutationFn in the collection config` - ) - } - const items = Array.isArray(data) ? data : [data] const mutations: Array = [] @@ -590,6 +572,7 @@ export class Collection> { type: `insert`, createdAt: new Date(), updatedAt: new Date(), + collectionId: this.config.id, } mutations.push(mutation) @@ -605,7 +588,6 @@ export class Collection> { * @param maybeCallback - Update callback if config was provided * @returns A Transaction object representing the update operation(s) * @throws {SchemaValidationError} If the updated data fails schema validation - * @throws {Error} If mutationFn is not provided * @example * // Update a single item * update(todo, (draft) => { draft.completed = true }) @@ -636,13 +618,6 @@ export class Collection> { configOrCallback: ((draft: TItem | Array) => void) | OperationConfig, maybeCallback?: (draft: TItem | Array) => void ) { - // Throw error if mutationFn is not provided - if (!this.config.mutationFn) { - throw new Error( - `Cannot use mutation operators without providing a mutationFn in the collection config` - ) - } - if (typeof items === `undefined`) { throw new Error(`The first argument to update is missing`) } @@ -714,6 +689,7 @@ export class Collection> { type: `update`, createdAt: new Date(), updatedAt: new Date(), + collectionId: this.config.id, } }) .filter(Boolean) as Array @@ -731,7 +707,6 @@ export class Collection> { * @param items - Single item/key or array of items/keys to delete * @param config - Optional configuration including metadata * @returns A Transaction object representing the delete operation(s) - * @throws {Error} If mutationFn is not provided * @example * // Delete a single item * delete(todo) @@ -746,13 +721,6 @@ export class Collection> { items: Array | T | string, config?: OperationConfig ) => { - // Throw error if mutationFn is not provided - if (!this.config.mutationFn) { - throw new Error( - `Cannot use mutation operators without providing a mutationFn in the collection config` - ) - } - const itemsArray = Array.isArray(items) ? items : [items] const mutations: Array = [] @@ -788,6 +756,7 @@ export class Collection> { type: `delete`, createdAt: new Date(), updatedAt: new Date(), + collectionId: this.config.id, } mutations.push(mutation) diff --git a/packages/optimistic/src/types.ts b/packages/optimistic/src/types.ts index 4ccdfe087..fda1ec38e 100644 --- a/packages/optimistic/src/types.ts +++ b/packages/optimistic/src/types.ts @@ -4,6 +4,10 @@ import type { StandardSchemaV1 } from "@standard-schema/spec" export type TransactionState = `pending` | `persisting` | `completed` | `failed` +/** + * Represents a pending mutation within a transaction + * Contains information about the original and modified data, as well as metadata + */ export interface PendingMutation { mutationId: string original: Record @@ -15,8 +19,24 @@ export interface PendingMutation { syncMetadata: Record createdAt: Date updatedAt: Date + /** The ID of the collection this mutation belongs to */ + collectionId: string +} + +/** + * Configuration options for creating a new transaction + */ +export interface TransactionConfig { + /** Unique identifier for the transaction */ + id?: string + /** Custom metadata to associate with the transaction */ + metadata?: Record } +/** + * Represents a transaction in the system + * A transaction groups related mutations across collections + */ export interface Transaction { id: string state: TransactionState @@ -39,6 +59,25 @@ export interface Transaction { export type TransactionWithoutToObject = Omit +/** + * Configuration for the mutation factory + * Used to create mutation functions that can span multiple collections + */ +export interface MutationFactoryConfig { + /** + * Function to persist mutations to the backend + * Receives all mutations across all collections involved in the transaction + */ + mutationFn: (params: { + mutations: Array + transaction: Transaction + }) => Promise + /** + * Custom metadata to associate with all transactions created by this factory + */ + metadata?: Record +} + type Value = | string | number @@ -83,11 +122,6 @@ export interface OptimisticChangeMessage< isActive?: boolean } -export type MutationFn> = (params: { - transaction: Transaction - collection: Collection -}) => Promise - /** * The Standard Schema interface. * This follows the standard-schema specification: https://github.com/standard-schema/standard-schema @@ -118,7 +152,6 @@ export interface InsertConfig { export interface CollectionConfig> { id: string sync: SyncConfig - mutationFn?: MutationFn schema?: StandardSchema } diff --git a/packages/optimistic/tests/collection.test.ts b/packages/optimistic/tests/collection.test.ts index f797dca9b..4e0d1b26e 100644 --- a/packages/optimistic/tests/collection.test.ts +++ b/packages/optimistic/tests/collection.test.ts @@ -24,53 +24,53 @@ describe(`Collection`, () => { expect(collection).toBeInstanceOf(Collection) }) - it(`should throw an error when trying to use mutation operations without a mutationFn`, async () => { - // Create a collection with sync but no mutationFn - const collection = new Collection<{ value: string }>({ - id: `no-mutation-fn`, - sync: { - sync: ({ begin, write, commit }) => { - // Immediately execute the sync cycle - begin() - write({ - type: `insert`, - key: `initial`, - value: { value: `initial value` }, - }) - commit() - }, - }, - }) - - // Wait for the collection to be ready - await collection.stateWhenReady() - - // Verify initial state - expect(collection.state.get(`initial`)).toEqual({ value: `initial value` }) - - // Verify that insert throws an error - expect(() => { - collection.insert({ value: `new value` }, { key: `new-key` }) - }).toThrow( - `Cannot use mutation operators without providing a mutationFn in the collection config` - ) - - // Verify that update throws an error - expect(() => { - collection.update(collection.state.get(`initial`)!, (draft) => { - draft.value = `updated value` - }) - }).toThrow( - `Cannot use mutation operators without providing a mutationFn in the collection config` - ) - - // Verify that delete throws an error - expect(() => { - collection.delete(`initial`) - }).toThrow( - `Cannot use mutation operators without providing a mutationFn in the collection config` - ) - }) + // it(`should throw an error when trying to use mutation operations without a mutationFn`, async () => { + // // Create a collection with sync but no mutationFn + // const collection = new Collection<{ value: string }>({ + // id: `no-mutation-fn`, + // sync: { + // sync: ({ begin, write, commit }) => { + // // Immediately execute the sync cycle + // begin() + // write({ + // type: `insert`, + // key: `initial`, + // value: { value: `initial value` }, + // }) + // commit() + // }, + // }, + // }) + // + // // Wait for the collection to be ready + // await collection.stateWhenReady() + // + // // Verify initial state + // expect(collection.state.get(`initial`)).toEqual({ value: `initial value` }) + // + // // Verify that insert throws an error + // expect(() => { + // collection.insert({ value: `new value` }, { key: `new-key` }) + // }).toThrow( + // `Cannot use mutation operators without providing a mutationFn in the collection config` + // ) + // + // // Verify that update throws an error + // expect(() => { + // collection.update(collection.state.get(`initial`)!, (draft) => { + // draft.value = `updated value` + // }) + // }).toThrow( + // `Cannot use mutation operators without providing a mutationFn in the collection config` + // ) + // + // // Verify that delete throws an error + // expect(() => { + // collection.delete(`initial`) + // }).toThrow( + // `Cannot use mutation operators without providing a mutationFn in the collection config` + // ) + // }) it(`It shouldn't expose any state until the initial sync is finished`, () => { // Create a collection with a mock sync plugin diff --git a/packages/optimistic/tests/query/function-integration.test.ts b/packages/optimistic/tests/query/function-integration.test.ts index e35cfa503..1769c3cac 100644 --- a/packages/optimistic/tests/query/function-integration.test.ts +++ b/packages/optimistic/tests/query/function-integration.test.ts @@ -230,7 +230,7 @@ describe(`Query Function Integration`, () => { expect(results[0].id).toBe(1) // Alice expect(results[0].joined.getFullYear()).toBe(2023) expect(results[0].joined.getMonth()).toBe(0) // January (0-indexed) - expect(results[0].joined.getDate()).toBe(15) + expect(results[0].joined.getUTCDate()).toBe(15) }) }) diff --git a/packages/optimistic/tests/query/functions.test.ts b/packages/optimistic/tests/query/functions.test.ts index 0b265dcc8..6c32e6765 100644 --- a/packages/optimistic/tests/query/functions.test.ts +++ b/packages/optimistic/tests/query/functions.test.ts @@ -200,7 +200,7 @@ describe(`Query > Functions`, () => { expect(result).toBeInstanceOf(Date) expect((result as Date).getFullYear()).toBe(2023) expect((result as Date).getMonth()).toBe(0) // January = 0 - expect((result as Date).getDate()).toBe(15) + expect((result as Date).getUTCDate()).toBe(15) // Test other date formats const isoResult = evaluateFunction(`DATE`, `2023-02-20T12:30:45Z`) diff --git a/packages/optimistic/tests/transaction-types.test.ts b/packages/optimistic/tests/transaction-types.test.ts new file mode 100644 index 000000000..8d9dc6578 --- /dev/null +++ b/packages/optimistic/tests/transaction-types.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" +import type { + MutationFactoryConfig, + PendingMutation, + Transaction, + TransactionConfig, + TransactionState, +} from "../src/types" + +describe(`Transaction Types`, () => { + it(`should validate PendingMutation structure with collectionId`, () => { + // Type assertion test - this will fail at compile time if the type is incorrect + const pendingMutation: PendingMutation = { + mutationId: `test-mutation-1`, + original: { id: 1, name: `Original` }, + modified: { id: 1, name: `Modified` }, + changes: { name: `Modified` }, + key: `1`, + type: `update`, + metadata: null, + syncMetadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + collectionId: `users`, // New required field + } + + expect(pendingMutation.collectionId).toBe(`users`) + expect(pendingMutation.type).toBe(`update`) + }) + + it(`should validate TransactionConfig structure`, () => { + // Empty config is valid + const minimalConfig: TransactionConfig = {} + expect(minimalConfig).toBeDefined() + + // Full config + const fullConfig: TransactionConfig = { + id: `custom-transaction-id`, + metadata: { source: `user-form` }, + } + + expect(fullConfig.id).toBe(`custom-transaction-id`) + expect(fullConfig.metadata).toEqual({ source: `user-form` }) + }) + + it(`should validate Transaction structure`, () => { + const mockToObject = () => ({ + id: `test-transaction`, + state: `pending` as TransactionState, + createdAt: new Date(), + updatedAt: new Date(), + mutations: [], + metadata: {}, + }) + + const transaction: Transaction = { + id: `test-transaction`, + state: `pending`, + createdAt: new Date(), + updatedAt: new Date(), + mutations: [], + metadata: {}, + toObject: mockToObject, + } + + expect(transaction.id).toBe(`test-transaction`) + + // Test toObject method + const plainObject = transaction.toObject() + expect(plainObject.id).toBe(`test-transaction`) + expect(plainObject).not.toHaveProperty(`toObject`) + }) + + it(`should validate MutationFactoryConfig structure`, () => { + // Minimal config with just the required mutationFn + const minimalConfig: MutationFactoryConfig = { + mutationFn: async () => { + return Promise.resolve({ success: true }) + }, + } + + expect(typeof minimalConfig.mutationFn).toBe(`function`) + + // Full config with metadata + const fullConfig: MutationFactoryConfig = { + mutationFn: async () => { + return Promise.resolve({ success: true }) + }, + metadata: { source: `signup-form` }, + } + + expect(fullConfig.metadata).toEqual({ source: `signup-form` }) + }) +}) From 62622fbe121c5331924e4c7e74a00c06592cc6fc Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 5 May 2025 15:01:27 -0600 Subject: [PATCH 02/34] Add transaction registry --- .../optimistic/src/TransactionRegistry.ts | 89 ++++++++++++++ .../tests/TransactionRegistry.test.ts | 110 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 packages/optimistic/src/TransactionRegistry.ts create mode 100644 packages/optimistic/tests/TransactionRegistry.test.ts diff --git a/packages/optimistic/src/TransactionRegistry.ts b/packages/optimistic/src/TransactionRegistry.ts new file mode 100644 index 000000000..1c428b5a8 --- /dev/null +++ b/packages/optimistic/src/TransactionRegistry.ts @@ -0,0 +1,89 @@ +import type { Transaction } from "./types" + +/** + * Global registry for managing active transactions + * Supports nested transactions through a transaction stack + */ +export class TransactionRegistry { + private static instance: TransactionRegistry + private transactionStack: Array = [] + + private constructor() { + // Private constructor to enforce singleton pattern + } + + /** + * Get the singleton instance of TransactionRegistry + */ + public static getInstance(): TransactionRegistry { + return this.instance + } + + /** + * Register a new active transaction + * The transaction is pushed onto the stack, supporting nested transactions + * @param transaction - The transaction to register + * @throws Error if the transaction is already registered + */ + public registerActiveTransaction(transaction: Transaction): void { + if (this.isTransactionActive(transaction.id)) { + throw new Error(`Transaction ${transaction.id} is already active`) + } + this.transactionStack.push(transaction) + } + + /** + * Unregister an active transaction + * Removes the transaction from the stack if it matches the given ID + * @param transactionId - ID of the transaction to unregister + * @throws Error if the transaction is not the most recently active one + */ + public unregisterActiveTransaction(transactionId: string): void { + const activeTransaction = this.getActiveTransaction() + if (!activeTransaction) { + throw new Error(`No active transaction to unregister`) + } + if (activeTransaction.id !== transactionId) { + throw new Error( + `Cannot unregister transaction ${transactionId} as it's not the most recently active transaction` + ) + } + this.transactionStack.pop() + } + + /** + * Get the currently active transaction + * Returns the transaction at the top of the stack + * @returns The active transaction or undefined if no transaction is active + */ + public getActiveTransaction(): Transaction | undefined { + return this.transactionStack[this.transactionStack.length - 1] + } + + /** + * Check if a transaction with the given ID is currently active + * @param transactionId - ID of the transaction to check + * @returns true if the transaction is in the stack, false otherwise + */ + public isTransactionActive(transactionId: string): boolean { + return this.transactionStack.some( + (transaction) => transaction.id === transactionId + ) + } + + /** + * Get the current depth of nested transactions + * @returns The number of active nested transactions + */ + public getTransactionDepth(): number { + return this.transactionStack.length + } + + /** + * Clear all active transactions + * Should only be used for testing or error recovery + */ + public clearTransactions(): void { + this.transactionStack = [] + } +} diff --git a/packages/optimistic/tests/TransactionRegistry.test.ts b/packages/optimistic/tests/TransactionRegistry.test.ts new file mode 100644 index 000000000..d7246a675 --- /dev/null +++ b/packages/optimistic/tests/TransactionRegistry.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { TransactionRegistry } from "../src/TransactionRegistry" +import type { Transaction } from "../src/types" + +describe(`TransactionRegistry`, () => { + let registry: TransactionRegistry + + const createMockTransaction = (id: string): Transaction => ({ + id, + state: `pending`, + createdAt: new Date(), + updatedAt: new Date(), + mutations: [], + metadata: {}, + toObject: () => ({ + id, + state: `pending`, + createdAt: new Date(), + updatedAt: new Date(), + mutations: [], + metadata: {}, + }), + }) + + beforeEach(() => { + registry = TransactionRegistry.getInstance() + registry.clearTransactions() + }) + + afterEach(() => { + registry.clearTransactions() + }) + + it(`should maintain a singleton instance`, () => { + const instance1 = TransactionRegistry.getInstance() + const instance2 = TransactionRegistry.getInstance() + expect(instance1).toBe(instance2) + }) + + describe(`registerActiveTransaction`, () => { + it(`should register a new transaction`, () => { + const transaction = createMockTransaction(`tx1`) + registry.registerActiveTransaction(transaction) + expect(registry.getActiveTransaction()).toBe(transaction) + }) + + it(`should throw when registering the same transaction twice`, () => { + const transaction = createMockTransaction(`tx1`) + registry.registerActiveTransaction(transaction) + expect(() => registry.registerActiveTransaction(transaction)).toThrow( + `Transaction tx1 is already active` + ) + }) + + it(`should support nested transactions`, () => { + const tx1 = createMockTransaction(`tx1`) + const tx2 = createMockTransaction(`tx2`) + const tx3 = createMockTransaction(`tx3`) + + registry.registerActiveTransaction(tx1) + registry.registerActiveTransaction(tx2) + registry.registerActiveTransaction(tx3) + + expect(registry.getTransactionDepth()).toBe(3) + expect(registry.getActiveTransaction()).toBe(tx3) + }) + }) + + describe(`unregisterActiveTransaction`, () => { + it(`should unregister the active transaction`, () => { + const transaction = createMockTransaction(`tx1`) + registry.registerActiveTransaction(transaction) + registry.unregisterActiveTransaction(transaction.id) + expect(registry.getActiveTransaction()).toBeUndefined() + }) + + it(`should throw when unregistering with no active transaction`, () => { + expect(() => registry.unregisterActiveTransaction(`tx1`)).toThrow( + `No active transaction to unregister` + ) + }) + + it(`should throw when unregistering a non-active transaction`, () => { + const tx1 = createMockTransaction(`tx1`) + const tx2 = createMockTransaction(`tx2`) + registry.registerActiveTransaction(tx1) + registry.registerActiveTransaction(tx2) + + expect(() => registry.unregisterActiveTransaction(`tx1`)).toThrow( + `Cannot unregister transaction tx1 as it's not the most recently active transaction` + ) + }) + + it(`should maintain proper stack order when unregistering nested transactions`, () => { + const tx1 = createMockTransaction(`tx1`) + const tx2 = createMockTransaction(`tx2`) + const tx3 = createMockTransaction(`tx3`) + + registry.registerActiveTransaction(tx1) + registry.registerActiveTransaction(tx2) + registry.registerActiveTransaction(tx3) + + registry.unregisterActiveTransaction(tx3.id) + expect(registry.getActiveTransaction()).toBe(tx2) + + registry.unregisterActiveTransaction(tx2.id) + expect(registry.getActiveTransaction()).toBe(tx1) + }) + }) +}) From 27532fb944d33746072a78f9be97b3b7ceac6a4b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 6 May 2025 15:29:31 -0600 Subject: [PATCH 03/34] tests passing --- package.json | 3 +- packages/optimistic/package.json | 1 + packages/optimistic/src/TransactionManager.ts | 4 +- .../optimistic/src/TransactionRegistry.ts | 89 ------ packages/optimistic/src/collection.ts | 80 +++-- packages/optimistic/src/transactions.ts | 142 +++++++++ packages/optimistic/src/types.ts | 59 ++-- .../tests/TransactionManager.test.ts | 6 +- .../tests/TransactionRegistry.test.ts | 110 ------- .../collection-subscribe-changes.test.ts | 125 ++++---- packages/optimistic/tests/collection.test.ts | 276 ++++++++++-------- .../optimistic/tests/object-key-map.test.ts | 85 ++++-- .../optimistic/tests/transactions.test.ts | 43 +++ packages/react-optimistic/package.json | 3 +- 14 files changed, 578 insertions(+), 448 deletions(-) delete mode 100644 packages/optimistic/src/TransactionRegistry.ts create mode 100644 packages/optimistic/src/transactions.ts delete mode 100644 packages/optimistic/tests/TransactionRegistry.test.ts create mode 100644 packages/optimistic/tests/transactions.test.ts diff --git a/package.json b/package.json index 0a58c3648..7f3698f11 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "changeset": "changeset", "changeset:publish": "changeset publish", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile", - "lint": "eslint . --fix", + "lint": "pnpm --filter \"./packages/**\" lint", + "lint-all": "eslint . --fix", "prepack": "vite build", "prepare": "husky", "test": "pnpm --filter \"./packages/**\" test" diff --git a/packages/optimistic/package.json b/packages/optimistic/package.json index 8975b5d4f..71a763639 100644 --- a/packages/optimistic/package.json +++ b/packages/optimistic/package.json @@ -48,6 +48,7 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", + "lint": "eslint . --fix", "test": "npx vitest --run" }, "sideEffects": false, diff --git a/packages/optimistic/src/TransactionManager.ts b/packages/optimistic/src/TransactionManager.ts index 00bea38e9..2fca66a3a 100644 --- a/packages/optimistic/src/TransactionManager.ts +++ b/packages/optimistic/src/TransactionManager.ts @@ -212,7 +212,7 @@ export class TransactionManager> { if (!tx) return // Mark as persisted - tx.isPersisted?.resolve(true) + tx.isPersisted.resolve(true) this.setTransactionState(transactionId, `completed`) }) .catch((error) => { @@ -226,7 +226,7 @@ export class TransactionManager> { } // Reject the promise - tx.isPersisted?.reject(tx.error.error) + tx.isPersisted.reject(tx.error.error) // Set transaction state to failed this.setTransactionState(transactionId, `failed`) diff --git a/packages/optimistic/src/TransactionRegistry.ts b/packages/optimistic/src/TransactionRegistry.ts deleted file mode 100644 index 1c428b5a8..000000000 --- a/packages/optimistic/src/TransactionRegistry.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Transaction } from "./types" - -/** - * Global registry for managing active transactions - * Supports nested transactions through a transaction stack - */ -export class TransactionRegistry { - private static instance: TransactionRegistry - private transactionStack: Array = [] - - private constructor() { - // Private constructor to enforce singleton pattern - } - - /** - * Get the singleton instance of TransactionRegistry - */ - public static getInstance(): TransactionRegistry { - return this.instance - } - - /** - * Register a new active transaction - * The transaction is pushed onto the stack, supporting nested transactions - * @param transaction - The transaction to register - * @throws Error if the transaction is already registered - */ - public registerActiveTransaction(transaction: Transaction): void { - if (this.isTransactionActive(transaction.id)) { - throw new Error(`Transaction ${transaction.id} is already active`) - } - this.transactionStack.push(transaction) - } - - /** - * Unregister an active transaction - * Removes the transaction from the stack if it matches the given ID - * @param transactionId - ID of the transaction to unregister - * @throws Error if the transaction is not the most recently active one - */ - public unregisterActiveTransaction(transactionId: string): void { - const activeTransaction = this.getActiveTransaction() - if (!activeTransaction) { - throw new Error(`No active transaction to unregister`) - } - if (activeTransaction.id !== transactionId) { - throw new Error( - `Cannot unregister transaction ${transactionId} as it's not the most recently active transaction` - ) - } - this.transactionStack.pop() - } - - /** - * Get the currently active transaction - * Returns the transaction at the top of the stack - * @returns The active transaction or undefined if no transaction is active - */ - public getActiveTransaction(): Transaction | undefined { - return this.transactionStack[this.transactionStack.length - 1] - } - - /** - * Check if a transaction with the given ID is currently active - * @param transactionId - ID of the transaction to check - * @returns true if the transaction is in the stack, false otherwise - */ - public isTransactionActive(transactionId: string): boolean { - return this.transactionStack.some( - (transaction) => transaction.id === transactionId - ) - } - - /** - * Get the current depth of nested transactions - * @returns The number of active nested transactions - */ - public getTransactionDepth(): number { - return this.transactionStack.length - } - - /** - * Clear all active transactions - * Should only be used for testing or error recovery - */ - public clearTransactions(): void { - this.transactionStack = [] - } -} diff --git a/packages/optimistic/src/collection.ts b/packages/optimistic/src/collection.ts index 236788439..3c98cb408 100644 --- a/packages/optimistic/src/collection.ts +++ b/packages/optimistic/src/collection.ts @@ -1,6 +1,7 @@ import { Derived, Store, batch } from "@tanstack/store" import { withArrayChangeTracking, withChangeTracking } from "./proxy" -import { getTransactionManager } from "./TransactionManager" +import { getActiveTransaction } from "./transactions" +import { SortedMap } from "./SortedMap" import type { ChangeMessage, CollectionConfig, @@ -144,8 +145,7 @@ export class SchemaValidationError extends Error { } export class Collection> { - public transactionManager!: ReturnType> - + public transactions: Store> public optimisticOperations: Derived>> public derivedState: Derived> public derivedArray: Derived> @@ -185,7 +185,11 @@ export class Collection> { throw new Error(`Collection requires a sync config`) } - this.transactionManager = getTransactionManager(this) + this.transactions = new Store( + new SortedMap( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime() + ) + ) // Copies of live mutations are stored here and removed once the transaction completes. this.optimisticOperations = new Derived({ @@ -215,7 +219,7 @@ export class Collection> { return result }, - deps: [this.transactionManager.transactions], + deps: [this.transactions], }) this.optimisticOperations.mount() @@ -363,7 +367,7 @@ export class Collection> { */ commitPendingTransactions = () => { if ( - !Array.from(this.transactions.values()).some( + !Array.from(this.transactions.state.values()).some( ({ state }) => state === `persisting` ) ) { @@ -538,6 +542,11 @@ export class Collection> { * insert({ text: "Buy groceries" }, { key: "grocery-task" }) */ insert = (data: T | Array, config?: InsertConfig) => { + const transaction = getActiveTransaction() + if (typeof transaction === `undefined`) { + throw `no transaction found when calling collection.insert` + } + const items = Array.isArray(data) ? data : [data] const mutations: Array = [] @@ -572,13 +581,24 @@ export class Collection> { type: `insert`, createdAt: new Date(), updatedAt: new Date(), - collectionId: this.config.id, + collection: this, } + // TODO make this applyMutations so it overwrites previous mutations like the + // current implementation does. mutations.push(mutation) }) - return this.transactionManager.applyTransaction(mutations) + transaction.mutations.push(...mutations) + + this.transactions.setState((sortedMap) => { + sortedMap.set(transaction.id, transaction) + return sortedMap + }) + + return transaction + + // return this.transactionManager.applyTransaction(mutations) } /** @@ -621,6 +641,12 @@ export class Collection> { if (typeof items === `undefined`) { throw new Error(`The first argument to update is missing`) } + + const transaction = getActiveTransaction() + if (typeof transaction === `undefined`) { + throw `no transaction found when calling collection.update` + } + const isArray = Array.isArray(items) const itemsArray = Array.isArray(items) ? items : [items] const callback = @@ -689,7 +715,7 @@ export class Collection> { type: `update`, createdAt: new Date(), updatedAt: new Date(), - collectionId: this.config.id, + collection: this, } }) .filter(Boolean) as Array @@ -699,7 +725,15 @@ export class Collection> { throw new Error(`No changes were made to any of the objects`) } - return this.transactionManager.applyTransaction(mutations) + transaction.mutations.push(...mutations) + + this.transactions.setState((sortedMap) => { + sortedMap.set(transaction.id, transaction) + return sortedMap + }) + + return transaction + // return this.transactionManager.applyTransaction(mutations) } /** @@ -721,6 +755,11 @@ export class Collection> { items: Array | T | string, config?: OperationConfig ) => { + const transaction = getActiveTransaction() + if (typeof transaction === `undefined`) { + throw `no transaction found when calling collection.delete` + } + const itemsArray = Array.isArray(items) ? items : [items] const mutations: Array = [] @@ -756,7 +795,7 @@ export class Collection> { type: `delete`, createdAt: new Date(), updatedAt: new Date(), - collectionId: this.config.id, + collection: this, } mutations.push(mutation) @@ -770,7 +809,15 @@ export class Collection> { } }) - return this.transactionManager.applyTransaction(mutations) + transaction.mutations.push(...mutations) + + this.transactions.setState((sortedMap) => { + sortedMap.set(transaction.id, transaction) + return sortedMap + }) + + return transaction + // return this.transactionManager.applyTransaction(mutations) } /** @@ -831,15 +878,6 @@ export class Collection> { }) } - /** - * Gets the current transactions in the collection - * - * @returns A SortedMap of all transactions in the collection - */ - get transactions() { - return this.transactionManager.transactions.state - } - /** * Returns the current state of the collection as an array of changes * @returns An array of changes diff --git a/packages/optimistic/src/transactions.ts b/packages/optimistic/src/transactions.ts new file mode 100644 index 000000000..7a507723a --- /dev/null +++ b/packages/optimistic/src/transactions.ts @@ -0,0 +1,142 @@ +import { createDeferred } from "./deferred" +import type { Deferred } from "./deferred" +import type { + PendingMutation, + TransactionConfig, + TransactionState, +} from "./types" + +function generateUUID() { + // Check if crypto.randomUUID is available (modern browsers and Node.js 15+) + if ( + typeof crypto !== `undefined` && + typeof crypto.randomUUID === `function` + ) { + return crypto.randomUUID() + } + + // Fallback implementation for older environments + return `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c === `x` ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +export function createTransaction({ + id, + mutationFn, + metadata, +}: TransactionConfig): Transaction { + if (typeof mutationFn === `undefined`) { + throw `mutationFn is required when creating a transaction` + } + + let transactionId = id + if (!transactionId) { + transactionId = generateUUID() + } + return new Transaction({ id: transactionId, mutationFn, metadata }) +} + +let transactionStack: Array = [] + +export function getActiveTransaction(): Transaction | undefined { + if (transactionStack.length > 0) { + return transactionStack.slice(-1)[0] + } else { + return undefined + } +} + +function registerTransaction(tx: Transaction) { + transactionStack.push(tx) +} + +function unregisterTransaction(tx: Transaction) { + transactionStack = transactionStack.filter((t) => t.id !== tx.id) +} + +export class Transaction { + public id: string + public state: TransactionState + public mutationFn + public mutations: Array + public isPersisted: Deferred + public autoCommit: boolean + public createdAt: Date + constructor({ id, mutationFn, autoCommit = true }: TransactionConfig) { + this.id = id! + this.mutationFn = mutationFn + this.state = `pending` + this.mutations = [] + this.isPersisted = createDeferred() + this.autoCommit = autoCommit + this.createdAt = new Date() + } + + setState(newState: TransactionState) { + this.state = newState + } + + mutate(callback: () => void): Transaction { + if (this.state !== `pending`) { + throw `You can no longer call .mutate() as the transaction is no longer pending` + } + + registerTransaction(this) + try { + callback() + } finally { + unregisterTransaction(this) + } + + if (this.autoCommit) { + return this.commit() + } + + return this + } + + rollback(): Transaction { + if (this.state === `completed`) { + throw `You can no longer call .rollback() as the transaction is already completed` + } + } + + commit(): Transaction { + if (this.state !== `pending`) { + throw `You can no longer call .commit() as the transaction is no longer pending` + } + + this.setState(`persisting`) + // const hasCalled = new Set() + // this.mutations.forEach((mutation) => { + // if (!hasCalled.has(mutation.collection.id)) { + // mutation.collection.transactions.setState((state) => state) + // hasCalled.add(mutation.collection.id) + // } + // }) + + if (this.mutations.length === 0) { + this.setState(`completed`) + } + + // Run mutationFn + this.mutationFn({ transaction: this }).then(() => { + this.setState(`completed`) + const hasCalled = new Set() + this.mutations.forEach((mutation) => { + if (!hasCalled.has(mutation.collection.id)) { + mutation.collection.transactions.setState((state) => state) + mutation.collection.commitPendingTransactions() + hasCalled.add(mutation.collection.id) + } + }) + + this.isPersisted.resolve(this) + }) + + return this + } +} diff --git a/packages/optimistic/src/types.ts b/packages/optimistic/src/types.ts index fda1ec38e..6c29c43d6 100644 --- a/packages/optimistic/src/types.ts +++ b/packages/optimistic/src/types.ts @@ -1,6 +1,6 @@ import type { Collection } from "../src/collection" -import type { Deferred } from "../src/deferred" import type { StandardSchemaV1 } from "@standard-schema/spec" +import type { Transaction } from "../src/transactions" export type TransactionState = `pending` | `persisting` | `completed` | `failed` @@ -19,8 +19,7 @@ export interface PendingMutation { syncMetadata: Record createdAt: Date updatedAt: Date - /** The ID of the collection this mutation belongs to */ - collectionId: string + collection: Collection } /** @@ -29,33 +28,41 @@ export interface PendingMutation { 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: (params: { + mutations: Array + transaction: Transaction + }) => Promise /** Custom metadata to associate with the transaction */ metadata?: Record } -/** - * Represents a transaction in the system - * A transaction groups related mutations across collections - */ -export interface Transaction { - id: string - state: TransactionState - createdAt: Date - updatedAt: Date - mutations: Array - metadata: Record - isPersisted?: Deferred - error?: { - transactionId?: string // For dependency failures - message: string - error: Error - } - /** - * Get a plain object representation of the transaction - * This is useful for creating clones or serializing the transaction - */ - toObject: () => Omit -} +export type { Transaction } + +// /** +// * Represents a transaction in the system +// * A transaction groups related mutations across collections +// */ +// export interface Transaction { +// id: string +// state: TransactionState +// createdAt: Date +// updatedAt: Date +// mutations: Array +// metadata: Record +// isPersisted?: Deferred +// error?: { +// transactionId?: string // For dependency failures +// message: string +// error: Error +// } +// /** +// * Get a plain object representation of the transaction +// * This is useful for creating clones or serializing the transaction +// */ +// toObject: () => Omit +// } export type TransactionWithoutToObject = Omit diff --git a/packages/optimistic/tests/TransactionManager.test.ts b/packages/optimistic/tests/TransactionManager.test.ts index 623c1d9fa..cf1e6de7f 100644 --- a/packages/optimistic/tests/TransactionManager.test.ts +++ b/packages/optimistic/tests/TransactionManager.test.ts @@ -295,7 +295,7 @@ describe(`TransactionManager`, () => { const mutations = [createMockMutation(`error-test-5`)] const transaction = errorManager.applyTransaction(mutations) - await expect(transaction.isPersisted?.promise).rejects.toThrow( + await expect(transaction.isPersisted.promise).rejects.toThrow( `Persist error` ) @@ -324,10 +324,10 @@ describe(`TransactionManager`, () => { const transaction = nonErrorManager.applyTransaction(mutations) // The promise should reject with a converted Error - await expect(transaction.isPersisted?.promise).rejects.toThrow( + await expect(transaction.isPersisted.promise).rejects.toThrow( `String error message` ) - transaction.isPersisted?.promise.catch(() => {}) + transaction.isPersisted.promise.catch(() => {}) // Verify the transaction state and error handling expect(transaction.state).toBe(`failed`) diff --git a/packages/optimistic/tests/TransactionRegistry.test.ts b/packages/optimistic/tests/TransactionRegistry.test.ts deleted file mode 100644 index d7246a675..000000000 --- a/packages/optimistic/tests/TransactionRegistry.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { TransactionRegistry } from "../src/TransactionRegistry" -import type { Transaction } from "../src/types" - -describe(`TransactionRegistry`, () => { - let registry: TransactionRegistry - - const createMockTransaction = (id: string): Transaction => ({ - id, - state: `pending`, - createdAt: new Date(), - updatedAt: new Date(), - mutations: [], - metadata: {}, - toObject: () => ({ - id, - state: `pending`, - createdAt: new Date(), - updatedAt: new Date(), - mutations: [], - metadata: {}, - }), - }) - - beforeEach(() => { - registry = TransactionRegistry.getInstance() - registry.clearTransactions() - }) - - afterEach(() => { - registry.clearTransactions() - }) - - it(`should maintain a singleton instance`, () => { - const instance1 = TransactionRegistry.getInstance() - const instance2 = TransactionRegistry.getInstance() - expect(instance1).toBe(instance2) - }) - - describe(`registerActiveTransaction`, () => { - it(`should register a new transaction`, () => { - const transaction = createMockTransaction(`tx1`) - registry.registerActiveTransaction(transaction) - expect(registry.getActiveTransaction()).toBe(transaction) - }) - - it(`should throw when registering the same transaction twice`, () => { - const transaction = createMockTransaction(`tx1`) - registry.registerActiveTransaction(transaction) - expect(() => registry.registerActiveTransaction(transaction)).toThrow( - `Transaction tx1 is already active` - ) - }) - - it(`should support nested transactions`, () => { - const tx1 = createMockTransaction(`tx1`) - const tx2 = createMockTransaction(`tx2`) - const tx3 = createMockTransaction(`tx3`) - - registry.registerActiveTransaction(tx1) - registry.registerActiveTransaction(tx2) - registry.registerActiveTransaction(tx3) - - expect(registry.getTransactionDepth()).toBe(3) - expect(registry.getActiveTransaction()).toBe(tx3) - }) - }) - - describe(`unregisterActiveTransaction`, () => { - it(`should unregister the active transaction`, () => { - const transaction = createMockTransaction(`tx1`) - registry.registerActiveTransaction(transaction) - registry.unregisterActiveTransaction(transaction.id) - expect(registry.getActiveTransaction()).toBeUndefined() - }) - - it(`should throw when unregistering with no active transaction`, () => { - expect(() => registry.unregisterActiveTransaction(`tx1`)).toThrow( - `No active transaction to unregister` - ) - }) - - it(`should throw when unregistering a non-active transaction`, () => { - const tx1 = createMockTransaction(`tx1`) - const tx2 = createMockTransaction(`tx2`) - registry.registerActiveTransaction(tx1) - registry.registerActiveTransaction(tx2) - - expect(() => registry.unregisterActiveTransaction(`tx1`)).toThrow( - `Cannot unregister transaction tx1 as it's not the most recently active transaction` - ) - }) - - it(`should maintain proper stack order when unregistering nested transactions`, () => { - const tx1 = createMockTransaction(`tx1`) - const tx2 = createMockTransaction(`tx2`) - const tx3 = createMockTransaction(`tx3`) - - registry.registerActiveTransaction(tx1) - registry.registerActiveTransaction(tx2) - registry.registerActiveTransaction(tx3) - - registry.unregisterActiveTransaction(tx3.id) - expect(registry.getActiveTransaction()).toBe(tx2) - - registry.unregisterActiveTransaction(tx2.id) - expect(registry.getActiveTransaction()).toBe(tx1) - }) - }) -}) diff --git a/packages/optimistic/tests/collection-subscribe-changes.test.ts b/packages/optimistic/tests/collection-subscribe-changes.test.ts index d467ed748..df64181aa 100644 --- a/packages/optimistic/tests/collection-subscribe-changes.test.ts +++ b/packages/optimistic/tests/collection-subscribe-changes.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vitest" import mitt from "mitt" import { Collection } from "../src/collection" +import { createTransaction } from "../src/transactions" import type { ChangeMessage, ChangesPayload, PendingMutation, + Transaction, + TransactionConfig, } from "../src/types" // Helper function to wait for changes to be processed @@ -34,7 +37,6 @@ describe(`Collection.subscribeChanges`, () => { commit() }, }, - mutationFn: async () => {}, }) // Wait for initial sync to complete @@ -90,7 +92,6 @@ describe(`Collection.subscribeChanges`, () => { commit() }, }, - mutationFn: async () => {}, }) // Subscribe to changes @@ -207,12 +208,13 @@ describe(`Collection.subscribeChanges`, () => { }) }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, }) + const mutationFn = async ({ transaction }) => { + emitter.emit(`sync`, transaction.mutations) + return Promise.resolve() + } + // Subscribe to changes const unsubscribe = collection.subscribeChanges(callback) @@ -220,7 +222,13 @@ describe(`Collection.subscribeChanges`, () => { callback.mockReset() // Perform optimistic insert - collection.insert({ value: `optimistic value` }, { key: `optimisticItem` }) + const tx = createTransaction({ mutationFn }) + tx.mutate(() => + collection.insert( + { value: `optimistic value` }, + { key: `optimisticItem` } + ) + ) // Verify that insert was emitted immediately (optimistically) expect(callback).toHaveBeenCalledTimes(1) @@ -249,10 +257,13 @@ describe(`Collection.subscribeChanges`, () => { if (!item) { throw new Error(`Item not found`) } - collection.update(item, (draft) => { - draft.value = `updated optimistic value` - draft.updated = true - }) + const updateTx = createTransaction({ mutationFn }) + updateTx.mutate(() => + collection.update(item, (draft) => { + draft.value = `updated optimistic value` + draft.updated = true + }) + ) // Verify that update was emitted expect(callback).toHaveBeenCalledTimes(1) @@ -278,7 +289,8 @@ describe(`Collection.subscribeChanges`, () => { callback.mockReset() // Perform optimistic delete - collection.delete(`optimisticItem`) + const deleteTx = createTransaction({ mutationFn }) + deleteTx.mutate(() => collection.delete(`optimisticItem`)) // Verify that delete was emitted expect(callback).toHaveBeenCalledTimes(1) @@ -327,12 +339,13 @@ describe(`Collection.subscribeChanges`, () => { commit() }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, }) + const mutationFn = async ({ transaction }) => { + emitter.emit(`sync`, transaction.mutations) + return Promise.resolve() + } + // Subscribe to changes const unsubscribe = collection.subscribeChanges(callback) @@ -363,9 +376,12 @@ describe(`Collection.subscribeChanges`, () => { callback.mockReset() // Now add an optimistic item - const tx = collection.insert( - { value: `optimistic value` }, - { key: `optimisticItem` } + const tx = createTransaction({ mutationFn }) + tx.mutate(() => + collection.insert( + { value: `optimistic value` }, + { key: `optimisticItem` } + ) ) // Verify optimistic insert was emitted - this is the synchronous optimistic update @@ -380,13 +396,12 @@ describe(`Collection.subscribeChanges`, () => { ]) callback.mockReset() - await tx.isPersisted?.promise + await tx.isPersisted.promise // Verify synced update was emitted - expect(callback).toHaveBeenCalledTimes(2) - // This is called 3 times: - // 1. Set transaction state to persisting - // 2. The sync operation arrives and is applied to the state + expect(callback).toHaveBeenCalledTimes(1) + // This is called 1 time when the mutationFn call returns + // and the optimistic state is dropped and the synced state applied. callback.mockReset() // Update both items in optimistic and synced ways @@ -394,9 +409,12 @@ describe(`Collection.subscribeChanges`, () => { const optItem = collection.state.get(`optimisticItem`) let updateTx if (optItem) { - updateTx = collection.update(optItem, (draft) => { - draft.value = `updated optimistic value` - }) + updateTx = createTransaction({ mutationFn }) + updateTx.mutate(() => + collection.update(optItem, (draft) => { + draft.value = `updated optimistic value` + }) + ) } // We don't await here as the optimistic update is sync @@ -417,14 +435,12 @@ describe(`Collection.subscribeChanges`, () => { ]) callback.mockReset() - await updateTx?.isPersisted?.promise + await updateTx?.isPersisted.promise // Verify synced update was emitted - expect(callback).toHaveBeenCalledTimes(2) - - // This is called 3 times: - // 1. Set transaction state to persisting - // 2. The sync operation arrives and is applied to the state + expect(callback).toHaveBeenCalledTimes(1) + // This is called 1 time when the mutationFn call returns + // and the optimistic state is dropped and the synced state applied. callback.mockReset() // Then update the synced item with a synced update @@ -496,11 +512,11 @@ describe(`Collection.subscribeChanges`, () => { }) }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, }) + const mutationFn = async ({ transaction }) => { + emitter.emit(`sync`, transaction.mutations) + return Promise.resolve() + } // Subscribe to changes const unsubscribe = collection.subscribeChanges(callback) @@ -516,11 +532,14 @@ describe(`Collection.subscribeChanges`, () => { callback.mockReset() // Insert multiple items at once - collection.insert([ - { value: `batch1` }, - { value: `batch2` }, - { value: `batch3` }, - ]) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => + collection.insert([ + { value: `batch1` }, + { value: `batch2` }, + { value: `batch3` }, + ]) + ) // Verify only the 3 new items were emitted, not the existing ones expect(callback).toHaveBeenCalledTimes(1) @@ -537,10 +556,10 @@ describe(`Collection.subscribeChanges`, () => { await waitForChanges() // Verify synced update was emitted - expect(callback).toHaveBeenCalledTimes(2) - // This is called 3 times: - // 1. Set transaction state to persisting - // 2. The sync operation arrives and is applied to the state + expect(callback).toHaveBeenCalledTimes(1) + // This is called when the mutationFn returns and + // the optimistic state is dropped and synced state is + // applied. callback.mockReset() // Update one item only @@ -548,9 +567,12 @@ describe(`Collection.subscribeChanges`, () => { if (!itemToUpdate) { throw new Error(`Item not found`) } - collection.update(itemToUpdate, (draft) => { - draft.value = `updated value` - }) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => + collection.update(itemToUpdate, (draft) => { + draft.value = `updated value` + }) + ) // Verify only the updated item was emitted expect(callback).toHaveBeenCalledTimes(1) @@ -582,8 +604,8 @@ describe(`Collection.subscribeChanges`, () => { commit() }, }, - mutationFn: async () => {}, }) + const mutationFn = async () => {} // Subscribe to changes const unsubscribe = collection.subscribeChanges(callback) @@ -598,7 +620,8 @@ describe(`Collection.subscribeChanges`, () => { unsubscribe() // Insert an item - collection.insert({ value: `test value` }) + const tx = createTransaction({ mutationFn }) + tx.mutate(() => collection.insert({ value: `test value` })) // Callback shouldn't be called after unsubscribe expect(callback).not.toHaveBeenCalled() diff --git a/packages/optimistic/tests/collection.test.ts b/packages/optimistic/tests/collection.test.ts index 4e0d1b26e..3903ee250 100644 --- a/packages/optimistic/tests/collection.test.ts +++ b/packages/optimistic/tests/collection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest" import mitt from "mitt" import { z } from "zod" import { Collection, SchemaValidationError } from "../src/collection" +import { createTransaction } from "../src/transactions" import type { ChangeMessage, OptimisticChangeMessage, @@ -13,17 +14,7 @@ describe(`Collection`, () => { expect(() => new Collection()).toThrow(`Collection requires a sync config`) }) - it(`should allow creating a collection without a mutationFn`, () => { - // This should not throw an error - const collection = new Collection({ - id: `foo`, - sync: { sync: async () => {} }, - }) - - // Verify that the collection was created successfully - expect(collection).toBeInstanceOf(Collection) - }) - + // Test perhaps this throws if you try to use a collection operation outside of a transaction? // it(`should throw an error when trying to use mutation operations without a mutationFn`, async () => { // // Create a collection with sync but no mutationFn // const collection = new Collection<{ value: string }>({ @@ -107,7 +98,6 @@ describe(`Collection`, () => { expect(collection.state).toEqual(expectedData) }, }, - mutationFn: async () => {}, }) }) @@ -137,38 +127,42 @@ describe(`Collection`, () => { }) }, }, - mutationFn: ({ transaction }) => { - // Redact time-based and random fields - const redactedTransaction = { - ...transaction.toObject(), - mutations: { - ...transaction.mutations.map((mutation) => { - return { - ...mutation, - createdAt: `[REDACTED]`, - updatedAt: `[REDACTED]`, - mutationId: `[REDACTED]`, - } - }), - }, - } + }) - // Call the mock function with the redacted transaction - persistMock({ transaction: redactedTransaction }) + const mutationFn = ({ transaction }) => { + // Redact time-based and random fields + const redactedTransaction = { + ...transaction, + mutations: { + ...transaction.mutations.map((mutation) => { + return { + ...mutation, + createdAt: `[REDACTED]`, + updatedAt: `[REDACTED]`, + mutationId: `[REDACTED]`, + } + }), + }, + } - // Call the mock function with the transaction - syncMock({ transaction }) + // Call the mock function with the redacted transaction + persistMock({ transaction: redactedTransaction }) - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, - }) + // Call the mock function with the transaction + syncMock({ transaction }) + + emitter.emit(`sync`, transaction.mutations) + return Promise.resolve() + } // Test insert with auto-generated key const data = { value: `bar` } - const transaction = collection.insert(data) + // TODO create transaction manually with the above mutationFn & get assertions passing. + const tx = createTransaction({ mutationFn }) + tx.mutate(() => collection.insert(data)) + // @ts-expect-error possibly undefined is ok in test - const insertedKey = transaction.mutations[0].key + const insertedKey = tx.mutations[0].key // The merged value should immediately contain the new insert expect(collection.state).toEqual(new Map([[insertedKey, { value: `bar` }]])) @@ -176,7 +170,7 @@ describe(`Collection`, () => { // check there's a transaction in peristing state expect( // @ts-expect-error possibly undefined is ok in test - Array.from(collection.transactions.values())[0].mutations[0].changes + tx.mutations[0].changes ).toEqual({ value: `bar`, }) @@ -190,8 +184,6 @@ describe(`Collection`, () => { } expect(collection.optimisticOperations.state[0]).toEqual(insertOperation) - await transaction.isPersisted?.promise - // Check persist data (moved outside the persist callback) // @ts-expect-error possibly undefined is ok in test const persistData = persistMock.mock.calls[0][0] @@ -204,7 +196,7 @@ describe(`Collection`, () => { value: `bar`, }) - await transaction.isPersisted?.promise + await tx.isPersisted.promise // @ts-expect-error possibly undefined is ok in test const syncData = syncMock.mock.calls[0][0] @@ -219,43 +211,54 @@ describe(`Collection`, () => { // optimistic update is gone & synced data & comibned state are all updated. expect( // @ts-expect-error possibly undefined is ok in test - Array.from(collection.transactions.values())[0].state + Array.from(collection.transactions.state.values())[0].state ).toMatchInlineSnapshot(`"completed"`) + expect(collection.state).toEqual(new Map([[insertedKey, { value: `bar` }]])) expect( collection.optimisticOperations.state.filter((o) => o.isActive) ).toEqual([]) - expect(collection.state).toEqual(new Map([[insertedKey, { value: `bar` }]])) // Test insert with provided key - collection.insert({ value: `baz` }, { key: `custom-key` }) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => collection.insert({ value: `baz` }, { key: `custom-key` })) expect(collection.state.get(`custom-key`)).toEqual({ value: `baz` }) + await tx2.isPersisted.promise // Test bulk insert + const tx3 = createTransaction({ mutationFn }) const bulkData = [{ value: `item1` }, { value: `item2` }] - collection.insert(bulkData) + tx3.mutate(() => collection.insert(bulkData)) const keys = Array.from(collection.state.keys()) // @ts-expect-error possibly undefined is ok in test expect(collection.state.get(keys[2])).toEqual(bulkData[0]) // @ts-expect-error possibly undefined is ok in test expect(collection.state.get(keys[3])).toEqual(bulkData[1]) + await tx3.isPersisted.promise + const tx4 = createTransaction({ mutationFn }) // Test update with callback - collection.update([collection.state.get(insertedKey)!], (item) => { - // @ts-expect-error possibly undefined is ok in test - item[0].value = `bar2` - }) + tx4.mutate(() => + collection.update([collection.state.get(insertedKey)!], (item) => { + // @ts-expect-error possibly undefined is ok in test + item[0].value = `bar2` + }) + ) // The merged value should contain the update. expect(collection.state.get(insertedKey)).toEqual({ value: `bar2` }) + await tx4.isPersisted.promise + const tx5 = createTransaction({ mutationFn }) // Test update with config and callback - collection.update( - collection.state.get(insertedKey), - { metadata: { updated: true } }, - (item) => { - item.value = `bar3` - item.newProp = `new value` - } + tx5.mutate(() => + collection.update( + collection.state.get(insertedKey), + { metadata: { updated: true } }, + (item) => { + item.value = `bar3` + item.newProp = `new value` + } + ) ) // The merged value should contain the update @@ -264,6 +267,9 @@ describe(`Collection`, () => { newProp: `new value`, }) + await tx5.isPersisted.promise + + const tx6 = createTransaction({ mutationFn }) // Test bulk update const items = [ // @ts-expect-error possibly undefined is ok in test @@ -271,41 +277,54 @@ describe(`Collection`, () => { // @ts-expect-error possibly undefined is ok in test collection.state.get(keys[3])!, ] - collection.update(items, { metadata: { bulkUpdate: true } }, (drafts) => { - drafts.forEach((draft) => { - draft.value += `-updated` + tx6.mutate(() => + collection.update(items, { metadata: { bulkUpdate: true } }, (drafts) => { + drafts.forEach((draft) => { + draft.value += `-updated` + }) }) - }) + ) // Check bulk updates // @ts-expect-error possibly undefined is ok in test expect(collection.state.get(keys[2])).toEqual({ value: `item1-updated` }) // @ts-expect-error possibly undefined is ok in test expect(collection.state.get(keys[3])).toEqual({ value: `item2-updated` }) + await tx6.isPersisted.promise + const tx7 = createTransaction({ mutationFn }) const toBeDeleted = collection.state.get(insertedKey)! // Test delete single item - collection.delete(toBeDeleted) + tx7.mutate(() => collection.delete(toBeDeleted)) expect(collection.state.has(insertedKey)).toBe(false) expect(collection.objectKeyMap.has(toBeDeleted)).toBe(false) + await tx7.isPersisted.promise // Test delete with metadata - collection.delete(collection.state.get(`custom-key`), { - metadata: { reason: `test` }, - }) + const tx8 = createTransaction({ mutationFn }) + tx8.mutate(() => + collection.delete(collection.state.get(`custom-key`), { + metadata: { reason: `test` }, + }) + ) expect(collection.state.has(`custom-key`)).toBe(false) + await tx8.isPersisted.promise // Test bulk delete - collection.delete([ - // @ts-expect-error possibly undefined is ok in test - collection.state.get(keys[2])!, - // @ts-expect-error possibly undefined is ok in test - collection.state.get(keys[3])!, - ]) + const tx9 = createTransaction({ mutationFn }) + tx9.mutate(() => + collection.delete([ + // @ts-expect-error possibly undefined is ok in test + collection.state.get(keys[2])!, + // @ts-expect-error possibly undefined is ok in test + collection.state.get(keys[3])!, + ]) + ) // @ts-expect-error possibly undefined is ok in test expect(collection.state.has(keys[2])).toBe(false) // @ts-expect-error possibly undefined is ok in test expect(collection.state.has(keys[3])).toBe(false) + await tx9.isPersisted.promise }) it(`synced updates should be applied while there's an ongoing transaction`, async () => { @@ -331,29 +350,34 @@ describe(`Collection`, () => { }) }, }, - mutationFn: ({ transaction }) => { - // Sync something and check that that it isn't applied because - // we're still in the middle of persisting a transaction. - emitter.emit(`update`, [ - { key: `the-key`, type: `insert`, changes: { bar: `value` } }, - // This update is ignored because the optimistic update overrides it. - { key: `foo`, type: `update`, changes: { bar: `value2` } }, - ]) - expect(collection.state).toEqual(new Map([[`foo`, { value: `bar` }]])) - // Remove it so we don't have to assert against it below - emitter.emit(`update`, [{ key: `the-key`, type: `delete` }]) - - emitter.emit(`update`, transaction.mutations) - return Promise.resolve() - }, }) + const mutationFn = ({ transaction }) => { + // Sync something and check that that it isn't applied because + // we're still in the middle of persisting a transaction. + emitter.emit(`update`, [ + { key: `the-key`, type: `insert`, changes: { bar: `value` } }, + // This update is ignored because the optimistic update overrides it. + { key: `foo`, type: `update`, changes: { bar: `value2` } }, + ]) + expect(collection.state).toEqual(new Map([[`foo`, { value: `bar` }]])) + // Remove it so we don't have to assert against it below + emitter.emit(`update`, [{ key: `the-key`, type: `delete` }]) + + emitter.emit(`update`, transaction.mutations) + return Promise.resolve() + } + + const tx1 = createTransaction({ mutationFn }) + // insert - const transaction = collection.insert( - { - value: `bar`, - }, - { key: `foo` } + tx1.mutate(() => + collection.insert( + { + value: `bar`, + }, + { key: `foo` } + ) ) // The merged value should immediately contain the new insert @@ -362,7 +386,7 @@ describe(`Collection`, () => { // check there's a transaction in peristing state expect( // @ts-expect-error possibly undefined is ok in test - Array.from(collection.transactions.values())[0].mutations[0].changes + Array.from(collection.transactions.state.values())[0].mutations[0].changes ).toEqual({ value: `bar`, }) @@ -376,7 +400,7 @@ describe(`Collection`, () => { } expect(collection.optimisticOperations.state[0]).toEqual(insertOperation) - await transaction.isPersisted?.promise + await tx1.isPersisted.promise expect(collection.state).toEqual(new Map([[`foo`, { value: `bar` }]])) }) @@ -390,8 +414,8 @@ describe(`Collection`, () => { commit() }, }, - mutationFn: async () => {}, }) + const mutationFn = async () => {} // Insert multiple items with a sparse key array const items = [ @@ -401,13 +425,16 @@ describe(`Collection`, () => { { value: `item4` }, ] + const tx1 = createTransaction({ mutationFn }) // Only provide keys for first and third items - const transaction = collection.insert(items, { - key: [`key1`, undefined, `key3`], - }) + tx1.mutate(() => + collection.insert(items, { + key: [`key1`, undefined, `key3`], + }) + ) // Get all keys from the transaction - const keys = transaction.mutations.map((m) => m.key) + const keys = tx1.mutations.map((m) => m.key) // Verify explicit keys were used expect(keys[0]).toBe(`key1`) @@ -427,11 +454,14 @@ describe(`Collection`, () => { // @ts-expect-error possibly undefined is ok in test expect(collection.state.get(keys[3])).toEqual({ value: `item4` }) + const tx2 = createTransaction({ mutationFn }) // Test error case: more keys than items expect(() => { - collection.insert([{ value: `test` }], { - key: [`key1`, `key2`], - }) + tx2.mutate(() => + collection.insert([{ value: `test` }], { + key: [`key1`, `key2`], + }) + ) }).toThrow(`More keys provided than items to insert`) }) @@ -444,30 +474,38 @@ describe(`Collection`, () => { commit() }, }, - mutationFn: () => Promise.resolve(), }) + const mutationFn = () => Promise.resolve() + // Add an item to the collection const item = { name: `Test Item` } - collection.insert(item) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(item)) // Should throw when trying to delete an object not in the collection const notInCollection = { name: `Not In Collection` } - expect(() => collection.delete(notInCollection)).toThrow( + const tx2 = createTransaction({ mutationFn }) + expect(() => tx2.mutate(() => collection.delete(notInCollection))).toThrow( `Object not found in collection` ) // Should throw when trying to delete an invalid type // @ts-expect-error testing error handling with invalid type - expect(() => collection.delete(123)).toThrow( + const tx3 = createTransaction({ mutationFn }) + expect(() => tx3.mutate(() => collection.delete(123))).toThrow( `Invalid item type for delete - must be an object or string key` ) // Should not throw when deleting by string key (even if key doesn't exist) - expect(() => collection.delete(`non-existent-key`)).not.toThrow() + const tx4 = createTransaction({ mutationFn }) + expect(() => + tx4.mutate(() => collection.delete(`non-existent-key`)) + ).not.toThrow() // Should not throw when deleting an object that exists in the collection - expect(() => collection.delete(item)).not.toThrow() + const tx5 = createTransaction({ mutationFn }) + expect(() => tx5.mutate(() => collection.delete(item))).not.toThrow() }) }) @@ -489,9 +527,9 @@ describe(`Collection with schema validation`, () => { commit() }, }, - mutationFn: async () => {}, schema: userSchema, }) + const mutationFn = async () => {} // Valid data should work const validUser = { @@ -500,7 +538,8 @@ describe(`Collection with schema validation`, () => { email: `alice@example.com`, } - collection.insert(validUser, { key: `user1` }) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(validUser, { key: `user1` })) // Invalid data should throw SchemaValidationError const invalidUser = { @@ -510,7 +549,8 @@ describe(`Collection with schema validation`, () => { } try { - collection.insert(invalidUser, { key: `user2` }) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => collection.insert(invalidUser, { key: `user2` })) // Should not reach here expect(true).toBe(false) } catch (error) { @@ -532,15 +572,21 @@ describe(`Collection with schema validation`, () => { } // Partial updates should work with valid data - collection.update(collection.state.get(`user1`), (draft) => { - draft.age = 31 - }) + const tx3 = createTransaction({ mutationFn }) + tx3.mutate(() => + collection.update(collection.state.get(`user1`), (draft) => { + draft.age = 31 + }) + ) // Partial updates should fail with invalid data try { - collection.update(collection.state.get(`user1`), (draft) => { - draft.age = -1 - }) + const tx4 = createTransaction({ mutationFn }) + tx4.mutate(() => + collection.update(collection.state.get(`user1`), (draft) => { + draft.age = -1 + }) + ) // Should not reach here expect(true).toBe(false) } catch (error) { diff --git a/packages/optimistic/tests/object-key-map.test.ts b/packages/optimistic/tests/object-key-map.test.ts index 2fff637fa..016ddb933 100644 --- a/packages/optimistic/tests/object-key-map.test.ts +++ b/packages/optimistic/tests/object-key-map.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest" import { z } from "zod" import { Collection } from "../src/collection" +import { createTransaction } from "../src/transactions" describe(`Object-Key Association`, () => { let collection: Collection<{ name: string; age: number }> + let mutationFn: () => {} beforeEach(() => { const schema = z.object({ @@ -17,14 +19,15 @@ describe(`Object-Key Association`, () => { sync: { sync: async () => {}, }, - mutationFn: async () => {}, }) + mutationFn = async () => {} }) it(`should associate an object with its key after insert`, () => { // Insert an object const data = { name: `John`, age: 30 } - collection.insert(data, { key: `user1` }) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(data, { key: `user1` })) const item = collection.state.get(`user1`) @@ -32,9 +35,12 @@ describe(`Object-Key Association`, () => { expect(item).toBeDefined() // Update using the object reference - collection.update(item, (draft) => { - draft.age = 31 - }) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => + collection.update(item, (draft) => { + draft.age = 31 + }) + ) // Verify the update worked const updated = collection.state.get(`user1`) @@ -46,20 +52,26 @@ describe(`Object-Key Association`, () => { const johnData = { name: `John`, age: 30 } const janeData = { name: `Jane`, age: 28 } - collection.insert([johnData, janeData], { - key: [`user1`, `user2`], - }) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => + collection.insert([johnData, janeData], { + key: [`user1`, `user2`], + }) + ) const john = collection.state.get(`user1`) const jane = collection.state.get(`user2`) // Update multiple objects using their references - collection.update([john!, jane!], (items) => { - if (items[0] && items[1]) { - items[0].age = 31 - items[1].name = `Jane Doe` - } - }) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => + collection.update([john!, jane!], (items) => { + if (items[0] && items[1]) { + items[0].age = 31 + items[1].name = `Jane Doe` + } + }) + ) // Verify updates expect(collection.state.get(`user1`)).toEqual({ name: `John`, age: 31 }) @@ -69,12 +81,14 @@ describe(`Object-Key Association`, () => { it(`should handle delete with object reference`, () => { // Insert an object const data = { name: `John`, age: 30 } - collection.insert(data, { key: `user1` }) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(data, { key: `user1` })) const john = collection.state.get(`user1`) // Delete using the object reference - collection.delete(john) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => collection.delete(john)) // Verify deletion expect(collection.state.get(`user1`)).toBeUndefined() @@ -83,19 +97,26 @@ describe(`Object-Key Association`, () => { it(`should maintain object-key association after updates`, () => { // Insert an object const data = { name: `John`, age: 30 } - collection.insert(data, { key: `user1` }) + const tx1 = createTransaction({ mutationFn }) + tx1.mutate(() => collection.insert(data, { key: `user1` })) const john = collection.state.get(`user1`) // First update - collection.update(john, (item) => { - item.age = 31 - }) + const tx2 = createTransaction({ mutationFn }) + tx2.mutate(() => + collection.update(john, (item) => { + item.age = 31 + }) + ) // Second update using the same object reference - collection.update(john, (item) => { - item.name = `John Doe` - }) + const tx3 = createTransaction({ mutationFn }) + tx3.mutate(() => + collection.update(john, (item) => { + item.name = `John Doe` + }) + ) // Verify both updates worked const updated = collection.state.get(`user1`) @@ -106,10 +127,13 @@ describe(`Object-Key Association`, () => { const unknownObject = { name: `Unknown`, age: 25 } // Try to update using an object that wasn't inserted + const tx = createTransaction({ mutationFn }) expect(() => { - collection.update(unknownObject, (item) => { - item.age = 26 - }) + tx.mutate(() => + collection.update(unknownObject, (item) => { + item.age = 26 + }) + ) }).toThrow() }) @@ -119,9 +143,12 @@ describe(`Object-Key Association`, () => { const janeData = { name: `Jane`, age: 28 } const bobData = { name: `Bob`, age: 35 } - collection.insert([johnData, janeData, bobData], { - key: [`user1`, `user2`, `user3`], - }) + const tx = createTransaction({ mutationFn }) + tx.mutate(() => + collection.insert([johnData, janeData, bobData], { + key: [`user1`, `user2`, `user3`], + }) + ) const john = collection.state.get(`user1`) const jane = collection.state.get(`user2`) diff --git a/packages/optimistic/tests/transactions.test.ts b/packages/optimistic/tests/transactions.test.ts new file mode 100644 index 000000000..250d5ee14 --- /dev/null +++ b/packages/optimistic/tests/transactions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest" +import { createTransaction } from "../src/transactions" + +describe(`Transactions`, () => { + it(`calling createTransaction creates a transaction`, () => { + const transaction = createTransaction({ + mutationFn: async () => Promise.resolve(), + }) + + expect(transaction.commit).toBeTruthy() + }) + it(`goes straight to completed if you call commit w/o any mutations`, () => { + const transaction = createTransaction({ + mutationFn: async () => Promise.resolve(), + }) + + transaction.commit() + expect(transaction.state).toBe(`completed`) + }) + it(`thows an error if you don't pass in mutationFn`, () => { + // eslint-disable-next-line deliberate + expect(() => createTransaction({})).toThrowError( + `mutationFn is required when creating a transaction` + ) + }) + it(`thows an error if call mutate or commit or rollback when it's completed`, () => { + const transaction = createTransaction({ + mutationFn: async () => Promise.resolve(), + }) + + transaction.commit() + + expect(() => transaction.commit()).toThrowError( + `You can no longer call .commit() as the transaction is no longer pending` + ) + expect(() => transaction.rollback()).toThrowError( + `You can no longer call .rollback() as the transaction is already completed` + ) + expect(() => transaction.mutate(() => {})).toThrowError( + `You can no longer call .mutate() as the transaction is no longer pending` + ) + }) +}) diff --git a/packages/react-optimistic/package.json b/packages/react-optimistic/package.json index 8599a9635..4fa8d6644 100644 --- a/packages/react-optimistic/package.json +++ b/packages/react-optimistic/package.json @@ -57,7 +57,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "test": "npx vitest --run" + "test": "npx vitest --run", + "lint": "eslint . --fix" }, "sideEffects": false, "type": "module", From 1387613622c4fba9bbe6e8595fdb2f1f92c63d7a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 6 May 2025 15:52:22 -0600 Subject: [PATCH 04/34] fix typs --- packages/optimistic/src/TransactionManager.ts | 285 ------------- packages/optimistic/src/collection.ts | 12 +- packages/optimistic/src/index.ts | 2 +- packages/optimistic/src/transactions.ts | 39 +- packages/optimistic/src/types.ts | 6 +- .../tests/TransactionManager.test.ts | 381 ------------------ .../optimistic/tests/transactions.test.ts | 4 +- 7 files changed, 30 insertions(+), 699 deletions(-) delete mode 100644 packages/optimistic/src/TransactionManager.ts delete mode 100644 packages/optimistic/tests/TransactionManager.test.ts diff --git a/packages/optimistic/src/TransactionManager.ts b/packages/optimistic/src/TransactionManager.ts deleted file mode 100644 index 2fca66a3a..000000000 --- a/packages/optimistic/src/TransactionManager.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { Store } from "@tanstack/store" -import { SortedMap } from "./SortedMap" -import { createDeferred } from "./deferred" -import type { Collection } from "./collection" -import type { PendingMutation, Transaction, TransactionState } from "./types" - -// Singleton instance of TransactionManager with type map - -const transactionManagerInstances = new Map>() - -/** - * Get the global TransactionManager instance for a specific type - * Creates a new instance if one doesn't exist for that type - * - * @param collection - The collection this manager is associated with - * @returns The TransactionManager instance - */ -export function getTransactionManager< - T extends object = Record, ->(collection?: Collection): TransactionManager { - if (!collection) { - throw new Error( - `TransactionManager not initialized. You must provide its collection on the first call.` - ) - } - - if (!transactionManagerInstances.has(collection.id)) { - transactionManagerInstances.set( - collection.id, - new TransactionManager(collection) - ) - } - return transactionManagerInstances.get(collection.id) as TransactionManager -} - -export class TransactionManager> { - private collection: Collection - public transactions: Store> - - /** - * Creates a new TransactionManager instance - * - * @param collection - The collection this manager is associated with - */ - constructor(collection: Collection) { - this.collection = collection - // Initialize store with SortedMap that sorts by createdAt - this.transactions = new Store( - new SortedMap( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime() - ) - ) - } - - /** - * Retrieves a transaction by its ID - * - * @param id - The unique identifier of the transaction - * @returns The transaction if found, undefined otherwise - */ - getTransaction(id: string): Transaction | undefined { - const transaction = this.transactions.state.get(id) - - return transaction - } - - private setTransaction(transaction: Transaction): void { - this.transactions.setState((sortedMap) => { - sortedMap.set(transaction.id, transaction) - return sortedMap - }) - - this.collection.commitPendingTransactions() - } - - /** - * Create a live transaction reference that always returns the latest values - * @param id Transaction ID - * @returns A proxy that always gets the latest transaction values - */ - createLiveTransactionReference(id: string): Transaction { - const self: TransactionManager = this - return new Proxy( - { - // Implement the toObject method directly on the proxy target - toObject() { - const transaction = self.getTransaction(id) - if (!transaction) { - throw new Error(`Transaction with id ${id} not found`) - } - - // Create a shallow copy of the transaction without the toObject method - - const { toObject, ...transactionData } = transaction - return { ...transactionData } - }, - } as Transaction, - { - get(target, prop) { - // If the property is toObject, return the method from the target - if (prop === `toObject`) { - return target.toObject - } - - // Otherwise, get the latest transaction data - const latest = self.getTransaction(id) - if (!latest) { - throw new Error(`Transaction with id ${id} not found`) - } - return latest[prop as keyof Transaction] - }, - set() { - // We don't allow direct setting of properties on the transaction - console.warn( - `Direct modification of transaction properties is not allowed. Use setTransactionState if updating the state` - ) - return true - }, - } - ) - } - - /** - * Applies mutations to the current transaction. A given transaction accumulates mutations - * within a single event loop. - * - * @param mutations - Array of pending mutations to apply - * @returns A live reference to the created or updated transaction - */ - applyTransaction(mutations: Array): Transaction { - // See if there's an existing transaction with overlapping queued mutation. - const mutationKeys = mutations.map((m) => m.key) - let transaction: Transaction | undefined = Array.from( - this.transactions.state.values() - ).filter( - (t) => - t.state === `pending` && - t.mutations.some((m) => mutationKeys.includes(m.key)) - )[0] - - // If there's a transaction, overwrite matching mutations. - if (transaction) { - for (const newMutation of mutations) { - const existingIndex = transaction.mutations.findIndex( - (m) => m.key === newMutation.key - ) - - if (existingIndex >= 0) { - // Replace existing mutation - // TODO this won't work for cases where different mutations modify different keys - transaction.mutations[existingIndex] = newMutation - } else { - // Insert new mutation - transaction.mutations.push(newMutation) - } - } - } else { - // Create a new transaction if none exists - transaction = { - id: crypto.randomUUID(), - state: `pending`, - createdAt: new Date(), - updatedAt: new Date(), - mutations, - metadata: {}, - isPersisted: createDeferred(), - } as Transaction - } - - this.setTransaction(transaction) - - // Start processing in the next event loop tick. - setTimeout(() => { - this.processTransaction(transaction.id) - }, 0) - - // Return a live reference to the transaction - return this.createLiveTransactionReference(transaction.id) - } - - /** - * Process a transaction through the mutation function - * - * @param transactionId - The ID of the transaction to process - * @private - */ - private processTransaction(transactionId: string): void { - const transaction = this.getTransaction(transactionId) - if (!transaction) return - - // Throw an error if no mutation function is provided - if (!this.collection.config.mutationFn) { - throw new Error( - `No mutation function provided for transaction ${transactionId}` - ) - } - - // Set transaction state to persisting - this.setTransactionState(transactionId, `persisting`) - - // Create a live reference to the transaction that always returns the latest state - const transactionRef = this.createLiveTransactionReference(transactionId) - - // Call the mutation function - this.collection.config - .mutationFn({ - transaction: transactionRef, - collection: this.collection, - }) - .then(() => { - const tx = this.getTransaction(transactionId) - if (!tx) return - - // Mark as persisted - tx.isPersisted.resolve(true) - this.setTransactionState(transactionId, `completed`) - }) - .catch((error) => { - const tx = this.getTransaction(transactionId) - if (!tx) return - - // Update transaction with error information - tx.error = { - message: error instanceof Error ? error.message : String(error), - error: error instanceof Error ? error : new Error(String(error)), - } - - // Reject the promise - tx.isPersisted.reject(tx.error.error) - - // Set transaction state to failed - this.setTransactionState(transactionId, `failed`) - }) - } - - /** - * Updates the state of a transaction - * - * @param id - The unique identifier of the transaction - * @param newState - The new state to set - * @throws Error if the transaction is not found - */ - setTransactionState(id: string, newState: TransactionState): void { - const transaction = this.getTransaction(id) - if (!transaction) { - throw new Error(`Transaction ${id} not found`) - } - - // Force a small delay to ensure updatedAt is different - const updatedTransaction: Transaction = { - ...transaction, - state: newState, - updatedAt: new Date(Date.now() + 1), - } - - this.setTransaction(updatedTransaction) - } - - /** - * Gets all active transactions (those not in completed or failed state) - * - * @returns Array of active transactions - */ - getActiveTransactions(): Array { - return Array.from(this.transactions.state.values()).filter( - (tx) => tx.state !== `completed` && tx.state !== `failed` - ) - } - - /** - * Checks if two sets of mutations have overlapping keys - * - * @param mutations1 - First set of mutations - * @param mutations2 - Second set of mutations - * @returns True if there are overlapping mutations, false otherwise - */ - hasOverlappingMutations( - mutations1: Array, - mutations2: Array - ): boolean { - const ids1 = new Set(mutations1.map((m) => m.original.id)) - const ids2 = new Set(mutations2.map((m) => m.original.id)) - return Array.from(ids1).some((id) => ids2.has(id)) - } -} diff --git a/packages/optimistic/src/collection.ts b/packages/optimistic/src/collection.ts index 3c98cb408..c754ed132 100644 --- a/packages/optimistic/src/collection.ts +++ b/packages/optimistic/src/collection.ts @@ -548,7 +548,7 @@ export class Collection> { } const items = Array.isArray(data) ? data : [data] - const mutations: Array = [] + const mutations: Array> = [] // Handle keys - convert to array if string, or generate if not provided let keys: Array @@ -570,7 +570,7 @@ export class Collection> { const validatedData = this.validateData(item, `insert`) const key = keys[index]! - const mutation: PendingMutation = { + const mutation: PendingMutation = { mutationId: crypto.randomUUID(), original: {}, modified: validatedData as Record, @@ -686,7 +686,7 @@ export class Collection> { } // Create mutations for each object that has changes - const mutations: Array = keys + const mutations: Array> = keys .map((key, index) => { const changes = changesArray[index] @@ -718,7 +718,7 @@ export class Collection> { collection: this, } }) - .filter(Boolean) as Array + .filter(Boolean) as Array> // If no changes were made, return early if (mutations.length === 0) { @@ -761,7 +761,7 @@ export class Collection> { } const itemsArray = Array.isArray(items) ? items : [items] - const mutations: Array = [] + const mutations: Array> = [] for (const item of itemsArray) { let key: string @@ -781,7 +781,7 @@ export class Collection> { ) } - const mutation: PendingMutation = { + const mutation: PendingMutation = { mutationId: crypto.randomUUID(), original: (this.state.get(key) || {}) as Record, modified: { _deleted: true }, diff --git a/packages/optimistic/src/index.ts b/packages/optimistic/src/index.ts index 6977607a1..9d059ef8c 100644 --- a/packages/optimistic/src/index.ts +++ b/packages/optimistic/src/index.ts @@ -1,7 +1,7 @@ // Re-export all public APIs export * from "./collection" export * from "./SortedMap" -export * from "./TransactionManager" +export * from "./transactions" export * from "./types" export * from "./errors" export * from "./utils" diff --git a/packages/optimistic/src/transactions.ts b/packages/optimistic/src/transactions.ts index 7a507723a..b1e293eb1 100644 --- a/packages/optimistic/src/transactions.ts +++ b/packages/optimistic/src/transactions.ts @@ -61,7 +61,7 @@ export class Transaction { public id: string public state: TransactionState public mutationFn - public mutations: Array + public mutations: Array> public isPersisted: Deferred public autoCommit: boolean public createdAt: Date @@ -102,6 +102,8 @@ export class Transaction { if (this.state === `completed`) { throw `You can no longer call .rollback() as the transaction is already completed` } + + return this } commit(): Transaction { @@ -110,32 +112,27 @@ export class Transaction { } this.setState(`persisting`) - // const hasCalled = new Set() - // this.mutations.forEach((mutation) => { - // if (!hasCalled.has(mutation.collection.id)) { - // mutation.collection.transactions.setState((state) => state) - // hasCalled.add(mutation.collection.id) - // } - // }) if (this.mutations.length === 0) { this.setState(`completed`) } // Run mutationFn - this.mutationFn({ transaction: this }).then(() => { - this.setState(`completed`) - const hasCalled = new Set() - this.mutations.forEach((mutation) => { - if (!hasCalled.has(mutation.collection.id)) { - mutation.collection.transactions.setState((state) => state) - mutation.collection.commitPendingTransactions() - hasCalled.add(mutation.collection.id) - } - }) - - this.isPersisted.resolve(this) - }) + this.mutationFn({ transaction: this, mutations: this.mutations }).then( + () => { + this.setState(`completed`) + const hasCalled = new Set() + this.mutations.forEach((mutation) => { + if (!hasCalled.has(mutation.collection.id)) { + mutation.collection.transactions.setState((state) => state) + mutation.collection.commitPendingTransactions() + hasCalled.add(mutation.collection.id) + } + }) + + this.isPersisted.resolve(this) + } + ) return this } diff --git a/packages/optimistic/src/types.ts b/packages/optimistic/src/types.ts index 6c29c43d6..65667616b 100644 --- a/packages/optimistic/src/types.ts +++ b/packages/optimistic/src/types.ts @@ -8,7 +8,7 @@ export type TransactionState = `pending` | `persisting` | `completed` | `failed` * Represents a pending mutation within a transaction * Contains information about the original and modified data, as well as metadata */ -export interface PendingMutation { +export interface PendingMutation> { mutationId: string original: Record modified: Record @@ -19,7 +19,7 @@ export interface PendingMutation { syncMetadata: Record createdAt: Date updatedAt: Date - collection: Collection + collection: Collection } /** @@ -31,7 +31,7 @@ export interface TransactionConfig { /* If the transaction should autocommit after a mutate call or should commit be called explicitly */ autoCommit?: boolean mutationFn: (params: { - mutations: Array + mutations: Array> transaction: Transaction }) => Promise /** Custom metadata to associate with the transaction */ diff --git a/packages/optimistic/tests/TransactionManager.test.ts b/packages/optimistic/tests/TransactionManager.test.ts deleted file mode 100644 index cf1e6de7f..000000000 --- a/packages/optimistic/tests/TransactionManager.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest" -import { TransactionManager } from "../src/TransactionManager" -import { Collection } from "../src/collection" -import type { PendingMutation } from "../src/types" - -describe(`TransactionManager`, () => { - let collection: Collection - let manager: TransactionManager - - beforeEach(() => { - collection = new Collection({ - id: `foo`, - sync: { - sync: () => {}, - }, - mutationFn: async () => { - await new Promise((resolve) => setTimeout(resolve, 1)) - }, - }) - manager = new TransactionManager(collection) - }) - - const createMockMutation = (id: string): PendingMutation => ({ - mutationId: id, - original: { id, value: `original` }, - modified: { id, value: `modified` }, - changes: { value: `modified` }, - type: `insert`, - key: id, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - syncMetadata: {}, - }) - - describe(`Basic Transaction Management`, () => { - it(`should create a transaction in pending state`, () => { - const mutations = [createMockMutation(`test-1`)] - const transaction = manager.applyTransaction(mutations) - - expect(transaction.id).toBeDefined() - expect(transaction.state).toBe(`pending`) - expect(transaction.mutations).toEqual(mutations) - }) - - it(`should update transaction state`, () => { - const mutations = [createMockMutation(`test-2`)] - const transaction = manager.applyTransaction(mutations) - - // Add a small delay to ensure timestamps are different - const beforeUpdate = transaction.updatedAt - manager.setTransactionState(transaction.id, `pending`) - - const updated = manager.getTransaction(transaction.id) - expect(updated?.state).toBe(`pending`) - expect(updated?.updatedAt.getTime()).toBeGreaterThan( - beforeUpdate.getTime() - ) - }) - - it(`should throw when updating non-existent transaction`, () => { - expect(() => - manager.setTransactionState(`non-existent`, `completed`) - ).toThrow(`Transaction non-existent not found`) - }) - }) - - describe(`Ordered vs Parallel Transactions`, () => { - it(`should not queue transactions`, () => { - // Create multiple parallel transactions modifying same object - const tx1 = manager.applyTransaction([createMockMutation(`object-1`)]) - const tx2 = manager.applyTransaction([createMockMutation(`object-1`)]) - const tx3 = manager.applyTransaction([createMockMutation(`object-1`)]) - - // All should be in pending state and not queued - expect(tx1.state).toBe(`pending`) - expect(tx2.state).toBe(`pending`) - expect(tx3.state).toBe(`pending`) - }) - }) - - describe(`Transaction Ordering`, () => { - it(`should maintain transactions sorted by createdAt`, async () => { - // Create transactions with different timestamps - const now = Date.now() - const timestamps = [now, now - 1000, now - 2000] - - // Create transactions in reverse chronological order - await Promise.all( - timestamps.map((timestamp, i) => { - const tx = manager.applyTransaction([ - createMockMutation(`test-${i + 1}`), - ]) - // Force the createdAt time - const updatedTx = { - ...tx.toObject(), - createdAt: new Date(timestamp), - } - - manager.transactions.setState((sortedMap) => { - // @ts-expect-error this is fine for a test - sortedMap.set(updatedTx.id, updatedTx) - return sortedMap - }) - return updatedTx - }) - ) - - // Verify transactions are returned in chronological order (oldest first) - const sortedTransactions = Array.from(manager.transactions.state.values()) - expect(sortedTransactions[0]?.createdAt.getTime()).toBe(timestamps[2]) // Oldest - expect(sortedTransactions[1]?.createdAt.getTime()).toBe(timestamps[1]) - expect(sortedTransactions[2]?.createdAt.getTime()).toBe(timestamps[0]) // Newest - }) - - it(`should create a new transaction when no existing transactions with overlapping keys exist`, () => { - // Create a new transaction - const mutations = [createMockMutation(`test-apply-1`)] - const transaction = manager.applyTransaction(mutations) - - // Verify transaction was created with the expected properties - expect(transaction.id).toBeDefined() - expect(transaction.state).toBe(`pending`) - expect(transaction.mutations).toEqual(mutations) - }) - - it(`should overwrite mutations for the same key in existing pending transactions`, () => { - // Create first transaction with a mutation - const originalMutation = { - ...createMockMutation(`test-apply-2`), - modified: { id: `test-apply-2`, value: `original-value` }, - changes: { value: `original-value` }, - } - - manager.applyTransaction([originalMutation]) - - // Create second transaction with a mutation - this should be queued behind the first. - const tx1 = manager.applyTransaction([originalMutation]) - expect(tx1.mutations[0]?.modified.value).toBe(`original-value`) - - // Apply a new transaction with a mutation for the same key but different value. - const newMutation = { - ...createMockMutation(`test-apply-2`), - modified: { id: `test-apply-2`, value: `updated-value` }, - changes: { value: `updated-value` }, - } - - const tx2 = manager.applyTransaction([newMutation]) - - // Should reuse the same transaction ID - expect(tx2.id).toBe(tx1.id) - - // Should have updated the mutation - expect(tx2.mutations.length).toBe(1) - expect(tx2.mutations[0]?.modified.value).toBe(`updated-value`) - expect(tx2.mutations[0]?.changes.value).toBe(`updated-value`) - }) - - it(`should add new mutations while preserving existing ones for different keys`, () => { - // Create first transaction with a mutation - const mutation1 = createMockMutation(`test-apply-3a`) - const tx1 = manager.applyTransaction([mutation1]) - - // Apply a new transaction with a mutation for a different key - const mutation2 = createMockMutation(`test-apply-3b`) - const tx2 = manager.applyTransaction([mutation2]) - - // Should create a new transaction since keys don't overlap - expect(tx2.id).not.toBe(tx1.id) - expect(tx2.mutations.length).toBe(1) - expect(tx2.mutations[0]?.key).toBe(`test-apply-3b`) - }) - - it(`should handle multiple transactions with overlapping keys`, () => { - // Create first transaction with mutations for keys A and B - const mutationA1 = { - ...createMockMutation(`key-A`), - modified: { id: `key-A`, value: `A-original` }, - changes: { value: `A-original` }, - } - - const mutationB1 = { - ...createMockMutation(`key-B`), - modified: { id: `key-B`, value: `B-original` }, - changes: { value: `B-original` }, - } - - // Apply an initial one so the second is queued behind it. - manager.applyTransaction([mutationA1, mutationB1]) - const tx1 = manager.applyTransaction([mutationA1, mutationB1]) - - // Create second transaction with mutations for keys B and C - const mutationB2 = { - ...createMockMutation(`key-B`), - modified: { id: `key-B`, value: `B-updated` }, - changes: { value: `B-updated` }, - } - - const mutationC1 = { - ...createMockMutation(`key-C`), - modified: { id: `key-C`, value: `C-original` }, - changes: { value: `C-original` }, - } - - // Apply the new transaction - const tx2 = manager.applyTransaction([mutationB2, mutationC1]) - - // Should update tx1 since it has an overlapping key (B) - expect(tx2.id).toBe(tx1.id) - - // Should have 3 mutations now (A, B-updated, C) - expect(tx2.mutations.length).toBe(3) - - // Find each mutation by key - const mutationA = tx2.mutations.find((m) => m.key === `key-A`) - const mutationB = tx2.mutations.find((m) => m.key === `key-B`) - const mutationC = tx2.mutations.find((m) => m.key === `key-C`) - - // Verify A is unchanged - expect(mutationA).toBeDefined() - expect(mutationA?.modified.value).toBe(`A-original`) - - // Verify B is updated - expect(mutationB).toBeDefined() - expect(mutationB?.modified.value).toBe(`B-updated`) - - // Verify C is added - expect(mutationC).toBeDefined() - expect(mutationC?.modified.value).toBe(`C-original`) - }) - - it(`should handle the case where mutations don't overlap at all`, () => { - // Create three transactions with non-overlapping mutations - const tx1 = manager.applyTransaction([createMockMutation(`key-1`)]) - const tx2 = manager.applyTransaction([createMockMutation(`key-2`)]) - - // Apply a transaction with a new non-overlapping key - const tx3 = manager.applyTransaction([createMockMutation(`key-3`)]) - - // Should be a new transaction - expect(tx3.id).not.toBe(tx1.id) - expect(tx3.id).not.toBe(tx2.id) - expect(tx3.mutations.length).toBe(1) - expect(tx3.mutations[0]?.key).toBe(`key-3`) - - // Original transactions should be unchanged - const updatedTx1 = manager.getTransaction(tx1.id) - const updatedTx2 = manager.getTransaction(tx2.id) - - expect(updatedTx1?.mutations.length).toBe(1) - expect(updatedTx1?.mutations[0]?.key).toBe(`key-1`) - - expect(updatedTx2?.mutations.length).toBe(1) - expect(updatedTx2?.mutations[0]?.key).toBe(`key-2`) - }) - - it(`should only consider active transactions for applying updates`, () => { - // Create a transaction and mark it as completed - const mutation1 = createMockMutation(`completed-key`) - const tx1 = manager.applyTransaction([mutation1]) - manager.setTransactionState(tx1.id, `completed`) - - // Apply a new transaction with the same key - const mutation2 = { - ...createMockMutation(`completed-key`), - modified: { id: `completed-key`, value: `new-value` }, - changes: { value: `new-value` }, - } - - const tx2 = manager.applyTransaction([mutation2]) - - // Should create a new transaction since the existing one is completed - expect(tx2.id).not.toBe(tx1.id) - expect(tx2.mutations.length).toBe(1) - expect(tx2.mutations[0]?.modified.value).toBe(`new-value`) - }) - }) - - describe(`Error Handling`, () => { - it(`should reject isPersisted persist fails`, async () => { - // Create a collection with a persist function that throws an error - const errorCollection = new Collection({ - id: `foo`, - sync: { - sync: () => {}, - }, - // eslint-disable-next-line @typescript-eslint/require-await - mutationFn: async () => { - throw new Error(`Persist error`) - }, - }) - const errorManager = new TransactionManager(errorCollection) - - // Apply a transaction - const mutations = [createMockMutation(`error-test-5`)] - const transaction = errorManager.applyTransaction(mutations) - - await expect(transaction.isPersisted.promise).rejects.toThrow( - `Persist error` - ) - - // Verify the transaction state - expect(transaction.state).toBe(`failed`) - expect(transaction.error?.message).toBe(`Persist error`) - }) - - it(`should handle non-Error objects thrown during persist`, async () => { - // Create a collection with a persist function that throws a non-Error object - const nonErrorCollection = new Collection({ - id: `non-error-object`, - sync: { - sync: () => {}, - }, - // eslint-disable-next-line @typescript-eslint/require-await - mutationFn: async () => { - // Throw a string instead of an Error object - throw `String error message` - }, - }) - const nonErrorManager = new TransactionManager(nonErrorCollection) - - // Apply a transaction - const mutations = [createMockMutation(`non-error-test`)] - const transaction = nonErrorManager.applyTransaction(mutations) - - // The promise should reject with a converted Error - await expect(transaction.isPersisted.promise).rejects.toThrow( - `String error message` - ) - transaction.isPersisted.promise.catch(() => {}) - - // Verify the transaction state and error handling - expect(transaction.state).toBe(`failed`) - expect(transaction.error?.message).toBe(`String error message`) - expect(transaction.error?.error).toBeInstanceOf(Error) - }) - - // TODO figure out why this isn't working. - // it(`should handle non-Error objects thrown during awaitSync`, async () => { - // // Create a collection with an awaitSync function that throws a non-Error object - // const nonErrorSyncCollection = new Collection({ - // id: `non-error-sync-object`, - // sync: { - // sync: () => {}, - // }, - // mutationFn: { - // persist: () => { - // return Promise.resolve({ success: true }) - // }, - // awaitSync: () => { - // // Throw a number instead of an Error object - // throw 123 - // }, - // }, - // }) - // const nonErrorSyncManager = new TransactionManager( - // nonErrorSyncCollection - // ) - // - // // Apply a transaction - // const mutations = [createMockMutation(`non-error-sync-test`)] - // const transaction = nonErrorSyncManager.applyTransaction( - // mutations, - // - // ) - // - // // The promise should reject with a converted Error - // // await expect(transaction.isPersisted?.promise).rejects.toThrow(`123`) - // try { - // await transaction.isPersisted?.promise - // } catch (e) { - // console.log(e) - // } - // - // // Verify the transaction state and error handling - // expect(transaction.state).toBe(`failed`) - // expect(transaction.error?.message).toBe(`123`) - // expect(transaction.error?.error).toBeInstanceOf(Error) - // }) - }) -}) diff --git a/packages/optimistic/tests/transactions.test.ts b/packages/optimistic/tests/transactions.test.ts index 250d5ee14..028aa8d2c 100644 --- a/packages/optimistic/tests/transactions.test.ts +++ b/packages/optimistic/tests/transactions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest" +import { describe, expect, it } from "vitest" import { createTransaction } from "../src/transactions" describe(`Transactions`, () => { @@ -18,7 +18,7 @@ describe(`Transactions`, () => { expect(transaction.state).toBe(`completed`) }) it(`thows an error if you don't pass in mutationFn`, () => { - // eslint-disable-next-line deliberate + // @ts-expect-error missing argument on purpose expect(() => createTransaction({})).toThrowError( `mutationFn is required when creating a transaction` ) From f7e2b31a037e866beb5acd0f3a812bfccdc6d3d6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 6 May 2025 16:11:28 -0600 Subject: [PATCH 05/34] fix react-optimistic --- examples/react/todo/package.json | 2 +- packages/optimistic/src/transactions.ts | 9 +- .../react-optimistic/src/useCollection.ts | 16 +- .../react-optimistic/tests/electric.test.ts | 36 ++--- .../tests/useCollection.test.tsx | 143 ++++++++++-------- 5 files changed, 111 insertions(+), 95 deletions(-) diff --git a/examples/react/todo/package.json b/examples/react/todo/package.json index cfec16b6a..84eb537b5 100644 --- a/examples/react/todo/package.json +++ b/examples/react/todo/package.json @@ -45,7 +45,7 @@ "db:push": "tsx scripts/migrate.ts", "db:studio": "drizzle-kit studio", "dev": "docker-compose up -d && concurrently \"pnpm api:dev\" \"vite\"", - "lint": "eslint .", + "lint": "eslint . --fix", "preview": "vite preview" }, "type": "module" diff --git a/packages/optimistic/src/transactions.ts b/packages/optimistic/src/transactions.ts index b1e293eb1..03f3f4fcb 100644 --- a/packages/optimistic/src/transactions.ts +++ b/packages/optimistic/src/transactions.ts @@ -65,7 +65,13 @@ export class Transaction { public isPersisted: Deferred public autoCommit: boolean public createdAt: Date - constructor({ id, mutationFn, autoCommit = true }: TransactionConfig) { + public metadata: Record + constructor({ + id, + mutationFn, + autoCommit = true, + metadata = {}, + }: TransactionConfig) { this.id = id! this.mutationFn = mutationFn this.state = `pending` @@ -73,6 +79,7 @@ export class Transaction { this.isPersisted = createDeferred() this.autoCommit = autoCommit this.createdAt = new Date() + this.metadata = metadata } setState(newState: TransactionState) { diff --git a/packages/react-optimistic/src/useCollection.ts b/packages/react-optimistic/src/useCollection.ts index ee308713f..27071f78b 100644 --- a/packages/react-optimistic/src/useCollection.ts +++ b/packages/react-optimistic/src/useCollection.ts @@ -40,15 +40,8 @@ export function useCollections() { snapshotCache = null // Invalidate cache when state changes callback() }) - const transactionsUnsub = - collection.transactionManager.transactions.subscribe(() => { - snapshotCache = null // Invalidate cache when transactions change - callback() - }) - return () => { - derivedStateUnsub() - transactionsUnsub() - } + + return derivedStateUnsub }) return () => { @@ -72,7 +65,7 @@ export function useCollections() { for (const [id, collection] of collectionsStore.state) { snapshot.set(id, { state: collection.derivedState.state, - transactions: collection.transactionManager.transactions.state, + transactions: collection.transactions.state, }) } snapshotCache = snapshot @@ -94,7 +87,7 @@ export function useCollections() { for (const [id, collection] of collectionsStore.state) { snapshot.set(id, { state: collection.derivedState.state, - transactions: collection.transactionManager.transactions.state, + transactions: collection.transactions.state, }) } snapshotCache = snapshot @@ -308,7 +301,6 @@ export function useCollection( new Collection({ id: config.id, sync: config.sync, - mutationFn: config.mutationFn, schema: config.schema, }) ) diff --git a/packages/react-optimistic/tests/electric.test.ts b/packages/react-optimistic/tests/electric.test.ts index f71829460..8a3bb1225 100644 --- a/packages/react-optimistic/tests/electric.test.ts +++ b/packages/react-optimistic/tests/electric.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -import { Collection } from "@tanstack/optimistic" +import { Collection, createTransaction } from "@tanstack/optimistic" import { createElectricSync } from "../src/electric" import type { PendingMutation, Transaction } from "@tanstack/optimistic" import type { Message, Row } from "@electric-sql/client" @@ -48,7 +48,6 @@ describe(`Electric Integration`, () => { collection = new Collection({ id: `test`, sync: electricSync, - mutationFn: vi.fn().mockResolvedValue(undefined), }) }) @@ -362,17 +361,17 @@ describe(`Electric Integration`, () => { const testCollection = new Collection({ id: `ofo`, sync: electricSync, - mutationFn: testMutationFn, }) - let transaction = testCollection.insert( - { id: 1, name: `Test item 1` }, - { key: `item1` } + const tx1 = createTransaction({ mutationFn: testMutationFn }) + + let transaction = tx1.mutate(() => + testCollection.insert({ id: 1, name: `Test item 1` }, { key: `item1` }) ) - await transaction.isPersisted?.promise + await transaction.isPersisted.promise - transaction = testCollection.transactions.get(transaction.id)! + transaction = testCollection.transactions.state.get(transaction.id)! // Verify the mutation function was called correctly expect(testMutationFn).toHaveBeenCalledTimes(1) @@ -414,32 +413,27 @@ describe(`Electric Integration`, () => { it(`Transaction proxy with toObject method works correctly`, () => { // Create a collection with a simple mutation function - const testCollection = new Collection({ + new Collection({ id: `foo`, sync: createElectricSync({ url: `foo` }, { primaryKey: [`id`] }), // Add primary key - mutationFn: async () => {}, }) // Create a transaction - const transaction = testCollection.transactionManager.applyTransaction( - [] // No mutations - ) + const transaction = createTransaction({ + mutationFn: () => { + return Promise.resolve() + }, + }) // Test that we can access properties expect(transaction.id).toBeDefined() expect(transaction.state).toBe(`pending`) - // Test the toObject method - const transactionObj = transaction.toObject() - expect(transactionObj).toBeDefined() - expect(transactionObj.id).toBe(transaction.id) - expect(transactionObj.state).toBe(transaction.state) - // Test that we can create a modified clone const modifiedTransaction = { - ...transaction.toObject(), + ...transaction, metadata: { - ...transaction.toObject().metadata, + ...transaction.metadata, testKey: `testValue`, }, } diff --git a/packages/react-optimistic/tests/useCollection.test.tsx b/packages/react-optimistic/tests/useCollection.test.tsx index db3451d38..4f2f8f3ce 100644 --- a/packages/react-optimistic/tests/useCollection.test.tsx +++ b/packages/react-optimistic/tests/useCollection.test.tsx @@ -1,6 +1,8 @@ +import { create } from "domain" import { describe, expect, it, vi } from "vitest" import { act, renderHook } from "@testing-library/react" import mitt from "mitt" +import { createTransaction } from "@tanstack/optimistic" import { useCollection } from "../src/useCollection" import type { PendingMutation } from "@tanstack/optimistic" @@ -28,15 +30,15 @@ describe(`useCollection`, () => { }) }, }, - mutationFn: ({ transaction }) => { - persistMock() - act(() => { - emitter.emit(`update`, transaction.mutations) - }) - return Promise.resolve() - }, }) ) + const mutationFn = ({ transaction }) => { + persistMock() + act(() => { + emitter.emit(`update`, transaction.mutations) + }) + return Promise.resolve() + } // Initial state should be empty expect(result.current.state.size).toBe(0) @@ -44,7 +46,10 @@ describe(`useCollection`, () => { // Test single insert with explicit key await act(async () => { - await result.current.insert({ name: `Alice` }, { key: `user1` }) + const tx = createTransaction({ mutationFn }) + await tx.mutate(() => + result.current.insert({ name: `Alice` }, { key: `user1` }) + ) }) // Verify insert @@ -54,9 +59,12 @@ describe(`useCollection`, () => { // Test bulk insert with sparse keys await act(async () => { - await result.current.insert([{ name: `Bob` }, { name: `Charlie` }], { - key: [`user2`, undefined], - }) + const tx = createTransaction({ mutationFn }) + await tx.mutate(() => + result.current.insert([{ name: `Bob` }, { name: `Charlie` }], { + key: [`user2`, undefined], + }) + ) }) // Get the auto-generated key for Charlie @@ -72,16 +80,16 @@ describe(`useCollection`, () => { // Test update with callback const updateTransaction = await act(async () => { - return await result.current.update( - result.current.state.get(`user1`)!, - (item) => { + const tx = createTransaction({ mutationFn }) + return await tx.mutate(() => + result.current.update(result.current.state.get(`user1`)!, (item) => { item.name = `Alice Smith` - } + }) ) }) await act(async () => { - await updateTransaction.isPersisted?.promise + await updateTransaction.isPersisted.promise }) // Verify update @@ -94,18 +102,21 @@ describe(`useCollection`, () => { result.current.state.get(`user1`)!, result.current.state.get(`user2`)!, ] - return await result.current.update( - items, - { metadata: { bulkUpdate: true } }, - (drafts) => { - drafts.forEach((draft, i) => { - if (i === 0) { - draft.name = draft.name + ` Jr.` - } else if (i === 1) { - draft.name = draft.name + ` Sr.` - } - }) - } + const tx = createTransaction({ mutationFn }) + return await tx.mutate(() => + result.current.update( + items, + { metadata: { bulkUpdate: true } }, + (drafts) => { + drafts.forEach((draft, i) => { + if (i === 0) { + draft.name = draft.name + ` Jr.` + } else if (i === 1) { + draft.name = draft.name + ` Sr.` + } + }) + } + ) ) }) @@ -119,7 +130,10 @@ describe(`useCollection`, () => { // Test single delete await act(async () => { - await result.current.delete(result.current.state.get(`user1`)!) + const tx = createTransaction({ mutationFn }) + await tx.mutate(() => + result.current.delete(result.current.state.get(`user1`)!) + ) }) // Verify single delete @@ -132,9 +146,12 @@ describe(`useCollection`, () => { result.current.state.get(`user2`)!, result.current.state.get(charlieKey!)!, ] - await result.current.delete(items, { - metadata: { reason: `bulk cleanup` }, - }) + const tx = createTransaction({ mutationFn }) + await tx.mutate(() => + result.current.delete(items, { + metadata: { reason: `bulk cleanup` }, + }) + ) }) // Verify all items are deleted @@ -154,13 +171,6 @@ describe(`useCollection`, () => { const { result } = renderHook(() => useCollection({ id: `test-properties`, - mutationFn: ({ transaction }) => { - persistMock() - act(() => { - emitter.emit(`update`, transaction.mutations) - }) - return Promise.resolve() - }, sync: { sync: ({ begin, write, commit }) => { emitter.on(`*`, (_, mutations) => { @@ -178,6 +188,13 @@ describe(`useCollection`, () => { }, }) ) + const mutationFn = ({ transaction }) => { + persistMock() + act(() => { + emitter.emit(`update`, transaction.mutations) + }) + return Promise.resolve() + } // Initial state should be empty expect(result.current.state).toBeInstanceOf(Map) @@ -187,13 +204,16 @@ describe(`useCollection`, () => { // Insert some test data await act(async () => { - await result.current.insert( - [ - { id: 1, name: `Item 1` }, - { id: 2, name: `Item 2` }, - { id: 3, name: `Item 3` }, - ], - { key: [`key1`, `key2`, `key3`] } + const tx = createTransaction({ mutationFn }) + await tx.mutate(() => + result.current.insert( + [ + { id: 1, name: `Item 1` }, + { id: 2, name: `Item 2` }, + { id: 3, name: `Item 3` }, + ], + { key: [`key1`, `key2`, `key3`] } + ) ) emitter.emit(`update`, [ { key: `key1`, type: `insert`, changes: { id: 1, name: `Item 1` } }, @@ -223,13 +243,6 @@ describe(`useCollection`, () => { const { result } = renderHook(() => useCollection<{ id: number; name: string }>({ id: `test-selector`, - mutationFn: ({ transaction }) => { - persistMock() - act(() => { - emitter.emit(`update`, transaction.mutations) - }) - return Promise.resolve() - }, sync: { sync: ({ begin, write, commit }) => { emitter.on(`*`, (_, mutations) => { @@ -248,6 +261,13 @@ describe(`useCollection`, () => { }, }) ) + const mutationFn = ({ transaction }) => { + persistMock() + act(() => { + emitter.emit(`update`, transaction.mutations) + }) + return Promise.resolve() + } // Initial state expect(result.current.state).toBeInstanceOf(Map) @@ -257,13 +277,16 @@ describe(`useCollection`, () => { // Insert some test data await act(async () => { - await result.current.insert( - [ - { id: 1, name: `Alice` }, - { id: 2, name: `Bob` }, - { id: 3, name: `Charlie` }, - ], - { key: [`key1`, `key2`, `key3`] } + const tx = createTransaction({ mutationFn }) + await tx.mutate(() => + result.current.insert( + [ + { id: 1, name: `Alice` }, + { id: 2, name: `Bob` }, + { id: 3, name: `Charlie` }, + ], + { key: [`key1`, `key2`, `key3`] } + ) ) emitter.emit(`update`, [ { key: `key1`, type: `insert`, changes: { id: 1, name: `Alice` } }, From 3b5aedf8bf9ed80fbb994c44ea135898de61d9fe Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 6 May 2025 16:33:11 -0600 Subject: [PATCH 06/34] fix example app --- examples/react/todo/src/App.tsx | 153 ++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 59 deletions(-) diff --git a/examples/react/todo/src/App.tsx b/examples/react/todo/src/App.tsx index c9634a78c..97a9c1985 100644 --- a/examples/react/todo/src/App.tsx +++ b/examples/react/todo/src/App.tsx @@ -1,11 +1,59 @@ import React, { useState } from "react" -import { createElectricSync, useCollection } from "@tanstack/react-optimistic" +import { + createElectricSync, + createTransaction, + useCollection, +} from "@tanstack/react-optimistic" import { DevTools } from "./DevTools" import { updateConfigSchema, updateTodoSchema } from "./db/validation" import type { UpdateConfig, UpdateTodo } from "./db/validation" -import type { Collection } from "@tanstack/react-optimistic" import type { FormEvent } from "react" +async function todoMutationFn({ transaction }) { + const payload = transaction.mutations.map((m) => { + const { collection, ...payload } = m + console.log({ payload }) + return payload + }) + const response = await fetch(`http://localhost:3001/api/mutations`, { + method: `POST`, + headers: { + "Content-Type": `application/json`, + }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } + + const result = await response.json() + + // Start waiting for the txid + await transaction.mutations[0].collection.config.sync.awaitTxid(result.txid) +} +async function configMutationFn({ transaction }) { + const payload = transaction.mutations.map((m) => { + const { collection, ...payload } = m + console.log({ payload }) + return payload + }) + const response = await fetch(`http://localhost:3001/api/mutations`, { + method: `POST`, + headers: { + "Content-Type": `application/json`, + }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } + + const result = await response.json() + + // Start waiting for the txid + await transaction.mutations[0].collection.config.sync.awaitTxid(result.txid) +} + export default function App() { const [newTodo, setNewTodo] = useState(``) @@ -30,23 +78,6 @@ export default function App() { { primaryKey: [`id`] } ), schema: updateTodoSchema, - mutationFn: async ({ transaction, collection }) => { - const response = await fetch(`http://localhost:3001/api/mutations`, { - method: `POST`, - headers: { - "Content-Type": `application/json`, - }, - body: JSON.stringify(transaction.mutations), - }) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } - - const result = await response.json() - - // Start waiting for the txid - await collection.config.sync.awaitTxid(result.txid) - }, }) const { @@ -71,23 +102,6 @@ export default function App() { { primaryKey: [`id`] } ), schema: updateConfigSchema, - mutationFn: async ({ transaction, collection }) => { - const response = await fetch(`http://localhost:3001/api/mutations`, { - method: `POST`, - headers: { - "Content-Type": `application/json`, - }, - body: JSON.stringify(transaction.mutations), - }) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } - - const result = await response.json() - - // Start waiting for the txid - await collection.config.sync.awaitTxid(result.txid) - }, }) // Define a more robust type-safe helper function to get config values @@ -104,19 +118,23 @@ export default function App() { const setConfigValue = (key: string, value: string): void => { for (const config of configData) { if (config.key === key) { - updateConfig(config, (draft) => { - draft.value = value - }) + createTransaction({ mutationFn: configMutationFn }).mutate(() => + updateConfig(config, (draft) => { + draft.value = value + }) + ) return } } // If the config doesn't exist yet, create it - insertConfig({ - key, - value, - }) + createTransaction({ mutationFn: configMutationFn }).mutate(() => + insertConfig({ + key, + value, + }) + ) } const backgroundColor = getConfigValue(`backgroundColor`) @@ -176,17 +194,23 @@ export default function App() { e.preventDefault() if (!newTodo.trim()) return - insert({ - text: newTodo, - completed: false, - }) + const tx = createTransaction({ mutationFn: todoMutationFn }) + tx.mutate(() => + insert({ + text: newTodo, + completed: false, + }) + ) setNewTodo(``) } const toggleTodo = (todo: UpdateTodo) => { - update(todo, (draft) => { - draft.completed = !draft.completed - }) + const tx = createTransaction({ mutationFn: todoMutationFn }) + tx.mutate(() => + update(todo, (draft) => { + draft.completed = !draft.completed + }) + ) } const activeTodos = todos.filter((todo) => !todo.completed) @@ -233,13 +257,16 @@ export default function App() { className="absolute left-0 w-12 h-full text-[30px] text-[#e6e6e6] hover:text-[#4d4d4d]" onClick={() => { const allCompleted = completedTodos.length === todos.length - update( - allCompleted ? completedTodos : activeTodos, - (drafts) => { - drafts.forEach( - (draft) => (draft.completed = !allCompleted) - ) - } + const tx = createTransaction({ mutationFn: todoMutationFn }) + tx.mutate(() => + update( + allCompleted ? completedTodos : activeTodos, + (drafts) => { + drafts.forEach( + (draft) => (draft.completed = !allCompleted) + ) + } + ) ) }} > @@ -281,7 +308,12 @@ export default function App() { {todo.text} +
    + {data.map(todo => ( +
  • + toggleTodo(todo)} + disabled={todoMutation.isPending} + /> + {todo.title} + +
  • + ))} +
+ + ) } ``` From 30f802e780db70539d7970065f129affc8e53c6a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 7 May 2025 09:49:16 -0600 Subject: [PATCH 17/34] prettier --- README.md | 6 +++--- packages/optimistic/README.md | 4 ++-- packages/react-optimistic/README.md | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bd15c965e..232e26b73 100644 --- a/README.md +++ b/README.md @@ -183,11 +183,11 @@ const todoCollection = useCollection({ The library includes a simple yet powerful transaction management system. Transactions are created using the `createTransaction` function: ```typescript -const tx = createTransaction({ +const tx = createTransaction({ mutationFn: async ({ transaction }) => { // Implement your mutation logic here // This function is called when the transaction is committed - } + }, }) // Apply mutations within the transaction @@ -243,7 +243,7 @@ function TodoList() { const { collection, ...payload } = m return payload }) - + const response = await fetch(`http://localhost:3001/api/mutations`, { method: `POST`, headers: { diff --git a/packages/optimistic/README.md b/packages/optimistic/README.md index c767380b1..f505bff4a 100644 --- a/packages/optimistic/README.md +++ b/packages/optimistic/README.md @@ -131,11 +131,11 @@ const todoCollection = createCollection({ The library includes a simple yet powerful transaction management system. Transactions are created using the `createTransaction` function: ```typescript -const tx = createTransaction({ +const tx = createTransaction({ mutationFn: async ({ transaction }) => { // Implement your mutation logic here // This function is called when the transaction is committed - } + }, }) // Apply mutations within the transaction diff --git a/packages/react-optimistic/README.md b/packages/react-optimistic/README.md index 2b1cc1de5..53be3743a 100644 --- a/packages/react-optimistic/README.md +++ b/packages/react-optimistic/README.md @@ -144,7 +144,7 @@ const todosConfig = { // Use the collection in a component function TodoList() { const { data, insert, update, delete: deleteFn } = useCollection(todosConfig) - + // Create a mutation for handling all todo operations const todoMutation = useOptimisticMutation({ mutationFn: async ({ transaction }) => { @@ -152,7 +152,7 @@ function TodoList() { const { collection, ...payload } = m return payload }) - + const response = await fetch(`http://localhost:3001/api/mutations`, { method: `POST`, headers: { @@ -160,7 +160,7 @@ function TodoList() { }, body: JSON.stringify(payload), }) - + if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`) } @@ -192,7 +192,7 @@ function TodoList() { return (
-