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 ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2025 Matthew Weidner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # articulated
2
+
3
+ A TypeScript library for managing stable element identifiers in mutable lists, perfect for collaborative editing and other applications where elements need persistent identities despite insertions and deletions.
4
+
5
+ ## Features
6
+
7
+ - **Stable identifiers**: Elements keep their identity even as their indices change
8
+ - **Efficient storage**: Optimized compression for sequential IDs
9
+ - **Collaborative-ready**: Supports concurrent operations from multiple sources
10
+ - **Tombstone support**: Deleted elements remain addressable
11
+ - **TypeScript-first**: Full type safety and excellent IDE integration
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install --save articulated
17
+ # or
18
+ yarn add articulated
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { IdList } from "articulated";
25
+
26
+ // Create an empty list
27
+ const list = new IdList();
28
+
29
+ // Insert a new element at the beginning
30
+ list.insertAfter(null, { bunchId: "user1", counter: 0 });
31
+
32
+ // Insert another element after the first
33
+ list.insertAfter(
34
+ { bunchId: "user1", counter: 0 },
35
+ { bunchId: "user1", counter: 1 }
36
+ );
37
+
38
+ // Delete an element (marks as deleted but keeps as known)
39
+ list.delete({ bunchId: "user1", counter: 0 });
40
+
41
+ // Check if elements are present/known
42
+ console.log(list.has({ bunchId: "user1", counter: 0 })); // false (deleted)
43
+ console.log(list.isKnown({ bunchId: "user1", counter: 0 })); // true (known but deleted)
44
+ ```
45
+
46
+ ## Core Concepts
47
+
48
+ ### ElementId
49
+
50
+ An `ElementId` is a globally unique identifier for a list element, composed of:
51
+
52
+ - `bunchId`: A string UUID or similar globally unique ID
53
+ - `counter`: A numeric value to distinguish elements in the same bunch
54
+
55
+ For optimal compression, when inserting multiple elements in sequence, use the same `bunchId` with sequential `counter` values.
56
+
57
+ ```typescript
58
+ // Example of IDs that will compress well
59
+ const id1 = { bunchId: "abc123", counter: 0 };
60
+ const id2 = { bunchId: "abc123", counter: 1 };
61
+ const id3 = { bunchId: "abc123", counter: 2 };
62
+ ```
63
+
64
+ ### IdList Operations
65
+
66
+ #### Basic Operations
67
+
68
+ - `insertAfter(before, newId)`: Insert after a specific element
69
+ - `insertBefore(after, newId)`: Insert before a specific element
70
+ - `delete(id)`: Mark an element as deleted (remains known)
71
+ - `undelete(id)`: Restore a deleted element
72
+
73
+ #### Advanced Operations
74
+
75
+ - `uninsert(id)`: Remove an element completely (no longer known)
76
+ - `at(index)`: Get the element ID at a specific index
77
+ - `indexOf(id, bias)`: Get the index of an element with optional bias for deleted elements
78
+ - `clone()`: Create a deep copy of the list
79
+
80
+ #### Bulk Operations
81
+
82
+ ```typescript
83
+ // Insert multiple sequential ids at once
84
+ list.insertAfter(null, { bunchId: "user1", counter: 0 }, 5);
85
+ // Inserts 5 ids with bunchId="user1" and counters 0, 1, 2, 3, 4
86
+ ```
87
+
88
+ ### Persistence
89
+
90
+ Save and restore the list state:
91
+
92
+ ```typescript
93
+ // Save list state
94
+ const savedState = list.save();
95
+
96
+ // Later, restore from saved state
97
+ const newList = new IdList();
98
+ newList.load(savedState);
99
+ ```
100
+
101
+ ## Use Cases
102
+
103
+ - Text editors where characters need stable identities
104
+ - Todo lists with collaborative editing
105
+ - Any list where elements' positions change but need stable identifiers
106
+ - Conflict-free replicated data type (CRDT) implementations
@@ -0,0 +1,61 @@
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
+ * Equals function for ElementIds.
42
+ */
43
+ export declare function equalsId(a: ElementId, b: ElementId): boolean;
44
+ /**
45
+ * Expands a "compressed" sequence of ElementIds that have the same bunchId but
46
+ * sequentially increasing counters, starting at `startId.counter`.
47
+ *
48
+ * For example,
49
+ * ```ts
50
+ * expandIds({ bunchId: "foo", counter: 7 }, 3)
51
+ * ```
52
+ * returns
53
+ * ```ts
54
+ * [
55
+ * { bunchId: "foo", counter: 7 },
56
+ * { bunchId: "foo", counter: 8 },
57
+ * { bunchId: "foo", counter: 9 }
58
+ * ]
59
+ * ```
60
+ */
61
+ export declare function expandIds(startId: ElementId, count: number): ElementId[];
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.expandIds = exports.equalsId = void 0;
4
+ /**
5
+ * Equals function for ElementIds.
6
+ */
7
+ function equalsId(a, b) {
8
+ return a.counter === b.counter && a.bunchId === b.bunchId;
9
+ }
10
+ exports.equalsId = equalsId;
11
+ /**
12
+ * Expands a "compressed" sequence of ElementIds that have the same bunchId but
13
+ * sequentially increasing counters, starting at `startId.counter`.
14
+ *
15
+ * For example,
16
+ * ```ts
17
+ * expandIds({ bunchId: "foo", counter: 7 }, 3)
18
+ * ```
19
+ * returns
20
+ * ```ts
21
+ * [
22
+ * { bunchId: "foo", counter: 7 },
23
+ * { bunchId: "foo", counter: 8 },
24
+ * { bunchId: "foo", counter: 9 }
25
+ * ]
26
+ * ```
27
+ */
28
+ function expandIds(startId, count) {
29
+ if (!(Number.isSafeInteger(count) && count >= 0)) {
30
+ throw new Error(`Invalid count: ${count}`);
31
+ }
32
+ const ans = [];
33
+ for (let i = 0; i < count; i++) {
34
+ ans.push({ bunchId: startId.bunchId, counter: startId.counter + i });
35
+ }
36
+ return ans;
37
+ }
38
+ exports.expandIds = expandIds;
39
+ //# sourceMappingURL=id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.js","sourceRoot":"","sources":["../../src/id.ts"],"names":[],"mappings":";;;AAwCA;;GAEG;AACH,SAAgB,QAAQ,CAAC,CAAY,EAAE,CAAY;IACjD,OAAO,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,CAAC;AAC5D,CAAC;AAFD,4BAEC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,SAAS,CAAC,OAAkB,EAAE,KAAa;IACzD,IAAI,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC;QACjD,MAAM,IAAI,KAAK,CAAC,kBAAkB,KAAK,EAAE,CAAC,CAAC;IAC7C,CAAC;IAED,MAAM,GAAG,GAAgB,EAAE,CAAC;IAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;QAC/B,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC,CAAC;IACvE,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAVD,8BAUC"}
@@ -0,0 +1,226 @@
1
+ import { ElementId } from "./id";
2
+ import { SavedIdList } from "./saved_id_list";
3
+ interface ListElement {
4
+ id: ElementId;
5
+ isDeleted: boolean;
6
+ }
7
+ /**
8
+ * A list of ElementIds.
9
+ *
10
+ * An IdList helps you assign a unique immutable id to each element of a list, such
11
+ * as a todo-list or a text document (= list of characters). That way, you can keep track
12
+ * of those elements even as their (array) indices change due to insert/delete operations
13
+ * earlier in the list.
14
+ *
15
+ * Any id that has been inserted into an IdList remains **known** to that list indefinitely,
16
+ * allowing you to reference it in insertAfter/insertBefore operations. Calling {@link delete}
17
+ * merely marks an id as deleted (not present); it remains in memory as a "tombstone".
18
+ * This is useful in collaborative settings, since another user might instruct you to
19
+ * call `insertAfter(before, newId)` when you have already deleted `before` locally.
20
+ * If that is not a concern and you truly want to make an id no longer known, instead
21
+ * call {@link uninsert}.
22
+ *
23
+ * See {@link ElementId} for advice on generating ElementIds. IdList is optimized for
24
+ * the case where sequential ElementIds often have the same bunchId and sequential counters.
25
+ * However, you are not required to order ids in this way - it is okay if future edits
26
+ * cause such ids to be separated, partially deleted, or even reordered.
27
+ */
28
+ export declare class IdList {
29
+ private readonly state;
30
+ private _length;
31
+ /**
32
+ * Constructs an empty list.
33
+ *
34
+ * To begin with a non-empty list, use {@link IdList.from} or {@link IdList.fromIds}.
35
+ */
36
+ constructor();
37
+ /**
38
+ * Constructs a list with the given known ids and their isDeleted status, in list order.
39
+ */
40
+ static from(state: Iterable<{
41
+ id: ElementId;
42
+ isDeleted: boolean;
43
+ }>): IdList;
44
+ /**
45
+ * Constructs a list with the given present ids.
46
+ *
47
+ * Typically, you instead want {@link IdList.from}, which allows you to also
48
+ * specify known-but-deleted ids. That way, you can reference the known-but-deleted ids
49
+ * in future insertAfter/insertBefore operations.
50
+ */
51
+ static fromIds(ids: Iterable<ElementId>): IdList;
52
+ /**
53
+ * Inserts `newId` immediately after the given id (`before`), which may be deleted.
54
+ * All ids to the right of `before` are shifted one index to the right, in the manner
55
+ * of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice).
56
+ *
57
+ * Use `before = null` to insert at the beginning of the list, to the left of all
58
+ * known ids.
59
+ *
60
+ * @param count Provide this to bulk-insert `count` ids from left-to-right,
61
+ * starting with newId and proceeding with the same bunchId and sequential counters.
62
+ * @throws If `before` is not known.
63
+ * @throws If `newId` is already known.
64
+ */
65
+ insertAfter(before: ElementId | null, newId: ElementId, count?: number): void;
66
+ /**
67
+ * Inserts `newId` immediately before the given id (`after`), which may be deleted.
68
+ * All ids to the right of `after`, plus `after` itself, are shifted one index to the right, in the manner
69
+ * of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice).
70
+ *
71
+ * Use `after = null` to insert at the end of the list, to the right of all known ids.
72
+ *
73
+ * @param count Provide this to bulk-insert `count` ids from left-to-right,
74
+ * starting with newId and proceeding with the same bunchId and sequential counters.
75
+ * __Note__: Although the new ids are inserted to the left of `after`, they are still
76
+ * inserted in left-to-right order relative to each other.
77
+ * @throws If `after` is not known.
78
+ * @throws If `newId` is already known.
79
+ */
80
+ insertBefore(after: ElementId | null, newId: ElementId, count?: number): void;
81
+ /**
82
+ * Un-inserts `id` from the list, making it no longer known or present in this list.
83
+ *
84
+ * Typically, you instead want to call {@link delete}, which marks `id` as deleted while
85
+ * it remains known. That way, you can reference `id` in future insertAfter/insertBefore
86
+ * operations, including ones sent concurrently by other devices.
87
+ *
88
+ * If `id` is already not known, this method does nothing.
89
+ */
90
+ uninsert(id: ElementId): void;
91
+ /**
92
+ * Marks `id` as deleted from this list. The id remains known (a "tombstone").
93
+ *
94
+ * Because `id` is still known, you can reference it in future insertAfter/insertBefore
95
+ * operations, including ones sent concurrently by other devices.
96
+ * However, it does occupy space in memory (compressed in common cases).
97
+ *
98
+ * For an exact inverse to `insertAfter(-, id)` or `insertBefore(-, id)`
99
+ * that makes `id` no longer known, see {@link uninsert}.
100
+ *
101
+ * If `id` is already deleted or not known, this method does nothing.
102
+ */
103
+ delete(id: ElementId): void;
104
+ /**
105
+ * Un-marks `id` as deleted from this list, making it present again.
106
+ * This is an exact inverse to {@link delete}.
107
+ *
108
+ * If `id` is already present, this method does nothing.
109
+ *
110
+ * @throws If `id` is not known.
111
+ */
112
+ undelete(id: ElementId): void;
113
+ /**
114
+ * Returns whether `id` is present in the list, i.e., it is known and not deleted.
115
+ *
116
+ * If `id` is not known, false is returned.
117
+ *
118
+ * Compare to {@link isKnown}.
119
+ */
120
+ has(id: ElementId): boolean;
121
+ /**
122
+ * Returns whether id is known to this list.
123
+ *
124
+ * Compare to {@link has}.
125
+ */
126
+ isKnown(id: ElementId): boolean;
127
+ /**
128
+ * Returns the id at the given index in the list.
129
+ *
130
+ * @throws If index is out of bounds.
131
+ */
132
+ at(index: number): ElementId;
133
+ /**
134
+ * Returns the index of `id` in the list.
135
+ *
136
+ * If `id` is known but deleted, the bias specifies what to return:
137
+ * - "none": -1.
138
+ * - "left": The index immediately to the left of `id`, possibly -1.
139
+ * - "right": The index immediately to the right of `id`, possibly `this.length`.
140
+ * Equivalently, the index where `id` would be if present.
141
+ *
142
+ * @throws If `id` is not known.
143
+ */
144
+ indexOf(id: ElementId, bias?: "none" | "left" | "right"): number;
145
+ /**
146
+ * The length of the list.
147
+ */
148
+ get length(): number;
149
+ /**
150
+ * Iterates over all present ids in the list.
151
+ */
152
+ [Symbol.iterator](): IterableIterator<ElementId>;
153
+ /**
154
+ * Iterates over all present ids in the list.
155
+ */
156
+ values(): IterableIterator<ElementId>;
157
+ /**
158
+ * Iterates over all __known__ ids in the list, indicating which are deleted.
159
+ */
160
+ valuesWithDeleted(): IterableIterator<{
161
+ id: ElementId;
162
+ isDeleted: boolean;
163
+ }>;
164
+ /**
165
+ * Returns an independent copy of this list, including known but deleted ids.
166
+ */
167
+ clone(): IdList;
168
+ private _knownIds?;
169
+ /**
170
+ * A live-updating view of this list that treats all known ids as present.
171
+ * That is, it ignores isDeleted status when computing list indices or iterating.
172
+ */
173
+ get knownIds(): KnownIdView;
174
+ /**
175
+ * Returns a compact JSON representation of this list's internal state.
176
+ * Load with {@link load}.
177
+ *
178
+ * See {@link SavedIdList} for a description of the save format.
179
+ */
180
+ save(): SavedIdList;
181
+ /**
182
+ * Loads a saved state returned by {@link save}, __overwriting__ the current state of this list.
183
+ */
184
+ load(savedState: SavedIdList): void;
185
+ }
186
+ /**
187
+ * A live-updating view of an IdList that treats all known ids as present.
188
+ * That is, this class ignores the underlying list's isDeleted status when computing list indices.
189
+ *
190
+ * To mutate, call methods on the original IdList (`this.list`).
191
+ */
192
+ export declare class KnownIdView {
193
+ readonly list: IdList;
194
+ private readonly state;
195
+ /**
196
+ * Internal use only. Use {@link IdList.knownIds} instead.
197
+ */
198
+ constructor(list: IdList, state: ListElement[]);
199
+ /**
200
+ * Returns the id at the given index in this view.
201
+ *
202
+ * Equivalently, returns the index-th known id in `this.list`.
203
+ *
204
+ * @throws If index is out of bounds.
205
+ */
206
+ at(index: number): ElementId;
207
+ /**
208
+ * Returns the index of `id` in this view, or -1 if it is not known.
209
+ */
210
+ indexOf(id: ElementId): number;
211
+ /**
212
+ * The length of this view.
213
+ *
214
+ * Equivalently, the number of known ids in `this.list`.
215
+ */
216
+ get length(): number;
217
+ /**
218
+ * Iterates over all ids in this view, i.e., all known ids in `this.list`.
219
+ */
220
+ [Symbol.iterator](): IterableIterator<ElementId>;
221
+ /**
222
+ * Iterates over all ids in this view, i.e., all known ids in `this.list`.
223
+ */
224
+ values(): IterableIterator<ElementId>;
225
+ }
226
+ export {};