@homeostate/core 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +34 -7
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/apply.d.ts +37 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/apply.js +58 -0
- package/dist/apply.js.map +1 -0
- package/dist/change.d.ts +6 -3
- package/dist/change.d.ts.map +1 -1
- package/dist/change.js +0 -2
- package/dist/change.js.map +1 -1
- package/dist/diff.d.ts +2 -3
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +156 -166
- package/dist/diff.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/memory-backend.d.ts +11 -0
- package/dist/memory-backend.d.ts.map +1 -0
- package/dist/memory-backend.js +26 -0
- package/dist/memory-backend.js.map +1 -0
- package/dist/patching.d.ts +6 -16
- package/dist/patching.d.ts.map +1 -1
- package/dist/patching.js +46 -137
- package/dist/patching.js.map +1 -1
- package/dist/sync-engine.d.ts +14 -8
- package/dist/sync-engine.d.ts.map +1 -1
- package/dist/sync-engine.js +67 -93
- package/dist/sync-engine.js.map +1 -1
- package/dist/types.d.ts +42 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -3
- package/dist/types.js.map +1 -1
- package/package.json +7 -11
- package/src/apply.ts +87 -0
- package/src/change.ts +23 -0
- package/src/diff.ts +229 -0
- package/src/index.ts +18 -0
- package/src/memory-backend.ts +38 -0
- package/src/patching.ts +86 -0
- package/src/sync-engine.ts +135 -0
- package/src/types.ts +98 -0
- package/dist/mapping.d.ts +0 -19
- package/dist/mapping.d.ts.map +0 -1
- package/dist/mapping.js +0 -45
- package/dist/mapping.js.map +0 -1
package/dist/sync-engine.js
CHANGED
|
@@ -1,136 +1,110 @@
|
|
|
1
|
-
import { defaultSyncFilter } from
|
|
2
|
-
import {
|
|
1
|
+
import { defaultSyncFilter } from "./types.js";
|
|
2
|
+
import { patchState } from "./patching.js";
|
|
3
3
|
/**
|
|
4
|
-
* Creates a sync engine that
|
|
5
|
-
*
|
|
4
|
+
* Creates a sync engine that keeps a store (via its adapter) and a CRDT backend
|
|
5
|
+
* in sync in both directions.
|
|
6
6
|
*
|
|
7
|
-
* This is the core abstraction that makes the sync logic state-manager
|
|
7
|
+
* This is the core abstraction that makes the sync logic state-manager and
|
|
8
|
+
* CRDT-library agnostic.
|
|
9
|
+
*
|
|
10
|
+
* On `connect()` the two sides are reconciled per key over the filtered view: a key the
|
|
11
|
+
* backend holds wins over the store's value, and a synced key the backend lacks stays in
|
|
12
|
+
* the store and is seeded into the backend unless `seed` is `'never'`. Afterwards the
|
|
13
|
+
* backend owns the synced document: every write replaces it with the store's filtered
|
|
14
|
+
* state, and a key removed from the backend is removed from the store.
|
|
8
15
|
*
|
|
9
16
|
* @example
|
|
10
17
|
* ```typescript
|
|
11
|
-
* const
|
|
18
|
+
* const backend = createYjsBackend(new Y.Doc(), 'shared');
|
|
12
19
|
* const adapter = new ZustandAdapter(store);
|
|
13
|
-
* const engine = createSyncEngine(
|
|
20
|
+
* const engine = createSyncEngine(backend, adapter);
|
|
14
21
|
* engine.connect();
|
|
15
22
|
* ```
|
|
16
23
|
*/
|
|
17
|
-
export function createSyncEngine(
|
|
18
|
-
const {
|
|
19
|
-
// The root Y.Map that the store is written and read from
|
|
20
|
-
const yMap = doc.getMap(name);
|
|
24
|
+
export function createSyncEngine(backend, adapter, config = {}) {
|
|
25
|
+
const { filter = defaultSyncFilter, seed = "if-empty" } = config;
|
|
21
26
|
let connected = false;
|
|
22
27
|
let storeUnsubscribe = null;
|
|
23
|
-
let
|
|
24
|
-
let
|
|
25
|
-
/**
|
|
26
|
-
* Filter state to only include syncable properties
|
|
27
|
-
*/
|
|
28
|
+
let backendUnsubscribe = null;
|
|
29
|
+
let applyingRemote = false;
|
|
28
30
|
const filterState = (state) => {
|
|
29
|
-
if (typeof state !== 'object' || state === null) {
|
|
30
|
-
return state;
|
|
31
|
-
}
|
|
32
31
|
const filtered = {};
|
|
33
32
|
for (const [key, value] of Object.entries(state)) {
|
|
34
|
-
if (filter(key, value))
|
|
33
|
+
if (filter(key, value))
|
|
35
34
|
filtered[key] = value;
|
|
36
|
-
}
|
|
37
35
|
}
|
|
38
36
|
return filtered;
|
|
39
37
|
};
|
|
38
|
+
const readBackend = () => {
|
|
39
|
+
const value = backend.read();
|
|
40
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
41
|
+
};
|
|
40
42
|
/**
|
|
41
|
-
*
|
|
43
|
+
* Builds the next store state from `remote`. With `keepLocalOnly`, a synced key the
|
|
44
|
+
* backend does not hold is left in place instead of being deleted; that is the
|
|
45
|
+
* connect-time reconciliation. Otherwise `remote` is the whole synced state.
|
|
42
46
|
*/
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
isUpdatingFromStore = false;
|
|
47
|
+
const mergeStates = (current, remote, keepLocalOnly) => {
|
|
48
|
+
const synced = filterState(current);
|
|
49
|
+
const target = keepLocalOnly ? { ...synced, ...remote } : remote;
|
|
50
|
+
const patched = patchState(synced, target);
|
|
51
|
+
if (patched === synced)
|
|
52
|
+
return current;
|
|
53
|
+
const merged = { ...current, ...patched };
|
|
54
|
+
for (const key of Object.keys(synced)) {
|
|
55
|
+
if (!(key in patched))
|
|
56
|
+
delete merged[key];
|
|
56
57
|
}
|
|
58
|
+
return merged;
|
|
57
59
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
const syncToStore = () => {
|
|
62
|
-
if (isUpdatingFromStore)
|
|
60
|
+
const syncToBackend = () => {
|
|
61
|
+
if (applyingRemote)
|
|
63
62
|
return;
|
|
64
|
-
|
|
63
|
+
backend.write(filterState(adapter.getState()));
|
|
64
|
+
};
|
|
65
|
+
const applyRemote = (remote, keepLocalOnly) => {
|
|
66
|
+
applyingRemote = true;
|
|
65
67
|
try {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
adapter.setState(mergedState, true);
|
|
68
|
+
const current = adapter.getState();
|
|
69
|
+
const merged = mergeStates(current, remote, keepLocalOnly);
|
|
70
|
+
if (merged !== current)
|
|
71
|
+
adapter.setState(merged);
|
|
71
72
|
}
|
|
72
73
|
finally {
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
/**
|
|
77
|
-
* Merge Yjs state with current store state, preserving non-syncable properties
|
|
78
|
-
*/
|
|
79
|
-
const mergeStates = (current, fromYjs) => {
|
|
80
|
-
if (typeof current !== 'object' || current === null) {
|
|
81
|
-
return fromYjs;
|
|
82
|
-
}
|
|
83
|
-
const currentObj = current;
|
|
84
|
-
const yjsObj = fromYjs;
|
|
85
|
-
// Start with current state (preserves functions, etc.)
|
|
86
|
-
const merged = { ...currentObj };
|
|
87
|
-
// Apply Yjs state using patching for proper deep merging
|
|
88
|
-
const patchedData = patchState({ ...merged }, yjsObj);
|
|
89
|
-
// Restore non-syncable properties (like functions)
|
|
90
|
-
for (const [key, value] of Object.entries(currentObj)) {
|
|
91
|
-
if (!filter(key, value)) {
|
|
92
|
-
patchedData[key] = value;
|
|
93
|
-
}
|
|
74
|
+
applyingRemote = false;
|
|
94
75
|
}
|
|
95
|
-
return patchedData;
|
|
96
76
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
const handleYjsChange = () => {
|
|
101
|
-
syncToStore();
|
|
77
|
+
const syncToStore = () => {
|
|
78
|
+
applyRemote(filterState(readBackend()), false);
|
|
102
79
|
};
|
|
103
80
|
return {
|
|
104
81
|
connect: () => {
|
|
105
82
|
if (connected)
|
|
106
83
|
return;
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
84
|
+
const remote = filterState(readBackend());
|
|
85
|
+
const local = filterState(adapter.getState());
|
|
86
|
+
const remoteKeys = new Set(Object.keys(remote));
|
|
87
|
+
const missing = Object.keys(local).filter((key) => !remoteKeys.has(key));
|
|
88
|
+
if (seed === "if-empty" && missing.length > 0) {
|
|
89
|
+
const seeded = { ...remote };
|
|
90
|
+
for (const key of missing)
|
|
91
|
+
seeded[key] = local[key];
|
|
92
|
+
backend.write(seeded);
|
|
93
|
+
}
|
|
94
|
+
applyRemote(remote, true);
|
|
95
|
+
backendUnsubscribe = backend.subscribe(syncToStore);
|
|
96
|
+
storeUnsubscribe = adapter.subscribe(syncToBackend);
|
|
119
97
|
connected = true;
|
|
120
98
|
},
|
|
121
99
|
disconnect: () => {
|
|
122
100
|
if (!connected)
|
|
123
101
|
return;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
storeUnsubscribe();
|
|
129
|
-
storeUnsubscribe = null;
|
|
130
|
-
}
|
|
102
|
+
backendUnsubscribe?.();
|
|
103
|
+
backendUnsubscribe = null;
|
|
104
|
+
storeUnsubscribe?.();
|
|
105
|
+
storeUnsubscribe = null;
|
|
131
106
|
connected = false;
|
|
132
107
|
},
|
|
133
|
-
getYMap: () => yMap,
|
|
134
108
|
isConnected: () => connected,
|
|
135
109
|
};
|
|
136
110
|
}
|
package/dist/sync-engine.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-engine.js","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"sync-engine.js","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core types for the state-manager agnostic
|
|
3
|
-
* These interfaces
|
|
2
|
+
* Core types for the state-manager and CRDT-backend agnostic sync engine.
|
|
3
|
+
* These interfaces let any store be kept in sync through any replicated backend.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Unsubscribe function returned by subscribe
|
|
@@ -9,47 +9,73 @@ export type Unsubscribe = () => void;
|
|
|
9
9
|
/**
|
|
10
10
|
* Adapter interface that bridges a specific state manager with the sync engine.
|
|
11
11
|
* Implement this interface to add support for any state manager (Zustand, Redux, MobX, etc.)
|
|
12
|
+
*
|
|
13
|
+
* State must be plain JSON: objects, arrays, strings, numbers, booleans, and null.
|
|
14
|
+
* Functions are dropped by the default filter; other values such as Date, Map, or Set
|
|
15
|
+
* are neither diffed nor synced.
|
|
12
16
|
*/
|
|
13
|
-
export interface StoreAdapter<S> {
|
|
17
|
+
export interface StoreAdapter<S extends object> {
|
|
14
18
|
/** Get the current state from the store */
|
|
15
19
|
getState: () => S;
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* Replace the store state. Called only for changes coming from the backend; the
|
|
22
|
+
* engine ignores store notifications raised while it runs, so the adapter needs no
|
|
23
|
+
* echo suppression of its own.
|
|
20
24
|
*/
|
|
21
|
-
setState: (state: S
|
|
25
|
+
setState: (state: S) => void;
|
|
22
26
|
/**
|
|
23
|
-
* Subscribe to store changes that should
|
|
27
|
+
* Subscribe to store changes that should be written to the backend.
|
|
24
28
|
* @returns Unsubscribe function
|
|
25
29
|
*/
|
|
26
30
|
subscribe: (onStoreChange: () => void) => Unsubscribe;
|
|
27
|
-
/** Get the initial state of the store */
|
|
28
|
-
getInitialState: () => S;
|
|
29
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Backend that holds the synced subtree in a CRDT or any other replicated store.
|
|
34
|
+
* The engine only ever exchanges plain JSON with it.
|
|
35
|
+
*/
|
|
36
|
+
export interface CrdtBackend {
|
|
37
|
+
/** Plain JSON snapshot of the synced subtree. Must not alias backend internals. */
|
|
38
|
+
read: () => unknown;
|
|
39
|
+
/**
|
|
40
|
+
* Make the backend equal to `next` in one atomic transaction.
|
|
41
|
+
* The backend decides the granularity of the operations; `getChanges` is exported for that.
|
|
42
|
+
*/
|
|
43
|
+
write: (next: unknown) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Notify about changes that did not come through this backend's own `write`.
|
|
46
|
+
* @returns Unsubscribe function
|
|
47
|
+
*/
|
|
48
|
+
subscribe: (onRemoteChange: () => void) => Unsubscribe;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether `connect()` may write the store's synced keys that the backend does not hold.
|
|
52
|
+
* Reconciliation is per key over the filtered view: a key the backend holds always wins
|
|
53
|
+
* over the store's value, a key only the store holds always stays in the store.
|
|
54
|
+
* - `'if-empty'`: seed the keys the backend lacks, in one write (default)
|
|
55
|
+
* - `'never'`: leave seeding to the caller; the keys are written on the next local change
|
|
56
|
+
*/
|
|
57
|
+
export type SeedStrategy = "if-empty" | "never";
|
|
30
58
|
/**
|
|
31
59
|
* Configuration options for the sync engine
|
|
32
60
|
*/
|
|
33
61
|
export interface SyncEngineConfig {
|
|
34
|
-
/** Name of the Y.Map in the Y.Doc to store the state */
|
|
35
|
-
name: string;
|
|
36
62
|
/**
|
|
37
|
-
* Filter function to determine which state keys should be synced.
|
|
63
|
+
* Filter function to determine which state keys should be synced, in both directions.
|
|
38
64
|
* Return true to sync the key, false to exclude it.
|
|
39
65
|
* By default, functions are excluded from sync.
|
|
40
66
|
*/
|
|
41
67
|
filter?: (key: string, value: unknown) => boolean;
|
|
68
|
+
/** Seeding strategy used by `connect()`; defaults to `'if-empty'` */
|
|
69
|
+
seed?: SeedStrategy;
|
|
42
70
|
}
|
|
43
71
|
/**
|
|
44
|
-
* The sync engine interface - manages bidirectional sync between store and
|
|
72
|
+
* The sync engine interface - manages bidirectional sync between a store and a backend
|
|
45
73
|
*/
|
|
46
74
|
export interface SyncEngine {
|
|
47
75
|
/** Start synchronization */
|
|
48
76
|
connect: () => void;
|
|
49
77
|
/** Stop synchronization and cleanup */
|
|
50
78
|
disconnect: () => void;
|
|
51
|
-
/** Get the underlying Y.Map */
|
|
52
|
-
getYMap: () => unknown;
|
|
53
79
|
/** Check if engine is connected */
|
|
54
80
|
isConnected: () => boolean;
|
|
55
81
|
}
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM;IAC5C,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC,CAAC;IAElB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAE7B;;;OAGG;IACH,SAAS,EAAE,CAAC,aAAa,EAAE,MAAM,IAAI,KAAK,WAAW,CAAC;CACvD;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,IAAI,EAAE,MAAM,OAAO,CAAC;IAEpB;;;OAGG;IACH,KAAK,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IAE/B;;;OAGG;IACH,SAAS,EAAE,CAAC,cAAc,EAAE,MAAM,IAAI,KAAK,WAAW,CAAC;CACxD;AAED;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,OAAO,CAAC;AAEhD;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IAClD,qEAAqE;IACrE,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,4BAA4B;IAC5B,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,uCAAuC;IACvC,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,mCAAmC;IACnC,WAAW,EAAE,MAAM,OAAO,CAAC;CAC5B;AAED;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,OAAO,OAAO,KAAG,OAEhE,CAAC"}
|
package/dist/types.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core types for the state-manager agnostic
|
|
3
|
-
* These interfaces
|
|
2
|
+
* Core types for the state-manager and CRDT-backend agnostic sync engine.
|
|
3
|
+
* These interfaces let any store be kept in sync through any replicated backend.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Default filter that excludes functions from sync
|
|
7
7
|
*/
|
|
8
8
|
export const defaultSyncFilter = (_key, value) => {
|
|
9
|
-
return typeof value !==
|
|
9
|
+
return typeof value !== "function";
|
|
10
10
|
};
|
|
11
11
|
//# sourceMappingURL=types.js.map
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAyFH;;GAEG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,IAAY,EAAE,KAAc,EAAW,EAAE;IACzE,OAAO,OAAO,KAAK,KAAK,UAAU,CAAC;AACrC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeostate/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "State-manager agnostic sync engine between a store adapter and a
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "State-manager and CRDT-backend agnostic sync engine between a store adapter and a CrdtBackend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -9,12 +9,11 @@
|
|
|
9
9
|
"url": "git+https://github.com/mixedrays/homeostate.git",
|
|
10
10
|
"directory": "packages/core"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://
|
|
12
|
+
"homepage": "https://homeostate.pages.dev",
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/mixedrays/homeostate/issues"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
|
-
"yjs",
|
|
18
17
|
"crdt",
|
|
19
18
|
"sync",
|
|
20
19
|
"state-management",
|
|
@@ -30,19 +29,16 @@
|
|
|
30
29
|
},
|
|
31
30
|
"files": [
|
|
32
31
|
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"!src/**/__tests__",
|
|
33
34
|
"README.md",
|
|
34
35
|
"LICENSE",
|
|
35
|
-
"THIRD_PARTY_NOTICES.md"
|
|
36
|
+
"THIRD_PARTY_NOTICES.md",
|
|
37
|
+
"CHANGELOG.md"
|
|
36
38
|
],
|
|
37
39
|
"publishConfig": {
|
|
38
40
|
"access": "public"
|
|
39
41
|
},
|
|
40
|
-
"peerDependencies": {
|
|
41
|
-
"yjs": "^13.6.0"
|
|
42
|
-
},
|
|
43
|
-
"devDependencies": {
|
|
44
|
-
"yjs": "^13.6.23"
|
|
45
|
-
},
|
|
46
42
|
"scripts": {
|
|
47
43
|
"build": "tsc -b"
|
|
48
44
|
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ChangeType, type Change } from "./change.js";
|
|
2
|
+
|
|
3
|
+
type Plain = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How one store writes into its containers. `applyChanges` decides *what* to write; an
|
|
7
|
+
* implementation of this decides *how*, so the same edit script drives a MobX observable
|
|
8
|
+
* tree, a Valtio proxy, or a plain object.
|
|
9
|
+
*
|
|
10
|
+
* Every method receives a container that already lives in the store, never a copy.
|
|
11
|
+
*/
|
|
12
|
+
export interface ApplyOps {
|
|
13
|
+
/** Assign `value` at `key`, creating the property if the target is a record. */
|
|
14
|
+
set(target: object, key: string | number, value: unknown): void;
|
|
15
|
+
/** Remove the record property `key`. */
|
|
16
|
+
remove(target: object, key: string): void;
|
|
17
|
+
/** `Array.prototype.splice`: drop `deleteCount` elements at `index`, then add `inserted`. */
|
|
18
|
+
splice(
|
|
19
|
+
target: unknown[],
|
|
20
|
+
index: number,
|
|
21
|
+
deleteCount: number,
|
|
22
|
+
inserted: unknown[],
|
|
23
|
+
): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Revises `value` by the character-level edit script `changes`, which is what `getChanges`
|
|
28
|
+
* returns for two strings. Each index addresses the string as revised by the steps before it.
|
|
29
|
+
*/
|
|
30
|
+
export const applyStringChanges = (value: string, changes: Change[]): string =>
|
|
31
|
+
changes.reduce((revised, [type, index, inserted]) => {
|
|
32
|
+
const at = index as number;
|
|
33
|
+
if (type === ChangeType.INSERT)
|
|
34
|
+
return revised.slice(0, at) + (inserted as string) + revised.slice(at);
|
|
35
|
+
if (type === ChangeType.DELETE)
|
|
36
|
+
return revised.slice(0, at) + revised.slice(at + 1);
|
|
37
|
+
return revised;
|
|
38
|
+
}, value);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Applies `changes` to `target` in place, mutating only the paths the edit script names.
|
|
42
|
+
* Everything else keeps its identity, which is what fine-grained stores react to.
|
|
43
|
+
*
|
|
44
|
+
* `changes` must be `getChanges(before, after)` where `before` describes the shape `target`
|
|
45
|
+
* currently holds — an adapter gets that by diffing against the snapshot it last handed out.
|
|
46
|
+
* A `PENDING` step carries no replacement value, so it can only be followed by recursing into
|
|
47
|
+
* the container already there; a target that does not mirror `before` leaves such a step
|
|
48
|
+
* unapplied rather than writing something wrong.
|
|
49
|
+
*
|
|
50
|
+
* @param target The container to mutate — a record, or an array for a positional edit script.
|
|
51
|
+
* @param changes The edit script to apply, in order.
|
|
52
|
+
* @param ops How this store writes; see {@link ApplyOps}.
|
|
53
|
+
*/
|
|
54
|
+
export const applyChanges = (
|
|
55
|
+
target: object,
|
|
56
|
+
changes: Change[],
|
|
57
|
+
ops: ApplyOps,
|
|
58
|
+
): void => {
|
|
59
|
+
const array = Array.isArray(target) ? (target as unknown[]) : null;
|
|
60
|
+
|
|
61
|
+
for (const [type, key, value] of changes) {
|
|
62
|
+
switch (type) {
|
|
63
|
+
case ChangeType.PENDING: {
|
|
64
|
+
const child = (target as Plain)[key as string];
|
|
65
|
+
if (typeof child === "string")
|
|
66
|
+
ops.set(target, key, applyStringChanges(child, value as Change[]));
|
|
67
|
+
else if (child !== null && typeof child === "object")
|
|
68
|
+
applyChanges(child, value as Change[], ops);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case ChangeType.DELETE:
|
|
73
|
+
if (array) ops.splice(array, key as number, 1, []);
|
|
74
|
+
else ops.remove(target, key as string);
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case ChangeType.INSERT:
|
|
78
|
+
if (array) ops.splice(array, key as number, 0, [value]);
|
|
79
|
+
else ops.set(target, key, value);
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case ChangeType.UPDATE:
|
|
83
|
+
ops.set(target, key, value);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
package/src/change.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describes the change that needs to be made.
|
|
3
|
+
*/
|
|
4
|
+
export enum ChangeType {
|
|
5
|
+
/** A value was inserted. */
|
|
6
|
+
INSERT = "insert",
|
|
7
|
+
/** A value was replaced. */
|
|
8
|
+
UPDATE = "update",
|
|
9
|
+
/** A value was deleted. */
|
|
10
|
+
DELETE = "delete",
|
|
11
|
+
/** The value requires a recursive diff to identify further changes. */
|
|
12
|
+
PENDING = "pending",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One step in turning a container into another: `[type, key, value]`.
|
|
17
|
+
*
|
|
18
|
+
* Steps apply in order. A numeric key addresses the array or string as revised by the
|
|
19
|
+
* steps before it, so appliers never sort or clamp. `value` is the inserted or replacing
|
|
20
|
+
* value (a string insert may carry several characters), `undefined` for a delete, and the
|
|
21
|
+
* nested `Change[]` for a pending entry.
|
|
22
|
+
*/
|
|
23
|
+
export type Change = [ChangeType, string | number, unknown];
|