Skip to content

Commit

Permalink
feature: transaction api
Browse files Browse the repository at this point in the history
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

+

+

+
  • Loading branch information
lifeart committed Oct 3, 2024
1 parent 752e8f9 commit 36a8908
Show file tree
Hide file tree
Showing 4 changed files with 382 additions and 2 deletions.
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
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Signal } from "signal-polyfill";
import {
signalTransactionGetter,
signalTransactionSetter,
} from "./transaction";

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

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

type Mutation = () => void;

let activeTransaction: Transaction | null = null;
const activeTransactions: Transaction[] = [];
const createdTransactions: Set<Transaction> = 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<any>,
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>): 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<Signal.State<any>, unknown> = new WeakMap();
usedCells: Set<Signal.State<any>> = new Set();
seenCells: WeakSet<Signal.State<any>> = new WeakSet();
prevSeenCells: Set<WeakSet<Signal.State<any>>> = 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<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();
});
}
}
*/
Loading

0 comments on commit 36a8908

Please sign in to comment.