articulated 0.1.0
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/LICENSE +9 -0
- package/README.md +106 -0
- package/build/commonjs/id.d.ts +61 -0
- package/build/commonjs/id.js +39 -0
- package/build/commonjs/id.js.map +1 -0
- package/build/commonjs/id_list.d.ts +226 -0
- package/build/commonjs/id_list.js +426 -0
- package/build/commonjs/id_list.js.map +1 -0
- package/build/commonjs/index.d.ts +3 -0
- package/build/commonjs/index.js +20 -0
- package/build/commonjs/index.js.map +1 -0
- package/build/commonjs/saved_id_list.d.ts +13 -0
- package/build/commonjs/saved_id_list.js +3 -0
- package/build/commonjs/saved_id_list.js.map +1 -0
- package/build/esm/id.d.ts +61 -0
- package/build/esm/id.js +34 -0
- package/build/esm/id.js.map +1 -0
- package/build/esm/id_list.d.ts +226 -0
- package/build/esm/id_list.js +421 -0
- package/build/esm/id_list.js.map +1 -0
- package/build/esm/index.d.ts +3 -0
- package/build/esm/index.js +4 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/saved_id_list.d.ts +13 -0
- package/build/esm/saved_id_list.js +2 -0
- package/build/esm/saved_id_list.js.map +1 -0
- package/package.json +69 -0
- package/src/id.ts +75 -0
- package/src/id_list.ts +471 -0
- package/src/index.ts +3 -0
- package/src/saved_id_list.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saved_id_list.js","sourceRoot":"","sources":["../../src/saved_id_list.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "articulated",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A TypeScript library for managing stable element identifiers in mutable lists",
|
|
5
|
+
"author": "Matthew Weidner",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/mweidner037/articulated/issues"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/mweidner037/articulated#readme",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/mweidner037/articulated.git"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"list",
|
|
17
|
+
"UUID",
|
|
18
|
+
"CRDT"
|
|
19
|
+
],
|
|
20
|
+
"main": "build/commonjs/index.js",
|
|
21
|
+
"browser": "build/esm/index.js",
|
|
22
|
+
"module": "build/esm/index.js",
|
|
23
|
+
"types": "build/esm/index.d.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"/build",
|
|
26
|
+
"/src"
|
|
27
|
+
],
|
|
28
|
+
"directories": {
|
|
29
|
+
"lib": "src"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
|
37
|
+
"@types/chai": "^4.3.4",
|
|
38
|
+
"@types/mocha": "^10.0.1",
|
|
39
|
+
"@typescript-eslint/eslint-plugin": "^7.7.1",
|
|
40
|
+
"@typescript-eslint/parser": "^7.7.1",
|
|
41
|
+
"chai": "^4.3.7",
|
|
42
|
+
"eslint": "^8.57.0",
|
|
43
|
+
"eslint-config-prettier": "^9.1.0",
|
|
44
|
+
"eslint-plugin-import": "^2.29.1",
|
|
45
|
+
"mocha": "^10.2.0",
|
|
46
|
+
"npm-run-all": "^4.1.5",
|
|
47
|
+
"nyc": "^15.1.0",
|
|
48
|
+
"prettier": "^2.8.4",
|
|
49
|
+
"ts-node": "^10.9.2",
|
|
50
|
+
"typedoc": "^0.25.13",
|
|
51
|
+
"typescript": "^5.4.5"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"prepack": "npm run clean && npm run build && npm run test",
|
|
55
|
+
"build": "npm-run-all build:*",
|
|
56
|
+
"build:ts": "tsc -p tsconfig.json && tsc -p tsconfig.commonjs.json",
|
|
57
|
+
"test": "npm-run-all test:*",
|
|
58
|
+
"test:lint": "eslint --ext .ts,.js .",
|
|
59
|
+
"test:unit": "TS_NODE_PROJECT='./tsconfig.dev.json' mocha",
|
|
60
|
+
"test:format": "prettier --check .",
|
|
61
|
+
"coverage": "npm-run-all coverage:*",
|
|
62
|
+
"coverage:run": "nyc npm run test:unit",
|
|
63
|
+
"coverage:open": "open coverage/index.html > /dev/null 2>&1 &",
|
|
64
|
+
"fix": "npm-run-all fix:*",
|
|
65
|
+
"fix:format": "prettier --write .",
|
|
66
|
+
"docs": "typedoc --options typedoc.json src/index.ts",
|
|
67
|
+
"clean": "rm -rf build docs coverage .nyc_output"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/id.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unique and immutable id for a list element.
|
|
3
|
+
*
|
|
4
|
+
* ElementIds are conceptually the same as UUIDs (or nanoids, etc.).
|
|
5
|
+
* However, when a single thread generates a series of ElementIds, you are
|
|
6
|
+
* allowed to optimize by generating a single UUID/nanoid/etc. and using that as the "bunchId"
|
|
7
|
+
* for a "bunch" of elements, with varying `counter`.
|
|
8
|
+
* The resulting ElementIds compress better than a set of UUIDs, but they are
|
|
9
|
+
* still globally unique, even if another thread/user/device generates ElementIds concurrently.
|
|
10
|
+
*
|
|
11
|
+
* For example, if a user types a sentence from left to right, you may generate a
|
|
12
|
+
* single `bunchId` and assign their characters the sequential ElementIds
|
|
13
|
+
* `{ bunchId, counter: 0 }, { bunchId, counter: 1 }, { bunchId, counter: 2 }, ...`.
|
|
14
|
+
* An IdList will store all of these as a single object instead of
|
|
15
|
+
* one object per ElementId.
|
|
16
|
+
*/
|
|
17
|
+
export interface ElementId {
|
|
18
|
+
/**
|
|
19
|
+
* A UUID or similar globally unique ID.
|
|
20
|
+
*
|
|
21
|
+
* You must choose this so that the resulting ElementId is globally unique,
|
|
22
|
+
* even if another part of your application creates
|
|
23
|
+
* ElementIds concurrently (possibly on a different device).
|
|
24
|
+
*/
|
|
25
|
+
readonly bunchId: string;
|
|
26
|
+
/**
|
|
27
|
+
* An integer used to distinguish ElementIds in the same bunch.
|
|
28
|
+
*
|
|
29
|
+
* Typically, you will assign sequential counters 0, 1, 2, ... to list elements
|
|
30
|
+
* that are initially inserted in a left-to-right order.
|
|
31
|
+
* IdList is optimized for this case, but it is not mandatory.
|
|
32
|
+
* In particular, it is okay if future edits cause the sequential ids to be
|
|
33
|
+
* separated, partially deleted, or even reordered.
|
|
34
|
+
*
|
|
35
|
+
* Negative integers are supported by IdList (e.g., for optimized right-to-left insertions),
|
|
36
|
+
* though you may choose to avoid these in your application, to make serialization easier.
|
|
37
|
+
*/
|
|
38
|
+
readonly counter: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Equals function for ElementIds.
|
|
43
|
+
*/
|
|
44
|
+
export function equalsId(a: ElementId, b: ElementId) {
|
|
45
|
+
return a.counter === b.counter && a.bunchId === b.bunchId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Expands a "compressed" sequence of ElementIds that have the same bunchId but
|
|
50
|
+
* sequentially increasing counters, starting at `startId.counter`.
|
|
51
|
+
*
|
|
52
|
+
* For example,
|
|
53
|
+
* ```ts
|
|
54
|
+
* expandIds({ bunchId: "foo", counter: 7 }, 3)
|
|
55
|
+
* ```
|
|
56
|
+
* returns
|
|
57
|
+
* ```ts
|
|
58
|
+
* [
|
|
59
|
+
* { bunchId: "foo", counter: 7 },
|
|
60
|
+
* { bunchId: "foo", counter: 8 },
|
|
61
|
+
* { bunchId: "foo", counter: 9 }
|
|
62
|
+
* ]
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function expandIds(startId: ElementId, count: number): ElementId[] {
|
|
66
|
+
if (!(Number.isSafeInteger(count) && count >= 0)) {
|
|
67
|
+
throw new Error(`Invalid count: ${count}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ans: ElementId[] = [];
|
|
71
|
+
for (let i = 0; i < count; i++) {
|
|
72
|
+
ans.push({ bunchId: startId.bunchId, counter: startId.counter + i });
|
|
73
|
+
}
|
|
74
|
+
return ans;
|
|
75
|
+
}
|
package/src/id_list.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { ElementId, equalsId } from "./id";
|
|
2
|
+
import { SavedIdList } from "./saved_id_list";
|
|
3
|
+
|
|
4
|
+
interface ListElement {
|
|
5
|
+
id: ElementId;
|
|
6
|
+
isDeleted: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A list of ElementIds.
|
|
11
|
+
*
|
|
12
|
+
* An IdList helps you assign a unique immutable id to each element of a list, such
|
|
13
|
+
* as a todo-list or a text document (= list of characters). That way, you can keep track
|
|
14
|
+
* of those elements even as their (array) indices change due to insert/delete operations
|
|
15
|
+
* earlier in the list.
|
|
16
|
+
*
|
|
17
|
+
* Any id that has been inserted into an IdList remains **known** to that list indefinitely,
|
|
18
|
+
* allowing you to reference it in insertAfter/insertBefore operations. Calling {@link delete}
|
|
19
|
+
* merely marks an id as deleted (not present); it remains in memory as a "tombstone".
|
|
20
|
+
* This is useful in collaborative settings, since another user might instruct you to
|
|
21
|
+
* call `insertAfter(before, newId)` when you have already deleted `before` locally.
|
|
22
|
+
* If that is not a concern and you truly want to make an id no longer known, instead
|
|
23
|
+
* call {@link uninsert}.
|
|
24
|
+
*
|
|
25
|
+
* See {@link ElementId} for advice on generating ElementIds. IdList is optimized for
|
|
26
|
+
* the case where sequential ElementIds often have the same bunchId and sequential counters.
|
|
27
|
+
* However, you are not required to order ids in this way - it is okay if future edits
|
|
28
|
+
* cause such ids to be separated, partially deleted, or even reordered.
|
|
29
|
+
*/
|
|
30
|
+
export class IdList {
|
|
31
|
+
private readonly state: ListElement[];
|
|
32
|
+
private _length: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Constructs an empty list.
|
|
36
|
+
*
|
|
37
|
+
* To begin with a non-empty list, use {@link IdList.from} or {@link IdList.fromIds}.
|
|
38
|
+
*/
|
|
39
|
+
constructor() {
|
|
40
|
+
this.state = [];
|
|
41
|
+
this._length = 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Constructs a list with the given known ids and their isDeleted status, in list order.
|
|
46
|
+
*/
|
|
47
|
+
static from(state: Iterable<{ id: ElementId; isDeleted: boolean }>) {
|
|
48
|
+
const list = new IdList();
|
|
49
|
+
for (const { id, isDeleted } of state) {
|
|
50
|
+
// Clone to prevent aliasing.
|
|
51
|
+
list.state.push({ id, isDeleted });
|
|
52
|
+
if (!isDeleted) list._length++;
|
|
53
|
+
}
|
|
54
|
+
return list;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Constructs a list with the given present ids.
|
|
59
|
+
*
|
|
60
|
+
* Typically, you instead want {@link IdList.from}, which allows you to also
|
|
61
|
+
* specify known-but-deleted ids. That way, you can reference the known-but-deleted ids
|
|
62
|
+
* in future insertAfter/insertBefore operations.
|
|
63
|
+
*/
|
|
64
|
+
static fromIds(ids: Iterable<ElementId>) {
|
|
65
|
+
const list = new IdList();
|
|
66
|
+
for (const id of ids) {
|
|
67
|
+
list.state.push({ id, isDeleted: false });
|
|
68
|
+
list._length++;
|
|
69
|
+
}
|
|
70
|
+
return list;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Inserts `newId` immediately after the given id (`before`), which may be deleted.
|
|
75
|
+
* All ids to the right of `before` are shifted one index to the right, in the manner
|
|
76
|
+
* of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice).
|
|
77
|
+
*
|
|
78
|
+
* Use `before = null` to insert at the beginning of the list, to the left of all
|
|
79
|
+
* known ids.
|
|
80
|
+
*
|
|
81
|
+
* @param count Provide this to bulk-insert `count` ids from left-to-right,
|
|
82
|
+
* starting with newId and proceeding with the same bunchId and sequential counters.
|
|
83
|
+
* @throws If `before` is not known.
|
|
84
|
+
* @throws If `newId` is already known.
|
|
85
|
+
*/
|
|
86
|
+
insertAfter(before: ElementId | null, newId: ElementId, count = 1) {
|
|
87
|
+
if (this.isKnown(newId)) {
|
|
88
|
+
throw new Error("newId is already known");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let index: number;
|
|
92
|
+
if (before === null) {
|
|
93
|
+
// -1 so index + 1 is 0: insert at the beginning of the list.
|
|
94
|
+
index = -1;
|
|
95
|
+
} else {
|
|
96
|
+
index = this.state.findIndex((elt) => equalsId(elt.id, before));
|
|
97
|
+
if (index === -1) {
|
|
98
|
+
throw new Error("before is not known");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.state.splice(index + 1, 0, ...expandElements(newId, false, count));
|
|
103
|
+
this._length += count;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inserts `newId` immediately before the given id (`after`), which may be deleted.
|
|
108
|
+
* All ids to the right of `after`, plus `after` itself, are shifted one index to the right, in the manner
|
|
109
|
+
* of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice).
|
|
110
|
+
*
|
|
111
|
+
* Use `after = null` to insert at the end of the list, to the right of all known ids.
|
|
112
|
+
*
|
|
113
|
+
* @param count Provide this to bulk-insert `count` ids from left-to-right,
|
|
114
|
+
* starting with newId and proceeding with the same bunchId and sequential counters.
|
|
115
|
+
* __Note__: Although the new ids are inserted to the left of `after`, they are still
|
|
116
|
+
* inserted in left-to-right order relative to each other.
|
|
117
|
+
* @throws If `after` is not known.
|
|
118
|
+
* @throws If `newId` is already known.
|
|
119
|
+
*/
|
|
120
|
+
insertBefore(after: ElementId | null, newId: ElementId, count = 1) {
|
|
121
|
+
if (this.isKnown(newId)) {
|
|
122
|
+
throw new Error("newId is already known");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let index: number;
|
|
126
|
+
if (after === null) {
|
|
127
|
+
index = this.state.length;
|
|
128
|
+
} else {
|
|
129
|
+
index = this.state.findIndex((elt) => equalsId(elt.id, after));
|
|
130
|
+
if (index === -1) {
|
|
131
|
+
throw new Error("after is not known");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// We insert the bunch from left-to-right even though it's insertBefore.
|
|
136
|
+
this.state.splice(index, 0, ...expandElements(newId, false, count));
|
|
137
|
+
this._length += count;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Un-inserts `id` from the list, making it no longer known or present in this list.
|
|
142
|
+
*
|
|
143
|
+
* Typically, you instead want to call {@link delete}, which marks `id` as deleted while
|
|
144
|
+
* it remains known. That way, you can reference `id` in future insertAfter/insertBefore
|
|
145
|
+
* operations, including ones sent concurrently by other devices.
|
|
146
|
+
*
|
|
147
|
+
* If `id` is already not known, this method does nothing.
|
|
148
|
+
*/
|
|
149
|
+
uninsert(id: ElementId) {
|
|
150
|
+
const index = this.state.findIndex((elt) => equalsId(elt.id, id));
|
|
151
|
+
if (index !== -1) {
|
|
152
|
+
this.state.splice(index, 1);
|
|
153
|
+
this._length--;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Marks `id` as deleted from this list. The id remains known (a "tombstone").
|
|
159
|
+
*
|
|
160
|
+
* Because `id` is still known, you can reference it in future insertAfter/insertBefore
|
|
161
|
+
* operations, including ones sent concurrently by other devices.
|
|
162
|
+
* However, it does occupy space in memory (compressed in common cases).
|
|
163
|
+
*
|
|
164
|
+
* For an exact inverse to `insertAfter(-, id)` or `insertBefore(-, id)`
|
|
165
|
+
* that makes `id` no longer known, see {@link uninsert}.
|
|
166
|
+
*
|
|
167
|
+
* If `id` is already deleted or not known, this method does nothing.
|
|
168
|
+
*/
|
|
169
|
+
delete(id: ElementId) {
|
|
170
|
+
const elt = this.state.find((elt) => equalsId(elt.id, id));
|
|
171
|
+
if (elt !== undefined && !elt.isDeleted) {
|
|
172
|
+
elt.isDeleted = true;
|
|
173
|
+
this._length--;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Un-marks `id` as deleted from this list, making it present again.
|
|
179
|
+
* This is an exact inverse to {@link delete}.
|
|
180
|
+
*
|
|
181
|
+
* If `id` is already present, this method does nothing.
|
|
182
|
+
*
|
|
183
|
+
* @throws If `id` is not known.
|
|
184
|
+
*/
|
|
185
|
+
undelete(id: ElementId) {
|
|
186
|
+
const elt = this.state.find((elt) => equalsId(elt.id, id));
|
|
187
|
+
if (elt === undefined) {
|
|
188
|
+
throw new Error("id is not known");
|
|
189
|
+
}
|
|
190
|
+
if (elt.isDeleted) {
|
|
191
|
+
elt.isDeleted = false;
|
|
192
|
+
this._length++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Accessors
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns whether `id` is present in the list, i.e., it is known and not deleted.
|
|
200
|
+
*
|
|
201
|
+
* If `id` is not known, false is returned.
|
|
202
|
+
*
|
|
203
|
+
* Compare to {@link isKnown}.
|
|
204
|
+
*/
|
|
205
|
+
has(id: ElementId): boolean {
|
|
206
|
+
const elt = this.state.find((elt) => equalsId(elt.id, id));
|
|
207
|
+
if (elt === undefined) return false;
|
|
208
|
+
return !elt.isDeleted;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Returns whether id is known to this list.
|
|
213
|
+
*
|
|
214
|
+
* Compare to {@link has}.
|
|
215
|
+
*/
|
|
216
|
+
isKnown(id: ElementId): boolean {
|
|
217
|
+
return this.state.some((elt) => equalsId(elt.id, id));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Returns the id at the given index in the list.
|
|
222
|
+
*
|
|
223
|
+
* @throws If index is out of bounds.
|
|
224
|
+
*/
|
|
225
|
+
at(index: number): ElementId {
|
|
226
|
+
if (!(Number.isSafeInteger(index) && 0 <= index && index < this.length)) {
|
|
227
|
+
throw new Error(`Index out of bounds: ${index} (length: ${this.length}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let remaining = index;
|
|
231
|
+
for (const elt of this.state) {
|
|
232
|
+
if (!elt.isDeleted) {
|
|
233
|
+
if (remaining === 0) return elt.id;
|
|
234
|
+
remaining--;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw new Error("Internal error");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Returns the index of `id` in the list.
|
|
243
|
+
*
|
|
244
|
+
* If `id` is known but deleted, the bias specifies what to return:
|
|
245
|
+
* - "none": -1.
|
|
246
|
+
* - "left": The index immediately to the left of `id`, possibly -1.
|
|
247
|
+
* - "right": The index immediately to the right of `id`, possibly `this.length`.
|
|
248
|
+
* Equivalently, the index where `id` would be if present.
|
|
249
|
+
*
|
|
250
|
+
* @throws If `id` is not known.
|
|
251
|
+
*/
|
|
252
|
+
indexOf(id: ElementId, bias: "none" | "left" | "right" = "none"): number {
|
|
253
|
+
/**
|
|
254
|
+
* The number of present ids less than id.
|
|
255
|
+
* Equivalently, the index id would have if present.
|
|
256
|
+
*/
|
|
257
|
+
let index = 0;
|
|
258
|
+
for (const elt of this.state) {
|
|
259
|
+
if (equalsId(elt.id, id)) {
|
|
260
|
+
// Found it.
|
|
261
|
+
if (elt.isDeleted) {
|
|
262
|
+
switch (bias) {
|
|
263
|
+
case "none":
|
|
264
|
+
return -1;
|
|
265
|
+
case "left":
|
|
266
|
+
return index - 1;
|
|
267
|
+
case "right":
|
|
268
|
+
return index;
|
|
269
|
+
}
|
|
270
|
+
} else return index;
|
|
271
|
+
}
|
|
272
|
+
if (!elt.isDeleted) index++;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
throw new Error("id is not known");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The length of the list.
|
|
280
|
+
*/
|
|
281
|
+
get length(): number {
|
|
282
|
+
return this._length;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Iterators and views
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Iterates over all present ids in the list.
|
|
289
|
+
*/
|
|
290
|
+
*[Symbol.iterator](): IterableIterator<ElementId> {
|
|
291
|
+
for (const elt of this.state) {
|
|
292
|
+
if (!elt.isDeleted) yield elt.id;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Iterates over all present ids in the list.
|
|
298
|
+
*/
|
|
299
|
+
values() {
|
|
300
|
+
return this[Symbol.iterator]();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Iterates over all __known__ ids in the list, indicating which are deleted.
|
|
305
|
+
*/
|
|
306
|
+
valuesWithDeleted(): IterableIterator<{ id: ElementId; isDeleted: boolean }> {
|
|
307
|
+
return this.state.values();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Returns an independent copy of this list, including known but deleted ids.
|
|
312
|
+
*/
|
|
313
|
+
clone(): IdList {
|
|
314
|
+
return IdList.from(this.state);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _knownIds?: KnownIdView;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A live-updating view of this list that treats all known ids as present.
|
|
321
|
+
* That is, it ignores isDeleted status when computing list indices or iterating.
|
|
322
|
+
*/
|
|
323
|
+
get knownIds(): KnownIdView {
|
|
324
|
+
if (this._knownIds === undefined) {
|
|
325
|
+
this._knownIds = new KnownIdView(this, this.state);
|
|
326
|
+
}
|
|
327
|
+
return this._knownIds;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Save and load
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Returns a compact JSON representation of this list's internal state.
|
|
334
|
+
* Load with {@link load}.
|
|
335
|
+
*
|
|
336
|
+
* See {@link SavedIdList} for a description of the save format.
|
|
337
|
+
*/
|
|
338
|
+
save(): SavedIdList {
|
|
339
|
+
const ans: SavedIdList = [];
|
|
340
|
+
|
|
341
|
+
for (const { id, isDeleted } of this.state) {
|
|
342
|
+
if (ans.length !== 0) {
|
|
343
|
+
const current = ans[ans.length - 1];
|
|
344
|
+
if (
|
|
345
|
+
id.bunchId === current.bunchId &&
|
|
346
|
+
id.counter === current.startCounter + current.count &&
|
|
347
|
+
isDeleted === current.isDeleted
|
|
348
|
+
) {
|
|
349
|
+
current.count++;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
ans.push({
|
|
355
|
+
bunchId: id.bunchId,
|
|
356
|
+
startCounter: id.counter,
|
|
357
|
+
count: 1,
|
|
358
|
+
isDeleted,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return ans;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Loads a saved state returned by {@link save}, __overwriting__ the current state of this list.
|
|
367
|
+
*/
|
|
368
|
+
load(savedState: SavedIdList) {
|
|
369
|
+
this.state.length = 0;
|
|
370
|
+
this._length = 0;
|
|
371
|
+
|
|
372
|
+
for (const { bunchId, startCounter, count, isDeleted } of savedState) {
|
|
373
|
+
if (!(Number.isSafeInteger(count) && count >= 0)) {
|
|
374
|
+
throw new Error(`Invalid length: ${count}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i < count; i++) {
|
|
378
|
+
this.state.push({
|
|
379
|
+
id: { bunchId, counter: startCounter + i },
|
|
380
|
+
isDeleted,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (!isDeleted) this._length += count;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A live-updating view of an IdList that treats all known ids as present.
|
|
390
|
+
* That is, this class ignores the underlying list's isDeleted status when computing list indices.
|
|
391
|
+
*
|
|
392
|
+
* To mutate, call methods on the original IdList (`this.list`).
|
|
393
|
+
*/
|
|
394
|
+
export class KnownIdView {
|
|
395
|
+
/**
|
|
396
|
+
* Internal use only. Use {@link IdList.knownIds} instead.
|
|
397
|
+
*/
|
|
398
|
+
constructor(readonly list: IdList, private readonly state: ListElement[]) {}
|
|
399
|
+
|
|
400
|
+
// Mutators are omitted - mutate this.list instead.
|
|
401
|
+
|
|
402
|
+
// Accessors
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Returns the id at the given index in this view.
|
|
406
|
+
*
|
|
407
|
+
* Equivalently, returns the index-th known id in `this.list`.
|
|
408
|
+
*
|
|
409
|
+
* @throws If index is out of bounds.
|
|
410
|
+
*/
|
|
411
|
+
at(index: number): ElementId {
|
|
412
|
+
if (!(Number.isSafeInteger(index) && 0 <= index && index < this.length)) {
|
|
413
|
+
throw new Error(`Index out of bounds: ${index} (length: ${this.length}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return this.state[index].id;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Returns the index of `id` in this view, or -1 if it is not known.
|
|
421
|
+
*/
|
|
422
|
+
indexOf(id: ElementId): number {
|
|
423
|
+
return this.state.findIndex((elt) => equalsId(elt.id, id));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The length of this view.
|
|
428
|
+
*
|
|
429
|
+
* Equivalently, the number of known ids in `this.list`.
|
|
430
|
+
*/
|
|
431
|
+
get length(): number {
|
|
432
|
+
return this.state.length;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Iterators
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Iterates over all ids in this view, i.e., all known ids in `this.list`.
|
|
439
|
+
*/
|
|
440
|
+
*[Symbol.iterator](): IterableIterator<ElementId> {
|
|
441
|
+
for (const elt of this.state) {
|
|
442
|
+
yield elt.id;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Iterates over all ids in this view, i.e., all known ids in `this.list`.
|
|
448
|
+
*/
|
|
449
|
+
values() {
|
|
450
|
+
return this[Symbol.iterator]();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function expandElements(
|
|
455
|
+
startId: ElementId,
|
|
456
|
+
isDeleted: boolean,
|
|
457
|
+
count: number
|
|
458
|
+
): ListElement[] {
|
|
459
|
+
if (!(Number.isSafeInteger(count) && count >= 0)) {
|
|
460
|
+
throw new Error(`Invalid count: ${count}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const ans: ListElement[] = [];
|
|
464
|
+
for (let i = 0; i < count; i++) {
|
|
465
|
+
ans.push({
|
|
466
|
+
id: { bunchId: startId.bunchId, counter: startId.counter + i },
|
|
467
|
+
isDeleted,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return ans;
|
|
471
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved state for an IdList.
|
|
3
|
+
*
|
|
4
|
+
* It describes all of the list's known ElementIds in list order, with basic compression:
|
|
5
|
+
* if sequential ElementIds have the same bunchId, the same isDeleted status,
|
|
6
|
+
* and sequential counters, then they are combined into a single object.
|
|
7
|
+
*/
|
|
8
|
+
export type SavedIdList = Array<{
|
|
9
|
+
bunchId: string;
|
|
10
|
+
startCounter: number;
|
|
11
|
+
count: number;
|
|
12
|
+
isDeleted: boolean;
|
|
13
|
+
}>;
|