Skip to content

Commit 00b9735

Browse files
committed
first version
1 parent dcd2f87 commit 00b9735

12 files changed

+1836
-74
lines changed

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
/dist
3+
.DS_Store
4+
.env
5+
yarn-error.log

README.md

+67-68
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,55 @@
1-
# Sync Engine
1+
# Immerhin
2+
23
The core idea is to use [Immer](https://immerjs.github.io/immer/) as an interface for state mutations and provide a convenient way to group mutations into a single transaction.
3-
As an end-result, we get patches that can be used to sync the app state across the application and to sync the changes to the server. This also gives us a way to undo/redo a transaction automatically.
4+
5+
Using Immer's patches we sync the state across the application and to the server. This also gives us a way to undo/redo a transaction automatically.
46

57
## Features
68

79
1. Update application state using [patches](https://immerjs.github.io/immer/patches)
8-
2. Synchronize with the server
10+
2. Synchronize to the server
911
3. Get undo/redo on the client that does both updating the client state and syncing to the server
1012
4. Server agnostic
1113
5. State management agnostic (mostly)
1214

1315
## Example
1416

1517
```js
16-
import {createTransaction, register, sync, undo, redo} from 'sync-engine'
18+
import { createTransaction, register, sync, undo, redo } from "sync-engine";
1719

1820
// Create containers for each state. Sync engine only cares that the result has a "value" and a "dispatch(newValue)"
1921
const container1 = createContainer(initialValue);
2022
const container2 = createContainer(initialValue);
2123

2224
// - Explicitely enable containers for transactions
2325
// - Define a namespace for each container, so that server knows which object it has to patch.
24-
register('some-name1', container1);
25-
register('some-name2', container2);
26+
register("some-name1", container1);
27+
register("some-name2", container2);
2628

2729
// Creating the actual transaction that will:
2830
// - generate patches
2931
// - update states
3032
// - inform all subscribers
3133
// - register a transaction for potential undo/redo and sync calls
32-
createTransaction([container1, container2, ...rest], (value1, value2, ...rest) => {
34+
createTransaction(
35+
[container1, container2, ...rest],
36+
(value1, value2, ...rest) => {
3337
mutateValue(value1);
3438
mutateValue(value2);
3539
// ...
36-
});
40+
}
41+
);
3742

3843
// Setup periodic sync with a fetch, or do this with Websocket
3944
setInterval(async () => {
40-
const entries = sync()
41-
await fetch('/patch', {method: 'POST', payload: JSON.stringify(entries)})
42-
}, 1000)
45+
const entries = sync();
46+
await fetch("/patch", { method: "POST", payload: JSON.stringify(entries) });
47+
}, 1000);
4348

4449
// Undo/redo
4550

46-
undo()
47-
redo()
48-
51+
undo();
52+
redo();
4953
```
5054

5155
## How it works
@@ -63,28 +67,29 @@ You can use the same container instance to subscribe to the changes across the e
6367
Example using nano state:
6468

6569
```js
66-
import {createContainer, useValue} from 'react-nano-state'
67-
const myContainer = createContainer(initialValue)
70+
import { createContainer, useValue } from "react-nano-state";
71+
const myContainer = createContainer(initialValue);
6872

6973
// I can call a dispatch from anywhere
70-
myContainer.dispatch(newValue)
74+
myContainer.dispatch(newValue);
7175

7276
// I can subscribe to updates in React
7377
const Component = () => {
74-
const [value, setValue] = useValue(myContainer)
75-
}
78+
const [value, setValue] = useValue(myContainer);
79+
};
7680
```
7781

7882
### Container registration
7983

80-
We register containers for two reasons:
84+
We register containers for two reasons:
85+
8186
1. To define a namespace for each container so that whoever consumes the changes knows which object to apply the patches to.
8287
2. Ensure that the container was intentionally registered to be synced to the server and be part of undo/redo transactions. You may not want this for every container since you can use them for ephemeral states.
8388

8489
Example
8590

8691
```js
87-
register('myName', myContainer)
92+
register("myName", myContainer);
8893
```
8994

9095
### Creating a transaction
@@ -100,11 +105,14 @@ A call into `createTransaction()`does all of this:
100105
Example
101106

102107
```js
103-
createTransaction([container1, container2, ...rest], (value1, value2, ...rest) => {
108+
createTransaction(
109+
[container1, container2, ...rest],
110+
(value1, value2, ...rest) => {
104111
mutateValue(value1);
105112
mutateValue(value2);
106113
// ...
107-
});
114+
}
115+
);
108116
```
109117

110118
### Undo/redo
@@ -121,58 +129,49 @@ Example
121129
```js
122130
// Setup periodic sync with a fetch, or do this with Websocket
123131
setInterval(async () => {
124-
const entries = sync()
125-
await fetch('/patch', {method: 'POST', payload: JSON.stringify(entries)})
126-
}, 1000)
132+
const entries = sync();
133+
await fetch("/patch", { method: "POST", payload: JSON.stringify(entries) });
134+
}, 1000);
127135
```
128136

129137
Example entries:
130138

131139
```json
132140
[
133-
{
134-
"transactionId": "6243062b469f516835327f65",
135-
"changes": [
136-
{
137-
"namespace": "root",
138-
"patches": [
139-
{
140-
"op": "replace",
141-
"path": [
142-
"children",
143-
1
144-
],
145-
"value": {
146-
"component": "Box",
147-
"id": "6241f55791596f2467df9c2a",
148-
"style": {},
149-
"children": []
150-
}
151-
},
152-
{
153-
"op": "replace",
154-
"path": [
155-
"children",
156-
2
157-
],
158-
"value": {
159-
"component": "Box",
160-
"id": "6241f55a91596f2467df9c36",
161-
"style": {},
162-
"children": []
163-
}
164-
},
165-
{
166-
"op": "replace",
167-
"path": [
168-
"children",
169-
"length"
170-
],
171-
"value": 3
172-
}
173-
]
141+
{
142+
"transactionId": "6243062b469f516835327f65",
143+
"changes": [
144+
{
145+
"namespace": "root",
146+
"patches": [
147+
{
148+
"op": "replace",
149+
"path": ["children", 1],
150+
"value": {
151+
"component": "Box",
152+
"id": "6241f55791596f2467df9c2a",
153+
"style": {},
154+
"children": []
155+
}
156+
},
157+
{
158+
"op": "replace",
159+
"path": ["children", 2],
160+
"value": {
161+
"component": "Box",
162+
"id": "6241f55a91596f2467df9c36",
163+
"style": {},
164+
"children": []
174165
}
166+
},
167+
{
168+
"op": "replace",
169+
"path": ["children", "length"],
170+
"value": 3
171+
}
175172
]
176-
}
173+
}
174+
]
175+
}
177176
]
178177
```

babel.config.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
presets: ["@babel/preset-typescript"],
3+
};

package.json

+21-6
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
{
2-
"name": "sync-engine",
2+
"name": "immerhin",
33
"version": "0.1.0",
44
"description": "Send patches to your server, update the UI state, get undo-redo for free",
5-
"main": "lib/index.js",
5+
"main": "dist/immerhin.cjs.js",
6+
"module": "dist/immerhin.esm.js",
67
"scripts": {
7-
"test": "jest"
8+
"build": "preconstruct build",
9+
"dev": "preconstruct dev",
10+
"prepublishOnly": "yarn build"
811
},
912
"repository": {
1013
"type": "git",
11-
"url": "git+ssh://[email protected]/webstudio-is/sync-engine.git"
14+
"url": "git+ssh://[email protected]/webstudio-is/immerhin.git"
1215
},
1316
"keywords": [
1417
"Immer",
@@ -21,7 +24,19 @@
2124
"author": "Oleg Isonen",
2225
"license": "MIT",
2326
"bugs": {
24-
"url": "https://github.com/webstudio-is/sync-engine/issues"
27+
"url": "https://github.com/webstudio-is/immerhin/issues"
2528
},
26-
"homepage": "https://github.com/webstudio-is/sync-engine#readme"
29+
"homepage": "https://github.com/webstudio-is/immerhin#readme",
30+
"devDependencies": {
31+
"@babel/preset-typescript": "^7.16.7",
32+
"@preconstruct/cli": "^2.1.5",
33+
"typescript": "^4.6.3"
34+
},
35+
"dependencies": {
36+
"bson-objectid": "^2.0.3",
37+
"immer": "^9.0.12"
38+
},
39+
"files": [
40+
"dist"
41+
]
2742
}

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { register, createTransaction } from "./store";
2+
export { sync, type SyncItem } from "./sync-queue";
3+
export { undo, redo } from "./transactions-manager";

src/store.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { createDraft, finishDraft, enablePatches, type Patch } from "immer";
2+
import { type ValueContainer } from "./types";
3+
import { Transaction } from "./transaction";
4+
import { add } from "./transactions-manager";
5+
6+
enablePatches();
7+
8+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9+
type Any = any;
10+
11+
const registry: Map<ValueContainer<Any>, string> = new Map();
12+
13+
export const register = <Value>(
14+
namespace: string,
15+
container: ValueContainer<Value>
16+
) => {
17+
registry.set(container, namespace);
18+
};
19+
20+
type UnwrapContainers<Containers extends Array<ValueContainer<unknown>>> = {
21+
[Index in keyof Containers]: Containers[Index] extends ValueContainer<
22+
infer Value
23+
>
24+
? Value
25+
: never;
26+
};
27+
28+
export const createTransaction = <
29+
Containers extends Array<ValueContainer<Any>>
30+
>(
31+
containers: [...Containers],
32+
recipe: (...values: UnwrapContainers<Containers>) => void
33+
): UnwrapContainers<Containers> => {
34+
type Values = UnwrapContainers<Containers>;
35+
const drafts = [] as unknown as Values;
36+
for (const container of containers) {
37+
drafts.push(createDraft(container.value));
38+
}
39+
recipe(...drafts);
40+
const transaction = new Transaction();
41+
const values = [] as unknown as Values;
42+
drafts.forEach((draft, index) => {
43+
const namespace = registry.get(containers[index]);
44+
if (namespace === undefined) {
45+
throw new Error(
46+
"Container used for transaction is not registered in sync engine"
47+
);
48+
}
49+
const value = finishDraft(
50+
draft,
51+
(patches: Array<Patch>, revisePatches: Array<Patch>) => {
52+
transaction.add({
53+
namespace,
54+
patches,
55+
revisePatches,
56+
container: containers[index],
57+
});
58+
}
59+
);
60+
values.push(value);
61+
});
62+
add(transaction);
63+
return values;
64+
};

src/sync-queue.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type Change } from "./transaction";
2+
3+
export type SyncItem = {
4+
transactionId: string;
5+
changes: Array<Change>;
6+
};
7+
8+
const queue: Array<SyncItem> = [];
9+
10+
const dequeue = (transactionId: string) => {
11+
const index = queue.findIndex(
12+
(entry) => entry.transactionId === transactionId
13+
);
14+
if (index === -1) return false;
15+
queue.splice(index, 1);
16+
return true;
17+
};
18+
19+
export const enqueue = (transactionId: string, changes: Array<Change>) => {
20+
// We are trying to delete that transaction from the queue,
21+
// if it was not found - we are adding the patches, because they are new
22+
// if it was found - we don't add it because it is technically an undo operation.
23+
// This can happen if user runs undo before sync happened, so we we are avoiding
24+
// sending it to the server unnecessarily.
25+
if (dequeue(transactionId) === false) {
26+
queue.push({ transactionId, changes });
27+
}
28+
};
29+
30+
export const sync = () => {
31+
if (queue.length === 0) return [];
32+
const queueCopy = [...queue];
33+
queue.splice(0);
34+
return queueCopy;
35+
};

0 commit comments

Comments
 (0)