Skip to content

Commit

Permalink
feature: transaction api
Browse files Browse the repository at this point in the history
  • Loading branch information
lifeart committed Jul 6, 2024
1 parent 752e8f9 commit 8091778
Show file tree
Hide file tree
Showing 4 changed files with 241 additions and 1 deletion.
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<any>): Promise<any>` - Follows a promise, committing if it resolves and rolling back if it rejects.

### `Array`

A reactive Array.
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Signal } from "signal-polyfill";
import { signalTransactionSetter } from "./transaction";

/**
* Usage:
Expand Down Expand Up @@ -90,7 +91,9 @@ function stateDecorator<Value = any>(
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<Value>).set(value);
const signal = get.call(this) as Signal.State<Value>;
signalTransactionSetter(signal);
signal.set(value);
},

init(value: Value) {
Expand Down
98 changes: 98 additions & 0 deletions src/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Signal } from "signal-polyfill";

type Mutation = () => void;
let activeTransaction: null | Transaction = null;

export function setActiveTransaction(transaction: Transaction | null): void {
activeTransaction = transaction;
}

export function signalTransactionSetter(signal: Signal.State<any>): void {
if (activeTransaction) {
const { cellState, usedCells } = activeTransaction;
if (!cellState.has(signal)) {
cellState.set(signal, signal.get());
usedCells.add(signal);
}
}
}

export class Transaction {
constructor(fn?: Mutation) {
if (fn) {
this.execute(fn);
}
}
cellState: WeakMap<Signal.State<any>, unknown> = new WeakMap();
usedCells: Set<Signal.State<any>> = new Set();
execute(fn: Mutation): void {
try {
setActiveTransaction(this);
fn();
} finally {
setActiveTransaction(null);
}
}
commit(fn?: Mutation): void {
if (fn) {
this.execute(fn);
}
this.cleanup();
}
rollback(): void {
this.usedCells.forEach((signal) => {
signal.set(this.cellState.get(signal));
});
this.cleanup();
}
cleanup(): void {
this.cellState = new WeakMap();
this.usedCells = new Set();
}
follow(promise: Promise<any>): Promise<any> {
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();
});
}
}
*/
95 changes: 95 additions & 0 deletions tests/transaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, test, assert } from "vitest";
import { signal } from "../src/index";
import { Transaction } from "../src/transaction";

function getApp() {
class Obj {
@signal accessor value = 0;
}
const app = new Obj();
return app;
}

describe("transaction", () => {
test("rollback should work", () => {
const app = getApp();
app.value = 10;
const transaction = new Transaction();
transaction.execute(() => {
app.value = 20;
});
assert.equal(app.value, 20);
transaction.rollback();
assert.equal(app.value, 10);
});
test("commit should work", () => {
const app = getApp();
app.value = 10;
const transaction = new Transaction();
transaction.execute(() => {
app.value = 20;
});
assert.equal(app.value, 20);
transaction.commit();
assert.equal(app.value, 20);
});
test("should work with nested transactions", () => {
const app = getApp();
app.value = 10;
const transaction = new Transaction();
transaction.execute(() => {
app.value = 20;
const nestedTransaction = new Transaction();
nestedTransaction.execute(() => {
app.value = 30;
});
assert.equal(app.value, 30);
nestedTransaction.rollback();
assert.equal(app.value, 20);
});
assert.equal(app.value, 20);
transaction.rollback();
assert.equal(app.value, 10);
});
test("should execute mutation in constructor", () => {
const app = getApp();
const transaction = new Transaction(() => {
app.value = 20;
});
assert.equal(app.value, 20);
transaction.rollback();
assert.equal(app.value, 0);
});
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);
}
});
});

0 comments on commit 8091778

Please sign in to comment.