From 36a8908a0cd76894fe304c6a551835148a88bf94 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Tue, 4 Jun 2024 10:54:27 +0300 Subject: [PATCH] feature: transaction api feature: transaction api chore: transaction guards * we don't allow cells, used in transaction to be mutated outside transaction once it's alive * we don't allow cells, mutated during transaction (outside transaction) to be used in transaction + apply comments + + + --- README.md | 44 ++++++++++ src/index.ts | 10 ++- src/transaction.ts | 151 ++++++++++++++++++++++++++++++++ tests/transaction.test.ts | 179 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 src/transaction.ts create mode 100644 tests/transaction.test.ts diff --git a/README.md b/README.md index 95677c5..46398ee 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ npm add signal-utils signal-polyfill - [async function](#async-function) - [localCopy](#localcopy-function) - [deep](#deep-function) + - [transaction](#transaction) - class utilities - [@signal](#signal) - [@localCopy](#localcopy) @@ -222,6 +223,49 @@ Live, interactive demos of this concept: - [Preact](https://preactjs.com/repl?code=aW1wb3J0IHsgcmVuZGVyIH0gZnJvbSAncHJlYWN0JzsKaW1wb3J0IHsgdXNlUmVmLCB1c2VFZmZlY3QgfSBmcm9tICdwcmVhY3QvaG9va3MnOwppbXBvcnQgeyBzaWduYWwsIGVmZmVjdCwgdXNlU2lnbmFsIH0gZnJvbSAnQHByZWFjdC9zaWduYWxzJzsKaW1wb3J0IHsgaHRtbCB9IGZyb20gJ2h0bS9wcmVhY3QnOwoKZnVuY3Rpb24gdXNlTG9jYWxDb3B5KHJlbW90ZSkgewoJY29uc3QgbG9jYWwgPSB1c2VSZWYoKTsKCWlmICghbG9jYWwuY3VycmVudCkgewoJCWxvY2FsLmN1cnJlbnQgPSBzaWduYWwocmVtb3RlLnBlZWsoKSk7Cgl9CgoJdXNlRWZmZWN0KCgpID0%2BIHsKCSAgLy8gU3luY2hyb25vdXNseSB1cGRhdGUgdGhlIGxvY2FsIGNvcHkgd2hlbiByZW1vdGUgY2hhbmdlcy4KCSAgLy8gQ29yZSBlZmZlY3RzIGFyZSBqdXN0IGEgd2F5IHRvIGhhdmUgc3luY2hyb25vdXMgY2FsbGJhY2tzCgkgIC8vIHJlYWN0IHRvIHNpZ25hbCBjaGFuZ2VzIGluIGEgcHJldHR5IGVmZmljaWVudCB3YXkuCgkJcmV0dXJuIGVmZmVjdCgoKSA9PiB7CgkJCWxvY2FsLmN1cnJlbnQudmFsdWUgPSByZW1vdGUudmFsdWU7CgkJfSk7Cgl9LCBbcmVtb3RlXSk7CgoJcmV0dXJuIGxvY2FsLmN1cnJlbnQ7Cn0KCmZ1bmN0aW9uIERlbW8oeyBuYW1lLCBvblN1Ym1pdCB9KSB7CgkJY29uc3QgbG9jYWxOYW1lID0gdXNlTG9jYWxDb3B5KG5hbWUpOwoKICAgIGNvbnN0IHVwZGF0ZUxvY2FsTmFtZSA9IChpbnB1dEV2ZW50KSA9PiBsb2NhbE5hbWUudmFsdWUgPSBpbnB1dEV2ZW50LnRhcmdldC52YWx1ZTsKCiAgICBjb25zdCBoYW5kbGVTdWJtaXQgPSAoc3VibWl0RXZlbnQpID0%2BIHsKICAgICAgICBzdWJtaXRFdmVudC5wcmV2ZW50RGVmYXVsdCgpOwogICAgICAgIG9uU3VibWl0KHsgdmFsdWU6IGxvY2FsTmFtZS52YWx1ZSB9KTsKICAgIH0KCiAgICByZXR1cm4gaHRtbGAKICAgICAgICA8Zm9ybSBvblN1Ym1pdD0ke2hhbmRsZVN1Ym1pdH0%2BCiAgICAgICAgICAgIDxsYWJlbD4KICAgICAgICAgICAgICAgIEVkaXQgTmFtZTogICAKICAgICAgICAgICAgICAgIDxpbnB1dCB2YWx1ZT0ke2xvY2FsTmFtZS52YWx1ZX0gb25JbnB1dD0ke3VwZGF0ZUxvY2FsTmFtZX0gLz4KICAgICAgICAgICAgPC9sYWJlbD4KCiAgICAgICAgICAgIDxidXR0b24%2BU3VibWl0PC9idXR0b24%2BCiAgICAgICAgPC9mb3JtPgoKICAgICAgICA8cHJlPmxvY2FsVmFsdWU6ICR7bG9jYWxOYW1lfTxiciAvPnBhcmVudCB2YWx1ZTogJHtuYW1lfTwvcHJlPmA7Cn0KCmV4cG9ydCBmdW5jdGlvbiBBcHAoKSB7CiAgICBjb25zdCBuYW1lID0gdXNlU2lnbmFsKCdNYWNlIFdpbmR1Jyk7CiAgICBjb25zdCBkYXRhID0gdXNlU2lnbmFsKCcnKTsKCiAgICBjb25zdCBoYW5kbGVTdWJtaXQgPSAoZCkgPT4gZGF0YS52YWx1ZSA9IGQ7CiAgICBjb25zdCBjaGFuZ2VOYW1lID0gKCkgPT4gbmFtZS52YWx1ZSArPSAnISc7CgogICAgcmV0dXJuIGh0bWxgCiAgICAgICAgPCR7RGVtb30gbmFtZT0ke25hbWV9IG9uU3VibWl0PSR7aGFuZGxlU3VibWl0fSAvPgoKICAgICAgICA8aHIgLz4KCiAgICAgICAgQ2F1c2UgZXh0ZXJuYWwgY2hhbmdlIChtYXliZSBzaW11bGF0aW5nIGEgcmVmcmVzaCBvZiByZW1vdGUgZGF0YSk6CiAgICAgICAgPGJ1dHRvbiBvbkNsaWNrPSR7Y2hhbmdlTmFtZX0%2BQ2F1c2UgRXh0ZXJuYWwgQ2hhbmdlPC9idXR0b24%2BCgogICAgICAgIDxociAvPgogICAgICAgIExhc3QgU3VibWl0dGVkOjxiciAvPgogICAgICAgIDxwcmU%2BJHtKU09OLnN0cmluZ2lmeShkYXRhLnZhbHVlLCBudWxsLCAzKX08L3ByZT5gOwp9CgpyZW5kZXIoPEFwcCAvPiwgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2FwcCcpKTsK) - [Solid](https://playground.solidjs.com/anonymous/0cf7972e-f55d-4483-909d-6c172c80d5ac) +### `Transaction` + +A utility class for managing transactions that can be committed or rolled back. This is useful for implementing features like optimistic updates. + +```js +import { Transaction } from 'signal-utils'; + +class App { + @signal userName = ''; + @signal users = []; + + async addUser() { + const addUserTransaction = new Transaction(); + // optimistic update + addUserTransaction.execute(() => { + this.users = [...this.users, this.userName]; + this.userName = ''; + }); + try { + const response = await fetch('/api/add-user', { + method: 'POST', + body: JSON.stringify({ userName: this.userName }), + }); + const serverUsers = await response.json(); + // commit the transaction + addUserTransaction.commit(() => { + this.users = serverUsers; + }); + } catch (error) { + // rollback the transaction + addUserTransaction.rollback(); + } + } +} +``` + +Methods available in the `Transaction` class: + +- `execute(fn: Mutation): void` - Executes the given mutation function within the transaction. +- `commit(fn?: Mutation): void` - Commits the transaction. Optionally, you can pass a mutation function to execute during the commit. +- `rollback(): void` - Rolls back all changes made during the transaction. +- `follow(promise: Promise): Promise` - Follows a promise, committing if it resolves and rolling back if it rejects. + ### `Array` A reactive Array. diff --git a/src/index.ts b/src/index.ts index 58004f4..1b1f636 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ import { Signal } from "signal-polyfill"; +import { + signalTransactionGetter, + signalTransactionSetter, +} from "./transaction"; /** * Usage: @@ -84,13 +88,15 @@ function stateDecorator( get(): Value { // SAFETY: does TS not allow us to have a different type internally? // maybe I did something goofy. - return (get.call(this) as Signal.State).get(); + const signal = get.call(this) as Signal.State; + return signalTransactionGetter(signal); }, set(value: Value) { // SAFETY: does TS not allow us to have a different type internally? // maybe I did something goofy. - (get.call(this) as Signal.State).set(value); + const signal = get.call(this) as Signal.State; + signalTransactionSetter(signal, value); }, init(value: Value) { diff --git a/src/transaction.ts b/src/transaction.ts new file mode 100644 index 0000000..e656a98 --- /dev/null +++ b/src/transaction.ts @@ -0,0 +1,151 @@ +import type { Signal } from "signal-polyfill"; + +type Mutation = () => void; + +let activeTransaction: Transaction | null = null; +const activeTransactions: Transaction[] = []; +const createdTransactions: Set = new Set(); +export function popActiveTransaction(): void { + activeTransactions.pop(); + activeTransaction = activeTransactions[activeTransactions.length - 1] || null; +} +export function pushActiveTransaction(transaction: Transaction): void { + activeTransactions.push(transaction); + activeTransaction = transaction; +} + +export function signalTransactionSetter( + signal: Signal.State, + value: any, +): void { + if (activeTransaction) { + const { cellState, usedCells, seenCells } = activeTransaction; + if (!cellState.has(signal)) { + usedCells.add(signal); + } + seenCells.add(signal); + cellState.set(signal, value); + } else { + signal.set(value); + } +} +export function signalTransactionGetter(signal: Signal.State): any { + if (activeTransaction) { + const { cellState, usedCells } = activeTransaction; + if (usedCells.has(signal)) { + return cellState.get(signal); + } + } + return signal.get(); +} + +export class Transaction { + constructor(fn?: Mutation) { + if (fn) { + this.execute(fn); + } + } + cellState: WeakMap, unknown> = new WeakMap(); + usedCells: Set> = new Set(); + seenCells: WeakSet> = new WeakSet(); + prevSeenCells: Set>> = new Set(); + execute(fn: Mutation): void { + createdTransactions.add(this); + try { + pushActiveTransaction(this); + fn(); + } finally { + popActiveTransaction(); + } + } + ensureSafeToCommit() { + this.usedCells.forEach((signal) => { + for (const prevSeen of this.prevSeenCells) { + if (prevSeen.has(signal)) { + throw new Error("Transaction conflict"); + } + } + }); + } + commit(fn?: Mutation): void { + if (fn) { + this.execute(fn); + } + this.ensureSafeToCommit(); + const parentTransaction = + activeTransactions[activeTransactions.length - 1] || null; + if (parentTransaction) { + const { usedCells, cellState } = parentTransaction; + this.usedCells.forEach((signal) => { + usedCells.add(signal); + cellState.set(signal, this.cellState.get(signal)); + }); + } else { + this.usedCells.forEach((signal) => { + signal.set(this.cellState.get(signal)); + }); + } + for (const t of createdTransactions) { + if (t !== this && !activeTransactions.includes(t)) { + t.prevSeenCells.add(t.seenCells); + } + } + this.cleanup(); + } + rollback(): void { + this.cleanup(); + } + cleanup(): void { + createdTransactions.delete(this); + this.cellState = new WeakMap(); + this.usedCells = new Set(); + this.seenCells = new WeakSet(); + this.prevSeenCells = new Set(); + } + follow(promise: Promise): Promise { + return promise + .then((result) => { + this.commit(); + return result; + }) + .catch((error) => { + this.rollback(); + return Promise.reject(error); + }); + } +} + +/* Usage sample: + + Let's say we managing add user form, we have input with user name and list of users. + We have a state object that holds the user name and list of users. + + class App { + @signal userName = ''; + @signal users = []; + async addUser() { + const addUserTransaction = new Transaction(); + // optimistic update + addUserTransaction.execute(() => { + this.users = [...this.users, this.userName]; + this.userName = ''; + }); + fetch('/api/add-user', { + method: 'POST', + body: JSON.stringify({ userName: this.userName }), + + )).then(async (req) => { + const serverUsers = await req.json(); + // commit the transaction + addUserTransaction.commit(() => { + this.users = serverUsers; + }); + }).catch(() => { + // rollback the transaction + addUserTransaction.rollback(); + }); + } + + } + +*/ diff --git a/tests/transaction.test.ts b/tests/transaction.test.ts new file mode 100644 index 0000000..4bca16a --- /dev/null +++ b/tests/transaction.test.ts @@ -0,0 +1,179 @@ +import { describe, test, assert } from "vitest"; +import { signal } from "../src/index"; +import { Transaction } from "../src/transaction"; + +function getApp(initValue: any = 0) { + class Obj { + @signal accessor value = initValue; + } + const app = new Obj(); + return app; +} + +describe("transaction", () => { + test("rollback should work", () => { + const app = getApp(10); + const transaction = new Transaction(); + transaction.execute(() => { + app.value = 20; + }); + assert.equal(app.value, 10); + transaction.rollback(); + assert.equal(app.value, 10); + }); + test("commit should work", () => { + const app = getApp(10); + const transaction = new Transaction(); + transaction.execute(() => { + app.value = 20; + }); + assert.equal(app.value, 10); + transaction.commit(); + assert.equal(app.value, 20); + }); + test("should work with nested transactions", () => { + const app = getApp(10); + const transaction = new Transaction(); + transaction.execute(() => { + app.value = 20; + const nestedTransaction = new Transaction(); + nestedTransaction.execute(() => { + app.value = 30; + assert.equal(app.value, 30); + }); + assert.equal(app.value, 20); + nestedTransaction.rollback(); + assert.equal(app.value, 20); + }); + assert.equal(app.value, 10); + transaction.commit(); + assert.equal(app.value, 20); + }); + test("nested transactions should not affect each other", () => { + const app = getApp(10); + const transaction = new Transaction(); + transaction.execute(() => { + app.value = 20; + const nestedTransaction = new Transaction(); + nestedTransaction.execute(() => { + app.value = 30; + assert.equal(app.value, 30); + }); + assert.equal(app.value, 20); + nestedTransaction.commit(); + assert.equal(app.value, 30); + }); + assert.equal(app.value, 10); + transaction.commit(); + assert.equal(app.value, 30); + }); + test("no error appears if value mutation appears in parent and child transaction", () => { + const app = getApp(10); + const transaction = new Transaction(); + transaction.execute(() => { + app.value = 20; + const nestedTransaction = new Transaction(); + nestedTransaction.execute(() => { + app.value = 30; + assert.equal(app.value, 30); + }); + app.value = 40; + assert.equal(app.value, 40); + nestedTransaction.commit(); + assert.equal(app.value, 30); + }); + assert.equal(app.value, 10); + transaction.commit(); + assert.equal(app.value, 30); + }); + test("should execute mutation in constructor", () => { + const app = getApp(10); + const transaction = new Transaction(() => { + app.value = 20; + }); + assert.equal(app.value, 10); + transaction.execute(() => { + assert.equal(app.value, 20); + }); + transaction.rollback(); + assert.equal(app.value, 10); + }); + test("should work with promises", async () => { + const app = getApp(); + app.value = 10; + const transaction = new Transaction(() => { + app.value = 20; + }); + const asyncRequest = new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, 100); + }); + await transaction.follow(asyncRequest); + assert.equal(app.value, 20); + }); + test("should work with promises and rollback", async () => { + const app = getApp(); + app.value = 10; + const transaction = new Transaction(() => { + app.value = 20; + }); + const asyncRequest = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Failed")); + }, 100); + }); + try { + await transaction.follow(asyncRequest); + } catch (error: any) { + assert.equal(error.message, "Failed"); + assert.equal(app.value, 10); + } + }); + test("Changes made outside of a transaction are not visible within the transaction.", async () => { + const app = getApp(10); + assert.equal(app.value, 10); + const transaction = new Transaction(() => { + app.value = 20; + assert.equal(app.value, 20); + }); + app.value = 30; + assert.equal(app.value, 30); + transaction.execute(() => { + assert.equal(app.value, 20); + }); + transaction.rollback(); + assert.equal(app.value, 30); + }); + test("Changes made within the transaction are not visible outside of the transaction until it commits.", async () => { + const app = getApp(10); + assert.equal(app.value, 10); + const transaction = new Transaction(() => { + app.value = 20; + assert.equal(app.value, 20); + }); + assert.equal(app.value, 10); + transaction.commit(); + assert.equal(app.value, 20); + }); + test("Transactions fail to commit if they conflict with another transaction in some way (e.g. during the transaction a piece of data that was read or modified was changed by another transaction which committed first)", async () => { + const app = getApp(10); + const transaction1 = new Transaction(() => { + app.value = 20; + }); + const transaction2 = new Transaction(() => { + app.value = 30; + }); + let isErrored = false; + transaction1.commit(); + assert.equal(app.value, 20); + try { + transaction2.commit(); + } catch (error: any) { + isErrored = true; + assert.equal(error.message, "Transaction conflict"); + assert.equal(app.value, 20); + } + assert.equal(isErrored, true); + }); +});