├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .mocharc.jsonc ├── .nycrc ├── .prettierignore ├── LICENSE ├── README.md ├── benchmark_results.md ├── benchmarks ├── insert_after_custom.ts ├── insert_after_json.ts ├── internal │ ├── real_text_trace_edits.json │ └── util.ts └── main.ts ├── package-lock.json ├── package.json ├── src ├── id.ts ├── id_list.ts ├── index.ts ├── internal │ ├── leaf_map.ts │ └── seq_map.ts ├── saved_id_list.ts └── vendor │ ├── functional-red-black-tree.d.ts │ └── functional-red-black-tree.js ├── test ├── basic.test.ts ├── basic_fuzz.test.ts ├── btree_fuzz.test.ts ├── btree_implementation.test.ts ├── btree_structure_and_edge_cases.test.ts ├── fuzzer.ts ├── id_list_simple.ts ├── persistence.test.ts └── serialization_and_edge_cases.test.ts ├── tsconfig.commonjs.json ├── tsconfig.dev.json ├── tsconfig.json └── typedoc.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | docs 4 | *.js 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | node: true, 7 | }, 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | tsconfigRootDir: __dirname, 11 | project: ["./tsconfig.dev.json"], 12 | sourceType: "module", 13 | }, 14 | plugins: ["@typescript-eslint", "import"], 15 | extends: [ 16 | "eslint:recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 19 | "plugin:import/typescript", 20 | "prettier", 21 | ], 22 | rules: { 23 | // Allow inference in function return type. 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "@typescript-eslint/explicit-module-boundary-types": "off", 26 | // I like non-null assertions. 27 | "@typescript-eslint/no-non-null-assertion": "off", 28 | // Disallow default exports; only allow named exports. 29 | "import/no-default-export": "error", 30 | // Allow implicit string casts in template literals. 31 | "@typescript-eslint/restrict-template-expressions": "off", 32 | // Allow ts-ignore with justification. 33 | "@typescript-eslint/ban-ts-comment": [ 34 | "error", 35 | { 36 | "ts-expect-error": "allow-with-description", 37 | }, 38 | ], 39 | "@typescript-eslint/no-unused-vars": [ 40 | "warn", 41 | { 42 | // Allow unused parameter names that start with _, 43 | // like TypeScript does. 44 | argsIgnorePattern: "^_", 45 | }, 46 | ], 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | docs 5 | .vscode/ 6 | .idea/ 7 | *.tsbuildinfo 8 | .nyc_output 9 | coverage -------------------------------------------------------------------------------- /.mocharc.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | // To run tests written in TypeScript without compiling them, we need to run them in ts-node. 3 | "require": ["ts-node/register"], 4 | 5 | // Any *.test.ts file in ./test will be run as a test 6 | "spec": "test/**/*.test.ts", 7 | 8 | // A change in sources or tests should trigger test re-run 9 | "watch-files": ["test/**", "src/**"] 10 | } 11 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": true, 4 | "include": ["src/**/*.ts", "src/**/*.js"], 5 | "exclude": ["test/**/*.ts"], 6 | "reporter": ["html", "text", "text-summary"], 7 | "report-dir": "coverage" 8 | } 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /build/* 2 | /docs/* 3 | /.nyc_output/* 4 | /coverage/* 5 | LICENSE 6 | real_text_trace_edits.json 7 | benchmark_results.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | [Demos](https://github.com/mweidner037/articulated-demos) 6 | 7 | ## Features 8 | 9 | - **Stable identifiers**: Elements keep their identity even as their indices change 10 | - **Efficient storage**: Optimized compression for sequential IDs 11 | - **Collaborative-ready**: Supports concurrent operations from multiple sources 12 | - **Tombstone support**: Deleted elements remain addressable 13 | - **TypeScript-first**: Full type safety and excellent IDE integration 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npm install --save articulated 19 | # or 20 | yarn add articulated 21 | ``` 22 | 23 | ## Quick Start 24 | 25 | ```typescript 26 | import { IdList } from "articulated"; 27 | 28 | // Create an empty list. 29 | let list = IdList.new(); 30 | 31 | // Insert a new ElementId at the beginning. 32 | // Note: Persistent (immutable) data structure! Mutators return a new IdList. 33 | list = list.insertAfter(null, { bunchId: "user1", counter: 0 }); 34 | 35 | // Insert another ElementId after the first. 36 | list = list.insertAfter( 37 | { bunchId: "user1", counter: 0 }, 38 | { bunchId: "user1", counter: 1 } 39 | ); 40 | 41 | // Delete an ElementId (marks as deleted but keeps as known). 42 | list = list.delete({ bunchId: "user1", counter: 0 }); 43 | 44 | // Check if ElementIds are present/known. 45 | console.log(list.has({ bunchId: "user1", counter: 0 })); // false (deleted) 46 | console.log(list.isKnown({ bunchId: "user1", counter: 0 })); // true (known but deleted) 47 | ``` 48 | 49 | ## Core Concepts 50 | 51 | ### ElementId 52 | 53 | An `ElementId` is a globally unique identifier for a list element, composed of: 54 | 55 | - `bunchId`: A string UUID or similar globally unique ID 56 | - `counter`: A numeric value to distinguish ElementIds in the same bunch 57 | 58 | For optimal compression, when inserting multiple ElementIds in a left-to-right sequence, use the same `bunchId` with sequential `counter` values. 59 | 60 | ```typescript 61 | // Example of IDs that will compress well 62 | const id1 = { bunchId: "abc123", counter: 0 }; 63 | const id2 = { bunchId: "abc123", counter: 1 }; 64 | const id3 = { bunchId: "abc123", counter: 2 }; 65 | ``` 66 | 67 | ### IdList Operations 68 | 69 | To enable easy and efficient rollbacks, such as in a [server reconciliation](https://mattweidner.com/2024/06/04/server-architectures.html#1-server-reconciliation) architecture, IdList is a persistent (immutable) data structure. Mutating methods return a new IdList, sharing memory with the old IdList where possible. 70 | 71 | #### Basic Operations 72 | 73 | - `insertAfter(before, newId): IdList`: Insert after a specific ElementId. 74 | - `insertBefore(after, newId): IdList`: Insert before a specific ElementId. 75 | - `delete(id): IdList`: Mark an ElementId as deleted (it remains known). 76 | - `undelete(id): IdList`: Restore a deleted ElementId. 77 | - `uninsert(id): IdList`: Undo an insertion, making the ElementId no longer known. Use `delete(id)` instead in most cases (see method docs). 78 | 79 | #### Basic Accessors 80 | 81 | - `at(index)`: Get the ElementId at a specific index. 82 | - `indexOf(id, bias: "none" | "left" | "right" = "none")`: Get the index of an ElementId, with optional bias for deleted-but-known ElementIds. 83 | 84 | #### Bulk Operations 85 | 86 | ```typescript 87 | // Insert multiple sequential ids at once 88 | list = list.insertAfter(null, { bunchId: "user1", counter: 0 }, 5); 89 | // Inserts 5 ids with bunchId="user1" and counters 0, 1, 2, 3, 4 90 | ``` 91 | 92 | #### Save and load 93 | 94 | Save and load the list state in JSON form: 95 | 96 | ```typescript 97 | // Save list state 98 | const savedState = list.save(); 99 | 100 | // Later, load from saved state 101 | let newList = IdList.load(savedState); 102 | ``` 103 | 104 | ## Use Cases 105 | 106 | - Text editors where characters need stable identities 107 | - Todo lists with collaborative editing 108 | - Any list where elements' positions change but need stable identifiers 109 | - Conflict-free replicated data type (CRDT) implementations 110 | 111 | ## Internals 112 | 113 | IdList stores its state as a modified [B+Tree](https://en.wikipedia.org/wiki/B%2B_tree), described at the top of [its source code](./src/id_list.ts). Each leaf in the B+Tree represents multiple ElementIds (sharing a bunchId and sequential counters) in a compressed way; for normal collaborative text editing, expect 10-20 ElementIds per leaf. 114 | 115 | To speed up searches, we also maintain a "bottom-up" tree that maps from each node to a sequence number identifying its parent. (Using sequence numbers instead of pointers is necessary for persistence.) The map is implemented using persistent balanced trees from [functional-red-black-tree](https://www.npmjs.com/package/functional-red-black-tree). 116 | 117 | Asymptotic runtimes are given in terms of the number of leaves `L` and the maximum "fragmentation" of a leaf `F`, which is the number of times its ElementIds alternate between deleted vs present. 118 | 119 | - insertAfter, insertBefore: `O(log^2(L) + F)`. 120 | - The bottleneck is finding the B+Tree path of the before/after ElementId. This requires `O(log(L))` lookups in the bottom-up tree's map, each of which takes `O(log(L))` time. See the implementation of `IdList.locate`. 121 | - delete, undelete: `O(log^2(L) + F)`. 122 | - indexOf: `O(log^2(L) + F)`. 123 | - Bottleneck is locating the id. 124 | - at: `O(log(L) + F)`. 125 | - Simple B+Tree search. 126 | - has, isKnown: `O(log(L) + F)` 127 | - Part of the bottom-up tree is a sorted map with leaf keys; due to the sort, we can also use that map to look up the leaf corresponding to an ElementId, in `O(log(L))` time. 128 | - length: `O(1)`. 129 | - Cached. 130 | - save: `O(S + L)`, where `S <= L * F` is the saved state's length. 131 | - load: `O(S * log(S))` 132 | - The bottleneck is constructing the bottom-up tree: specifically, the map from each leaf to its parent's sequence number (`leafMap`). That map is itself a sorted tree, hence takes `O(L * log(L))` time to construct, and `L <= S`. 133 | 134 | If you want to get a sense of what IdList is or how to implement your own version, consider reading the source code for [IdListSimple](./test/id_list_simple.ts), which behaves identically to IdList. It is short (<300 SLOC) and direct, using an array and `Array.splice`. The downside is that IdListSimple does not compress ElementIds and all of its operations take `O(# ids)` time. We use it as a known-good implementation in our fuzz tests. 135 | 136 | 137 | -------------------------------------------------------------------------------- /benchmark_results.md: -------------------------------------------------------------------------------- 1 | # Benchmark Results 2 | Output of 3 | ```bash 4 | npm run benchmarks -s > benchmark_results.md 5 | ``` 6 | Each benchmark applies the [automerge-perf](https://github.com/automerge/automerge-perf) 260k edit text trace and measures various stats, modeled on [crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks/)' B4 experiment. 7 | 8 | For perspective on the save sizes: the final text (excluding deleted chars) is 104,852 bytes, or 27556 bytes GZIP'd. It is ~15 pages of two-column text (in LaTeX). 9 | 10 | Note: This is not a fair comparison to list/text CRDTs. The executions benchmarked here do not accommodate concurrency and would need to be used in conjunction with a server reconciliation strategy, which adds its own overhead. Also, we do not send or store the actual text, only the corresponding ElementIds. 11 | 12 | ## Insert-After, JSON Encoding 13 | 14 | Send insertAfter and delete operations over a reliable link (e.g. WebSocket) - ElementId only. 15 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 16 | 17 | - Sender time (ms): 2229 18 | - Avg update size (bytes): 147.3 19 | - Receiver time (ms): 2214 20 | - Save time (ms): 14 21 | - Save size (bytes): 1177551 22 | - Load time (ms): 28 23 | - Save time GZIP'd (ms): 83 24 | - Save size GZIP'd (bytes): 65897 25 | - Load time GZIP'd (ms): 53 26 | - Mem used estimate (MB): 2.7 27 | 28 | ## Insert-After, Custom Encoding 29 | 30 | Send insertAfter and delete operations over a reliable link (e.g. WebSocket) - ElementId only. 31 | Updates use a custom string encoding; saved states use JSON with optional GZIP. 32 | 33 | - Sender time (ms): 1943 34 | - Avg update size (bytes): 45.6 35 | - Receiver time (ms): 3237 36 | - Save time (ms): 13 37 | - Save size (bytes): 1177551 38 | - Load time (ms): 19 39 | - Save time GZIP'd (ms): 57 40 | - Save size GZIP'd (bytes): 65889 41 | - Load time GZIP'd (ms): 49 42 | - Mem used estimate (MB): 2.7 43 | -------------------------------------------------------------------------------- /benchmarks/insert_after_custom.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { ElementId, IdList, SavedIdList } from "../src"; 4 | import { 5 | avg, 6 | getMemUsed, 7 | gunzipString, 8 | gzipString, 9 | realTextTraceEdits, 10 | sleep, 11 | } from "./internal/util"; 12 | 13 | const { edits, finalText } = realTextTraceEdits(); 14 | 15 | type Update = string; 16 | 17 | export async function insertAfterCustom() { 18 | console.log("\n## Insert-After, Custom Encoding\n"); 19 | console.log( 20 | "Send insertAfter and delete operations over a reliable link (e.g. WebSocket) - ElementId only." 21 | ); 22 | console.log( 23 | "Updates use a custom string encoding; saved states use JSON with optional GZIP.\n" 24 | ); 25 | 26 | // TODO: Deterministic randomness. 27 | const replicaId = uuidv4(); 28 | let replicaCounter = 0; 29 | function nextBunchId(): string { 30 | // This is unrealistic (more than one replica will edit a document this large) 31 | // but the closest comparison to existing CRDT / list-positions benchmarks. 32 | return replicaId + replicaCounter++; 33 | } 34 | 35 | // Perform the whole trace, sending all updates. 36 | const updates: string[] = []; 37 | let startTime = process.hrtime.bigint(); 38 | let sender = IdList.new(); 39 | for (const edit of edits) { 40 | let update: Update; 41 | if (edit[2] !== undefined) { 42 | const before = edit[0] === 0 ? null : sender.at(edit[0] - 1); 43 | let id: ElementId; 44 | // Try to extend before's bunch, so that it will be compressed. 45 | if ( 46 | before !== null && 47 | sender.maxCounter(before.bunchId) === before.counter 48 | ) { 49 | id = { bunchId: before.bunchId, counter: before.counter + 1 }; 50 | } else { 51 | // id = { bunchId: uuidv4(), counter: 0 }; 52 | id = { bunchId: nextBunchId(), counter: 0 }; 53 | } 54 | 55 | sender = sender.insertAfter(before, id); 56 | 57 | update = encodeInsertAfter(id, before); 58 | } else { 59 | const id = sender.at(edit[0]); 60 | sender = sender.delete(id); 61 | update = "d" + id.bunchId + " " + id.counter.toString(36); 62 | } 63 | 64 | updates.push(update); 65 | } 66 | 67 | console.log( 68 | "- Sender time (ms):", 69 | Math.round( 70 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 71 | ) 72 | ); 73 | console.log( 74 | "- Avg update size (bytes):", 75 | avg(updates.map((message) => message.length)).toFixed(1) 76 | ); 77 | // TODO 78 | // assert.strictEqual(sender.toString(), finalText); 79 | 80 | // Receive all updates. 81 | startTime = process.hrtime.bigint(); 82 | let receiver = IdList.new(); 83 | for (const update of updates) { 84 | if (update[0] === "i") { 85 | // type "insertAfter" 86 | const { id, before } = decodeInsertAfter(update); 87 | receiver = receiver.insertAfter(before, id); 88 | // To simulate events, also compute the inserted index. 89 | void receiver.indexOf(id); 90 | } else { 91 | // type "delete" 92 | const parts = update.slice(1).split(" "); 93 | const id: ElementId = { 94 | bunchId: parts[0], 95 | counter: Number.parseInt(parts[1], 36), 96 | }; 97 | if (receiver.has(id)) { 98 | // To simulate events, also compute the inserted index. 99 | void receiver.indexOf(id); 100 | receiver = receiver.delete(id); // Also okay to call outside of the "has" guard. 101 | } 102 | } 103 | } 104 | 105 | console.log( 106 | "- Receiver time (ms):", 107 | Math.round( 108 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 109 | ) 110 | ); 111 | assert.deepStrictEqual( 112 | [...receiver.valuesWithIsDeleted()], 113 | [...sender.valuesWithIsDeleted()] 114 | ); 115 | // TODO 116 | // assert.strictEqual(receiver.toString(), finalText); 117 | 118 | const savedState = saveLoad(receiver, false) as string; 119 | saveLoad(receiver, true); 120 | 121 | await memory(savedState); 122 | } 123 | 124 | function encodeInsertAfter(id: ElementId, before: ElementId | null): Update { 125 | let update = "i" + id.bunchId + " " + id.counter.toString(36) + " "; 126 | if (before === null) { 127 | update += "A"; 128 | } else if (id.bunchId === before?.bunchId) { 129 | if (id.counter == before.counter + 1) { 130 | update += "B"; 131 | } else { 132 | update += "C" + before.counter.toString(36); 133 | } 134 | } else { 135 | update += "D" + before.bunchId + " " + before.counter.toString(36); 136 | } 137 | return update; 138 | } 139 | 140 | function decodeInsertAfter(update: Update): { 141 | id: ElementId; 142 | before: ElementId | null; 143 | } { 144 | const parts = update.slice(1).split(" "); 145 | const id: ElementId = { 146 | bunchId: parts[0], 147 | counter: Number.parseInt(parts[1], 36), 148 | }; 149 | 150 | let before: ElementId | null; 151 | switch (parts[2][0]) { 152 | case "A": 153 | before = null; 154 | break; 155 | case "B": 156 | before = { bunchId: id.bunchId, counter: id.counter - 1 }; 157 | break; 158 | case "C": 159 | before = { 160 | bunchId: id.bunchId, 161 | counter: Number.parseInt(parts[2].slice(1), 36), 162 | }; 163 | break; 164 | case "D": 165 | before = { 166 | bunchId: parts[2].slice(1), 167 | counter: Number.parseInt(parts[3], 36), 168 | }; 169 | break; 170 | default: 171 | throw new Error("parse error"); 172 | } 173 | 174 | return { id, before }; 175 | } 176 | 177 | function saveLoad(saver: IdList, gzip: boolean): string | Uint8Array { 178 | // Save. 179 | let startTime = process.hrtime.bigint(); 180 | const savedStateObj = saver.save(); 181 | const savedState = gzip 182 | ? gzipString(JSON.stringify(savedStateObj)) 183 | : JSON.stringify(savedStateObj); 184 | 185 | console.log( 186 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 187 | Math.round( 188 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 189 | ) 190 | ); 191 | console.log( 192 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 193 | savedState.length 194 | ); 195 | 196 | // Load the saved state. 197 | startTime = process.hrtime.bigint(); 198 | const toLoadStr = gzip 199 | ? gunzipString(savedState as Uint8Array) 200 | : (savedState as string); 201 | const toLoadObj = JSON.parse(toLoadStr) as SavedIdList; 202 | void IdList.load(toLoadObj); 203 | 204 | console.log( 205 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 206 | Math.round( 207 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 208 | ) 209 | ); 210 | 211 | return savedState; 212 | } 213 | 214 | async function memory(savedState: string) { 215 | // Measure memory usage of loading the saved state. 216 | 217 | // Pause (& separate function) seems to make GC more consistent - 218 | // less likely to get negative diffs. 219 | await sleep(1000); 220 | const startMem = getMemUsed(); 221 | 222 | let loader: IdList | null = null; 223 | // Keep the parsed saved state in a separate scope so it can be GC'd 224 | // before we measure memory. 225 | (function () { 226 | const savedStateObj = JSON.parse(savedState) as SavedIdList; 227 | loader = IdList.load(savedStateObj); 228 | })(); 229 | 230 | console.log( 231 | "- Mem used estimate (MB):", 232 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 233 | ); 234 | 235 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 236 | void loader; 237 | void savedState; 238 | } 239 | -------------------------------------------------------------------------------- /benchmarks/insert_after_json.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { ElementId, IdList, SavedIdList } from "../src"; 4 | import { 5 | avg, 6 | getMemUsed, 7 | gunzipString, 8 | gzipString, 9 | realTextTraceEdits, 10 | sleep, 11 | } from "./internal/util"; 12 | 13 | const { edits, finalText } = realTextTraceEdits(); 14 | 15 | type Update = 16 | | { 17 | type: "insertAfter"; 18 | id: ElementId; 19 | before: ElementId | null; 20 | } 21 | | { type: "delete"; id: ElementId }; 22 | 23 | export async function insertAfterJson() { 24 | console.log("\n## Insert-After, JSON Encoding\n"); 25 | console.log( 26 | "Send insertAfter and delete operations over a reliable link (e.g. WebSocket) - ElementId only." 27 | ); 28 | console.log( 29 | "Updates and saved states use JSON encoding, with optional GZIP for saved states.\n" 30 | ); 31 | 32 | // TODO: Deterministic randomness. 33 | const replicaId = uuidv4(); 34 | let replicaCounter = 0; 35 | function nextBunchId(): string { 36 | // This is unrealistic (more than one replica will edit a document this large) 37 | // but the closest comparison to existing CRDT / list-positions benchmarks. 38 | return replicaId + replicaCounter++; 39 | } 40 | 41 | // Perform the whole trace, sending all updates. 42 | const updates: string[] = []; 43 | let startTime = process.hrtime.bigint(); 44 | let sender = IdList.new(); 45 | for (const edit of edits) { 46 | let updateObj: Update; 47 | if (edit[2] !== undefined) { 48 | const before = edit[0] === 0 ? null : sender.at(edit[0] - 1); 49 | let id: ElementId; 50 | // Try to extend before's bunch, so that it will be compressed. 51 | if ( 52 | before !== null && 53 | sender.maxCounter(before.bunchId) === before.counter 54 | ) { 55 | id = { bunchId: before.bunchId, counter: before.counter + 1 }; 56 | } else { 57 | // id = { bunchId: uuidv4(), counter: 0 }; 58 | id = { bunchId: nextBunchId(), counter: 0 }; 59 | } 60 | 61 | sender = sender.insertAfter(before, id); 62 | 63 | updateObj = { type: "insertAfter", id, before }; 64 | } else { 65 | const id = sender.at(edit[0]); 66 | sender = sender.delete(id); 67 | updateObj = { type: "delete", id }; 68 | } 69 | 70 | updates.push(JSON.stringify(updateObj)); 71 | } 72 | 73 | console.log( 74 | "- Sender time (ms):", 75 | Math.round( 76 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 77 | ) 78 | ); 79 | console.log( 80 | "- Avg update size (bytes):", 81 | avg(updates.map((message) => message.length)).toFixed(1) 82 | ); 83 | // TODO 84 | // assert.strictEqual(sender.toString(), finalText); 85 | 86 | // Receive all updates. 87 | startTime = process.hrtime.bigint(); 88 | let receiver = IdList.new(); 89 | for (const update of updates) { 90 | const updateObj = JSON.parse(update) as Update; 91 | if (updateObj.type === "insertAfter") { 92 | receiver = receiver.insertAfter(updateObj.before, updateObj.id); 93 | // To simulate events, also compute the inserted index. 94 | void receiver.indexOf(updateObj.id); 95 | } else { 96 | // type "delete" 97 | if (receiver.has(updateObj.id)) { 98 | // To simulate events, also compute the inserted index. 99 | void receiver.indexOf(updateObj.id); 100 | receiver = receiver.delete(updateObj.id); // Also okay to call outside of the "has" guard. 101 | } 102 | } 103 | } 104 | 105 | console.log( 106 | "- Receiver time (ms):", 107 | Math.round( 108 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 109 | ) 110 | ); 111 | assert.deepStrictEqual( 112 | [...receiver.valuesWithIsDeleted()], 113 | [...sender.valuesWithIsDeleted()] 114 | ); 115 | // TODO 116 | // assert.strictEqual(receiver.toString(), finalText); 117 | 118 | const savedState = saveLoad(receiver, false) as string; 119 | saveLoad(receiver, true); 120 | 121 | await memory(savedState); 122 | } 123 | 124 | function saveLoad(saver: IdList, gzip: boolean): string | Uint8Array { 125 | // Save. 126 | let startTime = process.hrtime.bigint(); 127 | const savedStateObj = saver.save(); 128 | const savedState = gzip 129 | ? gzipString(JSON.stringify(savedStateObj)) 130 | : JSON.stringify(savedStateObj); 131 | 132 | console.log( 133 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 134 | Math.round( 135 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 136 | ) 137 | ); 138 | console.log( 139 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 140 | savedState.length 141 | ); 142 | 143 | // Load the saved state. 144 | startTime = process.hrtime.bigint(); 145 | const toLoadStr = gzip 146 | ? gunzipString(savedState as Uint8Array) 147 | : (savedState as string); 148 | const toLoadObj = JSON.parse(toLoadStr) as SavedIdList; 149 | void IdList.load(toLoadObj); 150 | 151 | console.log( 152 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 153 | Math.round( 154 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 155 | ) 156 | ); 157 | 158 | return savedState; 159 | } 160 | 161 | async function memory(savedState: string) { 162 | // Measure memory usage of loading the saved state. 163 | 164 | // Pause (& separate function) seems to make GC more consistent - 165 | // less likely to get negative diffs. 166 | await sleep(1000); 167 | const startMem = getMemUsed(); 168 | 169 | let loader: IdList | null = null; 170 | // Keep the parsed saved state in a separate scope so it can be GC'd 171 | // before we measure memory. 172 | (function () { 173 | const savedStateObj = JSON.parse(savedState) as SavedIdList; 174 | loader = IdList.load(savedStateObj); 175 | })(); 176 | 177 | console.log( 178 | "- Mem used estimate (MB):", 179 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 180 | ); 181 | 182 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 183 | void loader; 184 | void savedState; 185 | } 186 | -------------------------------------------------------------------------------- /benchmarks/internal/util.ts: -------------------------------------------------------------------------------- 1 | import { gunzipSync, gzipSync } from "fflate"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | export function realTextTraceEdits(): { 6 | finalText: string; 7 | edits: Array<[number, number, string | undefined]>; 8 | } { 9 | // A JSON import would be nicer, but it blows up the heap usage for some reason, 10 | // making heap snapshots slow. 11 | return JSON.parse( 12 | fs.readFileSync(path.join(__dirname, "real_text_trace_edits.json"), { 13 | encoding: "utf8", 14 | }) 15 | ) as { 16 | finalText: string; 17 | edits: Array<[number, number, string | undefined]>; 18 | }; 19 | } 20 | 21 | export function getMemUsed() { 22 | if (global.gc) { 23 | // Experimentally, calling gc() twice makes memory msmts more reliable - 24 | // otherwise may get negative diffs (last trial getting GC'd in the middle?). 25 | global.gc(); 26 | global.gc(); 27 | } 28 | return process.memoryUsage().heapUsed; 29 | } 30 | 31 | export function avg(values: number[]): number { 32 | if (values.length === 0) return 0; 33 | return values.reduce((a, b) => a + b, 0) / values.length; 34 | } 35 | 36 | export async function sleep(ms: number) { 37 | await new Promise((resolve) => setTimeout(resolve, ms)); 38 | } 39 | 40 | /** 41 | * @param percentiles Out of 100 42 | * @returns Nearest-rank percentiles 43 | */ 44 | export function percentiles(values: number[], percentiles: number[]): number[] { 45 | if (values.length === 0) return new Array(percentiles.length).fill(0); 46 | 47 | values.sort((a, b) => a - b); 48 | const ans: number[] = []; 49 | for (const perc of percentiles) { 50 | ans.push(values[Math.ceil(values.length * (perc / 100)) - 1]); 51 | } 52 | return ans; 53 | } 54 | 55 | export function gzipString(str: string): Uint8Array { 56 | return gzipSync(new TextEncoder().encode(str)); 57 | } 58 | 59 | export function gunzipString(data: Uint8Array): string { 60 | return new TextDecoder().decode(gunzipSync(data)); 61 | } 62 | -------------------------------------------------------------------------------- /benchmarks/main.ts: -------------------------------------------------------------------------------- 1 | import { insertAfterCustom } from "./insert_after_custom"; 2 | import { insertAfterJson } from "./insert_after_json"; 3 | 4 | void (async function () { 5 | console.log("# Benchmark Results"); 6 | console.log( 7 | "Output of\n```bash\nnpm run benchmarks -s > benchmark_results.md\n```" 8 | ); 9 | console.log( 10 | "Each benchmark applies the [automerge-perf](https://github.com/automerge/automerge-perf) 260k edit text trace and measures various stats, modeled on [crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks/)' B4 experiment.\n" 11 | ); 12 | console.log( 13 | "For perspective on the save sizes: the final text (excluding deleted chars) is 104,852 bytes, or 27556 bytes GZIP'd. It is ~15 pages of two-column text (in LaTeX).\n" 14 | ); 15 | console.log( 16 | "Note: This is not a fair comparison to list/text CRDTs. The executions benchmarked here do not accommodate concurrency and would need to be used in conjunction with a server reconciliation strategy, which adds its own overhead. Also, we do not send or store the actual text, only the corresponding ElementIds." 17 | ); 18 | 19 | await insertAfterJson(); 20 | await insertAfterCustom(); 21 | })(); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "articulated", 3 | "version": "1.0.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 | "dependencies": { 36 | "sparse-array-rled": "^2.0.1" 37 | }, 38 | "devDependencies": { 39 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 40 | "@types/chai": "^4.3.4", 41 | "@types/functional-red-black-tree": "^1.0.6", 42 | "@types/mocha": "^10.0.1", 43 | "@types/seedrandom": "^3.0.8", 44 | "@typescript-eslint/eslint-plugin": "^7.7.1", 45 | "@typescript-eslint/parser": "^7.7.1", 46 | "chai": "^4.3.7", 47 | "eslint": "^8.57.0", 48 | "eslint-config-prettier": "^9.1.0", 49 | "eslint-plugin-import": "^2.29.1", 50 | "fflate": "^0.8.2", 51 | "mocha": "^10.2.0", 52 | "npm-run-all": "^4.1.5", 53 | "nyc": "^15.1.0", 54 | "prettier": "^2.8.4", 55 | "seedrandom": "^3.0.5", 56 | "ts-node": "^10.9.2", 57 | "typedoc": "^0.25.13", 58 | "typescript": "^5.4.5", 59 | "uuid": "^11.1.0" 60 | }, 61 | "scripts": { 62 | "prepack": "npm run clean && npm run build && npm run test", 63 | "build": "npm-run-all build:*", 64 | "build:ts": "tsc -p tsconfig.json && tsc -p tsconfig.commonjs.json", 65 | "test": "npm-run-all test:*", 66 | "test:lint": "eslint --ext .ts,.js .", 67 | "test:unit": "TS_NODE_PROJECT='./tsconfig.dev.json' mocha", 68 | "test:format": "prettier --check .", 69 | "coverage": "npm-run-all coverage:*", 70 | "coverage:run": "nyc npm run test:unit", 71 | "coverage:open": "open coverage/index.html > /dev/null 2>&1 &", 72 | "fix": "npm-run-all fix:*", 73 | "fix:format": "prettier --write .", 74 | "docs": "typedoc --options typedoc.json src/index.ts", 75 | "benchmarks": "TS_NODE_PROJECT='./tsconfig.dev.json' node -r ts-node/register --expose-gc benchmarks/main.ts", 76 | "inspect": "TS_NODE_PROJECT='./tsconfig.dev.json' node -r ts-node/register --expose-gc --inspect benchmarks/main.ts", 77 | "clean": "rm -rf build docs coverage .nyc_output" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/id.ts: -------------------------------------------------------------------------------- 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/device/user generates ElementIds concurrently. 10 | * 11 | * For example, if a user types a sentence from left to right, you can 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.maxCounter(bunchId) can help with this.) 32 | * IdList is optimized for this case, but it is not mandatory. 33 | * In particular, it is okay if future edits cause the sequential ids to be 34 | * separated, partially deleted, or even reordered. 35 | */ 36 | readonly counter: number; 37 | } 38 | 39 | /** 40 | * Equals function for ElementIds. 41 | */ 42 | export function equalsId(a: ElementId, b: ElementId) { 43 | return a.counter === b.counter && a.bunchId === b.bunchId; 44 | } 45 | 46 | /** 47 | * Expands a "compressed" sequence of ElementIds that have the same bunchId but 48 | * sequentially increasing counters, starting at `startId.counter`. 49 | * 50 | * For example, 51 | * ```ts 52 | * expandIds({ bunchId: "foo", counter: 7 }, 3) 53 | * ``` 54 | * returns 55 | * ```ts 56 | * [ 57 | * { bunchId: "foo", counter: 7 }, 58 | * { bunchId: "foo", counter: 8 }, 59 | * { bunchId: "foo", counter: 9 } 60 | * ] 61 | * ``` 62 | */ 63 | export function expandIds(startId: ElementId, count: number): ElementId[] { 64 | if (!(Number.isSafeInteger(count) && count >= 0)) { 65 | throw new Error(`Invalid count: ${count}`); 66 | } 67 | 68 | const ans: ElementId[] = []; 69 | for (let i = 0; i < count; i++) { 70 | ans.push({ bunchId: startId.bunchId, counter: startId.counter + i }); 71 | } 72 | return ans; 73 | } 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./id"; 2 | export { IdList, KnownIdView } from "./id_list"; 3 | export * from "./saved_id_list"; 4 | -------------------------------------------------------------------------------- /src/internal/leaf_map.ts: -------------------------------------------------------------------------------- 1 | import createRBTree, { Tree } from "../vendor/functional-red-black-tree"; 2 | import type { LeafNode } from "../id_list"; 3 | 4 | /** 5 | * A persistent sorted map from each LeafNode to its parent's seq. 6 | * 7 | * Leaves are sorted by their first ElementId. 8 | * This lets you quickly look up the LeafNode containing an ElementId, 9 | * even though the LeafNode might start at a lower counter. 10 | */ 11 | export class LeafMap { 12 | private constructor(private readonly tree: Tree) {} 13 | 14 | static new() { 15 | return new this(createRBTree(compareLeaves)); 16 | } 17 | 18 | /** 19 | * Returns the greatest leaf whose first id is <= the given id, 20 | * or undefined if none exists. Also returns the associated seq (or -1 if not found). 21 | * 22 | * The returned leaf might not actually contain the given id. 23 | */ 24 | getLeaf( 25 | bunchId: string, 26 | counter: number 27 | ): [leaf: LeafNode | undefined, seq: number] { 28 | const iter = this.tree.le({ bunchId, startCounter: counter } as LeafNode); 29 | return [iter.key, iter.value ?? -1]; 30 | } 31 | 32 | set(leaf: LeafNode, seq: number): LeafMap { 33 | return new LeafMap(this.tree.set(leaf, seq)); 34 | } 35 | 36 | delete(leaf: LeafNode): LeafMap { 37 | return new LeafMap(this.tree.remove(leaf)); 38 | } 39 | } 40 | 41 | /** 42 | * Sort function for LeafNodes in LeafMap. 43 | * 44 | * Sorting by startCounters lets us quickly look up the LeafNode containing an ElementId, 45 | * even though the LeafNode might start at a lower counter. 46 | */ 47 | function compareLeaves(a: LeafNode, b: LeafNode) { 48 | if (a.bunchId === b.bunchId) { 49 | return a.startCounter - b.startCounter; 50 | } else { 51 | return a.bunchId > b.bunchId ? 1 : -1; 52 | } 53 | } 54 | 55 | export interface MutableLeafMap { 56 | value: LeafMap; 57 | } 58 | -------------------------------------------------------------------------------- /src/internal/seq_map.ts: -------------------------------------------------------------------------------- 1 | import createRBTree, { Tree } from "../vendor/functional-red-black-tree"; 2 | 3 | /** 4 | * A persistent map from an InnerNode's seq to its parent's seq 5 | * (or 0 for the root). 6 | * 7 | * Sequence numbers start at 1 and increment each time you call set(nextSeq, ...). 8 | */ 9 | export class SeqMap { 10 | constructor( 11 | private readonly tree: Tree, 12 | private readonly nextSeq: number 13 | ) {} 14 | 15 | static new(): SeqMap { 16 | return new this( 17 | createRBTree((a, b) => a - b), 18 | 1 19 | ); 20 | } 21 | 22 | bumpNextSeq(): SeqMap { 23 | return new SeqMap(this.tree, this.nextSeq + 1); 24 | } 25 | 26 | get(seq: number): number { 27 | return this.tree.get(seq)!; 28 | } 29 | 30 | set(seq: number, value: number): SeqMap { 31 | return new SeqMap(this.tree.set(seq, value), this.nextSeq); 32 | } 33 | 34 | // delete(seq: number): SeqMap { 35 | // return new SeqMap(this.tree.remove(seq), this.nextSeq); 36 | // } 37 | } 38 | 39 | export interface MutableSeqMap { 40 | value: SeqMap; 41 | } 42 | 43 | export function getAndBumpNextSeq(seqsMut: MutableSeqMap): number { 44 | // @ts-expect-error Ignore private 45 | const nextSeq = seqsMut.value.nextSeq; 46 | seqsMut.value = seqsMut.value.bumpNextSeq(); 47 | return nextSeq; 48 | } 49 | -------------------------------------------------------------------------------- /src/saved_id_list.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JSON 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 | readonly bunchId: string; 10 | readonly startCounter: number; 11 | readonly count: number; 12 | readonly isDeleted: boolean; 13 | }>; 14 | -------------------------------------------------------------------------------- /src/vendor/functional-red-black-tree.d.ts: -------------------------------------------------------------------------------- 1 | // Modified from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/functional-red-black-tree/functional-red-black-tree-tests.ts 2 | // which is MIT Licensed. 3 | 4 | declare namespace createRBTree { 5 | /** Represents a functional red-black tree. */ 6 | interface Tree { 7 | /** Returns the root node of the tree. */ 8 | root: Node; 9 | 10 | // /** A sorted array of all keys in the tree. */ 11 | // readonly keys: K[]; 12 | 13 | // /** An array of all values in the tree, sorted by key. */ 14 | // readonly values: V[]; 15 | 16 | /** 17 | * Creates a new tree with `key` set to `value`, overwriting any 18 | * existing value. 19 | * 20 | * @param key The key of the item to insert. 21 | * @param value The value of the item to insert. 22 | * @returns A new tree with `key` set to `value`. 23 | */ 24 | set: (key: K, value: V) => Tree; 25 | 26 | // /** 27 | // * Walks a visitor function over the nodes of the tree in order. 28 | // * 29 | // * @param visitor The callback to be executed on each node. If a truthy 30 | // * value is returned from the visitor, then iteration is stopped. 31 | // * @param lo An optional start of the range to visit (inclusive). 32 | // * @param hi An optional end of the range to visit (non-inclusive). 33 | // * @returns The last value returned by the callback. 34 | // */ 35 | // forEach: { 36 | // (visitor: (key: K, value: V) => T): T; 37 | // // tslint:disable-next-line:unified-signatures 38 | // (visitor: (key: K, value: V) => T, lo: K, hi?: K): T; 39 | // }; 40 | 41 | // /** An iterator pointing to the first element in the tree. */ 42 | // readonly begin: Iterator; 43 | 44 | // /** An iterator pointing to the last element in the tree. */ 45 | // readonly end: Iterator; 46 | 47 | // /** 48 | // * Finds the first item in the tree whose key is >= `key`. 49 | // * 50 | // * @param key The key to search for. 51 | // * @returns An iterator at the given element. 52 | // */ 53 | // ge: (key: K) => Iterator; 54 | 55 | // /** 56 | // * Finds the first item in the tree whose key is > `key`. 57 | // * 58 | // * @param key The key to search for. 59 | // * @returns An iterator at the given element. 60 | // */ 61 | // gt: (key: K) => Iterator; 62 | 63 | // /** 64 | // * Finds the last item in the tree whose key is < `key`. 65 | // * 66 | // * @param key The key to search for. 67 | // * @returns An iterator at the given element. 68 | // */ 69 | // lt: (key: K) => Iterator; 70 | 71 | /** 72 | * Finds the last item in the tree whose key is <= `key`. 73 | * 74 | * @param key The key to search for. 75 | * @returns An iterator at the given element. 76 | */ 77 | le: (key: K) => Iterator; 78 | 79 | /** 80 | * @returns An iterator pointing to the first item in the tree with `key`, otherwise null. 81 | */ 82 | find: (key: K) => Iterator; 83 | 84 | /** 85 | * Removes the first item with `key` in the tree. 86 | * 87 | * @param key The key of the item to remove. 88 | * @returns A new tree with the given item removed, if it exists. 89 | */ 90 | remove: (key: K) => Tree; 91 | 92 | /** 93 | * Retrieves the value associated with `key`. 94 | * 95 | * @param key The key of the item to look up. 96 | * @returns The value of the first node associated with `key`. 97 | */ 98 | // eslint-disable-next-line @typescript-eslint/no-invalid-void-type 99 | get: (key: K) => V | void; 100 | } 101 | 102 | /** Iterates through the nodes in a red-black tree. */ 103 | interface Iterator { 104 | /** The tree associated with the iterator. */ 105 | tree: Tree; 106 | 107 | // /** Checks if the iterator is valid. */ 108 | // readonly valid: boolean; 109 | 110 | // /** 111 | // * The value of the node at the iterator's current position, or null if the 112 | // * iterator is invalid. 113 | // */ 114 | // readonly node: Node | null; 115 | 116 | // /** Makes a copy of the iterator. */ 117 | // clone: () => Iterator; 118 | 119 | /** 120 | * Removes the iterator's current item form the tree. 121 | * 122 | * @returns A new binary search tree with the item removed. 123 | */ 124 | remove: () => Tree; 125 | 126 | /** The key of the iterator's current item. */ 127 | readonly key?: K | undefined; 128 | 129 | /** The value of the iterator's current item. */ 130 | readonly value?: V | undefined; 131 | 132 | // /** Advances the iterator to the next position. */ 133 | // next: () => void; 134 | 135 | // /** If true, then the iterator is not at the end of the sequence. */ 136 | // readonly hasNext: boolean; 137 | 138 | // /** 139 | // * Updates the value of the iterator's current item. 140 | // * 141 | // * @returns A new binary search tree with the corresponding node updated. 142 | // */ 143 | // update: (value: V) => Tree; 144 | 145 | // /** Moves the iterator backward one element. */ 146 | // prev: () => void; 147 | 148 | // /** If true, then the iterator is not at the beginning of the sequence. */ 149 | // readonly hasPrev: boolean; 150 | } 151 | 152 | /** Represents a node in a red-black tree. */ 153 | interface Node { 154 | /** The key associated with the node. */ 155 | key: K; 156 | 157 | /** The value associated with the node. */ 158 | value: V; 159 | 160 | /** The left subtree of the node. */ 161 | left: Tree; 162 | 163 | /** The right subtree of the node. */ 164 | right: Tree; 165 | } 166 | } 167 | 168 | /** 169 | * Creates an empty red-black tree. 170 | * 171 | * @param compare Comparison function, same semantics as array.sort(). 172 | * @returns An empty tree ordered by `compare`. 173 | */ 174 | declare function createRBTree( 175 | compare: (key1: K, key2: K) => number 176 | ): createRBTree.Tree; 177 | export = createRBTree; 178 | -------------------------------------------------------------------------------- /src/vendor/functional-red-black-tree.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = createRBTree; 4 | 5 | // Modified from https://github.com/mikolalysenko/functional-red-black-tree/blob/master/rbtree.js 6 | // which is MIT Licensed, Copyright (c) 2013 Mikola Lysenko. 7 | 8 | var RED = 0; 9 | var BLACK = 1; 10 | 11 | function RBNode(color, key, value, left, right) { 12 | this._color = color; 13 | this.key = key; 14 | this.value = value; 15 | this.left = left; 16 | this.right = right; 17 | } 18 | 19 | function cloneNode(node) { 20 | return new RBNode(node._color, node.key, node.value, node.left, node.right); 21 | } 22 | 23 | function repaint(color, node) { 24 | return new RBNode(color, node.key, node.value, node.left, node.right); 25 | } 26 | 27 | function RedBlackTree(compare, root) { 28 | this._compare = compare; 29 | this.root = root; 30 | } 31 | 32 | var proto = RedBlackTree.prototype; 33 | 34 | // Object.defineProperty(proto, "keys", { 35 | // get: function () { 36 | // var result = []; 37 | // this.forEach(function (k, v) { 38 | // result.push(k); 39 | // }); 40 | // return result; 41 | // }, 42 | // }); 43 | 44 | // Object.defineProperty(proto, "values", { 45 | // get: function () { 46 | // var result = []; 47 | // this.forEach(function (k, v) { 48 | // result.push(v); 49 | // }); 50 | // return result; 51 | // }, 52 | // }); 53 | 54 | //Set a key-value pair 55 | proto.set = function (key, value) { 56 | var cmp = this._compare; 57 | //Find point to insert/replace node 58 | var n = this.root; 59 | var n_stack = []; 60 | var d_stack = []; 61 | let d = 0; 62 | while (n) { 63 | d = cmp(key, n.key); 64 | n_stack.push(n); 65 | d_stack.push(d); 66 | // If the keys are equivalent, skip straight to the replace = true case. 67 | if (d === 0) break; 68 | else if (d < 0) { 69 | n = n.left; 70 | } else { 71 | n = n.right; 72 | } 73 | } 74 | 75 | const replace = d === 0 && n_stack.length > 0; 76 | if (replace) { 77 | // The last node in the n_stack has key equivalent to `key`. 78 | // Replace its entry without changing the tree structure. 79 | const lastN = n_stack[n_stack.length - 1]; 80 | if (lastN.key === key && lastN.value === value) return this; 81 | n_stack[n_stack.length - 1] = new RBNode( 82 | lastN._color, 83 | key, 84 | value, 85 | lastN.left, 86 | lastN.right 87 | ); 88 | } else { 89 | n_stack.push(new RBNode(RED, key, value, null, null)); 90 | } 91 | 92 | //Rebuild path to leaf node 93 | for (var s = n_stack.length - 2; s >= 0; --s) { 94 | var n = n_stack[s]; 95 | if (d_stack[s] <= 0) { 96 | n_stack[s] = new RBNode( 97 | n._color, 98 | n.key, 99 | n.value, 100 | n_stack[s + 1], 101 | n.right 102 | ); 103 | } else { 104 | n_stack[s] = new RBNode(n._color, n.key, n.value, n.left, n_stack[s + 1]); 105 | } 106 | } 107 | 108 | if (replace) return new RedBlackTree(cmp, n_stack[0]); 109 | 110 | //Rebalance tree using rotations 111 | //console.log("start insert", key, d_stack) 112 | for (var s = n_stack.length - 1; s > 1; --s) { 113 | var p = n_stack[s - 1]; 114 | var n = n_stack[s]; 115 | if (p._color === BLACK || n._color === BLACK) { 116 | break; 117 | } 118 | var pp = n_stack[s - 2]; 119 | if (pp.left === p) { 120 | if (p.left === n) { 121 | var y = pp.right; 122 | if (y && y._color === RED) { 123 | //console.log("LLr") 124 | p._color = BLACK; 125 | pp.right = repaint(BLACK, y); 126 | pp._color = RED; 127 | s -= 1; 128 | } else { 129 | //console.log("LLb") 130 | pp._color = RED; 131 | pp.left = p.right; 132 | p._color = BLACK; 133 | p.right = pp; 134 | n_stack[s - 2] = p; 135 | n_stack[s - 1] = n; 136 | if (s >= 3) { 137 | var ppp = n_stack[s - 3]; 138 | if (ppp.left === pp) { 139 | ppp.left = p; 140 | } else { 141 | ppp.right = p; 142 | } 143 | } 144 | break; 145 | } 146 | } else { 147 | var y = pp.right; 148 | if (y && y._color === RED) { 149 | //console.log("LRr") 150 | p._color = BLACK; 151 | pp.right = repaint(BLACK, y); 152 | pp._color = RED; 153 | s -= 1; 154 | } else { 155 | //console.log("LRb") 156 | p.right = n.left; 157 | pp._color = RED; 158 | pp.left = n.right; 159 | n._color = BLACK; 160 | n.left = p; 161 | n.right = pp; 162 | n_stack[s - 2] = n; 163 | n_stack[s - 1] = p; 164 | if (s >= 3) { 165 | var ppp = n_stack[s - 3]; 166 | if (ppp.left === pp) { 167 | ppp.left = n; 168 | } else { 169 | ppp.right = n; 170 | } 171 | } 172 | break; 173 | } 174 | } 175 | } else { 176 | if (p.right === n) { 177 | var y = pp.left; 178 | if (y && y._color === RED) { 179 | //console.log("RRr", y.key) 180 | p._color = BLACK; 181 | pp.left = repaint(BLACK, y); 182 | pp._color = RED; 183 | s -= 1; 184 | } else { 185 | //console.log("RRb") 186 | pp._color = RED; 187 | pp.right = p.left; 188 | p._color = BLACK; 189 | p.left = pp; 190 | n_stack[s - 2] = p; 191 | n_stack[s - 1] = n; 192 | if (s >= 3) { 193 | var ppp = n_stack[s - 3]; 194 | if (ppp.right === pp) { 195 | ppp.right = p; 196 | } else { 197 | ppp.left = p; 198 | } 199 | } 200 | break; 201 | } 202 | } else { 203 | var y = pp.left; 204 | if (y && y._color === RED) { 205 | //console.log("RLr") 206 | p._color = BLACK; 207 | pp.left = repaint(BLACK, y); 208 | pp._color = RED; 209 | s -= 1; 210 | } else { 211 | //console.log("RLb") 212 | p.left = n.right; 213 | pp._color = RED; 214 | pp.right = n.left; 215 | n._color = BLACK; 216 | n.right = p; 217 | n.left = pp; 218 | n_stack[s - 2] = n; 219 | n_stack[s - 1] = p; 220 | if (s >= 3) { 221 | var ppp = n_stack[s - 3]; 222 | if (ppp.right === pp) { 223 | ppp.right = n; 224 | } else { 225 | ppp.left = n; 226 | } 227 | } 228 | break; 229 | } 230 | } 231 | } 232 | } 233 | //Return new tree 234 | n_stack[0]._color = BLACK; 235 | return new RedBlackTree(cmp, n_stack[0]); 236 | }; 237 | 238 | // //Visit all nodes inorder 239 | // function doVisitFull(visit, node) { 240 | // if (node.left) { 241 | // var v = doVisitFull(visit, node.left); 242 | // if (v) { 243 | // return v; 244 | // } 245 | // } 246 | // var v = visit(node.key, node.value); 247 | // if (v) { 248 | // return v; 249 | // } 250 | // if (node.right) { 251 | // return doVisitFull(visit, node.right); 252 | // } 253 | // } 254 | 255 | // //Visit half nodes in order 256 | // function doVisitHalf(lo, compare, visit, node) { 257 | // var l = compare(lo, node.key); 258 | // if (l <= 0) { 259 | // if (node.left) { 260 | // var v = doVisitHalf(lo, compare, visit, node.left); 261 | // if (v) { 262 | // return v; 263 | // } 264 | // } 265 | // var v = visit(node.key, node.value); 266 | // if (v) { 267 | // return v; 268 | // } 269 | // } 270 | // if (node.right) { 271 | // return doVisitHalf(lo, compare, visit, node.right); 272 | // } 273 | // } 274 | 275 | // //Visit all nodes within a range 276 | // function doVisit(lo, hi, compare, visit, node) { 277 | // var l = compare(lo, node.key); 278 | // var h = compare(hi, node.key); 279 | // var v; 280 | // if (l <= 0) { 281 | // if (node.left) { 282 | // v = doVisit(lo, hi, compare, visit, node.left); 283 | // if (v) { 284 | // return v; 285 | // } 286 | // } 287 | // if (h > 0) { 288 | // v = visit(node.key, node.value); 289 | // if (v) { 290 | // return v; 291 | // } 292 | // } 293 | // } 294 | // if (h > 0 && node.right) { 295 | // return doVisit(lo, hi, compare, visit, node.right); 296 | // } 297 | // } 298 | 299 | // proto.forEach = function rbTreeForEach(visit, lo, hi) { 300 | // if (!this.root) { 301 | // return; 302 | // } 303 | // switch (arguments.length) { 304 | // case 1: 305 | // return doVisitFull(visit, this.root); 306 | // break; 307 | 308 | // case 2: 309 | // return doVisitHalf(lo, this._compare, visit, this.root); 310 | // break; 311 | 312 | // case 3: 313 | // if (this._compare(lo, hi) >= 0) { 314 | // return; 315 | // } 316 | // return doVisit(lo, hi, this._compare, visit, this.root); 317 | // break; 318 | // } 319 | // }; 320 | 321 | // //First item in list 322 | // Object.defineProperty(proto, "begin", { 323 | // get: function () { 324 | // var stack = []; 325 | // var n = this.root; 326 | // while (n) { 327 | // stack.push(n); 328 | // n = n.left; 329 | // } 330 | // return new RedBlackTreeIterator(this, stack); 331 | // }, 332 | // }); 333 | 334 | // //Last item in list 335 | // Object.defineProperty(proto, "end", { 336 | // get: function () { 337 | // var stack = []; 338 | // var n = this.root; 339 | // while (n) { 340 | // stack.push(n); 341 | // n = n.right; 342 | // } 343 | // return new RedBlackTreeIterator(this, stack); 344 | // }, 345 | // }); 346 | 347 | // proto.ge = function (key) { 348 | // var cmp = this._compare; 349 | // var n = this.root; 350 | // var stack = []; 351 | // var last_ptr = 0; 352 | // while (n) { 353 | // var d = cmp(key, n.key); 354 | // stack.push(n); 355 | // if (d <= 0) { 356 | // last_ptr = stack.length; 357 | // } 358 | // if (d <= 0) { 359 | // n = n.left; 360 | // } else { 361 | // n = n.right; 362 | // } 363 | // } 364 | // stack.length = last_ptr; 365 | // return new RedBlackTreeIterator(this, stack); 366 | // }; 367 | 368 | // proto.gt = function (key) { 369 | // var cmp = this._compare; 370 | // var n = this.root; 371 | // var stack = []; 372 | // var last_ptr = 0; 373 | // while (n) { 374 | // var d = cmp(key, n.key); 375 | // stack.push(n); 376 | // if (d < 0) { 377 | // last_ptr = stack.length; 378 | // } 379 | // if (d < 0) { 380 | // n = n.left; 381 | // } else { 382 | // n = n.right; 383 | // } 384 | // } 385 | // stack.length = last_ptr; 386 | // return new RedBlackTreeIterator(this, stack); 387 | // }; 388 | 389 | // proto.lt = function (key) { 390 | // var cmp = this._compare; 391 | // var n = this.root; 392 | // var stack = []; 393 | // var last_ptr = 0; 394 | // while (n) { 395 | // var d = cmp(key, n.key); 396 | // stack.push(n); 397 | // if (d > 0) { 398 | // last_ptr = stack.length; 399 | // } 400 | // if (d <= 0) { 401 | // n = n.left; 402 | // } else { 403 | // n = n.right; 404 | // } 405 | // } 406 | // stack.length = last_ptr; 407 | // return new RedBlackTreeIterator(this, stack); 408 | // }; 409 | 410 | proto.le = function (key) { 411 | var cmp = this._compare; 412 | var n = this.root; 413 | var stack = []; 414 | var last_ptr = 0; 415 | while (n) { 416 | var d = cmp(key, n.key); 417 | stack.push(n); 418 | if (d >= 0) { 419 | last_ptr = stack.length; 420 | } 421 | if (d < 0) { 422 | n = n.left; 423 | } else { 424 | n = n.right; 425 | } 426 | } 427 | stack.length = last_ptr; 428 | return new RedBlackTreeIterator(this, stack); 429 | }; 430 | 431 | //Finds the item with key if it exists 432 | proto.find = function (key) { 433 | var cmp = this._compare; 434 | var n = this.root; 435 | var stack = []; 436 | while (n) { 437 | var d = cmp(key, n.key); 438 | stack.push(n); 439 | if (d === 0) { 440 | return new RedBlackTreeIterator(this, stack); 441 | } 442 | if (d <= 0) { 443 | n = n.left; 444 | } else { 445 | n = n.right; 446 | } 447 | } 448 | return new RedBlackTreeIterator(this, []); 449 | }; 450 | 451 | //Removes item with key from tree 452 | proto.remove = function (key) { 453 | var iter = this.find(key); 454 | return iter.remove(); 455 | }; 456 | 457 | //Returns the item at `key` 458 | proto.get = function (key) { 459 | var cmp = this._compare; 460 | var n = this.root; 461 | while (n) { 462 | var d = cmp(key, n.key); 463 | if (d === 0) { 464 | return n.value; 465 | } 466 | if (d <= 0) { 467 | n = n.left; 468 | } else { 469 | n = n.right; 470 | } 471 | } 472 | return; 473 | }; 474 | 475 | //Iterator for red black tree 476 | function RedBlackTreeIterator(tree, stack) { 477 | this.tree = tree; 478 | this._stack = stack; 479 | } 480 | 481 | var iproto = RedBlackTreeIterator.prototype; 482 | 483 | // //Test if iterator is valid 484 | // Object.defineProperty(iproto, "valid", { 485 | // get: function () { 486 | // return this._stack.length > 0; 487 | // }, 488 | // }); 489 | 490 | // //Node of the iterator 491 | // Object.defineProperty(iproto, "node", { 492 | // get: function () { 493 | // if (this._stack.length > 0) { 494 | // return this._stack[this._stack.length - 1]; 495 | // } 496 | // return null; 497 | // }, 498 | // enumerable: true, 499 | // }); 500 | 501 | // //Makes a copy of an iterator 502 | // iproto.clone = function () { 503 | // return new RedBlackTreeIterator(this.tree, this._stack.slice()); 504 | // }; 505 | 506 | //Swaps two nodes 507 | function swapNode(n, v) { 508 | n.key = v.key; 509 | n.value = v.value; 510 | n.left = v.left; 511 | n.right = v.right; 512 | n._color = v._color; 513 | } 514 | 515 | //Fix up a double black node in a tree 516 | function fixDoubleBlack(stack) { 517 | var n, p, s, z; 518 | for (var i = stack.length - 1; i >= 0; --i) { 519 | n = stack[i]; 520 | if (i === 0) { 521 | n._color = BLACK; 522 | return; 523 | } 524 | //console.log("visit node:", n.key, i, stack[i].key, stack[i-1].key) 525 | p = stack[i - 1]; 526 | if (p.left === n) { 527 | //console.log("left child") 528 | s = p.right; 529 | if (s.right && s.right._color === RED) { 530 | //console.log("case 1: right sibling child red") 531 | s = p.right = cloneNode(s); 532 | z = s.right = cloneNode(s.right); 533 | p.right = s.left; 534 | s.left = p; 535 | s.right = z; 536 | s._color = p._color; 537 | n._color = BLACK; 538 | p._color = BLACK; 539 | z._color = BLACK; 540 | if (i > 1) { 541 | var pp = stack[i - 2]; 542 | if (pp.left === p) { 543 | pp.left = s; 544 | } else { 545 | pp.right = s; 546 | } 547 | } 548 | stack[i - 1] = s; 549 | return; 550 | } else if (s.left && s.left._color === RED) { 551 | //console.log("case 1: left sibling child red") 552 | s = p.right = cloneNode(s); 553 | z = s.left = cloneNode(s.left); 554 | p.right = z.left; 555 | s.left = z.right; 556 | z.left = p; 557 | z.right = s; 558 | z._color = p._color; 559 | p._color = BLACK; 560 | s._color = BLACK; 561 | n._color = BLACK; 562 | if (i > 1) { 563 | var pp = stack[i - 2]; 564 | if (pp.left === p) { 565 | pp.left = z; 566 | } else { 567 | pp.right = z; 568 | } 569 | } 570 | stack[i - 1] = z; 571 | return; 572 | } 573 | if (s._color === BLACK) { 574 | if (p._color === RED) { 575 | //console.log("case 2: black sibling, red parent", p.right.value) 576 | p._color = BLACK; 577 | p.right = repaint(RED, s); 578 | return; 579 | } else { 580 | //console.log("case 2: black sibling, black parent", p.right.value) 581 | p.right = repaint(RED, s); 582 | continue; 583 | } 584 | } else { 585 | //console.log("case 3: red sibling") 586 | s = cloneNode(s); 587 | p.right = s.left; 588 | s.left = p; 589 | s._color = p._color; 590 | p._color = RED; 591 | if (i > 1) { 592 | var pp = stack[i - 2]; 593 | if (pp.left === p) { 594 | pp.left = s; 595 | } else { 596 | pp.right = s; 597 | } 598 | } 599 | stack[i - 1] = s; 600 | stack[i] = p; 601 | if (i + 1 < stack.length) { 602 | stack[i + 1] = n; 603 | } else { 604 | stack.push(n); 605 | } 606 | i = i + 2; 607 | } 608 | } else { 609 | //console.log("right child") 610 | s = p.left; 611 | if (s.left && s.left._color === RED) { 612 | //console.log("case 1: left sibling child red", p.value, p._color) 613 | s = p.left = cloneNode(s); 614 | z = s.left = cloneNode(s.left); 615 | p.left = s.right; 616 | s.right = p; 617 | s.left = z; 618 | s._color = p._color; 619 | n._color = BLACK; 620 | p._color = BLACK; 621 | z._color = BLACK; 622 | if (i > 1) { 623 | var pp = stack[i - 2]; 624 | if (pp.right === p) { 625 | pp.right = s; 626 | } else { 627 | pp.left = s; 628 | } 629 | } 630 | stack[i - 1] = s; 631 | return; 632 | } else if (s.right && s.right._color === RED) { 633 | //console.log("case 1: right sibling child red") 634 | s = p.left = cloneNode(s); 635 | z = s.right = cloneNode(s.right); 636 | p.left = z.right; 637 | s.right = z.left; 638 | z.right = p; 639 | z.left = s; 640 | z._color = p._color; 641 | p._color = BLACK; 642 | s._color = BLACK; 643 | n._color = BLACK; 644 | if (i > 1) { 645 | var pp = stack[i - 2]; 646 | if (pp.right === p) { 647 | pp.right = z; 648 | } else { 649 | pp.left = z; 650 | } 651 | } 652 | stack[i - 1] = z; 653 | return; 654 | } 655 | if (s._color === BLACK) { 656 | if (p._color === RED) { 657 | //console.log("case 2: black sibling, red parent") 658 | p._color = BLACK; 659 | p.left = repaint(RED, s); 660 | return; 661 | } else { 662 | //console.log("case 2: black sibling, black parent") 663 | p.left = repaint(RED, s); 664 | continue; 665 | } 666 | } else { 667 | //console.log("case 3: red sibling") 668 | s = cloneNode(s); 669 | p.left = s.right; 670 | s.right = p; 671 | s._color = p._color; 672 | p._color = RED; 673 | if (i > 1) { 674 | var pp = stack[i - 2]; 675 | if (pp.right === p) { 676 | pp.right = s; 677 | } else { 678 | pp.left = s; 679 | } 680 | } 681 | stack[i - 1] = s; 682 | stack[i] = p; 683 | if (i + 1 < stack.length) { 684 | stack[i + 1] = n; 685 | } else { 686 | stack.push(n); 687 | } 688 | i = i + 2; 689 | } 690 | } 691 | } 692 | } 693 | 694 | //Removes item at iterator from tree 695 | iproto.remove = function () { 696 | var stack = this._stack; 697 | if (stack.length === 0) { 698 | return this.tree; 699 | } 700 | //First copy path to node 701 | var cstack = new Array(stack.length); 702 | var n = stack[stack.length - 1]; 703 | cstack[cstack.length - 1] = new RBNode( 704 | n._color, 705 | n.key, 706 | n.value, 707 | n.left, 708 | n.right 709 | ); 710 | for (var i = stack.length - 2; i >= 0; --i) { 711 | var n = stack[i]; 712 | if (n.left === stack[i + 1]) { 713 | cstack[i] = new RBNode(n._color, n.key, n.value, cstack[i + 1], n.right); 714 | } else { 715 | cstack[i] = new RBNode(n._color, n.key, n.value, n.left, cstack[i + 1]); 716 | } 717 | } 718 | 719 | //Get node 720 | n = cstack[cstack.length - 1]; 721 | //console.log("start remove: ", n.value) 722 | 723 | //If not leaf, then swap with previous node 724 | if (n.left && n.right) { 725 | //console.log("moving to leaf") 726 | 727 | //First walk to previous leaf 728 | var split = cstack.length; 729 | n = n.left; 730 | while (n.right) { 731 | cstack.push(n); 732 | n = n.right; 733 | } 734 | //Copy path to leaf 735 | var v = cstack[split - 1]; 736 | cstack.push(new RBNode(n._color, v.key, v.value, n.left, n.right)); 737 | cstack[split - 1].key = n.key; 738 | cstack[split - 1].value = n.value; 739 | 740 | //Fix up stack 741 | for (var i = cstack.length - 2; i >= split; --i) { 742 | n = cstack[i]; 743 | cstack[i] = new RBNode(n._color, n.key, n.value, n.left, cstack[i + 1]); 744 | } 745 | cstack[split - 1].left = cstack[split]; 746 | } 747 | //console.log("stack=", cstack.map(function(v) { return v.value })) 748 | 749 | //Remove leaf node 750 | n = cstack[cstack.length - 1]; 751 | if (n._color === RED) { 752 | //Easy case: removing red leaf 753 | //console.log("RED leaf") 754 | var p = cstack[cstack.length - 2]; 755 | if (p.left === n) { 756 | p.left = null; 757 | } else if (p.right === n) { 758 | p.right = null; 759 | } 760 | cstack.pop(); 761 | return new RedBlackTree(this.tree._compare, cstack[0]); 762 | } else { 763 | if (n.left || n.right) { 764 | //Second easy case: Single child black parent 765 | //console.log("BLACK single child") 766 | if (n.left) { 767 | swapNode(n, n.left); 768 | } else if (n.right) { 769 | swapNode(n, n.right); 770 | } 771 | //Child must be red, so repaint it black to balance color 772 | n._color = BLACK; 773 | return new RedBlackTree(this.tree._compare, cstack[0]); 774 | } else if (cstack.length === 1) { 775 | //Third easy case: root 776 | //console.log("ROOT") 777 | return new RedBlackTree(this.tree._compare, null); 778 | } else { 779 | //Hard case: Repaint n, and then do some nasty stuff 780 | //console.log("BLACK leaf no children") 781 | var parent = cstack[cstack.length - 2]; 782 | fixDoubleBlack(cstack); 783 | //Fix up links 784 | if (parent.left === n) { 785 | parent.left = null; 786 | } else { 787 | parent.right = null; 788 | } 789 | } 790 | } 791 | return new RedBlackTree(this.tree._compare, cstack[0]); 792 | }; 793 | 794 | //Returns key 795 | Object.defineProperty(iproto, "key", { 796 | get: function () { 797 | if (this._stack.length > 0) { 798 | return this._stack[this._stack.length - 1].key; 799 | } 800 | return; 801 | }, 802 | enumerable: true, 803 | }); 804 | 805 | //Returns value 806 | Object.defineProperty(iproto, "value", { 807 | get: function () { 808 | if (this._stack.length > 0) { 809 | return this._stack[this._stack.length - 1].value; 810 | } 811 | return; 812 | }, 813 | enumerable: true, 814 | }); 815 | 816 | // //Advances iterator to next element in list 817 | // iproto.next = function () { 818 | // var stack = this._stack; 819 | // if (stack.length === 0) { 820 | // return; 821 | // } 822 | // var n = stack[stack.length - 1]; 823 | // if (n.right) { 824 | // n = n.right; 825 | // while (n) { 826 | // stack.push(n); 827 | // n = n.left; 828 | // } 829 | // } else { 830 | // stack.pop(); 831 | // while (stack.length > 0 && stack[stack.length - 1].right === n) { 832 | // n = stack[stack.length - 1]; 833 | // stack.pop(); 834 | // } 835 | // } 836 | // }; 837 | 838 | // //Checks if iterator is at end of tree 839 | // Object.defineProperty(iproto, "hasNext", { 840 | // get: function () { 841 | // var stack = this._stack; 842 | // if (stack.length === 0) { 843 | // return false; 844 | // } 845 | // if (stack[stack.length - 1].right) { 846 | // return true; 847 | // } 848 | // for (var s = stack.length - 1; s > 0; --s) { 849 | // if (stack[s - 1].left === stack[s]) { 850 | // return true; 851 | // } 852 | // } 853 | // return false; 854 | // }, 855 | // }); 856 | 857 | // //Update value 858 | // iproto.update = function (value) { 859 | // var stack = this._stack; 860 | // if (stack.length === 0) { 861 | // throw new Error("Can't update empty node!"); 862 | // } 863 | // var cstack = new Array(stack.length); 864 | // var n = stack[stack.length - 1]; 865 | // cstack[cstack.length - 1] = new RBNode( 866 | // n._color, 867 | // n.key, 868 | // value, 869 | // n.left, 870 | // n.right 871 | // ); 872 | // for (var i = stack.length - 2; i >= 0; --i) { 873 | // n = stack[i]; 874 | // if (n.left === stack[i + 1]) { 875 | // cstack[i] = new RBNode(n._color, n.key, n.value, cstack[i + 1], n.right); 876 | // } else { 877 | // cstack[i] = new RBNode(n._color, n.key, n.value, n.left, cstack[i + 1]); 878 | // } 879 | // } 880 | // return new RedBlackTree(this.tree._compare, cstack[0]); 881 | // }; 882 | 883 | // //Moves iterator backward one element 884 | // iproto.prev = function () { 885 | // var stack = this._stack; 886 | // if (stack.length === 0) { 887 | // return; 888 | // } 889 | // var n = stack[stack.length - 1]; 890 | // if (n.left) { 891 | // n = n.left; 892 | // while (n) { 893 | // stack.push(n); 894 | // n = n.right; 895 | // } 896 | // } else { 897 | // stack.pop(); 898 | // while (stack.length > 0 && stack[stack.length - 1].left === n) { 899 | // n = stack[stack.length - 1]; 900 | // stack.pop(); 901 | // } 902 | // } 903 | // }; 904 | 905 | // //Checks if iterator is at start of tree 906 | // Object.defineProperty(iproto, "hasPrev", { 907 | // get: function () { 908 | // var stack = this._stack; 909 | // if (stack.length === 0) { 910 | // return false; 911 | // } 912 | // if (stack[stack.length - 1].left) { 913 | // return true; 914 | // } 915 | // for (var s = stack.length - 1; s > 0; --s) { 916 | // if (stack[s - 1].right === stack[s]) { 917 | // return true; 918 | // } 919 | // } 920 | // return false; 921 | // }, 922 | // }); 923 | 924 | // //Default comparison function 925 | // function defaultCompare(a, b) { 926 | // if (a < b) { 927 | // return -1; 928 | // } 929 | // if (a > b) { 930 | // return 1; 931 | // } 932 | // return 0; 933 | // } 934 | 935 | //Build a tree 936 | function createRBTree(compare) { 937 | return new RedBlackTree(compare, null); 938 | } 939 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from "chai"; 2 | import { ElementId, equalsId, expandIds, IdList, SavedIdList } from "../src"; 3 | 4 | describe("ElementId utilities", () => { 5 | describe("equalsId", () => { 6 | it("should return true for identical IDs", () => { 7 | const id1: ElementId = { bunchId: "abc123", counter: 5 }; 8 | const id2: ElementId = { bunchId: "abc123", counter: 5 }; 9 | expect(equalsId(id1, id2)).to.be.true; 10 | }); 11 | 12 | it("should return false for different bunchIds", () => { 13 | const id1: ElementId = { bunchId: "abc123", counter: 5 }; 14 | const id2: ElementId = { bunchId: "def456", counter: 5 }; 15 | expect(equalsId(id1, id2)).to.be.false; 16 | }); 17 | 18 | it("should return false for different counters", () => { 19 | const id1: ElementId = { bunchId: "abc123", counter: 5 }; 20 | const id2: ElementId = { bunchId: "abc123", counter: 6 }; 21 | expect(equalsId(id1, id2)).to.be.false; 22 | }); 23 | }); 24 | 25 | describe("expandIds", () => { 26 | it("should expand a single ID to a list of sequential IDs", () => { 27 | const startId: ElementId = { bunchId: "abc123", counter: 5 }; 28 | const expanded = expandIds(startId, 3); 29 | 30 | expect(expanded).to.have.length(3); 31 | expect(equalsId(expanded[0], { bunchId: "abc123", counter: 5 })).to.be 32 | .true; 33 | expect(equalsId(expanded[1], { bunchId: "abc123", counter: 6 })).to.be 34 | .true; 35 | expect(equalsId(expanded[2], { bunchId: "abc123", counter: 7 })).to.be 36 | .true; 37 | }); 38 | 39 | it("should handle count = 0", () => { 40 | const startId: ElementId = { bunchId: "abc123", counter: 5 }; 41 | const expanded = expandIds(startId, 0); 42 | expect(expanded).to.have.length(0); 43 | }); 44 | 45 | it("should throw for negative count", () => { 46 | const startId: ElementId = { bunchId: "abc123", counter: 5 }; 47 | expect(() => expandIds(startId, -1)).to.throw(); 48 | }); 49 | }); 50 | }); 51 | 52 | describe("IdList", () => { 53 | describe("constructor and static factory methods", () => { 54 | it("should create an empty list with default constructor", () => { 55 | const list = IdList.new(); 56 | expect(list.length).to.equal(0); 57 | }); 58 | 59 | it("should create a list with present elements using fromIds", () => { 60 | const ids: ElementId[] = [ 61 | { bunchId: "abc", counter: 1 }, 62 | { bunchId: "abc", counter: 2 }, 63 | { bunchId: "def", counter: 1 }, 64 | ]; 65 | 66 | const list = IdList.fromIds(ids); 67 | expect(list.length).to.equal(3); 68 | expect([...list].map((id) => id.counter)).to.deep.equal([1, 2, 1]); 69 | expect([...list].map((id) => id.bunchId)).to.deep.equal([ 70 | "abc", 71 | "abc", 72 | "def", 73 | ]); 74 | }); 75 | 76 | it("should create a list with deleted elements using from", () => { 77 | const elements = [ 78 | { id: { bunchId: "abc", counter: 1 }, isDeleted: false }, 79 | { id: { bunchId: "abc", counter: 2 }, isDeleted: true }, 80 | { id: { bunchId: "def", counter: 1 }, isDeleted: false }, 81 | ]; 82 | 83 | const list = IdList.from(elements); 84 | expect(list.length).to.equal(2); // Only non-deleted elements count toward length 85 | 86 | // First one should be present 87 | expect(list.has({ bunchId: "abc", counter: 1 })).to.be.true; 88 | 89 | // Second one should be known but deleted 90 | expect(list.has({ bunchId: "abc", counter: 2 })).to.be.false; 91 | expect(list.isKnown({ bunchId: "abc", counter: 2 })).to.be.true; 92 | 93 | // Third one should be present 94 | expect(list.has({ bunchId: "def", counter: 1 })).to.be.true; 95 | }); 96 | }); 97 | 98 | describe("insert operations", () => { 99 | it("should insert at the beginning with insertAfter(null)", () => { 100 | let list = IdList.new(); 101 | const id: ElementId = { bunchId: "abc", counter: 1 }; 102 | 103 | list = list.insertAfter(null, id); 104 | expect(list.length).to.equal(1); 105 | expect(equalsId(list.at(0), id)).to.be.true; 106 | }); 107 | 108 | it("should insert after a specific element", () => { 109 | let list = IdList.new(); 110 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 111 | const id2: ElementId = { bunchId: "def", counter: 1 }; 112 | 113 | list = list.insertAfter(null, id1); 114 | list = list.insertAfter(id1, id2); 115 | 116 | expect(list.length).to.equal(2); 117 | expect(equalsId(list.at(0), id1)).to.be.true; 118 | expect(equalsId(list.at(1), id2)).to.be.true; 119 | }); 120 | 121 | it("should insert at the end with insertBefore(null)", () => { 122 | let list = IdList.new(); 123 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 124 | const id2: ElementId = { bunchId: "def", counter: 1 }; 125 | 126 | list = list.insertAfter(null, id1); 127 | list = list.insertBefore(null, id2); 128 | 129 | expect(list.length).to.equal(2); 130 | expect(equalsId(list.at(0), id1)).to.be.true; 131 | expect(equalsId(list.at(1), id2)).to.be.true; 132 | }); 133 | 134 | it("should insert before a specific element", () => { 135 | let list = IdList.new(); 136 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 137 | const id2: ElementId = { bunchId: "def", counter: 1 }; 138 | 139 | list = list.insertAfter(null, id1); 140 | list = list.insertBefore(id1, id2); 141 | 142 | expect(list.length).to.equal(2); 143 | expect(equalsId(list.at(0), id2)).to.be.true; 144 | expect(equalsId(list.at(1), id1)).to.be.true; 145 | }); 146 | 147 | it("should insert before the end", () => { 148 | let list = IdList.new(); 149 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 150 | const id2: ElementId = { bunchId: "def", counter: 1 }; 151 | 152 | // Insert before null when the list is empty. 153 | list = list.insertBefore(null, id1, 3); 154 | 155 | expect(list.length).to.equal(3); 156 | expect(equalsId(list.at(0), id1)).to.be.true; 157 | 158 | // Insert before null when the list has ids. 159 | list = list.insertBefore(null, id2); 160 | 161 | expect(list.length).to.equal(4); 162 | expect(equalsId(list.at(3), id2)).to.be.true; 163 | expect(equalsId(list.at(0), id1)).to.be.true; 164 | }); 165 | 166 | it("should bulk insert multiple elements", () => { 167 | let list = IdList.new(); 168 | const startId: ElementId = { bunchId: "abc", counter: 1 }; 169 | 170 | list = list.insertAfter(null, startId, 3); 171 | 172 | expect(list.length).to.equal(3); 173 | expect(equalsId(list.at(0), { bunchId: "abc", counter: 1 })).to.be.true; 174 | expect(equalsId(list.at(1), { bunchId: "abc", counter: 2 })).to.be.true; 175 | expect(equalsId(list.at(2), { bunchId: "abc", counter: 3 })).to.be.true; 176 | }); 177 | 178 | it("should throw when inserting an ID that is already known", () => { 179 | let list = IdList.new(); 180 | const id: ElementId = { bunchId: "abc", counter: 1 }; 181 | 182 | list = list.insertAfter(null, id); 183 | expect(() => (list = list.insertAfter(null, id))).to.throw(); 184 | expect(() => (list = list.insertBefore(null, id))).to.throw(); 185 | }); 186 | 187 | it("should throw when inserting after an ID that is not known", () => { 188 | let list = IdList.new(); 189 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 190 | const id2: ElementId = { bunchId: "def", counter: 1 }; 191 | 192 | expect(() => (list = list.insertAfter(id1, id2))).to.throw(); 193 | }); 194 | 195 | it("should throw when inserting before an ID that is not known", () => { 196 | let list = IdList.new(); 197 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 198 | const id2: ElementId = { bunchId: "def", counter: 1 }; 199 | 200 | expect(() => (list = list.insertBefore(id1, id2))).to.throw(); 201 | }); 202 | 203 | it("should throw on bulk insertAfter with an invalid count", () => { 204 | let list = IdList.new(); 205 | const id: ElementId = { bunchId: "abc", counter: 1 }; 206 | 207 | expect(() => (list = list.insertAfter(null, id, -7))).to.throw(); 208 | expect(() => (list = list.insertAfter(null, id, 3.5))).to.throw(); 209 | expect(() => (list = list.insertAfter(null, id, NaN))).to.throw(); 210 | 211 | // Bulk insert 0 is okay (no-op). 212 | const newList = list.insertAfter(null, id, 0); 213 | expect(newList).to.equal(list); 214 | }); 215 | 216 | it("should throw on bulk insertBefore with an invalid count", () => { 217 | let list = IdList.new(); 218 | const id: ElementId = { bunchId: "abc", counter: 1 }; 219 | 220 | expect(() => (list = list.insertBefore(null, id, -7))).to.throw(); 221 | expect(() => (list = list.insertBefore(null, id, 3.5))).to.throw(); 222 | expect(() => (list = list.insertBefore(null, id, NaN))).to.throw(); 223 | 224 | // Bulk insert 0 is okay (no-op). 225 | const newList = list.insertBefore(null, id, 0); 226 | expect(newList).to.equal(list); 227 | }); 228 | }); 229 | 230 | describe("uninsert operations", () => { 231 | it("should completely remove an element", () => { 232 | let list = IdList.new(); 233 | const id: ElementId = { bunchId: "abc", counter: 1 }; 234 | 235 | list = list.insertAfter(null, id); 236 | expect(list.length).to.equal(1); 237 | expect(list.isKnown(id)).to.be.true; 238 | 239 | list = list.uninsert(id); 240 | expect(list.length).to.equal(0); 241 | expect(list.isKnown(id)).to.be.false; // Unlike delete, the id is no longer known 242 | }); 243 | 244 | it("should do nothing when uninsert is called on an unknown ID", () => { 245 | const list = IdList.new(); 246 | const id: ElementId = { bunchId: "abc", counter: 1 }; 247 | 248 | const newList = list.uninsert(id); 249 | expect(newList).to.equal(list); // Should return the same list without changes 250 | expect(list.isKnown(id)).to.be.false; 251 | }); 252 | 253 | it("should bulk uninsert multiple elements", () => { 254 | let list = IdList.new(); 255 | const startId: ElementId = { bunchId: "abc", counter: 1 }; 256 | 257 | // Insert 3 sequential IDs 258 | list = list.insertAfter(null, startId, 3); 259 | expect(list.length).to.equal(3); 260 | 261 | // Uninsert all 3 262 | list = list.uninsert(startId, 3); 263 | 264 | expect(list.length).to.equal(0); 265 | expect(list.isKnown({ bunchId: "abc", counter: 1 })).to.be.false; 266 | expect(list.isKnown({ bunchId: "abc", counter: 2 })).to.be.false; 267 | expect(list.isKnown({ bunchId: "abc", counter: 3 })).to.be.false; 268 | }); 269 | 270 | it("should throw on uninsert with an invalid count", () => { 271 | let list = IdList.new(); 272 | const id: ElementId = { bunchId: "abc", counter: 1 }; 273 | 274 | list = list.insertAfter(null, id); 275 | 276 | expect(() => list.uninsert(id, -1)).to.throw(); 277 | expect(() => list.uninsert(id, 3.5)).to.throw(); 278 | expect(() => list.uninsert(id, NaN)).to.throw(); 279 | }); 280 | 281 | it("should handle uninsert with count = 0 as a no-op", () => { 282 | let list = IdList.new(); 283 | const id: ElementId = { bunchId: "abc", counter: 1 }; 284 | 285 | list = list.insertAfter(null, id); 286 | const newList = list.uninsert(id, 0); 287 | 288 | expect(newList).to.equal(list); // Should return the same list 289 | expect(list.isKnown(id)).to.be.true; // ID should still be known 290 | }); 291 | 292 | it("should be the exact inverse of insertAfter", () => { 293 | let list = IdList.new(); 294 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 295 | const id2: ElementId = { bunchId: "def", counter: 5 }; 296 | 297 | // Insert some elements 298 | list = list.insertAfter(null, id1); 299 | const beforeInsert = list; 300 | 301 | // Insert a bunch of elements after id1 302 | list = list.insertAfter(id1, id2, 3); 303 | expect(list.length).to.equal(4); // id1 + 3 new elements 304 | 305 | // Uninsert should revert to the original state 306 | list = list.uninsert(id2, 3); 307 | expect(list.length).to.equal(1); // Only id1 remains 308 | 309 | // The list should be equivalent to beforeInsert 310 | expect([...list]).to.deep.equal([...beforeInsert]); 311 | }); 312 | 313 | it("should be the exact inverse of insertBefore", () => { 314 | let list = IdList.new(); 315 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 316 | const id2: ElementId = { bunchId: "def", counter: 5 }; 317 | 318 | // Insert some elements 319 | list = list.insertAfter(null, id1); 320 | const beforeInsert = list; 321 | 322 | // Insert a bunch of elements before id1 323 | list = list.insertBefore(id1, id2, 3); 324 | expect(list.length).to.equal(4); // id1 + 3 new elements 325 | 326 | // Uninsert should revert to the original state 327 | list = list.uninsert(id2, 3); 328 | expect(list.length).to.equal(1); // Only id1 remains 329 | 330 | // The list should be equivalent to beforeInsert 331 | expect([...list]).to.deep.equal([...beforeInsert]); 332 | }); 333 | 334 | it("should handle partial uninsert from a bulk insertion", () => { 335 | let list = IdList.new(); 336 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 337 | 338 | // Insert 5 sequential IDs 339 | list = list.insertAfter(null, id1, 5); 340 | expect(list.length).to.equal(5); 341 | 342 | // Uninsert the middle 2 343 | const middleId: ElementId = { bunchId: "abc", counter: 2 }; 344 | list = list.uninsert(middleId, 2); 345 | 346 | expect(list.length).to.equal(3); 347 | expect(list.isKnown({ bunchId: "abc", counter: 1 })).to.be.true; 348 | expect(list.isKnown({ bunchId: "abc", counter: 2 })).to.be.false; 349 | expect(list.isKnown({ bunchId: "abc", counter: 3 })).to.be.false; 350 | expect(list.isKnown({ bunchId: "abc", counter: 4 })).to.be.true; 351 | expect(list.isKnown({ bunchId: "abc", counter: 5 })).to.be.true; 352 | 353 | // Check that the IDs are in the correct order 354 | const ids = [...list]; 355 | expect(ids).to.have.length(3); 356 | expect(ids[0].counter).to.equal(1); 357 | expect(ids[1].counter).to.equal(4); 358 | expect(ids[2].counter).to.equal(5); 359 | 360 | // Uninsert the whole bunch 361 | list = list.uninsert(id1, 5); 362 | expect(list.length).to.equal(0); 363 | expect(list.knownIds.length).to.equal(0); 364 | expect(list.isKnown({ bunchId: "abc", counter: 1 })).to.be.false; 365 | expect(list.isKnown({ bunchId: "abc", counter: 2 })).to.be.false; 366 | expect(list.isKnown({ bunchId: "abc", counter: 3 })).to.be.false; 367 | expect(list.isKnown({ bunchId: "abc", counter: 4 })).to.be.false; 368 | expect(list.isKnown({ bunchId: "abc", counter: 5 })).to.be.false; 369 | }); 370 | 371 | it("should handle uninsert of IDs from different leaves", () => { 372 | let list = IdList.new(); 373 | 374 | // Insert IDs with different bunchIds to ensure they're in different leaves 375 | list = list.insertAfter(null, { bunchId: "abc", counter: 1 }); 376 | list = list.insertAfter( 377 | { bunchId: "abc", counter: 1 }, 378 | { bunchId: "def", counter: 1 } 379 | ); 380 | list = list.insertAfter( 381 | { bunchId: "def", counter: 1 }, 382 | { bunchId: "def", counter: 2 } 383 | ); 384 | 385 | expect(list.length).to.equal(3); 386 | 387 | // Uninsert one from each bunch 388 | list = list.uninsert({ bunchId: "abc", counter: 1 }); 389 | list = list.uninsert({ bunchId: "def", counter: 2 }); 390 | 391 | expect(list.length).to.equal(1); 392 | expect(list.isKnown({ bunchId: "abc", counter: 1 })).to.be.false; 393 | expect(list.isKnown({ bunchId: "def", counter: 1 })).to.be.true; 394 | expect(list.isKnown({ bunchId: "def", counter: 2 })).to.be.false; 395 | }); 396 | }); 397 | 398 | describe("delete operations", () => { 399 | it("should mark an element as deleted", () => { 400 | let list = IdList.new(); 401 | const id: ElementId = { bunchId: "abc", counter: 1 }; 402 | 403 | list = list.insertAfter(null, id); 404 | expect(list.length).to.equal(1); 405 | 406 | list = list.delete(id); 407 | expect(list.length).to.equal(0); 408 | expect(list.has(id)).to.be.false; 409 | expect(list.isKnown(id)).to.be.true; 410 | }); 411 | 412 | it("should do nothing when deleting an unknown ID", () => { 413 | let list = IdList.new(); 414 | const id: ElementId = { bunchId: "abc", counter: 1 }; 415 | 416 | list = list.delete(id); 417 | expect(list.length).to.equal(0); 418 | expect(list.isKnown(id)).to.be.false; 419 | }); 420 | 421 | it("should do nothing when deleting an already deleted ID", () => { 422 | let list = IdList.new(); 423 | const id: ElementId = { bunchId: "abc", counter: 1 }; 424 | 425 | list = list.insertAfter(null, id); 426 | list = list.delete(id); 427 | list = list.delete(id); // Second delete should do nothing 428 | 429 | expect(list.length).to.equal(0); 430 | expect(list.isKnown(id)).to.be.true; 431 | }); 432 | }); 433 | 434 | describe("undelete operations", () => { 435 | it("should restore a deleted element", () => { 436 | let list = IdList.new(); 437 | const id: ElementId = { bunchId: "abc", counter: 1 }; 438 | 439 | list = list.insertAfter(null, id); 440 | list = list.delete(id); 441 | list = list.undelete(id); 442 | 443 | expect(list.length).to.equal(1); 444 | expect(list.has(id)).to.be.true; 445 | }); 446 | 447 | it("should throw when undeleting an unknown ID", () => { 448 | let list = IdList.new(); 449 | const id: ElementId = { bunchId: "abc", counter: 1 }; 450 | 451 | expect(() => (list = list.undelete(id))).to.throw(); 452 | }); 453 | 454 | it("should do nothing when undeleting an already present ID", () => { 455 | let list = IdList.new(); 456 | const id: ElementId = { bunchId: "abc", counter: 1 }; 457 | 458 | list = list.insertAfter(null, id); 459 | list = list.undelete(id); // Should do nothing 460 | 461 | expect(list.length).to.equal(1); 462 | expect(list.has(id)).to.be.true; 463 | }); 464 | }); 465 | 466 | describe("accessor operations", () => { 467 | let list: IdList; 468 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 469 | const id2: ElementId = { bunchId: "def", counter: 1 }; 470 | const id3: ElementId = { bunchId: "ghi", counter: 1 }; 471 | 472 | beforeEach(() => { 473 | list = IdList.new(); 474 | list = list.insertAfter(null, id1); 475 | list = list.insertAfter(id1, id2); 476 | list = list.insertAfter(id2, id3); 477 | list = list.delete(id2); // Delete the middle element 478 | }); 479 | 480 | it("should get an element by index", () => { 481 | expect(equalsId(list.at(0), id1)).to.be.true; 482 | expect(equalsId(list.at(1), id3)).to.be.true; 483 | }); 484 | 485 | it("should throw when accessing an out-of-bounds index", () => { 486 | expect(() => list.at(-1)).to.throw(); 487 | expect(() => list.at(2)).to.throw(); 488 | }); 489 | 490 | it("should find index of an element", () => { 491 | expect(list.indexOf(id1)).to.equal(0); 492 | expect(list.indexOf(id3)).to.equal(1); 493 | }); 494 | 495 | it('should return -1 for index of a deleted element with bias "none"', () => { 496 | expect(list.indexOf(id2, "none")).to.equal(-1); 497 | }); 498 | 499 | it('should return left index for deleted element with bias "left"', () => { 500 | expect(list.indexOf(id2, "left")).to.equal(0); 501 | }); 502 | 503 | it('should return right index for deleted element with bias "right"', () => { 504 | expect(list.indexOf(id2, "right")).to.equal(1); 505 | }); 506 | 507 | it("should throw when finding index of an unknown element", () => { 508 | const unknownId: ElementId = { bunchId: "xyz", counter: 1 }; 509 | expect(() => list.indexOf(unknownId)).to.throw(); 510 | }); 511 | 512 | it("should return maxCounter", () => { 513 | expect(list.maxCounter("abc")).to.equal(1); 514 | expect(list.maxCounter("def")).to.equal(1); 515 | expect(list.maxCounter("ghi")).to.equal(1); 516 | 517 | // Non-existent bunchId. 518 | expect(list.maxCounter("non-existent")).to.be.undefined; 519 | }); 520 | }); 521 | 522 | describe("iteration", () => { 523 | let list: IdList; 524 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 525 | const id2: ElementId = { bunchId: "def", counter: 1 }; 526 | const id3: ElementId = { bunchId: "ghi", counter: 1 }; 527 | 528 | beforeEach(() => { 529 | list = IdList.new(); 530 | list = list.insertAfter(null, id1); 531 | list = list.insertAfter(id1, id2); 532 | list = list.insertAfter(id2, id3); 533 | list = list.delete(id2); // Delete the middle element 534 | }); 535 | 536 | it("should iterate over present elements", () => { 537 | const ids = [...list]; 538 | expect(ids).to.have.length(2); 539 | expect(equalsId(ids[0], id1)).to.be.true; 540 | expect(equalsId(ids[1], id3)).to.be.true; 541 | }); 542 | 543 | it("should iterate over all known elements with valuesWithIsDeleted", () => { 544 | const elements = [...list.valuesWithIsDeleted()]; 545 | expect(elements).to.have.length(3); 546 | 547 | expect(equalsId(elements[0].id, id1)).to.be.true; 548 | expect(elements[0].isDeleted).to.be.false; 549 | 550 | expect(equalsId(elements[1].id, id2)).to.be.true; 551 | expect(elements[1].isDeleted).to.be.true; 552 | 553 | expect(equalsId(elements[2].id, id3)).to.be.true; 554 | expect(elements[2].isDeleted).to.be.false; 555 | }); 556 | }); 557 | 558 | describe("KnownIdView", () => { 559 | let list: IdList; 560 | const id1: ElementId = { bunchId: "abc", counter: 1 }; 561 | const id2: ElementId = { bunchId: "def", counter: 1 }; 562 | const id3: ElementId = { bunchId: "ghi", counter: 1 }; 563 | 564 | beforeEach(() => { 565 | list = IdList.new(); 566 | list = list.insertAfter(null, id1); 567 | list = list.insertAfter(id1, id2); 568 | list = list.insertAfter(id2, id3); 569 | list = list.delete(id2); // Delete the middle element 570 | }); 571 | 572 | it("should include deleted elements in its view", () => { 573 | const knownIds = list.knownIds; 574 | expect(knownIds.length).to.equal(3); 575 | 576 | expect(equalsId(knownIds.at(0), id1)).to.be.true; 577 | expect(equalsId(knownIds.at(1), id2)).to.be.true; 578 | expect(equalsId(knownIds.at(2), id3)).to.be.true; 579 | }); 580 | 581 | it("should find index of any known element", () => { 582 | const knownIds = list.knownIds; 583 | expect(knownIds.indexOf(id1)).to.equal(0); 584 | expect(knownIds.indexOf(id2)).to.equal(1); 585 | expect(knownIds.indexOf(id3)).to.equal(2); 586 | }); 587 | 588 | it("should iterate over all known elements", () => { 589 | const knownIds = [...list.knownIds]; 590 | expect(knownIds).to.have.length(3); 591 | expect(equalsId(knownIds[0], id1)).to.be.true; 592 | expect(equalsId(knownIds[1], id2)).to.be.true; 593 | expect(equalsId(knownIds[2], id3)).to.be.true; 594 | }); 595 | 596 | it("should throw when accessing an out-of-bounds index", () => { 597 | const knownIds = list.knownIds; 598 | expect(() => knownIds.at(-1)).to.throw(); 599 | expect(() => knownIds.at(4)).to.throw(); 600 | 601 | // Out of bounds in list but not knownIds. 602 | expect(() => knownIds.at(2)).to.be.ok; 603 | }); 604 | }); 605 | 606 | describe("save and load", () => { 607 | it("should save and load a list state", () => { 608 | let list = IdList.new(); 609 | 610 | // Insert a sequential bunch 611 | const startId: ElementId = { bunchId: "abc", counter: 1 }; 612 | list = list.insertAfter(null, startId, 5); 613 | 614 | // Delete one of them 615 | list = list.delete({ bunchId: "abc", counter: 3 }); 616 | 617 | // Insert another element 618 | list = list.insertAfter( 619 | { bunchId: "abc", counter: 5 }, 620 | { bunchId: "def", counter: 1 } 621 | ); 622 | 623 | // Save the state 624 | const savedState = list.save(); 625 | 626 | // Create a new list and load the state 627 | const newList = IdList.load(savedState); 628 | 629 | // Check that the new list has the same state 630 | expect(newList.length).to.equal(5); 631 | expect(newList.has({ bunchId: "abc", counter: 1 })).to.be.true; 632 | expect(newList.has({ bunchId: "abc", counter: 2 })).to.be.true; 633 | expect(newList.has({ bunchId: "abc", counter: 3 })).to.be.false; // Deleted 634 | expect(newList.isKnown({ bunchId: "abc", counter: 3 })).to.be.true; 635 | expect(newList.has({ bunchId: "abc", counter: 4 })).to.be.true; 636 | expect(newList.has({ bunchId: "abc", counter: 5 })).to.be.true; 637 | expect(newList.has({ bunchId: "def", counter: 1 })).to.be.true; 638 | }); 639 | 640 | it("should handle compression of sequential IDs", () => { 641 | let list = IdList.new(); 642 | 643 | // Insert a large sequential bunch 644 | const startId: ElementId = { bunchId: "abc", counter: 1 }; 645 | list = list.insertAfter(null, startId, 100); 646 | 647 | // Save the state - this should be highly compressed 648 | const savedState = list.save(); 649 | 650 | // The saved state should be compact (just one entry if bunching worked) 651 | expect(savedState.length).to.equal(1); 652 | assert.deepStrictEqual(savedState[0], { 653 | bunchId: "abc", 654 | startCounter: 1, 655 | count: 100, 656 | isDeleted: false, 657 | }); 658 | 659 | // Create a new list and load the state 660 | const newList = IdList.load(savedState); 661 | 662 | // Check that the new list has all 100 elements 663 | expect(newList.length).to.equal(100); 664 | }); 665 | 666 | it("should throw when loading an invalid saved state", () => { 667 | const savedState1: SavedIdList = [ 668 | { 669 | bunchId: "abc", 670 | startCounter: 0, 671 | count: -1, 672 | isDeleted: false, 673 | }, 674 | ]; 675 | expect(() => IdList.load(savedState1)).to.throw(); 676 | 677 | const savedState2: SavedIdList = [ 678 | { 679 | bunchId: "abc", 680 | startCounter: 0, 681 | count: 7.5, 682 | isDeleted: false, 683 | }, 684 | ]; 685 | expect(() => IdList.load(savedState2)).to.throw(); 686 | 687 | const savedState3: SavedIdList = [ 688 | { 689 | bunchId: "abc", 690 | startCounter: -0.5, 691 | count: 5, 692 | isDeleted: false, 693 | }, 694 | ]; 695 | expect(() => IdList.load(savedState3)).to.throw(); 696 | 697 | // 0 count is ignored but okay. 698 | const savedState4: SavedIdList = [ 699 | { 700 | bunchId: "abc", 701 | startCounter: 3, 702 | count: 0, 703 | isDeleted: false, 704 | }, 705 | ]; 706 | expect([...IdList.load(savedState4)]).to.deep.equal([]); 707 | 708 | // // Negative counters are okay. 709 | // const savedState5: SavedIdList = [ 710 | // { 711 | // bunchId: "abc", 712 | // startCounter: -1, 713 | // count: 3, 714 | // isDeleted: false, 715 | // }, 716 | // ]; 717 | // expect([...IdList.load(savedState5)]).to.deep.equal([ 718 | // { bunchId: "abc", counter: -1 }, 719 | // { bunchId: "abc", counter: 0 }, 720 | // { bunchId: "abc", counter: 1 }, 721 | // ]); 722 | 723 | // Negative counters are not allowed. 724 | const savedState5: SavedIdList = [ 725 | { 726 | bunchId: "abc", 727 | startCounter: -1, 728 | count: 3, 729 | isDeleted: false, 730 | }, 731 | ]; 732 | expect(() => IdList.load(savedState5)).to.throw(); 733 | }); 734 | }); 735 | }); 736 | -------------------------------------------------------------------------------- /test/basic_fuzz.test.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from "chai"; 2 | import seedrandom from "seedrandom"; 3 | import { ElementId, equalsId } from "../src"; 4 | import { M } from "../src/id_list"; 5 | import { Fuzzer } from "./fuzzer"; 6 | 7 | describe("IdList Fuzzer Tests", () => { 8 | let prng!: seedrandom.PRNG; 9 | 10 | beforeEach(() => { 11 | prng = seedrandom("42"); 12 | }); 13 | 14 | // Helper to create random ElementIds 15 | const createRandomId = (): ElementId => { 16 | const bunchId = `bunch-${Math.floor(prng() * 1000)}`; 17 | const counter = Math.floor(prng() * 100); 18 | return { bunchId, counter }; 19 | }; 20 | 21 | // Helper to create sequential ElementIds 22 | const createSequentialIds = ( 23 | count: number, 24 | bunchId = "sequential", 25 | startCounter = 0 26 | ): ElementId[] => { 27 | const ids: ElementId[] = []; 28 | for (let i = 0; i < count; i++) { 29 | ids.push({ bunchId, counter: startCounter + i }); 30 | } 31 | return ids; 32 | }; 33 | 34 | describe("Random Operation Sequences", () => { 35 | it("should handle a random sequence of operations", function () { 36 | this.timeout(6000); 37 | 38 | const fuzzer = Fuzzer.new(); 39 | const knownIds: ElementId[] = []; 40 | 41 | // Perform a sequence of random operations 42 | const operationCount = 1000; 43 | for (let i = 0; i < operationCount; i++) { 44 | // Every 10 operations, check all accessors 45 | if (i % 10 === 0) { 46 | fuzzer.checkAll(); 47 | } 48 | 49 | const operation = Math.floor(prng() * 5); // 0-4 for different operations 50 | 51 | switch (operation) { 52 | case 0: // insertAfter 53 | { 54 | const newId = createRandomId(); 55 | const beforeIndex = 56 | knownIds.length > 0 ? Math.floor(prng() * knownIds.length) : -1; 57 | const before = beforeIndex >= 0 ? knownIds[beforeIndex] : null; 58 | const count = Math.floor(prng() * 3) + 1; // 1-3 elements 59 | 60 | try { 61 | fuzzer.insertAfter(before, newId, count); 62 | // Add the new IDs to our known list 63 | for (let j = 0; j < count; j++) { 64 | knownIds.push({ 65 | bunchId: newId.bunchId, 66 | counter: newId.counter + j, 67 | }); 68 | } 69 | } catch (e) { 70 | if (e instanceof AssertionError) { 71 | throw e; 72 | } 73 | // This might fail legitimately if the ID already exists 74 | // Just continue with the next operation 75 | } 76 | } 77 | break; 78 | 79 | case 1: // insertBefore 80 | { 81 | const newId = createRandomId(); 82 | const afterIndex = 83 | knownIds.length > 0 ? Math.floor(prng() * knownIds.length) : -1; 84 | const after = afterIndex >= 0 ? knownIds[afterIndex] : null; 85 | const count = Math.floor(prng() * 3) + 1; // 1-3 elements 86 | 87 | try { 88 | fuzzer.insertBefore(after, newId, count); 89 | // Add the new IDs to our known list 90 | for (let j = 0; j < count; j++) { 91 | knownIds.push({ 92 | bunchId: newId.bunchId, 93 | counter: newId.counter + j, 94 | }); 95 | } 96 | } catch (e) { 97 | if (e instanceof AssertionError) { 98 | throw e; 99 | } 100 | // This might fail legitimately if the ID already exists 101 | // Just continue with the next operation 102 | } 103 | } 104 | break; 105 | 106 | case 2: // uninsert 107 | if (knownIds.length > 0) { 108 | const count = Math.floor(prng() * 3) + 1; // 1-3 elements 109 | const index = Math.floor(prng() * knownIds.length); 110 | const id = knownIds[index]; 111 | fuzzer.uninsert(id, count); 112 | 113 | // Delete the uninserted IDs from our known list 114 | for (let j = 0; j < count; j++) { 115 | const jId: ElementId = { 116 | bunchId: id.bunchId, 117 | counter: id.counter + j, 118 | }; 119 | const jIndex = knownIds.findIndex((otherId) => 120 | equalsId(otherId, jId) 121 | ); 122 | if (jIndex !== -1) knownIds.splice(jIndex, 1); 123 | } 124 | } 125 | break; 126 | 127 | case 3: // delete 128 | if (knownIds.length > 0) { 129 | const index = Math.floor(prng() * knownIds.length); 130 | const id = knownIds[index]; 131 | fuzzer.delete(id); 132 | // We keep the ID in knownIds since it's still known, just deleted 133 | } 134 | break; 135 | 136 | case 4: // undelete 137 | if (knownIds.length > 0) { 138 | const index = Math.floor(prng() * knownIds.length); 139 | const id = knownIds[index]; 140 | try { 141 | fuzzer.undelete(id); 142 | } catch (e) { 143 | if (e instanceof AssertionError) { 144 | throw e; 145 | } 146 | // This might fail if the ID wasn't deleted 147 | // Just continue with the next operation 148 | } 149 | } 150 | break; 151 | } 152 | } 153 | 154 | // Final check of all accessors 155 | fuzzer.checkAll(); 156 | }); 157 | 158 | it("should handle multiple random sequences with different seeds", function () { 159 | this.timeout(5000); // Increase timeout for this test 160 | 161 | const seeds = ["42", "1337", "2468", "9876"]; 162 | const operationCount = 500; 163 | 164 | for (const seed of seeds) { 165 | prng = seedrandom(seed); 166 | const fuzzer = Fuzzer.new(); 167 | const knownIds: ElementId[] = []; 168 | 169 | for (let i = 0; i < operationCount; i++) { 170 | const operation = Math.floor(prng() * 5); 171 | 172 | if (i % 10 === 0) { 173 | fuzzer.checkAll(); 174 | } 175 | 176 | try { 177 | switch (operation) { 178 | case 0: // insertAfter 179 | { 180 | const newId = createRandomId(); 181 | const beforeIndex = 182 | knownIds.length > 0 183 | ? Math.floor(prng() * knownIds.length) 184 | : -1; 185 | const before = 186 | beforeIndex >= 0 ? knownIds[beforeIndex] : null; 187 | const count = Math.floor(prng() * 3) + 1; 188 | 189 | fuzzer.insertAfter(before, newId, count); 190 | for (let j = 0; j < count; j++) { 191 | knownIds.push({ 192 | bunchId: newId.bunchId, 193 | counter: newId.counter + j, 194 | }); 195 | } 196 | } 197 | break; 198 | 199 | case 1: // insertBefore 200 | { 201 | const newId = createRandomId(); 202 | const afterIndex = 203 | knownIds.length > 0 204 | ? Math.floor(prng() * knownIds.length) 205 | : -1; 206 | const after = afterIndex >= 0 ? knownIds[afterIndex] : null; 207 | const count = Math.floor(prng() * 3) + 1; 208 | 209 | fuzzer.insertBefore(after, newId, count); 210 | for (let j = 0; j < count; j++) { 211 | knownIds.push({ 212 | bunchId: newId.bunchId, 213 | counter: newId.counter + j, 214 | }); 215 | } 216 | } 217 | break; 218 | 219 | case 2: // uninsert 220 | if (knownIds.length > 0) { 221 | const count = Math.floor(prng() * 3) + 1; // 1-3 elements 222 | const index = Math.floor(prng() * knownIds.length); 223 | const id = knownIds[index]; 224 | fuzzer.uninsert(id, count); 225 | 226 | // Delete the uninserted IDs from our known list 227 | for (let j = 0; j < count; j++) { 228 | const jId: ElementId = { 229 | bunchId: id.bunchId, 230 | counter: id.counter + j, 231 | }; 232 | const jIndex = knownIds.findIndex((otherId) => 233 | equalsId(otherId, jId) 234 | ); 235 | if (jIndex !== -1) knownIds.splice(jIndex, 1); 236 | } 237 | } 238 | break; 239 | 240 | case 3: // delete 241 | if (knownIds.length > 0) { 242 | const index = Math.floor(prng() * knownIds.length); 243 | fuzzer.delete(knownIds[index]); 244 | } 245 | break; 246 | 247 | case 4: // undelete 248 | if (knownIds.length > 0) { 249 | const index = Math.floor(prng() * knownIds.length); 250 | fuzzer.undelete(knownIds[index]); 251 | } 252 | break; 253 | } 254 | } catch (e) { 255 | if (e instanceof AssertionError) { 256 | throw e; 257 | } 258 | // Expected exceptions might occur, continue 259 | } 260 | } 261 | 262 | fuzzer.checkAll(); 263 | } 264 | }); 265 | }); 266 | 267 | describe("B+Tree Specific Fuzzing", () => { 268 | it("should handle large sequential insertions to force tree growth", function () { 269 | this.timeout(5000); // This test may take longer 270 | 271 | const fuzzer = Fuzzer.new(); 272 | const batchSize = 10; // Number of elements to insert in each batch 273 | const batchCount = 100; // Number of batches to insert 274 | 275 | // This should create enough elements to force multiple tree levels 276 | for (let batch = 0; batch < batchCount; batch++) { 277 | // Check all accessors periodically 278 | if (batch % 3 === 0) { 279 | fuzzer.checkAll(); 280 | } 281 | 282 | const batchId = createRandomId(); 283 | try { 284 | fuzzer.insertAfter(null, batchId, batchSize); 285 | } catch (e) { 286 | if (e instanceof AssertionError) { 287 | throw e; 288 | } 289 | // If this insertion fails, try with a different ID 290 | const alternateBatchId = createRandomId(); 291 | fuzzer.insertAfter(null, alternateBatchId, batchSize); 292 | } 293 | } 294 | 295 | fuzzer.checkAll(); 296 | }); 297 | 298 | it("should handle interleaved insertions that cause leaf splits", () => { 299 | const fuzzer = Fuzzer.new(); 300 | 301 | // First create a sequential list of elements 302 | const baseId = { bunchId: "base", counter: 0 }; 303 | fuzzer.insertAfter(null, baseId, 20); 304 | fuzzer.checkAll(); 305 | 306 | // Now interleave new elements between existing ones to force leaf splits 307 | for (let i = 0; i < 15; i += 2) { 308 | const targetId = { bunchId: "base", counter: i }; 309 | const newId = { bunchId: `interleave-${i}`, counter: 0 }; 310 | 311 | fuzzer.insertAfter(targetId, newId); 312 | 313 | // Check occasionally 314 | if (i % 6 === 0) { 315 | fuzzer.checkAll(); 316 | } 317 | } 318 | 319 | fuzzer.checkAll(); 320 | }); 321 | 322 | it("should handle operations near B+Tree node boundaries", function () { 323 | // Create a list with exactly M elements 324 | const ids = createSequentialIds(M); 325 | const fuzzer = Fuzzer.fromIds(ids); 326 | fuzzer.checkAll(); 327 | 328 | // Insert at the boundary to force a split 329 | fuzzer.insertAfter(ids[M - 1], { 330 | bunchId: "boundary", 331 | counter: 0, 332 | }); 333 | fuzzer.checkAll(); 334 | 335 | // Insert at the middle of a leaf 336 | fuzzer.insertAfter(ids[Math.floor(M / 2)], { 337 | bunchId: "middle", 338 | counter: 0, 339 | }); 340 | fuzzer.checkAll(); 341 | 342 | // Delete elements at potential boundaries 343 | fuzzer.delete(ids[M - 1]); 344 | fuzzer.delete(ids[0]); 345 | fuzzer.checkAll(); 346 | 347 | // Reinsert at those boundaries 348 | fuzzer.insertBefore(ids[1], { 349 | bunchId: "reinsertion", 350 | counter: 0, 351 | }); 352 | fuzzer.insertAfter(ids[M - 2], { 353 | bunchId: "reinsertion", 354 | counter: 1, 355 | }); 356 | fuzzer.checkAll(); 357 | }); 358 | 359 | it("should handle bulk insertions at different tree positions", () => { 360 | // Create a base tree with some elements 361 | const baseIds = createSequentialIds(15); 362 | const fuzzer = Fuzzer.fromIds(baseIds); 363 | fuzzer.checkAll(); 364 | 365 | // Insert bulk elements at the beginning, middle, and end 366 | fuzzer.insertBefore(baseIds[0], { bunchId: "start", counter: 0 }, 5); 367 | fuzzer.checkAll(); 368 | 369 | fuzzer.insertAfter(baseIds[7], { bunchId: "middle", counter: 0 }, 5); 370 | fuzzer.checkAll(); 371 | 372 | fuzzer.insertAfter(baseIds[14], { bunchId: "end", counter: 0 }, 5); 373 | fuzzer.checkAll(); 374 | 375 | // Insert small batches at various positions 376 | for (let i = 0; i < 100; i++) { 377 | const targetIndex = Math.floor(prng() * baseIds.length); 378 | const targetId = baseIds[targetIndex]; 379 | const newId = { bunchId: `batch-${i}`, counter: 0 }; 380 | const count = 1 + Math.floor(prng() * 3); // 1-3 elements 381 | 382 | if (prng() > 0.5) { 383 | fuzzer.insertAfter(targetId, newId, count); 384 | } else { 385 | fuzzer.insertBefore(targetId, newId, count); 386 | } 387 | } 388 | 389 | fuzzer.checkAll(); 390 | }); 391 | }); 392 | 393 | describe("Edge Case Fuzzing", () => { 394 | it("should handle operations on empty and near-empty lists", () => { 395 | const fuzzer = Fuzzer.new(); 396 | fuzzer.checkAll(); 397 | 398 | // Insert and delete to empty 399 | const id1 = createRandomId(); 400 | fuzzer.insertAfter(null, id1); 401 | fuzzer.checkAll(); 402 | 403 | fuzzer.delete(id1); 404 | fuzzer.checkAll(); 405 | 406 | // Insert, delete, then undelete 407 | const id2 = createRandomId(); 408 | fuzzer.insertAfter(null, id2); 409 | fuzzer.delete(id2); 410 | fuzzer.undelete(id2); 411 | fuzzer.checkAll(); 412 | 413 | // Insert after a deleted ID 414 | const id3 = createRandomId(); 415 | fuzzer.insertAfter(id2, id3); 416 | fuzzer.checkAll(); 417 | 418 | // Insert before a deleted ID 419 | const id4 = createRandomId(); 420 | fuzzer.delete(id2); 421 | fuzzer.insertBefore(id2, id4); 422 | fuzzer.checkAll(); 423 | }); 424 | 425 | it("should handle extensive deletion and reinsertion", function () { 426 | // Create a list with sequential elements 427 | const ids = createSequentialIds(30); 428 | const fuzzer = Fuzzer.fromIds(ids); 429 | fuzzer.checkAll(); 430 | 431 | // Delete elements in a pattern 432 | for (let i = 0; i < 30; i += 3) { 433 | fuzzer.delete(ids[i]); 434 | 435 | if (i % 9 === 0) { 436 | fuzzer.checkAll(); 437 | } 438 | } 439 | 440 | fuzzer.checkAll(); 441 | 442 | // Reinsert elements at deleted positions 443 | for (let i = 0; i < 30; i += 3) { 444 | const newId = { bunchId: "reinsert", counter: i }; 445 | 446 | if (i % 2 === 0) { 447 | fuzzer.insertAfter(ids[i], newId); 448 | } else { 449 | fuzzer.insertBefore(ids[i], newId); 450 | } 451 | 452 | if (i % 9 === 0) { 453 | fuzzer.checkAll(); 454 | } 455 | } 456 | 457 | fuzzer.checkAll(); 458 | 459 | // Undelete some of the original deleted elements 460 | for (let i = 0; i < 30; i += 6) { 461 | fuzzer.undelete(ids[i]); 462 | } 463 | 464 | fuzzer.checkAll(); 465 | }); 466 | 467 | it("should handle sequential ID compression edge cases", () => { 468 | const fuzzer = Fuzzer.new(); 469 | 470 | // Insert sequences with the same bunchId but gaps in counters 471 | fuzzer.insertAfter(null, { bunchId: "sequence", counter: 0 }, 5); 472 | fuzzer.checkAll(); 473 | 474 | // Insert a gap 475 | fuzzer.insertAfter( 476 | { bunchId: "sequence", counter: 4 }, 477 | { bunchId: "sequence", counter: 10 }, 478 | 5 479 | ); 480 | fuzzer.checkAll(); 481 | 482 | // Fill some of the gap 483 | fuzzer.insertAfter( 484 | { bunchId: "sequence", counter: 4 }, 485 | { bunchId: "sequence", counter: 5 }, 486 | 2 487 | ); 488 | fuzzer.checkAll(); 489 | 490 | // Delete alternating elements 491 | for (let i = 0; i < 15; i += 2) { 492 | if (i !== 6 && i !== 8) { 493 | // Skip the gap 494 | fuzzer.delete({ bunchId: "sequence", counter: i }); 495 | } 496 | } 497 | fuzzer.checkAll(); 498 | 499 | // Reinsert some elements with the same bunchId 500 | fuzzer.insertAfter( 501 | { bunchId: "sequence", counter: 3 }, 502 | { bunchId: "sequence", counter: 20 }, 503 | 3 504 | ); 505 | fuzzer.checkAll(); 506 | }); 507 | 508 | it("should handle a parameterized complex sequence of operations", function () { 509 | this.timeout(10000); // Adjust timeout based on iterationCount 510 | 511 | const iterationCount = 50; // Parameter to adjust test intensity 512 | const fuzzer = Fuzzer.new(); 513 | const knownIds: ElementId[] = []; 514 | 515 | for (let iteration = 0; iteration < iterationCount; iteration++) { 516 | // Perform a mix of operations in each iteration 517 | 518 | // Operation 1: Insert a batch of sequential IDs 519 | const batchId = { bunchId: `batch-${iteration}`, counter: 0 }; 520 | const batchSize = 5 + Math.floor(prng() * 10); // 5-14 elements 521 | 522 | try { 523 | fuzzer.insertAfter(null, batchId, batchSize); 524 | for (let i = 0; i < batchSize; i++) { 525 | knownIds.push({ bunchId: batchId.bunchId, counter: i }); 526 | } 527 | } catch (e) { 528 | if (e instanceof AssertionError) { 529 | throw e; 530 | } 531 | // Handle case where ID already exists 532 | } 533 | 534 | if (iteration % 5 === 0) { 535 | fuzzer.checkAll(); 536 | } 537 | 538 | // Operation 2: Delete some elements if we have enough 539 | if (knownIds.length > 10) { 540 | const deleteCount = 2 + Math.floor(prng() * 5); // 2-6 elements 541 | for (let i = 0; i < deleteCount; i++) { 542 | const idx = Math.floor(prng() * knownIds.length); 543 | fuzzer.delete(knownIds[idx]); 544 | } 545 | } 546 | 547 | // Operation 3: Interleave some insertions 548 | if (knownIds.length > 0) { 549 | const insertCount = 1 + Math.floor(prng() * 3); // 1-3 elements 550 | for (let i = 0; i < insertCount; i++) { 551 | const idx = Math.floor(prng() * knownIds.length); 552 | const referenceId = knownIds[idx]; 553 | const newId = { 554 | bunchId: `interleave-${iteration}-${i}`, 555 | counter: 0, 556 | }; 557 | 558 | try { 559 | if (prng() > 0.5) { 560 | fuzzer.insertAfter(referenceId, newId); 561 | } else { 562 | fuzzer.insertBefore(referenceId, newId); 563 | } 564 | knownIds.push(newId); 565 | } catch (e) { 566 | if (e instanceof AssertionError) { 567 | throw e; 568 | } 569 | // Handle possible exceptions 570 | } 571 | } 572 | } 573 | 574 | // Operation 4: Undelete some elements 575 | if (knownIds.length > 0) { 576 | const undeleteCount = Math.floor(prng() * 3); // 0-2 elements 577 | for (let i = 0; i < undeleteCount; i++) { 578 | const idx = Math.floor(prng() * knownIds.length); 579 | try { 580 | fuzzer.undelete(knownIds[idx]); 581 | } catch (e) { 582 | if (e instanceof AssertionError) { 583 | throw e; 584 | } 585 | // Handle case where element wasn't deleted 586 | } 587 | } 588 | } 589 | 590 | if (iteration % 5 === 0 || iteration === iterationCount - 1) { 591 | fuzzer.checkAll(); 592 | } 593 | } 594 | 595 | // Final verification 596 | fuzzer.checkAll(); 597 | }); 598 | }); 599 | 600 | describe("Save and Load Fuzzing", () => { 601 | it("should maintain integrity through multiple save/load cycles", function () { 602 | this.timeout(5000); 603 | 604 | // Create an initial list with random operations 605 | const fuzzer = Fuzzer.new(); 606 | const ids: ElementId[] = []; 607 | 608 | // Perform some initial operations 609 | for (let i = 0; i < 20; i++) { 610 | const id = createRandomId(); 611 | try { 612 | fuzzer.insertAfter(ids.length > 0 ? ids[ids.length - 1] : null, id); 613 | ids.push(id); 614 | } catch (e) { 615 | if (e instanceof AssertionError) { 616 | throw e; 617 | } 618 | // Handle ID collision 619 | } 620 | } 621 | 622 | // Delete some elements 623 | for (let i = 0; i < 5; i++) { 624 | const idx = Math.floor(prng() * ids.length); 625 | fuzzer.delete(ids[idx]); 626 | } 627 | 628 | fuzzer.checkAll(); 629 | 630 | // Now perform save/load cycles with operations in between 631 | for (let cycle = 0; cycle < 5; cycle++) { 632 | // Get saved state from the current fuzzer 633 | const savedState = fuzzer.list.save(); 634 | 635 | // Reload the fuzzer from the saved state 636 | fuzzer.load(savedState); 637 | 638 | fuzzer.checkAll(); 639 | 640 | // Perform more operations 641 | for (let i = 0; i < 5; i++) { 642 | const operation = Math.floor(prng() * 5); 643 | 644 | switch (operation) { 645 | case 0: // insertAfter 646 | { 647 | const id = createRandomId(); 648 | const idx = Math.floor(prng() * ids.length); 649 | try { 650 | fuzzer.insertAfter(ids[idx], id); 651 | ids.push(id); 652 | } catch (e) { 653 | if (e instanceof AssertionError) { 654 | throw e; 655 | } 656 | // Handle exceptions 657 | } 658 | } 659 | break; 660 | 661 | case 1: // insertBefore 662 | { 663 | const id = createRandomId(); 664 | const idx = Math.floor(prng() * ids.length); 665 | try { 666 | fuzzer.insertBefore(ids[idx], id); 667 | ids.push(id); 668 | } catch (e) { 669 | if (e instanceof AssertionError) { 670 | throw e; 671 | } 672 | // Handle exceptions 673 | } 674 | } 675 | break; 676 | 677 | case 2: // uninsert 678 | if (ids.length > 0) { 679 | const count = Math.floor(prng() * 3) + 1; // 1-3 elements 680 | const index = Math.floor(prng() * ids.length); 681 | const id = ids[index]; 682 | fuzzer.uninsert(id, count); 683 | 684 | // Delete the uninserted IDs from our known list 685 | for (let j = 0; j < count; j++) { 686 | const jId: ElementId = { 687 | bunchId: id.bunchId, 688 | counter: id.counter + j, 689 | }; 690 | const jIndex = ids.findIndex((otherId) => 691 | equalsId(otherId, jId) 692 | ); 693 | if (jIndex !== -1) ids.splice(jIndex, 1); 694 | } 695 | } 696 | break; 697 | 698 | case 3: // delete 699 | if (ids.length > 0) { 700 | const idx = Math.floor(prng() * ids.length); 701 | fuzzer.delete(ids[idx]); 702 | } 703 | break; 704 | 705 | case 4: // undelete 706 | if (ids.length > 0) { 707 | const idx = Math.floor(prng() * ids.length); 708 | try { 709 | fuzzer.undelete(ids[idx]); 710 | } catch (e) { 711 | if (e instanceof AssertionError) { 712 | throw e; 713 | } 714 | // Handle exceptions 715 | } 716 | } 717 | break; 718 | } 719 | fuzzer.checkAll(); 720 | } 721 | 722 | fuzzer.checkAll(); 723 | } 724 | }); 725 | }); 726 | }); 727 | -------------------------------------------------------------------------------- /test/btree_fuzz.test.ts: -------------------------------------------------------------------------------- 1 | import { AssertionError } from "chai"; 2 | import seedrandom from "seedrandom"; 3 | import { ElementId } from "../src"; 4 | import { M } from "../src/id_list"; 5 | import { Fuzzer } from "./fuzzer"; 6 | 7 | describe("IdList B+Tree Specific Fuzz Tests", () => { 8 | let prng!: seedrandom.PRNG; 9 | 10 | beforeEach(() => { 11 | prng = seedrandom("42"); 12 | }); 13 | 14 | // Helper to create ElementIds 15 | const createId = (bunchId: string, counter: number): ElementId => ({ 16 | bunchId, 17 | counter, 18 | }); 19 | 20 | describe("Node Splitting and Merging", () => { 21 | it("should correctly handle splits at various tree levels", function () { 22 | this.timeout(10000); 23 | 24 | const fuzzer = Fuzzer.new(); 25 | 26 | // Start with M elements 27 | for (let i = 0; i < M; i++) { 28 | const id = createId(`id${i}`, 0); 29 | if (i === 0) fuzzer.insertAfter(null, id); 30 | else fuzzer.insertAfter(createId(`id${i - 1}`, 0), id); 31 | } 32 | 33 | fuzzer.checkAll(); 34 | 35 | // Force a split by adding one more element 36 | fuzzer.insertAfter(createId(`id${M - 1}`, 0), createId(`id${M}`, 0)); 37 | fuzzer.checkAll(); 38 | 39 | // Now add enough elements to force multiple levels in the tree 40 | // First level split typically happens at M elements 41 | // Second level split approximately at M² elements 42 | const secondLevelSplitTarget = M * M; 43 | 44 | for (let i = M + 1; i < secondLevelSplitTarget + 10; i++) { 45 | const id = createId(`id${i}`, 0); 46 | fuzzer.insertAfter(createId(`id${i - 1}`, 0), id); 47 | 48 | // Check more frequently near expected split points 49 | if ( 50 | i % M === 0 || 51 | (i >= secondLevelSplitTarget - 5 && i <= secondLevelSplitTarget + 5) 52 | ) { 53 | fuzzer.checkAll(); 54 | } 55 | } 56 | 57 | fuzzer.checkAll(); 58 | }); 59 | 60 | it("should maintain tree integrity during interleaved insert/delete at boundaries", function () { 61 | this.timeout(5000); 62 | 63 | const fuzzer = Fuzzer.new(); 64 | 65 | // Create initial structure with M-1 elements 66 | for (let i = 0; i < M - 1; i++) { 67 | const id = createId(`id${i}`, 0); 68 | if (i === 0) fuzzer.insertAfter(null, id); 69 | else fuzzer.insertAfter(createId(`id${i - 1}`, 0), id); 70 | } 71 | 72 | fuzzer.checkAll(); 73 | 74 | // Repeatedly push the structure to M elements (threshold for split), 75 | // then delete and reinsert at node boundaries 76 | for (let cycle = 0; cycle < 10; cycle++) { 77 | // Add element to trigger potential split 78 | const triggerSplitId = createId(`trigger${cycle}`, 0); 79 | fuzzer.insertAfter(createId(`id${M - 2}`, 0), triggerSplitId); 80 | fuzzer.checkAll(); 81 | 82 | // Delete from potential boundary points 83 | const boundaryIndex = cycle % 3; 84 | 85 | if (boundaryIndex === 0) { 86 | // Delete from beginning 87 | fuzzer.delete(createId(`id0`, 0)); 88 | } else if (boundaryIndex === 1) { 89 | // Delete from middle 90 | fuzzer.delete(createId(`id${Math.floor(M / 2)}`, 0)); 91 | } else { 92 | // Delete from end 93 | fuzzer.delete(triggerSplitId); 94 | } 95 | 96 | fuzzer.checkAll(); 97 | 98 | // Insert at a different boundary 99 | const insertIndex = (cycle + 1) % 3; 100 | const newId = createId(`insert${cycle}`, 0); 101 | 102 | if (insertIndex === 0) { 103 | // Insert at beginning 104 | fuzzer.insertBefore(createId(`id1`, 0), newId); 105 | } else if (insertIndex === 1) { 106 | // Insert in middle 107 | const midId = createId(`id${Math.floor(M / 2) + 1}`, 0); 108 | fuzzer.insertBefore(midId, newId); 109 | } else { 110 | // Insert at end 111 | fuzzer.insertAfter(createId(`id${M - 2}`, 0), newId); 112 | } 113 | 114 | fuzzer.checkAll(); 115 | } 116 | }); 117 | 118 | it("should handle bulk insertions that cause complex splits", function () { 119 | this.timeout(5000); 120 | 121 | const fuzzer = Fuzzer.new(); 122 | 123 | // Create initial structure with half M elements 124 | for (let i = 0; i < M / 2; i++) { 125 | const id = createId(`base${i}`, 0); 126 | if (i === 0) fuzzer.insertAfter(null, id); 127 | else fuzzer.insertAfter(createId(`base${i - 1}`, 0), id); 128 | } 129 | 130 | fuzzer.checkAll(); 131 | 132 | // Insert bulk batches at various positions that will cause splits 133 | for (let batch = 0; batch < 5; batch++) { 134 | const batchSize = M - 2; // Large enough to cause splits 135 | const referenceIdx = Math.floor(prng() * (M / 2)); 136 | const referenceId = createId(`base${referenceIdx}`, 0); 137 | const batchId = createId(`batch${batch}`, 0); 138 | 139 | if (batch % 2 === 0) { 140 | fuzzer.insertAfter(referenceId, batchId, batchSize); 141 | } else { 142 | fuzzer.insertBefore(referenceId, batchId, batchSize); 143 | } 144 | 145 | fuzzer.checkAll(); 146 | } 147 | }); 148 | }); 149 | 150 | describe("Complex Tree Operations", () => { 151 | it("should handle deep tree restructuring with mixed operations", function () { 152 | this.timeout(10000); 153 | 154 | const operationCount = 500; // Parameter to adjust test intensity 155 | const fuzzer = Fuzzer.new(); 156 | const ids: ElementId[] = []; 157 | 158 | // First create a tree large enough to have multiple levels 159 | for (let i = 0; i < 50; i++) { 160 | const id = createId(`base${i}`, 0); 161 | if (i === 0) { 162 | fuzzer.insertAfter(null, id); 163 | } else { 164 | fuzzer.insertAfter(ids[i - 1], id); 165 | } 166 | ids.push(id); 167 | 168 | if (i % 10 === 0) { 169 | fuzzer.checkAll(); 170 | } 171 | } 172 | 173 | fuzzer.checkAll(); 174 | 175 | // Now perform random operations targeting different tree levels 176 | for (let op = 0; op < operationCount; op++) { 177 | const operation = Math.floor(prng() * 5); 178 | 179 | if (op % 10 === 0) { 180 | fuzzer.checkAll(); 181 | } 182 | 183 | switch (operation) { 184 | case 0: // Insert at start/middle/end 185 | { 186 | const position = Math.floor(prng() * 3); // 0=start, 1=middle, 2=end 187 | const id = createId(`op${op}`, 0); 188 | 189 | try { 190 | if (position === 0) { 191 | // Insert at start 192 | fuzzer.insertBefore(ids[0], id); 193 | } else if (position === 1) { 194 | // Insert in middle 195 | const midIdx = 196 | Math.floor(ids.length / 2) + Math.floor(prng() * 10) - 5; 197 | const refIdx = Math.max(0, Math.min(ids.length - 1, midIdx)); 198 | fuzzer.insertAfter(ids[refIdx], id); 199 | } else { 200 | // Insert at end 201 | fuzzer.insertAfter(ids[ids.length - 1], id); 202 | } 203 | ids.push(id); 204 | } catch (e) { 205 | if (e instanceof AssertionError) { 206 | throw e; 207 | } 208 | // Handle ID collisions 209 | } 210 | } 211 | break; 212 | 213 | case 1: // Insert multiple elements 214 | { 215 | const count = 1 + Math.floor(prng() * 10); // 1-10 elements 216 | const refIdx = Math.floor(prng() * ids.length); 217 | const id = createId(`bulk${op}`, 0); 218 | 219 | try { 220 | if (prng() > 0.5) { 221 | fuzzer.insertAfter(ids[refIdx], id, count); 222 | } else { 223 | fuzzer.insertBefore(ids[refIdx], id, count); 224 | } 225 | 226 | // Add new IDs to known list 227 | for (let i = 0; i < count; i++) { 228 | ids.push({ bunchId: id.bunchId, counter: id.counter + i }); 229 | } 230 | } catch (e) { 231 | if (e instanceof AssertionError) { 232 | throw e; 233 | } 234 | // Handle ID collisions 235 | } 236 | } 237 | break; 238 | 239 | case 2: // Uninsert in patterns 240 | { 241 | const pattern = Math.floor(prng() * 3); // 0=start, 1=every-nth, 2=range 242 | 243 | if (pattern === 0 && ids.length > 10) { 244 | // Uninsert first few elements 245 | const count = 1 + Math.floor(prng() * 3); // 1-3 elements 246 | for (let i = 0; i < count; i++) { 247 | fuzzer.uninsert(ids[i]); 248 | } 249 | } else if (pattern === 1 && ids.length > 10) { 250 | // Uninsert every nth element 251 | const nth = 2 + Math.floor(prng() * 5); // Every 2nd to 6th 252 | for (let i = 0; i < ids.length; i += nth) { 253 | fuzzer.uninsert(ids[i]); 254 | } 255 | } else if (ids.length > 10) { 256 | // Uninsert a range 257 | const start = Math.floor(prng() * (ids.length / 2)); 258 | const count = 1 + Math.floor(prng() * 5); // 1-5 elements 259 | for (let i = 0; i < count && start + i < ids.length; i++) { 260 | fuzzer.uninsert(ids[start + i]); 261 | } 262 | } 263 | } 264 | break; 265 | 266 | case 3: // Delete in patterns 267 | { 268 | const pattern = Math.floor(prng() * 3); // 0=start, 1=every-nth, 2=range 269 | 270 | if (pattern === 0 && ids.length > 10) { 271 | // Delete first few elements 272 | const count = 1 + Math.floor(prng() * 3); // 1-3 elements 273 | for (let i = 0; i < count; i++) { 274 | fuzzer.delete(ids[i]); 275 | } 276 | } else if (pattern === 1 && ids.length > 10) { 277 | // Delete every nth element 278 | const nth = 2 + Math.floor(prng() * 5); // Every 2nd to 6th 279 | for (let i = 0; i < ids.length; i += nth) { 280 | fuzzer.delete(ids[i]); 281 | } 282 | } else if (ids.length > 10) { 283 | // Delete a range 284 | const start = Math.floor(prng() * (ids.length / 2)); 285 | const count = 1 + Math.floor(prng() * 5); // 1-5 elements 286 | for (let i = 0; i < count && start + i < ids.length; i++) { 287 | fuzzer.delete(ids[start + i]); 288 | } 289 | } 290 | } 291 | break; 292 | 293 | case 4: // Undelete 294 | { 295 | // Undelete a few random elements 296 | const count = 1 + Math.floor(prng() * 3); // 1-3 elements 297 | for (let i = 0; i < count; i++) { 298 | const idx = Math.floor(prng() * ids.length); 299 | try { 300 | fuzzer.undelete(ids[idx]); 301 | } catch (e) { 302 | if (e instanceof AssertionError) { 303 | throw e; 304 | } 305 | // Element might not be deleted 306 | } 307 | } 308 | } 309 | break; 310 | } 311 | } 312 | 313 | fuzzer.checkAll(); 314 | }); 315 | 316 | it("should maintain tree integrity with sequential run operations", function () { 317 | this.timeout(5000); 318 | 319 | const fuzzer = Fuzzer.new(); 320 | 321 | // Create sequential runs with same bunchId but varying patterns 322 | // This tests the compression and run handling logic 323 | 324 | // First create a base run 325 | fuzzer.insertAfter(null, createId("run", 0), 20); 326 | fuzzer.checkAll(); 327 | 328 | // Delete elements to create gaps in the run 329 | const deletePatterns = [ 330 | [1, 5, 9, 13, 17], // Every 4th element 331 | [3, 4], // Small contiguous range 332 | [10, 11, 12, 13, 14, 15], // Large contiguous range 333 | ]; 334 | 335 | for (const pattern of deletePatterns) { 336 | for (const idx of pattern) { 337 | fuzzer.delete(createId("run", idx)); 338 | } 339 | fuzzer.checkAll(); 340 | } 341 | 342 | // Insert elements that extend the run 343 | fuzzer.insertAfter(createId("run", 19), createId("run", 20), 10); 344 | fuzzer.checkAll(); 345 | 346 | // Insert elements that create a gap, then fill it 347 | fuzzer.insertAfter(createId("run", 29), createId("run", 40), 10); 348 | fuzzer.checkAll(); 349 | 350 | // Fill the gap 351 | fuzzer.insertAfter(createId("run", 29), createId("run", 30), 10); 352 | fuzzer.checkAll(); 353 | 354 | // Delete elements at the boundaries 355 | fuzzer.delete(createId("run", 0)); 356 | fuzzer.delete(createId("run", 19)); 357 | fuzzer.delete(createId("run", 20)); 358 | fuzzer.delete(createId("run", 29)); 359 | fuzzer.delete(createId("run", 30)); 360 | fuzzer.delete(createId("run", 49)); 361 | fuzzer.checkAll(); 362 | 363 | // Undelete some elements 364 | fuzzer.undelete(createId("run", 0)); 365 | fuzzer.undelete(createId("run", 29)); 366 | fuzzer.undelete(createId("run", 49)); 367 | fuzzer.checkAll(); 368 | }); 369 | }); 370 | 371 | describe("Edge Cases and Tree Balancing", () => { 372 | it("should handle interleaved sequences that affect leaf structure", function () { 373 | this.timeout(5000); 374 | 375 | const fuzzer = Fuzzer.new(); 376 | const ids: ElementId[] = []; 377 | 378 | // Create an interleaved sequence of different bunchIds 379 | // This creates a more complex leaf structure 380 | for (let i = 0; i < 30; i++) { 381 | const bunchId = `bunch${i % 5}`; // Use 5 different bunchIds 382 | const counter = Math.floor(i / 5); 383 | const id = createId(bunchId, counter); 384 | 385 | if (i === 0) { 386 | fuzzer.insertAfter(null, id); 387 | } else { 388 | fuzzer.insertAfter(ids[ids.length - 1], id); 389 | } 390 | 391 | ids.push(id); 392 | } 393 | 394 | fuzzer.checkAll(); 395 | 396 | // Delete elements in a pattern that affects multiple bunches 397 | for (let i = 0; i < 30; i += 6) { 398 | fuzzer.delete(ids[i]); 399 | } 400 | 401 | fuzzer.checkAll(); 402 | 403 | // Insert elements between existing ones 404 | for (let i = 0; i < 10; i++) { 405 | const insertIdx = 2 * i + 1; 406 | if (insertIdx < ids.length) { 407 | const id = createId(`insert${i}`, 0); 408 | fuzzer.insertAfter(ids[insertIdx], id); 409 | ids.push(id); 410 | } 411 | } 412 | 413 | fuzzer.checkAll(); 414 | 415 | // Merge sequences by inserting elements with matching bunchIds 416 | const inserted: { id: ElementId; count: number }[] = []; 417 | for (let i = 0; i < 5; i++) { 418 | // Find the last element with this bunchId 419 | let lastIdx = -1; 420 | let lastCounter = -1; 421 | for (let j = ids.length - 1; j >= 0; j--) { 422 | if (ids[j].bunchId === `bunch${i}`) { 423 | lastIdx = j; 424 | lastCounter = ids[j].counter; 425 | break; 426 | } 427 | } 428 | 429 | if (lastIdx >= 0) { 430 | // Insert elements that continue the sequence 431 | const id = createId(`bunch${i}`, lastCounter + 1); 432 | fuzzer.insertAfter(ids[lastIdx], id, 3); 433 | inserted.push({ id, count: 3 }); 434 | 435 | for (let j = 0; j < 3; j++) { 436 | ids.push({ bunchId: id.bunchId, counter: id.counter + j }); 437 | } 438 | } 439 | } 440 | 441 | fuzzer.checkAll(); 442 | 443 | // Undo the latest inserts in reverse order. 444 | for (let i = inserted.length - 1; i >= 0; i--) { 445 | const { id, count } = inserted[i]; 446 | fuzzer.uninsert(id, count); 447 | } 448 | 449 | fuzzer.checkAll(); 450 | }); 451 | 452 | it("should test the impact of bulk operations on tree balance", function () { 453 | this.timeout(10000); 454 | 455 | // Parameter to control test intensity 456 | const batchCount = 50; 457 | const batchSize = 20; 458 | 459 | const fuzzer = Fuzzer.new(); 460 | const ids: ElementId[] = []; 461 | 462 | // Add batches of elements that will force the tree to grow 463 | for (let batch = 0; batch < batchCount; batch++) { 464 | const batchId = createId(`batch${batch}`, 0); 465 | 466 | // Choose where to insert the batch 467 | if (batch === 0 || ids.length === 0) { 468 | // First batch at the beginning 469 | fuzzer.insertAfter(null, batchId, batchSize); 470 | } else if (batch === 1) { 471 | // Second batch at the end 472 | fuzzer.insertAfter(ids[ids.length - 1], batchId, batchSize); 473 | } else { 474 | // Other batches at random positions 475 | const position = Math.floor(prng() * ids.length); 476 | if (prng() > 0.5) { 477 | fuzzer.insertAfter(ids[position], batchId, batchSize); 478 | } else { 479 | fuzzer.insertBefore(ids[position], batchId, batchSize); 480 | } 481 | } 482 | 483 | // Add the batch IDs to our tracking 484 | for (let i = 0; i < batchSize; i++) { 485 | ids.push({ bunchId: batchId.bunchId, counter: batchId.counter + i }); 486 | } 487 | 488 | fuzzer.checkAll(); 489 | 490 | // Now delete a fraction of elements from previous batches 491 | if (batch > 0) { 492 | const deleteCount = Math.floor(batchSize / 4); // Delete 25% of a batch 493 | const targetBatch = Math.floor(prng() * batch); // Choose a previous batch 494 | 495 | // Delete elements from the target batch 496 | for (let i = 0; i < deleteCount; i++) { 497 | const deletePosition = targetBatch * batchSize + i * 2; // Delete every other element 498 | if (deletePosition < ids.length) { 499 | fuzzer.delete(ids[deletePosition]); 500 | } 501 | } 502 | 503 | fuzzer.checkAll(); 504 | } 505 | } 506 | 507 | // Now perform targeted operations that might affect balance 508 | 509 | // 1. Delete elements at potential node boundaries 510 | const boundaryCandidates = [0, 8, 16, 24, 32, 40, 48, 56]; 511 | for (const boundary of boundaryCandidates) { 512 | if (boundary < ids.length) { 513 | fuzzer.delete(ids[boundary]); 514 | } 515 | } 516 | 517 | fuzzer.checkAll(); 518 | 519 | // 2. Insert elements at those same boundaries 520 | for (const boundary of boundaryCandidates) { 521 | if (boundary < ids.length) { 522 | const id = createId(`boundary${boundary}`, 0); 523 | try { 524 | fuzzer.insertBefore(ids[boundary], id); 525 | ids.push(id); 526 | } catch (e) { 527 | if (e instanceof AssertionError) { 528 | throw e; 529 | } 530 | // Handle case where ID is already deleted 531 | } 532 | } 533 | } 534 | 535 | fuzzer.checkAll(); 536 | 537 | // 3. Bulk operation to insert a large batch in the middle 538 | if (ids.length > 20) { 539 | const midpoint = Math.floor(ids.length / 2); 540 | const id = createId("middle", 0); 541 | fuzzer.insertAfter(ids[midpoint], id, 30); 542 | 543 | fuzzer.checkAll(); 544 | } 545 | }); 546 | 547 | it("should handle interleaved operations on a deep tree", () => { 548 | const fuzzer = Fuzzer.new(); 549 | 550 | // Create a deep tree with many elements 551 | fuzzer.insertAfter(null, createId("base", 0), 100); 552 | 553 | // Insert elements with varying patterns in the middle 554 | for (let i = 0; i < 20; i++) { 555 | const baseIndex = 10 + i * 4; 556 | fuzzer.insertAfter( 557 | createId("base", baseIndex), 558 | createId(`interleaved${i}`, 0), 559 | (i % 3) + 1 // Insert 1, 2, or 3 elements 560 | ); 561 | } 562 | fuzzer.checkAll(); 563 | 564 | // Delete some elements to create fragmentation in leaves' presence 565 | for (let i = 0; i < 30; i++) { 566 | if (i % 7 === 0) { 567 | fuzzer.delete(createId("base", i)); 568 | } 569 | } 570 | fuzzer.checkAll(); 571 | }); 572 | }); 573 | }); 574 | -------------------------------------------------------------------------------- /test/btree_structure_and_edge_cases.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ElementId, IdList, SavedIdList } from "../src"; 3 | import { 4 | InnerNode, 5 | InnerNodeInner, 6 | InnerNodeLeaf, 7 | LeafNode, 8 | } from "../src/id_list"; 9 | 10 | describe("IdList Internal Structure", () => { 11 | // Helper to create ElementIds 12 | const createId = (bunchId: string, counter: number): ElementId => ({ 13 | bunchId, 14 | counter, 15 | }); 16 | 17 | describe("locate function and traversal", () => { 18 | it("should correctly locate elements after tree restructuring", () => { 19 | let list = IdList.new(); 20 | 21 | // Insert enough elements to force multiple levels in the B+Tree 22 | const ids: ElementId[] = []; 23 | for (let i = 0; i < 50; i++) { 24 | const id = createId(`id${i}`, 0); 25 | ids.push(id); 26 | list = list.insertAfter(i === 0 ? null : ids[i - 1], id); 27 | } 28 | 29 | // Verify locate works for all elements 30 | for (let i = 0; i < 50; i++) { 31 | const path = list["locate"](ids[i]); 32 | expect(path).to.not.be.null; 33 | // Although a balanced BTree would have root-exclusive depth 2 34 | // (ceil(log_8(50))), building a tree by insert (instead of load) 35 | // results in many nodes with M/2 [+ 1] children. So the depth 36 | // may instead be 3, consistently for all leaves. 37 | expect(path?.length).to.be.lessThanOrEqual(3); 38 | 39 | // First item in path should be the leaf containing our id 40 | expect(path?.[0].node.bunchId).to.equal(`id${i}`); 41 | expect(path?.[0].node.startCounter).to.equal(0); 42 | } 43 | 44 | // Test locate on an unknown element 45 | const unknownId = createId("unknown", 0); 46 | const unknownPath = list["locate"](unknownId); 47 | expect(unknownPath).to.be.null; 48 | }); 49 | 50 | it("should correctly update paths after insertions and splits", () => { 51 | let list = IdList.new(); 52 | 53 | // Insert elements to create a specific structure 54 | for (let i = 0; i < 20; i++) { 55 | list = list.insertAfter(null, createId(`id${i}`, 0)); 56 | } 57 | 58 | // Find the path to an element in the middle 59 | const middleId = createId("id10", 0); 60 | let path = list["locate"](middleId); 61 | 62 | // Remember the leaf node that contains middleId 63 | const originalLeaf = path?.[0].node; 64 | 65 | // Insert many elements after middleId to force a split 66 | for (let i = 0; i < 10; i++) { 67 | list = list.insertAfter(middleId, createId(`split${i}`, 0)); 68 | 69 | // After each insertion, re-check the path 70 | path = list["locate"](middleId); 71 | 72 | // The element should still be locatable 73 | expect(path).to.not.be.null; 74 | } 75 | 76 | // The leaf node containing middleId might have changed due to splits 77 | const newLeaf = path?.[0].node; 78 | 79 | // Either we have the same leaf (if it wasn't split) or a different one 80 | if (originalLeaf === newLeaf) { 81 | // If the same leaf, it should contain middleId in the same position 82 | expect(originalLeaf?.bunchId).to.equal("id10"); 83 | expect(originalLeaf?.startCounter).to.equal(0); 84 | } else { 85 | // If a new leaf, it should still contain middleId 86 | expect(newLeaf?.bunchId).to.equal("id10"); 87 | expect(newLeaf?.startCounter).to.equal(0); 88 | } 89 | }); 90 | }); 91 | 92 | describe("replaceLeaf function", () => { 93 | it("should properly replace a leaf node with multiple nodes", () => { 94 | let list = IdList.new(); 95 | 96 | // Create a list with sequential IDs 97 | list = list.insertAfter(null, createId("bunch", 0), 5); 98 | 99 | // Insert a value that should cause a leaf split 100 | list = list.insertAfter(createId("bunch", 2), createId("split", 0)); 101 | 102 | // Test that the insertion and leaf replacement worked 103 | expect(list.indexOf(createId("bunch", 0))).to.equal(0); 104 | expect(list.indexOf(createId("bunch", 1))).to.equal(1); 105 | expect(list.indexOf(createId("bunch", 2))).to.equal(2); 106 | expect(list.indexOf(createId("split", 0))).to.equal(3); 107 | expect(list.indexOf(createId("bunch", 3))).to.equal(4); 108 | expect(list.indexOf(createId("bunch", 4))).to.equal(5); 109 | }); 110 | 111 | it("should maintain correct sizes when replacing leaves", () => { 112 | let list = IdList.new(); 113 | 114 | // Create a list with sequential IDs 115 | list = list.insertAfter(null, createId("bunch", 0), 10); 116 | 117 | // Insert more elements that will cause leaf splits 118 | for (let i = 0; i < 5; i++) { 119 | list = list.insertAfter( 120 | createId("bunch", i * 2), 121 | createId(`split${i}`, 0) 122 | ); 123 | } 124 | 125 | const root = list["root"]; 126 | 127 | // Helper to verify node sizes are consistent 128 | function verifyNodeSizes(node: InnerNode | LeafNode) { 129 | if ("children" in node && "children" in node.children[0]) { 130 | // Inner node with inner node children 131 | const nodeTyped = node as InnerNodeInner; 132 | let calculatedSize = 0; 133 | let calculatedKnownSize = 0; 134 | 135 | for (const child of nodeTyped.children) { 136 | verifyNodeSizes(child); 137 | calculatedSize += child.size; 138 | calculatedKnownSize += child.knownSize; 139 | } 140 | 141 | // The node's size should equal the sum of its children's sizes 142 | expect(nodeTyped.size).to.equal(calculatedSize); 143 | expect(nodeTyped.knownSize).to.equal(calculatedKnownSize); 144 | } else if ("children" in node) { 145 | // Inner node with leaf children 146 | const nodeTyped = node as InnerNodeLeaf; 147 | let calculatedSize = 0; 148 | let calculatedKnownSize = 0; 149 | 150 | for (const child of nodeTyped.children) { 151 | calculatedSize += child.present.count(); 152 | calculatedKnownSize += child.count; 153 | } 154 | 155 | // The node's size should equal the sum of its children's sizes 156 | expect(nodeTyped.size).to.equal(calculatedSize); 157 | expect(nodeTyped.knownSize).to.equal(calculatedKnownSize); 158 | } 159 | } 160 | 161 | verifyNodeSizes(root); 162 | 163 | // The root size should match the list length 164 | expect(root.size).to.equal(list.length); 165 | expect(root.knownSize).to.equal(list.knownIds.length); 166 | }); 167 | }); 168 | 169 | describe("splitPresent function", () => { 170 | it("should correctly split present values", () => { 171 | let list = IdList.new(); 172 | 173 | // Create a list with sequential IDs 174 | list = list.insertAfter(null, createId("bunch", 0), 10); 175 | 176 | // Delete some elements to create gaps in present values 177 | list = list.delete(createId("bunch", 3)); 178 | list = list.delete(createId("bunch", 4)); 179 | list = list.delete(createId("bunch", 7)); 180 | 181 | // Now cause a split by inserting in the middle 182 | list = list.insertAfter(createId("bunch", 5), createId("split", 0)); 183 | 184 | // Verify the structure after split 185 | for (let i = 0; i < 10; i++) { 186 | if (i === 3 || i === 4 || i === 7) { 187 | expect(list.has(createId("bunch", i))).to.be.false; 188 | expect(list.isKnown(createId("bunch", i))).to.be.true; 189 | } else { 190 | expect(list.has(createId("bunch", i))).to.be.true; 191 | } 192 | } 193 | 194 | expect(list.has(createId("split", 0))).to.be.true; 195 | 196 | // The order should be preserved with the split element in the middle 197 | expect(list.indexOf(createId("bunch", 2))).to.equal(2); 198 | expect(list.indexOf(createId("bunch", 5))).to.equal(3); 199 | expect(list.indexOf(createId("split", 0))).to.equal(4); 200 | expect(list.indexOf(createId("bunch", 6))).to.equal(5); 201 | }); 202 | }); 203 | 204 | describe("save and load with edge cases", () => { 205 | it("should correctly serialize and deserialize complex tree structures", () => { 206 | let list = IdList.new(); 207 | 208 | // Create a list with elements that force a multi-level tree 209 | for (let i = 0; i < 50; i++) { 210 | // Alternate between sequential and non-sequential IDs 211 | if (i % 10 === 0) { 212 | // Start a new sequence 213 | list = list.insertAfter( 214 | i === 0 ? null : createId(`seq${Math.floor((i - 1) / 10)}`, 9), 215 | createId(`seq${Math.floor(i / 10)}`, 0) 216 | ); 217 | } else { 218 | // Continue the sequence 219 | list = list.insertAfter( 220 | createId(`seq${Math.floor(i / 10)}`, (i % 10) - 1), 221 | createId(`seq${Math.floor(i / 10)}`, i % 10) 222 | ); 223 | } 224 | } 225 | 226 | // Delete some elements to create gaps in the leaves' presence 227 | for (let i = 0; i < 5; i++) { 228 | list = list.delete(createId(`seq${i}`, 5)); 229 | } 230 | 231 | // Save the state 232 | const saved = list.save(); 233 | 234 | // Load the saved state 235 | const loadedList = IdList.load(saved); 236 | 237 | // Verify the loaded list matches the original 238 | expect(loadedList.length).to.equal(list.length); 239 | expect([...loadedList.valuesWithIsDeleted()]).to.deep.equal([ 240 | ...list.valuesWithIsDeleted(), 241 | ]); 242 | }); 243 | 244 | it("should correct handle serializing deleted items at end of leaf nodes", () => { 245 | let list = IdList.new(); 246 | 247 | // Insert sequential IDs 248 | list = list.insertAfter(null, createId("seq", 0), 10); 249 | 250 | // Delete the last few elements 251 | list = list.delete(createId("seq", 7)); 252 | list = list.delete(createId("seq", 8)); 253 | list = list.delete(createId("seq", 9)); 254 | 255 | // Save and load 256 | const saved = list.save(); 257 | const loadedList = IdList.load(saved); 258 | 259 | // Verify that save has two items 260 | expect(saved.length).to.equal(2); 261 | 262 | // Verify all deleted elements are still known 263 | for (let i = 7; i < 10; i++) { 264 | expect(loadedList.has(createId("seq", i))).to.be.false; 265 | expect(loadedList.isKnown(createId("seq", i))).to.be.true; 266 | } 267 | 268 | // Verify the correct number of elements are present 269 | expect(loadedList.length).to.equal(7); 270 | expect(loadedList.knownIds.length).to.equal(10); 271 | }); 272 | }); 273 | 274 | describe("buildTree function", () => { 275 | it("should create a balanced tree from leaves", () => { 276 | // Create a SavedIdList with enough entries to require multiple levels 277 | const savedState: SavedIdList = []; 278 | for (let i = 0; i < 100; i++) { 279 | savedState.push({ 280 | bunchId: `bunch${i}`, 281 | startCounter: 0, 282 | count: 1, 283 | isDeleted: i % 5 === 0, // Make some deleted 284 | }); 285 | } 286 | 287 | // Load into a new IdList 288 | const list = IdList.load(savedState); 289 | 290 | const root = list["root"]; 291 | 292 | // Helper to check tree height and balance 293 | function getTreeHeight(node: InnerNode): number { 294 | if (node.children && "children" in node.children[0]) { 295 | // Inner node with inner node children 296 | const nodeTyped = node as InnerNodeInner; 297 | const childHeights = nodeTyped.children.map(getTreeHeight); 298 | 299 | // All children should have the same height (balanced) 300 | const firstHeight = childHeights[0]; 301 | expect(childHeights.every((h) => h === firstHeight)).to.be.true; 302 | 303 | return 1 + firstHeight; 304 | } else { 305 | // Inner node with leaf children 306 | return 1; 307 | } 308 | } 309 | 310 | const height = getTreeHeight(root); 311 | 312 | // For 100 elements with M=8, height should be 3 313 | expect(height).to.be.equal(3); 314 | 315 | // Check that all elements are accessible 316 | let presentCount = 0; 317 | let knownCount = 0; 318 | 319 | for (let i = 0; i < 100; i++) { 320 | const id = createId(`bunch${i}`, 0); 321 | expect(list.isKnown(id)).to.be.true; 322 | knownCount++; 323 | 324 | if (i % 5 !== 0) { 325 | expect(list.has(id)).to.be.true; 326 | presentCount++; 327 | } else { 328 | expect(list.has(id)).to.be.false; 329 | } 330 | } 331 | 332 | expect(list.length).to.equal(presentCount); 333 | expect(list.knownIds.length).to.equal(knownCount); 334 | }); 335 | }); 336 | 337 | describe("KnownIdView", () => { 338 | it("should correctly handle at() and indexOf() with deleted elements", () => { 339 | let list = IdList.new(); 340 | 341 | // Insert sequential IDs 342 | list = list.insertAfter(null, createId("seq", 0), 10); 343 | 344 | // Delete some elements 345 | list = list.delete(createId("seq", 3)); 346 | list = list.delete(createId("seq", 7)); 347 | 348 | const knownIds = list.knownIds; 349 | 350 | // Check at() returns deleted elements at their position 351 | expect(knownIds.at(3)).to.deep.equal(createId("seq", 3)); 352 | expect(knownIds.at(7)).to.deep.equal(createId("seq", 7)); 353 | 354 | // Check indexOf() works for both present and deleted 355 | for (let i = 0; i < 10; i++) { 356 | expect(knownIds.indexOf(createId("seq", i))).to.equal(i); 357 | } 358 | }); 359 | 360 | it("should maintain knownIds view across complex operations", () => { 361 | let list = IdList.new(); 362 | 363 | // Insert sequential IDs 364 | list = list.insertAfter(null, createId("seq", 0), 10); 365 | 366 | // Get initial knownIds view 367 | const knownIds1 = list.knownIds; 368 | 369 | // Perform some operations 370 | list = list.delete(createId("seq", 3)); 371 | list = list.insertAfter(createId("seq", 5), createId("new", 0)); 372 | 373 | // Get updated knownIds view 374 | const knownIds2 = list.knownIds; 375 | 376 | // Verify the knownIds views are correct 377 | expect(knownIds1.length).to.equal(10); 378 | expect(knownIds2.length).to.equal(11); 379 | 380 | // Check the first view hasn't changed 381 | for (let i = 0; i < 10; i++) { 382 | expect(knownIds1.at(i)).to.deep.equal(createId("seq", i)); 383 | } 384 | 385 | // Check the second view has the new element and maintains its order 386 | for (let i = 0; i < 6; i++) { 387 | expect(knownIds2.at(i)).to.deep.equal(createId("seq", i)); 388 | } 389 | expect(knownIds2.at(6)).to.deep.equal(createId("new", 0)); 390 | for (let i = 6; i < 10; i++) { 391 | expect(knownIds2.at(i + 1)).to.deep.equal(createId("seq", i)); 392 | } 393 | }); 394 | }); 395 | }); 396 | -------------------------------------------------------------------------------- /test/fuzzer.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ElementId, IdList, SavedIdList } from "../src"; 3 | import { IdListSimple } from "./id_list_simple"; 4 | 5 | const DEBUG = false; 6 | 7 | /** 8 | * Applies mutations to both IdList and IdListSimple (a simpler, known-good implementation), 9 | * erroring if the resulting states differ. 10 | */ 11 | export class Fuzzer { 12 | private constructor(public list: IdList, readonly simple: IdListSimple) {} 13 | 14 | private mutate(makeList: () => IdList, mutateSimple: () => void) { 15 | let listError: unknown = null; 16 | try { 17 | this.list = makeList(); 18 | } catch (e) { 19 | listError = e; 20 | } 21 | 22 | let simpleError: unknown = null; 23 | try { 24 | mutateSimple(); 25 | } catch (e) { 26 | simpleError = e; 27 | } 28 | 29 | const anyError = simpleError ?? listError; 30 | if (anyError) { 31 | if (DEBUG) { 32 | console.log("An implementation threw error", anyError); 33 | } 34 | // If one throws, they both should throw. 35 | // E.g. you tried to insert a known id. 36 | expect(listError).to.not.equal(null, (anyError as Error).message); 37 | expect(simpleError).to.not.equal(null, (anyError as Error).message); 38 | 39 | // Throw the original error for the caller. 40 | // Our tests usually filter out non-AssertionErrors. 41 | throw anyError; 42 | } 43 | 44 | // Check that states agree. 45 | expect([...this.list.valuesWithIsDeleted()]).to.deep.equal([ 46 | ...this.simple.valuesWithIsDeleted(), 47 | ]); 48 | } 49 | 50 | /** 51 | * Check that all accessors agree. 52 | * 53 | * Not called on every mutation because it is more expensive. 54 | */ 55 | checkAll() { 56 | expect(this.list.length).to.equal(this.simple.length); 57 | for (let i = 0; i < this.simple.length; i++) { 58 | expect(this.list.at(i)).to.deep.equal(this.simple.at(i)); 59 | expect(this.list.indexOf(this.simple.at(i))).to.equal(i); 60 | } 61 | expect([...this.list.values()]).to.deep.equal([...this.simple.values()]); 62 | expect(this.list.save()).to.deep.equal(this.simple.save()); 63 | 64 | expect(this.list.knownIds.length).to.equal(this.simple.knownIds.length); 65 | for (let i = 0; i < this.simple.knownIds.length; i++) { 66 | expect(this.list.knownIds.at(i)).to.deep.equal( 67 | this.simple.knownIds.at(i) 68 | ); 69 | expect(this.list.knownIds.indexOf(this.simple.knownIds.at(i))).to.equal( 70 | i 71 | ); 72 | } 73 | expect([...this.list.knownIds.values()]).to.deep.equal([ 74 | ...this.simple.knownIds.values(), 75 | ]); 76 | 77 | const allBunchIds = new Set(); 78 | for (const id of this.simple.knownIds) allBunchIds.add(id.bunchId); 79 | for (const bunchId of allBunchIds) { 80 | expect(this.list.maxCounter(bunchId)).to.equal( 81 | this.simple.maxCounter(bunchId) 82 | ); 83 | } 84 | 85 | // Check loaded state as well. 86 | expect([ 87 | ...IdList.load(this.list.save()).valuesWithIsDeleted(), 88 | ]).to.deep.equal([...this.simple.valuesWithIsDeleted()]); 89 | 90 | if (DEBUG) console.log("checkAll passed"); 91 | } 92 | 93 | static new() { 94 | return new Fuzzer(IdList.new(), IdListSimple.new()); 95 | } 96 | 97 | static from(knownIds: Iterable<{ id: ElementId; isDeleted: boolean }>) { 98 | return new Fuzzer(IdList.from(knownIds), IdListSimple.from(knownIds)); 99 | } 100 | 101 | static fromIds(ids: Iterable) { 102 | return new Fuzzer(IdList.fromIds(ids), IdListSimple.fromIds(ids)); 103 | } 104 | 105 | insertAfter( 106 | before: ElementId | null, 107 | newId: ElementId, 108 | count?: number 109 | ): void { 110 | if (DEBUG) { 111 | console.log("insertAfter", before, newId, count); 112 | } 113 | this.mutate( 114 | () => this.list.insertAfter(before, newId, count), 115 | () => this.simple.insertAfter(before, newId, count) 116 | ); 117 | } 118 | 119 | insertBefore( 120 | after: ElementId | null, 121 | newId: ElementId, 122 | count?: number 123 | ): void { 124 | if (DEBUG) { 125 | console.log("insertBefore", after, newId, count); 126 | } 127 | this.mutate( 128 | () => this.list.insertBefore(after, newId, count), 129 | () => this.simple.insertBefore(after, newId, count) 130 | ); 131 | } 132 | 133 | uninsert(id: ElementId, count?: number): void { 134 | if (DEBUG) { 135 | console.log("uninsert", id, count); 136 | } 137 | this.mutate( 138 | () => this.list.uninsert(id, count), 139 | () => this.simple.uninsert(id, count) 140 | ); 141 | } 142 | 143 | delete(id: ElementId): void { 144 | if (DEBUG) { 145 | console.log("delete", id); 146 | } 147 | this.mutate( 148 | () => this.list.delete(id), 149 | () => this.simple.delete(id) 150 | ); 151 | } 152 | 153 | undelete(id: ElementId): void { 154 | if (DEBUG) { 155 | console.log("undelete", id); 156 | } 157 | this.mutate( 158 | () => this.list.undelete(id), 159 | () => this.simple.undelete(id) 160 | ); 161 | } 162 | 163 | load(savedState: SavedIdList) { 164 | if (DEBUG) { 165 | console.log("load"); 166 | } 167 | this.mutate( 168 | () => IdList.load(savedState), 169 | () => this.simple.load(savedState) 170 | ); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /test/id_list_simple.ts: -------------------------------------------------------------------------------- 1 | import { ElementId, equalsId, expandIds, SavedIdList } from "../src"; 2 | 3 | interface ListElement { 4 | readonly id: ElementId; 5 | isDeleted: boolean; 6 | } 7 | 8 | /** 9 | * Simplified implementation of IdList, used for illustration purposes and fuzz testing. 10 | * It omits IdList's optimizations and persistence but otherwise has identical behavior. 11 | */ 12 | export class IdListSimple { 13 | /** 14 | * Internal - construct an IdListSimple using a static method (e.g. `IdListSimple.new`). 15 | */ 16 | private constructor( 17 | private readonly state: ListElement[], 18 | private _length: number 19 | ) {} 20 | 21 | /** 22 | * Constructs an empty list. 23 | * 24 | * To begin with a non-empty list, use {@link IdListSimple.from} or {@link IdListSimple.fromIds}. 25 | */ 26 | static new() { 27 | return new this([], 0); 28 | } 29 | 30 | /** 31 | * Constructs a list with the given known ids and their isDeleted status, in list order. 32 | */ 33 | static from(knownIds: Iterable<{ id: ElementId; isDeleted: boolean }>) { 34 | const state: ListElement[] = []; 35 | let length = 0; 36 | for (const { id, isDeleted } of knownIds) { 37 | // Clone to prevent aliasing. 38 | state.push({ id, isDeleted }); 39 | if (!isDeleted) length++; 40 | } 41 | return new this(state, length); 42 | } 43 | 44 | /** 45 | * Constructs a list with the given present ids. 46 | * 47 | * Typically, you instead want {@link IdListSimple.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) { 52 | const state: ListElement[] = []; 53 | let length = 0; 54 | for (const id of ids) { 55 | state.push({ id, isDeleted: false }); 56 | length++; 57 | } 58 | return new this(state, length); 59 | } 60 | 61 | /** 62 | * Inserts `newId` immediately after the given id (`before`), which may be deleted. 63 | * 64 | * All ids to the right of `before` are shifted one index to the right, in the manner 65 | * of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice). 66 | * 67 | * Use `before = null` to insert at the beginning of the list, to the left of all 68 | * known ids. 69 | * 70 | * @param count Provide this to bulk-insert `count` ids from left-to-right, 71 | * starting with newId and proceeding with the same bunchId and sequential counters. 72 | * @throws If `before` is not known. 73 | * @throws If any inserted id is already known. 74 | */ 75 | insertAfter(before: ElementId | null, newId: ElementId, count = 1): void { 76 | let index: number; 77 | if (before === null) { 78 | // -1 so that index + 1 is 0: insert at the beginning of the list. 79 | index = -1; 80 | } else { 81 | index = this.state.findIndex((elt) => equalsId(elt.id, before)); 82 | if (index === -1) { 83 | throw new Error("before is not known"); 84 | } 85 | } 86 | 87 | if (count === 0) return; 88 | if (this.isAnyKnown(newId, count)) { 89 | throw new Error("An inserted id is already known"); 90 | } 91 | 92 | this.state.splice(index + 1, 0, ...expandElements(newId, false, count)); 93 | this._length += count; 94 | } 95 | 96 | /** 97 | * Inserts `newId` immediately before the given id (`after`), which may be deleted. 98 | * 99 | * All ids to the right of `after`, plus `after` itself, are shifted one index to the right, in the manner 100 | * of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice). 101 | * 102 | * Use `after = null` to insert at the end of the list, to the right of all known ids. 103 | * 104 | * @param count Provide this to bulk-insert `count` ids from left-to-right, 105 | * starting with newId and proceeding with the same bunchId and sequential counters. 106 | * __Note__: Although the new ids are inserted to the left of `after`, they are still 107 | * inserted in left-to-right order relative to each other. 108 | * @throws If `after` is not known. 109 | * @throws If any inserted id is already known. 110 | */ 111 | insertBefore(after: ElementId | null, newId: ElementId, count = 1): void { 112 | let index: number; 113 | if (after === null) { 114 | index = this.state.length; 115 | } else { 116 | index = this.state.findIndex((elt) => equalsId(elt.id, after)); 117 | if (index === -1) { 118 | throw new Error("after is not known"); 119 | } 120 | } 121 | 122 | if (count === 0) return; 123 | if (this.isAnyKnown(newId, count)) { 124 | throw new Error("An inserted id is already known"); 125 | } 126 | 127 | // We insert the bunch from left-to-right even though it's insertBefore. 128 | this.state.splice(index, 0, ...expandElements(newId, false, count)); 129 | this._length += count; 130 | } 131 | 132 | /** 133 | * Undoes the insertion of `id`, making it no longer known. 134 | * 135 | * This method is an exact inverse to `insertAfter(-, id)` or `insertBefore(-, id)`, 136 | * unlike `delete(id)`, which merely marks `id` as deleted. 137 | * You almost always want to use delete instead of uninsert, unless you are rolling 138 | * back the IdList state as part of a [server reconciliation](https://mattweidner.com/2024/06/04/server-architectures.html#1-server-reconciliation) 139 | * architecture. (Even then, you may find it easier to restore a snapshot instead 140 | * of explicitly undoing operations.) 141 | * 142 | * If `id` is already not known, this method does nothing. 143 | * 144 | * @param count Provide this to bulk-uninsert `count` ids, 145 | * starting with id and proceeding with the same bunchId and sequential counters. 146 | * `uninsert(id, count)` is an exact inverse to `insertAfter(-, id, count)` or `insertBefore(-, id, count)`. 147 | */ 148 | uninsert(id: ElementId, count = 1): void { 149 | for (const curId of expandIds(id, count)) { 150 | const index = this.state.findIndex((elt) => equalsId(elt.id, curId)); 151 | if (index !== -1) { 152 | const elt = this.state[index]; 153 | this.state.splice(index, 1); 154 | if (!elt.isDeleted) this._length--; 155 | } 156 | } 157 | } 158 | 159 | /** 160 | * Marks `id` as deleted from this list. 161 | * 162 | * The id remains known (a "tombstone"). 163 | * Because `id` is still known, you can reference it in future insertAfter/insertBefore 164 | * operations, including ones sent concurrently by other devices. 165 | * However, it does occupy space in memory (compressed in common cases). 166 | * 167 | * If `id` is already deleted or not known, this method does nothing. 168 | */ 169 | delete(id: ElementId): void { 170 | const index = this.state.findIndex((elt) => equalsId(elt.id, id)); 171 | if (index != -1) { 172 | const elt = this.state[index]; 173 | if (!elt.isDeleted) { 174 | elt.isDeleted = true; 175 | this._length--; 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * Un-marks `id` as deleted from this list, making it present again. 182 | * 183 | * This method is an exact inverse to {@link delete}. 184 | * 185 | * If `id` is already present, this method does nothing. 186 | * 187 | * @throws If `id` is not known. 188 | */ 189 | undelete(id: ElementId): void { 190 | const index = this.state.findIndex((elt) => equalsId(elt.id, id)); 191 | if (index == -1) { 192 | throw new Error("id is not known"); 193 | } 194 | const elt = this.state[index]; 195 | if (elt.isDeleted) { 196 | elt.isDeleted = false; 197 | this._length++; 198 | } 199 | } 200 | 201 | // Accessors 202 | 203 | /** 204 | * Returns whether `id` is present in the list, i.e., it is known and not deleted. 205 | * 206 | * If `id` is not known, false is returned. 207 | * 208 | * Compare to {@link isKnown}. 209 | */ 210 | has(id: ElementId): boolean { 211 | const elt = this.state.find((elt) => equalsId(elt.id, id)); 212 | if (elt === undefined) return false; 213 | return !elt.isDeleted; 214 | } 215 | 216 | /** 217 | * Returns whether id is known to this list. 218 | * 219 | * Compare to {@link has}. 220 | */ 221 | isKnown(id: ElementId): boolean { 222 | return this.state.some((elt) => equalsId(elt.id, id)); 223 | } 224 | 225 | private isAnyKnown(id: ElementId, count: number): boolean { 226 | return this.state.some( 227 | (elt) => 228 | elt.id.bunchId === id.bunchId && 229 | id.counter <= elt.id.counter && 230 | elt.id.counter < id.counter + count 231 | ); 232 | } 233 | 234 | /** 235 | * Returns the maximum counter across all known ElementIds with the given bunchId, 236 | * or undefined if no such ElementIds are known. 237 | * 238 | * This method is useful when creating ElementIds. 239 | */ 240 | maxCounter(bunchId: string): number | undefined { 241 | let max: number | undefined = undefined; 242 | for (const { id } of this.state) { 243 | if (id.bunchId === bunchId) { 244 | if (max === undefined || id.counter > max) max = id.counter; 245 | } 246 | } 247 | return max; 248 | } 249 | 250 | get length(): number { 251 | return this._length; 252 | } 253 | 254 | /** 255 | * Returns the id at the given index in the list. 256 | * 257 | * @throws If index is out of bounds. 258 | */ 259 | at(index: number): ElementId { 260 | if (!(Number.isSafeInteger(index) && 0 <= index && index < this.length)) { 261 | throw new Error(`Index out of bounds: ${index} (length: ${this.length}`); 262 | } 263 | 264 | let remaining = index; 265 | for (const elt of this.state) { 266 | if (!elt.isDeleted) { 267 | if (remaining === 0) return elt.id; 268 | remaining--; 269 | } 270 | } 271 | 272 | throw new Error("Internal error"); 273 | } 274 | 275 | /** 276 | * Returns the index of `id` in the list. 277 | * 278 | * If `id` is known but deleted, the bias specifies what to return: 279 | * - "none": -1. 280 | * - "left": The index immediately to the left of `id`, possibly -1. 281 | * - "right": The index immediately to the right of `id`, possibly `this.length`. 282 | * Equivalently, the index where `id` would be if present. 283 | * 284 | * @throws If `id` is not known. 285 | */ 286 | indexOf(id: ElementId, bias: "none" | "left" | "right" = "none"): number { 287 | /** 288 | * The number of present ids less than id. 289 | * Equivalently, the index id would have if present. 290 | */ 291 | let index = 0; 292 | for (const elt of this.state) { 293 | if (equalsId(elt.id, id)) { 294 | // Found it. 295 | if (elt.isDeleted) { 296 | switch (bias) { 297 | case "none": 298 | return -1; 299 | case "left": 300 | return index - 1; 301 | case "right": 302 | return index; 303 | } 304 | } else return index; 305 | } 306 | if (!elt.isDeleted) index++; 307 | } 308 | 309 | throw new Error("id is not known"); 310 | } 311 | 312 | // Iterators and views 313 | 314 | /** 315 | * Iterates over all present ids in the list. 316 | */ 317 | *[Symbol.iterator](): IterableIterator { 318 | for (const elt of this.state) { 319 | if (!elt.isDeleted) yield elt.id; 320 | } 321 | } 322 | 323 | /** 324 | * Iterates over all present ids in the list. 325 | */ 326 | values() { 327 | return this[Symbol.iterator](); 328 | } 329 | 330 | /** 331 | * Iterates over all __known__ ids in the list, indicating which are deleted. 332 | */ 333 | valuesWithIsDeleted(): IterableIterator<{ 334 | id: ElementId; 335 | isDeleted: boolean; 336 | }> { 337 | return this.state.values(); 338 | } 339 | 340 | private _knownIds?: KnownIdView; 341 | 342 | /** 343 | * A view of this list that treats all known ids as present. 344 | * That is, it ignores isDeleted status when computing list indices or iterating. 345 | */ 346 | get knownIds(): KnownIdView { 347 | if (this._knownIds === undefined) { 348 | this._knownIds = new KnownIdView(this, this.state); 349 | } 350 | return this._knownIds; 351 | } 352 | 353 | // Save and load 354 | 355 | /** 356 | * Returns a compact JSON representation of this list's internal state. 357 | * Load with {@link load}. 358 | * 359 | * See {@link SavedIdList} for a description of the save format. 360 | */ 361 | save(): SavedIdList { 362 | const ans: SavedIdList = []; 363 | 364 | for (const { id, isDeleted } of this.state) { 365 | if (ans.length !== 0) { 366 | const current = ans[ans.length - 1]; 367 | if ( 368 | id.bunchId === current.bunchId && 369 | id.counter === current.startCounter + current.count && 370 | isDeleted === current.isDeleted 371 | ) { 372 | // @ts-expect-error Mutating for convenience; no aliasing to worry about. 373 | current.count++; 374 | continue; 375 | } 376 | } 377 | 378 | ans.push({ 379 | bunchId: id.bunchId, 380 | startCounter: id.counter, 381 | count: 1, 382 | isDeleted, 383 | }); 384 | } 385 | 386 | return ans; 387 | } 388 | 389 | /** 390 | * Loads a saved state returned by {@link save}, **overwriting** the current list state. 391 | */ 392 | load(savedState: SavedIdList): void { 393 | this.state.splice(0, this.state.length); 394 | this._length = 0; 395 | 396 | for (const { bunchId, startCounter, count, isDeleted } of savedState) { 397 | if (!(Number.isSafeInteger(count) && count >= 0)) { 398 | throw new Error(`Invalid count: ${count}`); 399 | } 400 | if (!(Number.isSafeInteger(count) && count >= 0)) { 401 | throw new Error(`Invalid startCounter: ${startCounter}`); 402 | } 403 | 404 | for (let i = 0; i < count; i++) { 405 | this.state.push({ 406 | id: { bunchId, counter: startCounter + i }, 407 | isDeleted, 408 | }); 409 | } 410 | if (!isDeleted) this._length += count; 411 | } 412 | } 413 | } 414 | 415 | /** 416 | * A view of an IdListSimple that treats all known ids as present. 417 | * That is, this class ignores the underlying list's isDeleted status when computing list indices. 418 | * Access using {@link IdListSimple.knownIds}. 419 | * 420 | * This view is live-updating. To mutate, use a mutating method on the original IdListSimple. 421 | */ 422 | export class KnownIdView { 423 | /** 424 | * Internal use only. Use {@link IdListSimple.knownIds} instead. 425 | */ 426 | constructor( 427 | readonly list: IdListSimple, 428 | private readonly state: ListElement[] 429 | ) {} 430 | 431 | // Mutators are omitted - mutate this.list instead. 432 | 433 | // Accessors 434 | 435 | /** 436 | * Returns the id at the given index in this view. 437 | * 438 | * Equivalently, returns the index-th known id in `this.list`. 439 | * 440 | * @throws If index is out of bounds. 441 | */ 442 | at(index: number): ElementId { 443 | if (!(Number.isSafeInteger(index) && 0 <= index && index < this.length)) { 444 | throw new Error(`Index out of bounds: ${index} (length: ${this.length}`); 445 | } 446 | 447 | return this.state[index].id; 448 | } 449 | 450 | /** 451 | * Returns the index of `id` in this view, or -1 if it is not known. 452 | */ 453 | indexOf(id: ElementId): number { 454 | return this.state.findIndex((elt) => equalsId(elt.id, id)); 455 | } 456 | 457 | /** 458 | * The length of this view. 459 | * 460 | * Equivalently, the number of known ids in `this.list`. 461 | */ 462 | get length(): number { 463 | return this.state.length; 464 | } 465 | 466 | // Iterators 467 | 468 | /** 469 | * Iterates over all ids in this view, i.e., all known ids in `this.list`. 470 | */ 471 | *[Symbol.iterator](): IterableIterator { 472 | for (const elt of this.state) { 473 | yield elt.id; 474 | } 475 | } 476 | 477 | /** 478 | * Iterates over all ids in this view, i.e., all known ids in `this.list`. 479 | */ 480 | values() { 481 | return this[Symbol.iterator](); 482 | } 483 | } 484 | 485 | function expandElements( 486 | startId: ElementId, 487 | isDeleted: boolean, 488 | count: number 489 | ): ListElement[] { 490 | if (!(Number.isSafeInteger(count) && count >= 0)) { 491 | throw new Error(`Invalid count: ${count}`); 492 | } 493 | 494 | const ans: ListElement[] = []; 495 | for (let i = 0; i < count; i++) { 496 | ans.push({ 497 | id: { bunchId: startId.bunchId, counter: startId.counter + i }, 498 | isDeleted, 499 | }); 500 | } 501 | return ans; 502 | } 503 | -------------------------------------------------------------------------------- /test/persistence.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ElementId, IdList } from "../src"; 3 | 4 | describe("IdList Persistence", () => { 5 | // Helper to create ElementIds 6 | const createId = (bunchId: string, counter: number): ElementId => ({ 7 | bunchId, 8 | counter, 9 | }); 10 | 11 | describe("insertAfter", () => { 12 | it("returns a new data structure without modifying the original", () => { 13 | // Create an initial list with some IDs 14 | const initialId = createId("bunch1", 0); 15 | const initialList = IdList.fromIds([initialId]); 16 | 17 | // Perform insertAfter 18 | const newId = createId("bunch2", 0); 19 | const newList = initialList.insertAfter(initialId, newId); 20 | 21 | // Assert the new list has the newly inserted ID 22 | expect(newList.has(newId)).to.be.true; 23 | 24 | // Assert the original list is unchanged 25 | expect(initialList.has(newId)).to.be.false; 26 | expect(initialList.length).to.equal(1); 27 | expect(newList.length).to.equal(2); 28 | 29 | // Verify iterating over the lists produces the expected elements 30 | expect([...initialList]).to.deep.equal([initialId]); 31 | expect([...newList]).to.deep.equal([initialId, newId]); 32 | }); 33 | 34 | it("handles bulk insertion without modifying the original", () => { 35 | // Create an initial list with some IDs 36 | const initialId = createId("bunch1", 0); 37 | const initialList = IdList.fromIds([initialId]); 38 | 39 | // Perform bulk insertAfter 40 | const newId = createId("bunch2", 0); 41 | const count = 3; 42 | const newList = initialList.insertAfter(initialId, newId, count); 43 | 44 | // Assert the new list has all the newly inserted IDs 45 | for (let i = 0; i < count; i++) { 46 | expect(newList.has(createId(newId.bunchId, newId.counter + i))).to.be 47 | .true; 48 | } 49 | 50 | // Assert the original list is unchanged 51 | expect(initialList.length).to.equal(1); 52 | expect(newList.length).to.equal(4); // 1 original + 3 new 53 | }); 54 | }); 55 | 56 | describe("insertBefore", () => { 57 | it("returns a new data structure without modifying the original", () => { 58 | // Create an initial list with some IDs 59 | const initialId = createId("bunch1", 0); 60 | const initialList = IdList.fromIds([initialId]); 61 | 62 | // Perform insertBefore 63 | const newId = createId("bunch2", 0); 64 | const newList = initialList.insertBefore(initialId, newId); 65 | 66 | // Assert the new list has the newly inserted ID 67 | expect(newList.has(newId)).to.be.true; 68 | 69 | // Assert the original list is unchanged 70 | expect(initialList.has(newId)).to.be.false; 71 | expect(initialList.length).to.equal(1); 72 | expect(newList.length).to.equal(2); 73 | 74 | // Verify iterating over the lists produces the expected elements 75 | expect([...initialList]).to.deep.equal([initialId]); 76 | expect([...newList]).to.deep.equal([newId, initialId]); 77 | }); 78 | 79 | it("handles bulk insertion without modifying the original", () => { 80 | // Create an initial list with some IDs 81 | const initialId = createId("bunch1", 0); 82 | const initialList = IdList.fromIds([initialId]); 83 | 84 | // Perform bulk insertBefore 85 | const newId = createId("bunch2", 0); 86 | const count = 3; 87 | const newList = initialList.insertBefore(initialId, newId, count); 88 | 89 | // Assert the new list has all the newly inserted IDs 90 | for (let i = 0; i < count; i++) { 91 | expect(newList.has(createId(newId.bunchId, newId.counter + i))).to.be 92 | .true; 93 | } 94 | 95 | // Assert the original list is unchanged 96 | expect(initialList.length).to.equal(1); 97 | expect(newList.length).to.equal(4); // 1 original + 3 new 98 | }); 99 | }); 100 | 101 | describe("uninsert", () => { 102 | it("returns a new data structure without modifying the original", () => { 103 | // Create an initial list with multiple IDs 104 | const id1 = createId("bunch1", 0); 105 | const id2 = createId("bunch1", 1); 106 | const initialList = IdList.fromIds([id1, id2]); 107 | 108 | // Uninsert one element 109 | const newList = initialList.uninsert(id1); 110 | 111 | // Assert the new list has completely removed the item (not just marked deleted) 112 | expect(newList.has(id1)).to.be.false; 113 | expect(newList.isKnown(id1)).to.be.false; // Not known anymore (unlike delete) 114 | 115 | // Assert the original list is unchanged 116 | expect(initialList.has(id1)).to.be.true; 117 | expect(initialList.isKnown(id1)).to.be.true; 118 | expect(initialList.length).to.equal(2); 119 | expect(newList.length).to.equal(1); 120 | 121 | // Verify iterating over the lists produces the expected elements 122 | expect([...initialList]).to.deep.equal([id1, id2]); 123 | expect([...newList]).to.deep.equal([id2]); 124 | }); 125 | 126 | it("handles bulk uninsert without modifying the original", () => { 127 | // Create an initial list with sequential IDs 128 | const bunchId = "bunch1"; 129 | const ids = Array.from({ length: 5 }, (_, i) => createId(bunchId, i)); 130 | const initialList = IdList.fromIds(ids); 131 | 132 | // Bulk uninsert the middle 3 elements 133 | const startId = createId(bunchId, 1); // Start at the second element 134 | const count = 3; 135 | const newList = initialList.uninsert(startId, count); 136 | 137 | // Assert the new list has removed the specified IDs 138 | expect(newList.length).to.equal(2); // 5 - 3 = 2 remaining 139 | 140 | // Check specific IDs 141 | expect(newList.isKnown(createId(bunchId, 0))).to.be.true; // First ID remains 142 | expect(newList.isKnown(createId(bunchId, 1))).to.be.false; // Removed 143 | expect(newList.isKnown(createId(bunchId, 2))).to.be.false; // Removed 144 | expect(newList.isKnown(createId(bunchId, 3))).to.be.false; // Removed 145 | expect(newList.isKnown(createId(bunchId, 4))).to.be.true; // Last ID remains 146 | 147 | // Assert the original list is unchanged 148 | expect(initialList.length).to.equal(5); 149 | for (const id of ids) { 150 | expect(initialList.has(id)).to.be.true; 151 | } 152 | }); 153 | 154 | it("can uninsert multiple items without modifying originals", () => { 155 | // Create an initial list with multiple IDs 156 | const id1 = createId("bunch1", 0); 157 | const id2 = createId("bunch2", 0); 158 | const id3 = createId("bunch3", 0); 159 | const initialList = IdList.fromIds([id1, id2, id3]); 160 | 161 | // Uninsert in sequence to test persistence across multiple operations 162 | const list1 = initialList.uninsert(id1); 163 | const list2 = list1.uninsert(id2); 164 | 165 | // Each list should have the correct state 166 | expect(initialList.length).to.equal(3); 167 | expect(list1.length).to.equal(2); 168 | expect(list2.length).to.equal(1); 169 | 170 | expect([...initialList]).to.deep.equal([id1, id2, id3]); 171 | expect([...list1]).to.deep.equal([id2, id3]); 172 | expect([...list2]).to.deep.equal([id3]); 173 | 174 | // Verify knowledge state 175 | expect(initialList.isKnown(id1)).to.be.true; 176 | expect(list1.isKnown(id1)).to.be.false; 177 | expect(list2.isKnown(id1)).to.be.false; 178 | expect(list2.isKnown(id2)).to.be.false; 179 | }); 180 | 181 | it("verifies uninsert is inverse of insert by comparing snapshots", () => { 182 | // Start with an empty list 183 | const emptyList = IdList.new(); 184 | 185 | // Create some IDs 186 | const id1 = createId("bunch1", 0); 187 | const id2 = createId("bunch2", 0); 188 | 189 | // Insert id1 190 | const list1 = emptyList.insertAfter(null, id1); 191 | 192 | // Insert id2 after id1 193 | const list2 = list1.insertAfter(id1, id2); 194 | 195 | // Uninsert id2 - should revert to list1 state 196 | const list3 = list2.uninsert(id2); 197 | 198 | // Verify list3 is equivalent to list1 (but not the same object) 199 | expect(list3).not.to.equal(list1); // Different objects 200 | expect(list3.length).to.equal(list1.length); 201 | expect([...list3]).to.deep.equal([...list1]); 202 | 203 | // Original lists are unchanged 204 | expect(list1.length).to.equal(1); 205 | expect(list2.length).to.equal(2); 206 | }); 207 | 208 | it("maintains insertion order when uninsert removes elements", () => { 209 | // Create a list with multiple sequential bunch IDs 210 | const ids = [ 211 | createId("bunch1", 0), 212 | createId("bunch2", 0), 213 | createId("bunch3", 0), 214 | createId("bunch4", 0), 215 | createId("bunch5", 0), 216 | ]; 217 | 218 | const initialList = IdList.fromIds(ids); 219 | 220 | // Uninsert elements 1 and 3 (zero-indexed) 221 | const newList = initialList.uninsert(ids[1]).uninsert(ids[3]); 222 | 223 | // Verify correct elements were removed and order is maintained 224 | expect(newList.length).to.equal(3); 225 | expect([...newList]).to.deep.equal([ids[0], ids[2], ids[4]]); 226 | 227 | // Original list is unchanged 228 | expect(initialList.length).to.equal(5); 229 | expect([...initialList]).to.deep.equal(ids); 230 | }); 231 | 232 | it("handles uninsert in branching operations correctly", () => { 233 | // Start with a common ancestor 234 | const id1 = createId("bunch1", 0); 235 | const id2 = createId("bunch2", 0); 236 | const ancestor = IdList.fromIds([id1, id2]); 237 | 238 | // Branch A: Uninsert id1 239 | const branchA = ancestor.uninsert(id1); 240 | 241 | // Branch B: Uninsert id2 242 | const branchB = ancestor.uninsert(id2); 243 | 244 | // Branch C: Uninsert both ids 245 | const branchC = ancestor.uninsert(id1).uninsert(id2); 246 | 247 | // Verify each branch has its correct state 248 | expect(ancestor.length).to.equal(2); 249 | expect([...ancestor]).to.deep.equal([id1, id2]); 250 | 251 | expect(branchA.length).to.equal(1); 252 | expect([...branchA]).to.deep.equal([id2]); 253 | 254 | expect(branchB.length).to.equal(1); 255 | expect([...branchB]).to.deep.equal([id1]); 256 | 257 | expect(branchC.length).to.equal(0); 258 | expect([...branchC]).to.deep.equal([]); 259 | 260 | // Add a new element to branch C 261 | const id3 = createId("bunch3", 0); 262 | const branchC2 = branchC.insertAfter(null, id3); 263 | 264 | // Verify all other branches remain unchanged 265 | expect(ancestor.length).to.equal(2); 266 | expect(branchA.length).to.equal(1); 267 | expect(branchB.length).to.equal(1); 268 | expect(branchC.length).to.equal(0); 269 | expect(branchC2.length).to.equal(1); 270 | expect([...branchC2]).to.deep.equal([id3]); 271 | }); 272 | }); 273 | 274 | describe("delete", () => { 275 | it("returns a new data structure without modifying the original", () => { 276 | // Create an initial list with multiple IDs 277 | const id1 = createId("bunch1", 0); 278 | const id2 = createId("bunch1", 1); 279 | const initialList = IdList.fromIds([id1, id2]); 280 | 281 | // Delete one element 282 | const newList = initialList.delete(id1); 283 | 284 | // Assert the new list has the item marked as deleted 285 | expect(newList.has(id1)).to.be.false; 286 | expect(newList.isKnown(id1)).to.be.true; // Still known, just marked deleted 287 | 288 | // Assert the original list is unchanged 289 | expect(initialList.has(id1)).to.be.true; 290 | expect(initialList.length).to.equal(2); 291 | expect(newList.length).to.equal(1); 292 | 293 | // Verify iterating over the lists produces the expected elements 294 | expect([...initialList]).to.deep.equal([id1, id2]); 295 | expect([...newList]).to.deep.equal([id2]); 296 | }); 297 | 298 | it("can delete multiple items without modifying originals", () => { 299 | // Create an initial list with multiple IDs 300 | const id1 = createId("bunch1", 0); 301 | const id2 = createId("bunch1", 1); 302 | const id3 = createId("bunch1", 2); 303 | const initialList = IdList.fromIds([id1, id2, id3]); 304 | 305 | // Delete in sequence to test persistence across multiple operations 306 | const list1 = initialList.delete(id1); 307 | const list2 = list1.delete(id2); 308 | 309 | // Each list should have the correct state 310 | expect(initialList.length).to.equal(3); 311 | expect(list1.length).to.equal(2); 312 | expect(list2.length).to.equal(1); 313 | 314 | expect([...initialList]).to.deep.equal([id1, id2, id3]); 315 | expect([...list1]).to.deep.equal([id2, id3]); 316 | expect([...list2]).to.deep.equal([id3]); 317 | }); 318 | 319 | it("creates a proper persistent data structure with memory sharing", () => { 320 | // This test verifies the persistent aspect by checking that the same deletion 321 | // on two different branches results in equivalent but separate objects 322 | const id1 = createId("bunch1", 0); 323 | const id2 = createId("bunch1", 1); 324 | const initialList = IdList.fromIds([id1, id2]); 325 | 326 | // Branch 1: Delete id1 327 | const branch1 = initialList.delete(id1); 328 | 329 | // Branch 2: Delete id1 (same operation as branch1) 330 | const branch2 = initialList.delete(id1); 331 | 332 | // The two branches should be equivalent but not the same object 333 | expect(branch1).not.to.equal(branch2); // Different objects 334 | expect(branch1.length).to.equal(branch2.length); 335 | expect([...branch1]).to.deep.equal([...branch2]); 336 | 337 | // The original list is still untouched 338 | expect(initialList.has(id1)).to.be.true; 339 | }); 340 | }); 341 | 342 | describe("undelete", () => { 343 | it("returns a new data structure without modifying the original", () => { 344 | // Create a list with a deleted item 345 | const id1 = createId("bunch1", 0); 346 | const id2 = createId("bunch1", 1); 347 | let list = IdList.fromIds([id1, id2]); 348 | list = list.delete(id1); // Now id1 is deleted 349 | 350 | // Undelete the item 351 | const restoredList = list.undelete(id1); 352 | 353 | // Assert the new list has restored the item 354 | expect(restoredList.has(id1)).to.be.true; 355 | 356 | // Assert the original list is unchanged 357 | expect(list.has(id1)).to.be.false; 358 | expect(list.isKnown(id1)).to.be.true; 359 | expect(list.length).to.equal(1); 360 | expect(restoredList.length).to.equal(2); 361 | 362 | // Verify iterating over the lists produces the expected elements 363 | expect([...list]).to.deep.equal([id2]); 364 | expect([...restoredList]).to.deep.equal([id1, id2]); 365 | }); 366 | }); 367 | 368 | describe("complex operations", () => { 369 | it("maintains independence through a series of transformations", () => { 370 | // Start with an empty list 371 | const emptyList = IdList.new(); 372 | 373 | // Insert at the beginning 374 | const id1 = createId("bunch1", 0); 375 | const list1 = emptyList.insertAfter(null, id1); 376 | 377 | // Insert after id1 378 | const id2 = createId("bunch2", 0); 379 | const list2 = list1.insertAfter(id1, id2); 380 | 381 | // Insert before id2 382 | const id3 = createId("bunch3", 0); 383 | const list3 = list2.insertBefore(id2, id3); 384 | 385 | // Delete id1 386 | const list4 = list3.delete(id1); 387 | 388 | // Undelete id1 389 | const list5 = list4.undelete(id1); 390 | 391 | // Verify each list has its correct state 392 | expect(emptyList.length).to.equal(0); 393 | expect([...emptyList]).to.deep.equal([]); 394 | 395 | expect(list1.length).to.equal(1); 396 | expect([...list1]).to.deep.equal([id1]); 397 | 398 | expect(list2.length).to.equal(2); 399 | expect([...list2]).to.deep.equal([id1, id2]); 400 | 401 | expect(list3.length).to.equal(3); 402 | expect([...list3]).to.deep.equal([id1, id3, id2]); 403 | 404 | expect(list4.length).to.equal(2); 405 | expect([...list4]).to.deep.equal([id3, id2]); 406 | 407 | expect(list5.length).to.equal(3); 408 | expect([...list5]).to.deep.equal([id1, id3, id2]); 409 | }); 410 | 411 | it("allows multiple independent branches of operations", () => { 412 | // Start with a common ancestor 413 | const id1 = createId("bunch1", 0); 414 | const ancestor = IdList.fromIds([id1]); 415 | 416 | // Branch A: Insert new id after id1 417 | const idA = createId("branchA", 0); 418 | const branchA = ancestor.insertAfter(id1, idA); 419 | 420 | // Branch B: Insert new id after id1 421 | const idB = createId("branchB", 0); 422 | const branchB = ancestor.insertAfter(id1, idB); 423 | 424 | // Branch C: Delete id1 425 | const branchC = ancestor.delete(id1); 426 | 427 | // Verify each branch has its correct state 428 | expect(ancestor.length).to.equal(1); 429 | expect([...ancestor]).to.deep.equal([id1]); 430 | 431 | expect(branchA.length).to.equal(2); 432 | expect([...branchA]).to.deep.equal([id1, idA]); 433 | 434 | expect(branchB.length).to.equal(2); 435 | expect([...branchB]).to.deep.equal([id1, idB]); 436 | 437 | expect(branchC.length).to.equal(0); 438 | expect([...branchC]).to.deep.equal([]); 439 | 440 | // Combining operations from different branches 441 | // A -> C: Insert in A, then delete id1 442 | const branchAC = branchA.delete(id1); 443 | expect(branchAC.length).to.equal(1); 444 | expect([...branchAC]).to.deep.equal([idA]); 445 | 446 | // B -> A: Insert in B, then insert id from A 447 | const branchBA = branchB.insertAfter(id1, idA); 448 | expect(branchBA.length).to.equal(3); 449 | expect([...branchBA]).to.deep.equal([id1, idA, idB]); 450 | 451 | // Verify original branches are still intact 452 | expect(branchA.length).to.equal(2); 453 | expect([...branchA]).to.deep.equal([id1, idA]); 454 | 455 | expect(branchB.length).to.equal(2); 456 | expect([...branchB]).to.deep.equal([id1, idB]); 457 | }); 458 | }); 459 | 460 | describe("save and load", () => { 461 | it("preserves persistent semantics when saving and loading", () => { 462 | // Create a list with some operations 463 | const id1 = createId("bunch1", 0); 464 | const id2 = createId("bunch2", 0); 465 | const originalList = IdList.new() 466 | .insertAfter(null, id1) 467 | .insertAfter(id1, id2); 468 | 469 | // Save the list 470 | const savedState = originalList.save(); 471 | 472 | // Load the list 473 | const loadedList = IdList.load(savedState); 474 | 475 | // Verify the loaded list matches the original 476 | expect(loadedList.length).to.equal(originalList.length); 477 | expect([...loadedList]).to.deep.equal([...originalList]); 478 | 479 | // Make changes to the original list 480 | const id3 = createId("bunch3", 0); 481 | const modifiedOriginal = originalList.insertAfter(id2, id3); 482 | 483 | // Make changes to the loaded list 484 | const id4 = createId("bunch4", 0); 485 | const modifiedLoaded = loadedList.insertAfter(id2, id4); 486 | 487 | // Verify each list has maintained its own state 488 | expect(originalList.length).to.equal(2); 489 | expect([...originalList]).to.deep.equal([id1, id2]); 490 | 491 | expect(modifiedOriginal.length).to.equal(3); 492 | expect([...modifiedOriginal]).to.deep.equal([id1, id2, id3]); 493 | 494 | expect(loadedList.length).to.equal(2); 495 | expect([...loadedList]).to.deep.equal([id1, id2]); 496 | 497 | expect(modifiedLoaded.length).to.equal(3); 498 | expect([...modifiedLoaded]).to.deep.equal([id1, id2, id4]); 499 | }); 500 | }); 501 | }); 502 | -------------------------------------------------------------------------------- /test/serialization_and_edge_cases.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ElementId, IdList, SavedIdList } from "../src"; 3 | import { InnerNode, InnerNodeInner, LeafNode, M } from "../src/id_list"; 4 | 5 | describe("IdList Serialization and Edge Cases", () => { 6 | // Helper to create ElementIds 7 | const createId = (bunchId: string, counter: number): ElementId => ({ 8 | bunchId, 9 | counter, 10 | }); 11 | 12 | function checkIterators(loaded: IdList, list: IdList) { 13 | expect([...loaded.values()]).to.deep.equal([...list.values()]); 14 | expect([...loaded.knownIds.values()]).to.deep.equal([ 15 | ...list.knownIds.values(), 16 | ]); 17 | expect([...loaded.valuesWithIsDeleted()]).to.deep.equal([ 18 | ...list.valuesWithIsDeleted(), 19 | ]); 20 | } 21 | 22 | describe("saveNode function", () => { 23 | it("should properly serialize deleted elements at the end of leaves", () => { 24 | let list = IdList.new(); 25 | 26 | // Insert sequential IDs 27 | list = list.insertAfter(null, createId("bunch", 0), 10); 28 | 29 | // Delete the last few elements 30 | list = list.delete(createId("bunch", 7)); 31 | list = list.delete(createId("bunch", 8)); 32 | list = list.delete(createId("bunch", 9)); 33 | 34 | // Save the state 35 | const saved = list.save(); 36 | 37 | // Check that the saved state includes the deleted elements 38 | let hasDeletedEntries = false; 39 | for (const entry of saved) { 40 | if (entry.bunchId === "bunch" && entry.isDeleted) { 41 | hasDeletedEntries = true; 42 | expect(entry.startCounter).to.equal(7); 43 | expect(entry.count).to.equal(3); 44 | } 45 | } 46 | 47 | expect(hasDeletedEntries).to.be.true; 48 | 49 | // Load and verify 50 | const loaded = IdList.load(saved); 51 | 52 | // Verify deleted elements are still known 53 | for (let i = 7; i < 10; i++) { 54 | expect(loaded.isKnown(createId("bunch", i))).to.be.true; 55 | expect(loaded.has(createId("bunch", i))).to.be.false; 56 | } 57 | 58 | checkIterators(loaded, list); 59 | }); 60 | 61 | it("should handle complex patterns of present and deleted elements", () => { 62 | let list = IdList.new(); 63 | 64 | // Insert sequential IDs 65 | list = list.insertAfter(null, createId("bunch", 0), 20); 66 | 67 | // Create a complex pattern of deletions (alternating present/deleted) 68 | for (let i = 1; i < 20; i += 2) { 69 | list = list.delete(createId("bunch", i)); 70 | } 71 | 72 | // Save the state 73 | const saved = list.save(); 74 | 75 | // There should be multiple entries in the saved state 76 | // Each entry represents either all present or all deleted elements 77 | expect(saved.length).to.be.greaterThan(1); 78 | 79 | // The entries should alternate between present and deleted 80 | for (let i = 0; i < saved.length - 1; i++) { 81 | expect(saved[i].isDeleted).to.not.equal(saved[i + 1].isDeleted); 82 | } 83 | 84 | // Load and verify 85 | const loaded = IdList.load(saved); 86 | 87 | // Check all elements 88 | for (let i = 0; i < 20; i++) { 89 | expect(loaded.isKnown(createId("bunch", i))).to.be.true; 90 | 91 | if (i % 2 === 0) { 92 | expect(loaded.has(createId("bunch", i))).to.be.true; 93 | } else { 94 | expect(loaded.has(createId("bunch", i))).to.be.false; 95 | } 96 | } 97 | 98 | checkIterators(loaded, list); 99 | }); 100 | 101 | it("should handle interleaving bunchIds correctly", () => { 102 | let list = IdList.new(); 103 | 104 | // Create an interleaved pattern of bunchIds 105 | for (let i = 0; i < 10; i++) { 106 | if (i === 0) { 107 | list = list.insertAfter(null, createId("a", 0)); 108 | } else { 109 | const prevId = list.at(i - 1); 110 | if (i % 2 === 0) { 111 | list = list.insertAfter(prevId, createId("a", i / 2)); 112 | } else { 113 | list = list.insertAfter(prevId, createId("b", Math.floor(i / 2))); 114 | } 115 | } 116 | } 117 | 118 | // Save and load 119 | const saved = list.save(); 120 | const loaded = IdList.load(saved); 121 | 122 | // Verify the pattern is preserved 123 | for (let i = 0; i < 10; i++) { 124 | if (i % 2 === 0) { 125 | expect(loaded.at(i)).to.deep.equal(createId("a", i / 2)); 126 | } else { 127 | expect(loaded.at(i)).to.deep.equal(createId("b", Math.floor(i / 2))); 128 | } 129 | } 130 | 131 | checkIterators(loaded, list); 132 | }); 133 | 134 | it("should handle non-merged leaves correctly", () => { 135 | // See typedoc for pushSaveItem. 136 | 137 | let list = IdList.new(); 138 | 139 | // Create un-merged leaves. 140 | list = list.insertAfter(null, createId("a", 0)); 141 | list = list.insertAfter(createId("a", 0), createId("a", 2)); 142 | list = list.insertAfter(createId("a", 0), createId("a", 1)); 143 | 144 | // Verify that the leaves are not fully merged. 145 | expect(list["root"].children.length).to.be.greaterThan(1); 146 | 147 | // Verify that the resulting save item is merged. 148 | const saved = list.save(); 149 | expect(saved.length).to.equal(1); 150 | 151 | // Verify the loading "fixes" the un-merged leaves. 152 | const loaded = IdList.load(saved); 153 | expect(loaded["root"].children.length).to.equal(1); 154 | 155 | checkIterators(loaded, list); 156 | }); 157 | }); 158 | 159 | describe("load function", () => { 160 | it("should correctly handle empty SavedIdList", () => { 161 | const saved: SavedIdList = []; 162 | const list = IdList.load(saved); 163 | 164 | expect(list.length).to.equal(0); 165 | }); 166 | 167 | it("should skip entries with count = 0", () => { 168 | const saved = [ 169 | { 170 | bunchId: "bunch", 171 | startCounter: 0, 172 | count: 0, 173 | isDeleted: false, 174 | }, 175 | { 176 | bunchId: "bunch", 177 | startCounter: 1, 178 | count: 5, 179 | isDeleted: false, 180 | }, 181 | ]; 182 | 183 | const list = IdList.load(saved); 184 | 185 | // Should only have the second entry 186 | expect(list.length).to.equal(5); 187 | expect(list.has(createId("bunch", 0))).to.be.false; 188 | expect(list.has(createId("bunch", 1))).to.be.true; 189 | }); 190 | 191 | it("should throw on invalid count or startCounter", () => { 192 | // Negative count 193 | const saved1 = [ 194 | { 195 | bunchId: "bunch", 196 | startCounter: 0, 197 | count: -1, 198 | isDeleted: false, 199 | }, 200 | ]; 201 | expect(() => IdList.load(saved1)).to.throw(); 202 | 203 | // Non-integer count 204 | const saved2 = [ 205 | { 206 | bunchId: "bunch", 207 | startCounter: 0, 208 | count: 1.5, 209 | isDeleted: false, 210 | }, 211 | ]; 212 | expect(() => IdList.load(saved2)).to.throw(); 213 | 214 | // Negative startCounter 215 | const saved3 = [ 216 | { 217 | bunchId: "bunch", 218 | startCounter: -1, 219 | count: 5, 220 | isDeleted: false, 221 | }, 222 | ]; 223 | expect(() => IdList.load(saved3)).to.throw(); 224 | 225 | // Non-integer startCounter 226 | const saved4 = [ 227 | { 228 | bunchId: "bunch", 229 | startCounter: 0.5, 230 | count: 5, 231 | isDeleted: false, 232 | }, 233 | ]; 234 | expect(() => IdList.load(saved4)).to.throw(); 235 | }); 236 | 237 | it("should merge adjacent entries with the same bunchId", () => { 238 | const saved = [ 239 | { 240 | bunchId: "bunch", 241 | startCounter: 0, 242 | count: 5, 243 | isDeleted: false, 244 | }, 245 | { 246 | bunchId: "bunch", 247 | startCounter: 5, // Continues right after the previous entry 248 | count: 5, 249 | isDeleted: false, 250 | }, 251 | ]; 252 | 253 | const list = IdList.load(saved); 254 | 255 | // Should be merged into a single saved item 256 | expect(list.length).to.equal(10); 257 | 258 | // Save again to check if it's compressed 259 | const resaved = list.save(); 260 | expect(resaved.length).to.equal(1); 261 | expect(resaved[0].count).to.equal(10); 262 | }); 263 | 264 | it("should merge adjacent leaves with the same bunchId and opposite presence", () => { 265 | const saved = [ 266 | { 267 | bunchId: "bunch", 268 | startCounter: 0, 269 | count: 5, 270 | isDeleted: false, 271 | }, 272 | { 273 | bunchId: "bunch", 274 | startCounter: 5, // Continues right after the previous entry 275 | count: 5, 276 | isDeleted: true, 277 | }, 278 | ]; 279 | 280 | const list = IdList.load(saved); 281 | 282 | // Should be merged into a single leaf 283 | expect(list["root"].children.length).to.equal(1); 284 | 285 | // Save again to check if it's split into two items 286 | const resaved = list.save(); 287 | expect(resaved.length).to.equal(2); 288 | expect(resaved[0].count).to.equal(5); 289 | expect(resaved[0].isDeleted).to.be.false; 290 | expect(resaved[1].isDeleted).to.be.true; 291 | }); 292 | 293 | it("should not merge entries with different bunchIds or non-sequential counters", () => { 294 | const saved = [ 295 | { 296 | bunchId: "bunch1", 297 | startCounter: 0, 298 | count: 5, 299 | isDeleted: false, 300 | }, 301 | { 302 | bunchId: "bunch1", 303 | startCounter: 10, // Gap in counter sequence 304 | count: 5, 305 | isDeleted: false, 306 | }, 307 | { 308 | bunchId: "bunch2", // Different bunchId 309 | startCounter: 0, 310 | count: 5, 311 | isDeleted: false, 312 | }, 313 | ]; 314 | 315 | const list = IdList.load(saved); 316 | 317 | expect(list.length).to.equal(15); 318 | 319 | // Save again to check compression 320 | const resaved = list.save(); 321 | expect(resaved.length).to.equal(3); 322 | }); 323 | }); 324 | 325 | describe("building balanced trees", () => { 326 | it("should create appropriately balanced trees based on input size", () => { 327 | function testTreeBalance(numElements: number) { 328 | const saved: SavedIdList = []; 329 | 330 | // Create SavedIdList with numElements entries 331 | for (let i = 0; i < numElements; i++) { 332 | saved.push({ 333 | bunchId: `id${i}`, 334 | startCounter: 0, 335 | count: 1, 336 | isDeleted: false, 337 | }); 338 | } 339 | 340 | const list = IdList.load(saved); 341 | 342 | const root = list["root"]; 343 | 344 | // Helper to calculate tree height 345 | function getTreeHeight(node: InnerNode | LeafNode): number { 346 | if ("children" in node && "children" in node.children[0]) { 347 | // Inner node with inner node children 348 | return 1 + getTreeHeight(node.children[0]); 349 | } else if ("children" in node) { 350 | // Inner node with leaf children 351 | return 1; 352 | } else { 353 | // Leaf node 354 | return 0; 355 | } 356 | } 357 | 358 | const height = getTreeHeight(root); 359 | 360 | // Loading produces a balanced M-ary tree. Height should be exaclty ceil(log_M(n)). 361 | // Note: That is not true for a tree produced by insertions, since nodes may have 362 | // only M/2 children after splitting. 363 | const expectedHeight = Math.ceil(Math.log(numElements) / Math.log(M)); 364 | expect(height).to.equal(expectedHeight); 365 | 366 | // Check if the tree is balanced 367 | function checkNodeBalance(node: InnerNode) { 368 | if (node.children && "children" in node.children[0]) { 369 | // All children should have the same height 370 | const childHeights = node.children.map(getTreeHeight); 371 | const firstHeight = childHeights[0]; 372 | 373 | for (const h of childHeights) { 374 | expect(h).to.equal(firstHeight); 375 | } 376 | 377 | // Recurse into children 378 | for (const child of (node as InnerNodeInner).children) { 379 | checkNodeBalance(child); 380 | } 381 | } 382 | } 383 | 384 | checkNodeBalance(root); 385 | 386 | // Verify all elements are accessible 387 | for (let i = 0; i < numElements; i++) { 388 | expect(list.has(createId(`id${i}`, 0))).to.be.true; 389 | } 390 | } 391 | 392 | // Test various tree sizes 393 | testTreeBalance(5); // Small tree 394 | testTreeBalance(10); // Just over M 395 | testTreeBalance(70); // Medium tree (multiple levels) 396 | testTreeBalance(100); // Larger tree 397 | testTreeBalance(1000); // Large tree 398 | }); 399 | }); 400 | 401 | describe("splitPresent function edge cases", () => { 402 | it("should handle splitting with sparse present values", () => { 403 | let list = IdList.new(); 404 | 405 | // Insert sequential IDs 406 | list = list.insertAfter(null, createId("bunch", 0), 10); 407 | 408 | // Delete some elements to create gaps 409 | for (let i = 0; i < 10; i += 2) { 410 | list = list.delete(createId("bunch", i)); 411 | } 412 | 413 | // Insert in the middle to force a split 414 | list = list.insertAfter(createId("bunch", 5), createId("split", 0)); 415 | 416 | // Verify the structure after the split 417 | for (let i = 0; i < 10; i++) { 418 | if (i % 2 === 0) { 419 | expect(list.has(createId("bunch", i))).to.be.false; 420 | } else { 421 | expect(list.has(createId("bunch", i))).to.be.true; 422 | } 423 | } 424 | 425 | expect(list.has(createId("split", 0))).to.be.true; 426 | 427 | // The order should be preserved 428 | const presentIds = [...list]; 429 | 430 | // Should have the odd-indexed "bunch" IDs and the "split" ID 431 | expect(presentIds.length).to.equal(6); 432 | 433 | // Check positions after the split 434 | expect(list.indexOf(createId("bunch", 1))).to.equal(0); 435 | expect(list.indexOf(createId("bunch", 3))).to.equal(1); 436 | expect(list.indexOf(createId("bunch", 5))).to.equal(2); 437 | expect(list.indexOf(createId("split", 0))).to.equal(3); 438 | expect(list.indexOf(createId("bunch", 7))).to.equal(4); 439 | expect(list.indexOf(createId("bunch", 9))).to.equal(5); 440 | }); 441 | }); 442 | 443 | describe("iterateNode functions", () => { 444 | it("should correctly iterate through nodes with mixed present/deleted values", () => { 445 | let list = IdList.new(); 446 | 447 | // Insert sequential IDs 448 | list = list.insertAfter(null, createId("bunch", 0), 10); 449 | 450 | // Delete some elements 451 | list = list.delete(createId("bunch", 2)); 452 | list = list.delete(createId("bunch", 5)); 453 | list = list.delete(createId("bunch", 8)); 454 | 455 | // Test standard iteration (present values only) 456 | const presentIds = [...list]; 457 | expect(presentIds.length).to.equal(7); 458 | 459 | // Test valuesWithIsDeleted (all known values) 460 | const allValues = [...list.valuesWithIsDeleted()]; 461 | expect(allValues.length).to.equal(10); 462 | 463 | // Check the deleted status for each value 464 | for (let i = 0; i < 10; i++) { 465 | const item = allValues[i]; 466 | expect(item.id.bunchId).to.equal("bunch"); 467 | expect(item.id.counter).to.equal(i); 468 | 469 | if (i === 2 || i === 5 || i === 8) { 470 | expect(item.isDeleted).to.be.true; 471 | } else { 472 | expect(item.isDeleted).to.be.false; 473 | } 474 | } 475 | 476 | // Test knownIds iterator 477 | const knownIds = [...list.knownIds]; 478 | expect(knownIds.length).to.equal(10); 479 | for (let i = 0; i < 10; i++) { 480 | expect(knownIds[i].bunchId).to.equal("bunch"); 481 | expect(knownIds[i].counter).to.equal(i); 482 | } 483 | }); 484 | 485 | it("should handle iteration after complex operations and tree restructuring", () => { 486 | let list = IdList.new(); 487 | 488 | // Insert elements that will force tree restructuring 489 | for (let i = 0; i < 50; i++) { 490 | if (i === 0) { 491 | list = list.insertAfter(null, createId(`id${i}`, 0)); 492 | } else { 493 | list = list.insertAfter( 494 | createId(`id${i - 1}`, 0), 495 | createId(`id${i}`, 0) 496 | ); 497 | } 498 | } 499 | 500 | // Delete some elements in a pattern 501 | for (let i = 0; i < 50; i += 5) { 502 | list = list.delete(createId(`id${i}`, 0)); 503 | } 504 | 505 | // Insert new elements in between 506 | let lastIndex = 0; 507 | for (let i = 0; i < 10; i++) { 508 | const insertAfter = `id${lastIndex + 2}`; 509 | list = list.insertAfter( 510 | createId(insertAfter, 0), 511 | createId(`new${i}`, 0) 512 | ); 513 | lastIndex += 3; 514 | } 515 | 516 | // Check iteration after all operations 517 | const presentIds = [...list]; 518 | 519 | // Expected count: 50 original - 10 deleted + 10 new = 50 520 | expect(presentIds.length).to.equal(50); 521 | 522 | // Check for deleted elements using valuesWithIsDeleted 523 | const allValues = [...list.valuesWithIsDeleted()]; 524 | expect(allValues.length).to.equal(60); // 50 original + 10 new 525 | 526 | // Verify all new elements are present 527 | for (let i = 0; i < 10; i++) { 528 | expect(list.has(createId(`new${i}`, 0))).to.be.true; 529 | } 530 | 531 | // Verify deletion pattern 532 | for (let i = 0; i < 50; i++) { 533 | if (i % 5 === 0) { 534 | expect(list.has(createId(`id${i}`, 0))).to.be.false; 535 | expect(list.isKnown(createId(`id${i}`, 0))).to.be.true; 536 | } else { 537 | expect(list.has(createId(`id${i}`, 0))).to.be.true; 538 | } 539 | } 540 | }); 541 | }); 542 | 543 | describe("compression during save", () => { 544 | it("should optimally compress sequential runs during save", () => { 545 | let list = IdList.new(); 546 | 547 | // Insert sequential IDs 548 | list = list.insertAfter(null, createId("bunch", 0), 100); 549 | 550 | // Save and check compression 551 | const saved = list.save(); 552 | 553 | // Should be a single entry 554 | expect(saved.length).to.equal(1); 555 | expect(saved[0].bunchId).to.equal("bunch"); 556 | expect(saved[0].startCounter).to.equal(0); 557 | expect(saved[0].count).to.equal(100); 558 | expect(saved[0].isDeleted).to.be.false; 559 | 560 | // Add more sequential elements 561 | list = list.insertAfter( 562 | createId("bunch", 99), 563 | createId("bunch", 100), 564 | 50 565 | ); 566 | 567 | // Save and check compression 568 | const saved2 = list.save(); 569 | 570 | // Should still be a single entry 571 | expect(saved2.length).to.equal(1); 572 | expect(saved2[0].bunchId).to.equal("bunch"); 573 | expect(saved2[0].startCounter).to.equal(0); 574 | expect(saved2[0].count).to.equal(150); 575 | expect(saved2[0].isDeleted).to.be.false; 576 | }); 577 | }); 578 | }); 579 | -------------------------------------------------------------------------------- /tsconfig.commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "build/commonjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.commonjs.json", 3 | "compilerOptions": { 4 | "rootDir": "." 5 | }, 6 | "include": ["src", "test", "benchmarks"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "build/esm", 5 | "target": "es2021", 6 | "module": "es2015", 7 | "allowJs": true, 8 | /* Needed with module: es2015 or else stuff breaks. */ 9 | "moduleResolution": "node", 10 | /* Enable strict type checking. */ 11 | "strict": true, 12 | /* Enable interop with dependencies using different module systems. */ 13 | "esModuleInterop": true, 14 | /* Emit .d.ts files. */ 15 | "declaration": true, 16 | /* Emit sourcemap files. */ 17 | "sourceMap": true 18 | /* Don't turn on importHelpers, so we can avoid tslib dependency. */ 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "excludePrivate": true, 3 | "logLevel": "Warn" 4 | } 5 | --------------------------------------------------------------------------------