├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .mocharc.jsonc ├── .nycrc ├── .prettierignore ├── LICENSE ├── README.md ├── benchmark_results.md ├── benchmarks ├── abs_list_direct.ts ├── crdt.ts ├── internal │ ├── list_crdt.ts │ ├── real_text_trace_edits.json │ ├── text_crdt.ts │ └── util.ts ├── list_custom_encoding.ts ├── list_direct.ts ├── main.ts ├── outline_direct.ts └── text_direct.ts ├── images ├── fugue_tree.png ├── positions.png └── tree.png ├── internals.md ├── package-lock.json ├── package.json ├── src ├── index.ts ├── internal │ ├── item_list.ts │ └── util.ts ├── lists │ ├── abs_list.ts │ ├── list.ts │ ├── outline.ts │ └── text.ts ├── order │ ├── abs_position.ts │ ├── bunch.ts │ ├── bunch_ids.ts │ ├── lexicographic_string.ts │ ├── order.ts │ └── position.ts └── unordered_collections │ ├── position_char_map.ts │ ├── position_map.ts │ └── position_set.ts ├── test ├── bunch_ids.test.ts ├── lists │ ├── fuzz.test.ts │ ├── manual.test.ts │ └── util.ts └── order │ ├── fuzz.test.ts │ ├── manual.test.ts │ └── util.ts ├── tsconfig.commonjs.json ├── tsconfig.dev.json ├── tsconfig.json └── typedoc.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | benchmarks 4 | docs 5 | *.js 6 | -------------------------------------------------------------------------------- /.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 | // Impose alphabetically ordered imports. 31 | "import/order": "error", 32 | // Allow implicit string casts in template literals. 33 | "@typescript-eslint/restrict-template-expressions": "off", 34 | // Allow ts-ignore with justification. 35 | "@typescript-eslint/ban-ts-comment": [ 36 | "error", 37 | { 38 | "ts-expect-error": "allow-with-description", 39 | }, 40 | ], 41 | "@typescript-eslint/no-unused-vars": [ 42 | "warn", 43 | { 44 | // Allow unused parameter names that start with _, 45 | // like TypeScript does. 46 | argsIgnorePattern: "^_", 47 | }, 48 | ], 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /.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"], 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 | real_text_trace_edits.json 6 | LICENSE 7 | benchmark_results.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2024 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 | -------------------------------------------------------------------------------- /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 | 11 | ## List Direct 12 | 13 | Use `List` and send updates directly over a reliable link (e.g. WebSocket). 14 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 15 | 16 | - Sender time (ms): 671 17 | - Avg update size (bytes): 86.8 18 | - Receiver time (ms): 384 19 | - Save time (ms): 8 20 | - Save size (bytes): 803120 21 | - Load time (ms): 17 22 | - Save time GZIP'd (ms): 55 23 | - Save size GZIP'd (bytes): 89013 24 | - Load time GZIP'd (ms): 37 25 | - Mem used estimate (MB): 2.2 26 | 27 | ## AbsList Direct 28 | 29 | Use `AbsList` and send updates directly over a reliable link (e.g. WebSocket). 30 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 31 | 32 | - Sender time (ms): 1576 33 | - Avg update size (bytes): 216.2 34 | - AbsPosition length stats: avg = 187.4, percentiles [25, 50, 75, 100] = 170,184,202,272 35 | - Receiver time (ms): 791 36 | - Save time (ms): 14 37 | - Save size (bytes): 867679 38 | - Load time (ms): 21 39 | - Save time GZIP'd (ms): 63 40 | - Save size GZIP'd (bytes): 87108 41 | - Load time GZIP'd (ms): 46 42 | - Mem used estimate (MB): 2.2 43 | 44 | ## List Direct w/ Custom Encoding 45 | 46 | Use `List` and send updates directly over a reliable link (e.g. WebSocket). 47 | Updates use a custom string encoding; saved states use JSON with optional GZIP. 48 | 49 | - Sender time (ms): 556 50 | - Avg update size (bytes): 31.2 51 | - Receiver time (ms): 357 52 | - Save time (ms): 9 53 | - Save size (bytes): 803120 54 | - Load time (ms): 11 55 | - Save time GZIP'd (ms): 47 56 | - Save size GZIP'd (bytes): 89021 57 | - Load time GZIP'd (ms): 36 58 | - Mem used estimate (MB): 2.2 59 | 60 | ## Text Direct 61 | 62 | Use `Text` and send updates directly over a reliable link (e.g. WebSocket). 63 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 64 | 65 | - Sender time (ms): 693 66 | - Avg update size (bytes): 86.8 67 | - Receiver time (ms): 444 68 | - Save time (ms): 5 69 | - Save size (bytes): 492935 70 | - Load time (ms): 8 71 | - Save time GZIP'd (ms): 35 72 | - Save size GZIP'd (bytes): 73709 73 | - Load time GZIP'd (ms): 24 74 | - Mem used estimate (MB): 1.4 75 | 76 | ## Outline Direct 77 | 78 | Use `Outline` and send updates directly over a reliable link (e.g. WebSocket). 79 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 80 | Neither updates nor saved states include values (chars). 81 | 82 | - Sender time (ms): 648 83 | - Avg update size (bytes): 78.4 84 | - Receiver time (ms): 365 85 | - Save time (ms): 6 86 | - Save size (bytes): 382419 87 | - Load time (ms): 7 88 | - Save time GZIP'd (ms): 24 89 | - Save size GZIP'd (bytes): 39364 90 | - Load time GZIP'd (ms): 13 91 | - Mem used estimate (MB): 1.1 92 | 93 | ## TextCrdt 94 | 95 | Use a hybrid op-based/state-based CRDT implemented on top of the library's data structures, copied from [@list-positions/crdts](https://github.com/mweidner037/list-positions-crdts). 96 | This variant uses a Text + PositionSet to store the state and Positions in messages, manually managing BunchMetas. 97 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 98 | 99 | - Sender time (ms): 722 100 | - Avg update size (bytes): 92.7 101 | - Receiver time (ms): 416 102 | - Save time (ms): 11 103 | - Save size (bytes): 598917 104 | - Load time (ms): 11 105 | - Save time GZIP'd (ms): 40 106 | - Save size GZIP'd (bytes): 86969 107 | - Load time GZIP'd (ms): 30 108 | - Mem used estimate (MB): 2.0 109 | 110 | ## ListCrdt 111 | 112 | Use a hybrid op-based/state-based CRDT implemented on top of the library's data structures, copied from [@list-positions/crdts](https://github.com/mweidner037/list-positions-crdts). 113 | This variant uses a List of characters + PositionSet to store the state and Positions in messages, manually managing BunchMetas. 114 | Updates and saved states use JSON encoding, with optional GZIP for saved states. 115 | 116 | - Sender time (ms): 762 117 | - Avg update size (bytes): 94.8 118 | - Receiver time (ms): 507 119 | - Save time (ms): 13 120 | - Save size (bytes): 909102 121 | - Load time (ms): 15 122 | - Save time GZIP'd (ms): 57 123 | - Save size GZIP'd (bytes): 102554 124 | - Load time GZIP'd (ms): 36 125 | - Mem used estimate (MB): 2.6 126 | -------------------------------------------------------------------------------- /benchmarks/abs_list_direct.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { AbsList, AbsListSavedState, AbsPosition } from "../src"; 3 | import realTextTraceEdits from "./internal/real_text_trace_edits.json"; 4 | import { 5 | avg, 6 | getMemUsed, 7 | percentiles, 8 | sleep, 9 | gunzipString, 10 | gzipString, 11 | } from "./internal/util"; 12 | 13 | const { edits, finalText } = realTextTraceEdits as unknown as { 14 | finalText: string; 15 | edits: Array<[number, number, string | undefined]>; 16 | }; 17 | 18 | type Update = 19 | | { 20 | type: "set"; 21 | pos: AbsPosition; 22 | value: string; 23 | // No meta because AbsPosition embeds all dependencies. 24 | } 25 | | { type: "delete"; pos: AbsPosition }; 26 | 27 | // No OrderSavedState because AbsListSavedState embeds all dependencies. 28 | type SavedState = AbsListSavedState; 29 | 30 | export async function absListDirect() { 31 | console.log("\n## AbsList Direct\n"); 32 | console.log( 33 | "Use `AbsList` and send updates directly over a reliable link (e.g. WebSocket)." 34 | ); 35 | console.log( 36 | "Updates and saved states use JSON encoding, with optional GZIP for saved states.\n" 37 | ); 38 | 39 | const updates: string[] = []; 40 | let startTime = process.hrtime.bigint(); 41 | const sender = new AbsList(); 42 | for (const edit of edits) { 43 | let updateObj: Update; 44 | if (edit[2] !== undefined) { 45 | const pos = sender.insertAt(edit[0], edit[2]); 46 | updateObj = { type: "set", pos, value: edit[2] }; 47 | } else { 48 | const pos = sender.positionAt(edit[0]); 49 | sender.delete(pos); 50 | updateObj = { type: "delete", pos }; 51 | } 52 | 53 | updates.push(JSON.stringify(updateObj)); 54 | } 55 | 56 | console.log( 57 | "- Sender time (ms):", 58 | Math.round( 59 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 60 | ) 61 | ); 62 | console.log( 63 | "- Avg update size (bytes):", 64 | avg(updates.map((message) => message.length)).toFixed(1) 65 | ); 66 | assert.strictEqual(sender.slice().join(""), finalText); 67 | 68 | // Out of curiosity, also report the distribution of AbsPosition JSON sizes. 69 | reportPositionSizes(); 70 | 71 | // Receive all updates. 72 | startTime = process.hrtime.bigint(); 73 | const receiver = new AbsList(); 74 | for (const update of updates) { 75 | const updateObj: Update = JSON.parse(update); 76 | if (updateObj.type === "set") { 77 | receiver.set(updateObj.pos, updateObj.value); 78 | // To simulate events, also compute the inserted index. 79 | void receiver.indexOfPosition(updateObj.pos); 80 | } else { 81 | // type "delete" 82 | if (receiver.has(updateObj.pos)) { 83 | // To simulate events, also compute the inserted index. 84 | void receiver.indexOfPosition(updateObj.pos); 85 | receiver.delete(updateObj.pos); // Also okay to call outside of the "has" guard. 86 | } 87 | } 88 | } 89 | 90 | console.log( 91 | "- Receiver time (ms):", 92 | Math.round( 93 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 94 | ) 95 | ); 96 | assert.strictEqual(receiver.slice().join(""), finalText); 97 | 98 | const savedState = (await saveLoad(receiver, false)) as string; 99 | await saveLoad(receiver, true); 100 | 101 | await memory(savedState); 102 | } 103 | 104 | function reportPositionSizes(): void { 105 | const absPosLengths: number[] = []; 106 | const sender = new AbsList(); 107 | for (const edit of edits) { 108 | let updateObj: Update; 109 | if (edit[2] !== undefined) { 110 | const pos = sender.insertAt(edit[0], edit[2]); 111 | updateObj = { type: "set", pos, value: edit[2] }; 112 | absPosLengths.push(JSON.stringify(pos).length); 113 | } else { 114 | const pos = sender.positionAt(edit[0]); 115 | sender.delete(pos); 116 | updateObj = { type: "delete", pos }; 117 | } 118 | } 119 | 120 | console.log( 121 | `- AbsPosition length stats: avg = ${avg(absPosLengths).toFixed( 122 | 1 123 | )}, percentiles [25, 50, 75, 100] = ${percentiles( 124 | absPosLengths, 125 | [25, 50, 75, 100] 126 | )}` 127 | ); 128 | } 129 | 130 | async function saveLoad( 131 | saver: AbsList, 132 | gzip: boolean 133 | ): Promise { 134 | // Save. 135 | let startTime = process.hrtime.bigint(); 136 | const savedStateObj: SavedState = saver.save(); 137 | const savedState = gzip 138 | ? gzipString(JSON.stringify(savedStateObj)) 139 | : JSON.stringify(savedStateObj); 140 | 141 | console.log( 142 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 143 | Math.round( 144 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 145 | ) 146 | ); 147 | console.log( 148 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 149 | savedState.length 150 | ); 151 | 152 | // Load the saved state. 153 | startTime = process.hrtime.bigint(); 154 | const loader = new AbsList(); 155 | const toLoadStr = gzip 156 | ? gunzipString(savedState as Uint8Array) 157 | : (savedState as string); 158 | const toLoadObj: SavedState = JSON.parse(toLoadStr); 159 | loader.load(toLoadObj); 160 | 161 | console.log( 162 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 163 | Math.round( 164 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 165 | ) 166 | ); 167 | 168 | return savedState; 169 | } 170 | 171 | async function memory(savedState: string) { 172 | // Measure memory usage of loading the saved state. 173 | 174 | // Pause (& separate function)seems to make GC more consistent - 175 | // less likely to get negative diffs. 176 | await sleep(1000); 177 | const startMem = getMemUsed(); 178 | 179 | const loader = new AbsList(); 180 | // Keep the parsed saved state in a separate scope so it can be GC'd 181 | // before we measure memory. 182 | (function () { 183 | const savedStateObj: SavedState = JSON.parse(savedState); 184 | loader.load(savedStateObj); 185 | })(); 186 | 187 | console.log( 188 | "- Mem used estimate (MB):", 189 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 190 | ); 191 | 192 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 193 | void loader; 194 | void savedState; 195 | } 196 | -------------------------------------------------------------------------------- /benchmarks/crdt.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import realTextTraceEdits from "./internal/real_text_trace_edits.json"; 3 | import { 4 | avg, 5 | getMemUsed, 6 | gunzipString, 7 | gzipString, 8 | sleep, 9 | } from "./internal/util"; 10 | import { ListCrdt, ListCrdtMessage } from "./internal/list_crdt"; 11 | import { TextCrdt, TextCrdtMessage } from "./internal/text_crdt"; 12 | 13 | const { edits, finalText } = realTextTraceEdits as unknown as { 14 | finalText: string; 15 | edits: Array<[number, number, string | undefined]>; 16 | }; 17 | 18 | type TypeCrdt = typeof TextCrdt | typeof ListCrdt; 19 | 20 | export async function crdt(CRDT: TypeCrdt) { 21 | console.log("\n## " + CRDT.name + "\n"); 22 | console.log( 23 | "Use a hybrid op-based/state-based CRDT implemented on top of the library's data structures, copied from [@list-positions/crdts](https://github.com/mweidner037/list-positions-crdts)." 24 | ); 25 | switch (CRDT) { 26 | case TextCrdt: 27 | console.log( 28 | "This variant uses a Text + PositionSet to store the state and Positions in messages, manually managing BunchMetas." 29 | ); 30 | break; 31 | case ListCrdt: 32 | console.log( 33 | "This variant uses a List of characters + PositionSet to store the state and Positions in messages, manually managing BunchMetas." 34 | ); 35 | break; 36 | } 37 | console.log( 38 | "Updates and saved states use JSON encoding, with optional GZIP for saved states.\n" 39 | ); 40 | 41 | // Perform the whole trace, sending all updates. 42 | // Use a simple JSON encoding. 43 | const updates: string[] = []; 44 | let startTime = process.hrtime.bigint(); 45 | const sender = new CRDT( 46 | (message: TextCrdtMessage | ListCrdtMessage) => 47 | updates.push(JSON.stringify(message)) 48 | ); 49 | for (const edit of edits) { 50 | if (edit[2] !== undefined) { 51 | sender.insertAt(edit[0], edit[2]); 52 | } else sender.deleteAt(edit[0]); 53 | } 54 | 55 | console.log( 56 | "- Sender time (ms):", 57 | Math.round( 58 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 59 | ) 60 | ); 61 | console.log( 62 | "- Avg update size (bytes):", 63 | avg(updates.map((message) => message.length)).toFixed(1) 64 | ); 65 | if (sender instanceof ListCrdt) { 66 | assert.strictEqual(sender.slice().join(""), finalText); 67 | } else { 68 | assert.strictEqual(sender.toString(), finalText); 69 | } 70 | 71 | // Receive all updates. 72 | startTime = process.hrtime.bigint(); 73 | const receiver = new CRDT(() => {}); 74 | for (const update of updates) { 75 | receiver.receive(JSON.parse(update)); 76 | } 77 | 78 | console.log( 79 | "- Receiver time (ms):", 80 | Math.round( 81 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 82 | ) 83 | ); 84 | if (receiver instanceof ListCrdt) { 85 | assert.strictEqual(receiver.slice().join(""), finalText); 86 | } else { 87 | assert.strictEqual(receiver.toString(), finalText); 88 | } 89 | 90 | const savedState = (await saveLoad(CRDT, receiver, false)) as string; 91 | await saveLoad(CRDT, receiver, true); 92 | 93 | await memory(CRDT, savedState); 94 | } 95 | 96 | async function saveLoad( 97 | CRDT: TypeCrdt, 98 | saver: InstanceType, 99 | gzip: boolean 100 | ): Promise { 101 | // Save. 102 | let startTime = process.hrtime.bigint(); 103 | const savedStateStr = JSON.stringify(saver.save()); 104 | const savedState = gzip ? gzipString(savedStateStr) : savedStateStr; 105 | 106 | console.log( 107 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 108 | Math.round( 109 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 110 | ) 111 | ); 112 | console.log( 113 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 114 | savedState.length 115 | ); 116 | 117 | // Load the saved state. 118 | startTime = process.hrtime.bigint(); 119 | const loader = new CRDT(() => {}); 120 | const toLoadStr = gzip 121 | ? gunzipString(savedState as Uint8Array) 122 | : (savedState as string); 123 | loader.load(JSON.parse(toLoadStr)); 124 | 125 | console.log( 126 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 127 | Math.round( 128 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 129 | ) 130 | ); 131 | 132 | return savedState; 133 | } 134 | 135 | async function memory(CRDT: TypeCrdt, savedState: string) { 136 | // Measure memory usage of loading the saved state. 137 | 138 | // Pause (& separate function)seems to make GC more consistent - 139 | // less likely to get negative diffs. 140 | await sleep(1000); 141 | const startMem = getMemUsed(); 142 | 143 | const loader = new CRDT(() => {}); 144 | loader.load(JSON.parse(savedState)); 145 | 146 | console.log( 147 | "- Mem used estimate (MB):", 148 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 149 | ); 150 | 151 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 152 | void loader; 153 | void savedState; 154 | } 155 | -------------------------------------------------------------------------------- /benchmarks/internal/list_crdt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BunchMeta, 3 | List, 4 | ListSavedState, 5 | OrderSavedState, 6 | Outline, 7 | OutlineSavedState, 8 | Position, 9 | PositionSet, 10 | expandPositions, 11 | } from "../../src"; 12 | 13 | export type ListCrdtMessage = 14 | | { 15 | readonly type: "set"; 16 | readonly startPos: Position; 17 | readonly values: T[]; 18 | readonly meta?: BunchMeta; 19 | } 20 | | { 21 | readonly type: "delete"; 22 | readonly items: [startPos: Position, count: number][]; 23 | }; 24 | 25 | export type ListCrdtSavedState = { 26 | readonly order: OrderSavedState; 27 | readonly list: ListSavedState; 28 | readonly seen: OutlineSavedState; 29 | readonly buffer: ListCrdtMessage[]; 30 | }; 31 | 32 | // TODO: events 33 | 34 | /** 35 | * A traditional op-based/state-based list CRDT implemented on top of list-positions. 36 | * 37 | * Copied from [@list-positions/crdts](https://github.com/mweidner037/list-positions-crdts/) 38 | * to make benchmarking easier. 39 | * 40 | * send/receive work on general networks (they build in exactly-once partial-order delivery), 41 | * and save/load work as state-based merging. 42 | * 43 | * Internally, its state is a `List` (for values) and a PositionSet (for tracking 44 | * which Positions have been "seen"). This implementation uses Positions in messages 45 | * and manually manages metadata; in particular, it must buffer certain out-of-order 46 | * messages. 47 | */ 48 | export class ListCrdt { 49 | private readonly list: List; 50 | /** 51 | * A set of all Positions we've ever seen, whether currently present or deleted. 52 | * Used for state-based merging and handling reordered messages. 53 | * 54 | * We use PositionSet here because we don't care about the list order. If you did, 55 | * you could use Outline instead, with the same Order as this.list 56 | * (`this.seen = new Outline(this.order);`). 57 | * 58 | * Tracking all seen Positions (instead of just deleted ones) reduces 59 | * internal sparse array fragmentation, leading to smaller memory and saved state sizes. 60 | */ 61 | private readonly seen: PositionSet; 62 | /** 63 | * Maps from bunchID to a Set of messages that are waiting on that 64 | * bunch's BunchMeta before they can be processed. 65 | */ 66 | private readonly pending: Map>>; 67 | 68 | constructor(private readonly send: (message: ListCrdtMessage) => void) { 69 | this.list = new List(); 70 | this.seen = new PositionSet(); 71 | this.pending = new Map(); 72 | } 73 | 74 | getAt(index: number): T { 75 | return this.list.getAt(index); 76 | } 77 | 78 | [Symbol.iterator](): IterableIterator { 79 | return this.list.values(); 80 | } 81 | 82 | values(): IterableIterator { 83 | return this.list.values(); 84 | } 85 | 86 | slice(start?: number, end?: number): T[] { 87 | return this.list.slice(start, end); 88 | } 89 | 90 | insertAt(index: number, ...values: T[]): void { 91 | if (values.length === 0) return; 92 | 93 | const [pos, newMeta] = this.list.insertAt(index, ...values); 94 | this.seen.add(pos, values.length); 95 | const message: ListCrdtMessage = { 96 | type: "set", 97 | startPos: pos, 98 | values, 99 | ...(newMeta ? { meta: newMeta } : {}), 100 | }; 101 | this.send(message); 102 | } 103 | 104 | deleteAt(index: number, count = 1): void { 105 | if (count === 0) return; 106 | 107 | const items: [startPos: Position, count: number][] = []; 108 | if (count === 1) { 109 | // Common case: use positionAt, which is faster than items. 110 | items.push([this.list.positionAt(index), 1]); 111 | } else { 112 | for (const [startPos, values] of this.list.items(index, index + count)) { 113 | items.push([startPos, values.length]); 114 | } 115 | } 116 | 117 | for (const [startPos, itemCount] of items) { 118 | this.list.delete(startPos, itemCount); 119 | } 120 | this.send({ type: "delete", items }); 121 | } 122 | 123 | receive(message: ListCrdtMessage): void { 124 | switch (message.type) { 125 | case "delete": 126 | for (const [startPos, count] of message.items) { 127 | // Mark each position as seen immediately, even if we don't have metadata 128 | // for its bunch yet. Okay because this.seen is a PositionSet instead of an Outline. 129 | this.seen.add(startPos, count); 130 | 131 | // Delete the positions if present. 132 | // If the bunch is unknown, it's definitely not present, and we 133 | // should skip calling text.has to avoid a "Missing metadata" error. 134 | if (this.list.order.getNode(startPos.bunchID) !== undefined) { 135 | // For future events, we may need to delete individually. Do it now for consistency. 136 | for (const pos of expandPositions(startPos, count)) { 137 | if (this.list.has(pos)) { 138 | this.list.delete(pos); 139 | } 140 | } 141 | } 142 | } 143 | break; 144 | case "set": { 145 | const bunchID = message.startPos.bunchID; 146 | if (message.meta) { 147 | const parentID = message.meta.parentID; 148 | if (this.list.order.getNode(parentID) === undefined) { 149 | // The meta can't be processed yet because its parent bunch is unknown. 150 | // Add it to pending. 151 | this.addToPending(parentID, message); 152 | return; 153 | } else this.list.order.addMetas([message.meta]); 154 | } 155 | 156 | if (this.list.order.getNode(bunchID) === undefined) { 157 | // The message can't be processed yet because its bunch is unknown. 158 | // Add it to pending. 159 | this.addToPending(bunchID, message); 160 | return; 161 | } 162 | 163 | // At this point, BunchMeta dependencies are satisfied. Process the message. 164 | 165 | // Note that the insertion may have already been (partly) seen, due to 166 | // redundant or out-of-order messages; 167 | // only unseen positions need to be inserted. 168 | const poss = expandPositions(message.startPos, message.values.length); 169 | const toInsert: number[] = []; 170 | for (let i = 0; i < poss.length; i++) { 171 | if (!this.seen.has(poss[i])) toInsert.push(i); 172 | } 173 | if (toInsert.length === message.values.length) { 174 | // All need inserting (normal case). 175 | this.list.set(message.startPos, ...message.values); 176 | } else { 177 | for (const i of toInsert) { 178 | this.list.set(poss[i], message.values[i]); 179 | } 180 | } 181 | 182 | this.seen.add(message.startPos, message.values.length); 183 | 184 | if (message.meta) { 185 | // The meta may have unblocked pending messages. 186 | const unblocked = this.pending.get(message.meta.bunchID); 187 | if (unblocked !== undefined) { 188 | this.pending.delete(message.meta.bunchID); 189 | // TODO: if you unblock a long dependency chain (unlikely), 190 | // this recursion could overflow the stack. 191 | for (const msg2 of unblocked) this.receive(msg2); 192 | } 193 | } 194 | break; 195 | } 196 | } 197 | } 198 | 199 | private addToPending(bunchID: string, message: ListCrdtMessage): void { 200 | let bunchPending = this.pending.get(bunchID); 201 | if (bunchPending === undefined) { 202 | bunchPending = new Set(); 203 | this.pending.set(bunchID, bunchPending); 204 | } 205 | bunchPending.add(message); 206 | } 207 | 208 | save(): ListCrdtSavedState { 209 | const buffer: ListCrdtMessage[] = []; 210 | for (const messageSet of this.pending.values()) { 211 | buffer.push(...messageSet); 212 | } 213 | return { 214 | order: this.list.order.save(), 215 | list: this.list.save(), 216 | seen: this.seen.save(), 217 | buffer, 218 | }; 219 | } 220 | 221 | load(savedState: ListCrdtSavedState): void { 222 | if (this.seen.state.size === 0) { 223 | // Never been used, so okay to load directly instead of doing a state-based 224 | // merge. 225 | this.list.order.load(savedState.order); 226 | this.list.load(savedState.list); 227 | this.seen.load(savedState.seen); 228 | } else { 229 | // TODO: benchmark merging. 230 | const otherList = new List(); 231 | const otherSeen = new Outline(otherList.order); 232 | otherList.order.load(savedState.order); 233 | otherList.load(savedState.list); 234 | otherSeen.load(savedState.seen); 235 | 236 | // Loop over all positions that had been inserted or deleted into 237 | // the other list. 238 | this.list.order.load(savedState.order); 239 | for (const pos of otherSeen) { 240 | if (!this.seen.has(pos)) { 241 | // pos is new to us. Copy its state from the other list. 242 | if (otherList.has(pos)) this.list.set(pos, otherList.get(pos)!); 243 | this.seen.add(pos); 244 | } else { 245 | // We already know of pos. If it's deleted in the other list, 246 | // ensure it's deleted here too. 247 | if (!otherList.has(pos)) this.list.delete(pos); 248 | } 249 | } 250 | } 251 | 252 | // In either case, process buffer by re-delivering all of its messages. 253 | for (const message of savedState.buffer) { 254 | this.receive(message); 255 | } 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /benchmarks/internal/text_crdt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BunchMeta, 3 | OrderSavedState, 4 | Outline, 5 | OutlineSavedState, 6 | Position, 7 | PositionSet, 8 | Text, 9 | TextSavedState, 10 | expandPositions, 11 | } from "../../src"; 12 | 13 | export type TextCrdtMessage = 14 | | { 15 | readonly type: "set"; 16 | readonly startPos: Position; 17 | readonly chars: string; 18 | readonly meta?: BunchMeta; 19 | } 20 | | { 21 | readonly type: "delete"; 22 | readonly items: [startPos: Position, count: number][]; 23 | }; 24 | 25 | export type TextCrdtSavedState = { 26 | readonly order: OrderSavedState; 27 | readonly text: TextSavedState; 28 | readonly seen: OutlineSavedState; 29 | readonly buffer: TextCrdtMessage[]; 30 | }; 31 | 32 | // TODO: events 33 | 34 | /** 35 | * A traditional op-based/state-based text CRDT implemented on top of list-positions. 36 | * 37 | * Copied from [@list-positions/crdts](https://github.com/mweidner037/list-positions-crdts/) 38 | * to make benchmarking easier. 39 | * 40 | * send/receive work on general networks (they build in exactly-once partial-order delivery), 41 | * and save/load work as state-based merging. 42 | * 43 | * Internally, its state is a Text (for values) and a PositionSet (for tracking 44 | * which Positions have been "seen"). This implementation uses Positions in messages 45 | * and manually manages metadata; in particular, it must buffer certain out-of-order 46 | * messages. 47 | */ 48 | export class TextCrdt { 49 | private readonly text: Text; 50 | /** 51 | * A set of all Positions we've ever seen, whether currently present or deleted. 52 | * Used for state-based merging and handling reordered messages. 53 | * 54 | * We use PositionSet here because we don't care about the list order. If you did, 55 | * you could use Outline instead, with the same Order as this.list 56 | * (`this.seen = new Outline(this.order);`). 57 | * 58 | * Tracking all seen Positions (instead of just deleted ones) reduces 59 | * internal sparse array fragmentation, leading to smaller memory and saved state sizes. 60 | */ 61 | private readonly seen: PositionSet; 62 | /** 63 | * Maps from bunchID to a Set of messages that are waiting on that 64 | * bunch's BunchMeta before they can be processed. 65 | */ 66 | private readonly pending: Map>; 67 | 68 | constructor(private readonly send: (message: TextCrdtMessage) => void) { 69 | this.text = new Text(); 70 | this.seen = new PositionSet(); 71 | this.pending = new Map(); 72 | } 73 | 74 | getAt(index: number): string { 75 | return this.text.getAt(index); 76 | } 77 | 78 | [Symbol.iterator](): IterableIterator { 79 | return this.text.values(); 80 | } 81 | 82 | values(): IterableIterator { 83 | return this.text.values(); 84 | } 85 | 86 | slice(start?: number, end?: number): string { 87 | return this.text.slice(start, end); 88 | } 89 | 90 | toString(): string { 91 | return this.text.toString(); 92 | } 93 | 94 | insertAt(index: number, chars: string): void { 95 | if (chars.length === 0) return; 96 | 97 | const [pos, newMeta] = this.text.insertAt(index, chars); 98 | this.seen.add(pos, chars.length); 99 | const message: TextCrdtMessage = { 100 | type: "set", 101 | startPos: pos, 102 | chars, 103 | ...(newMeta ? { meta: newMeta } : {}), 104 | }; 105 | this.send(message); 106 | } 107 | 108 | deleteAt(index: number, count = 1): void { 109 | if (count === 0) return; 110 | 111 | const items: [startPos: Position, count: number][] = []; 112 | if (count === 1) { 113 | // Common case: use positionAt, which is faster than items. 114 | items.push([this.text.positionAt(index), 1]); 115 | } else { 116 | for (const [startPos, chars] of this.text.items(index, index + count)) { 117 | items.push([startPos, chars.length]); 118 | } 119 | } 120 | 121 | for (const [startPos, itemCount] of items) { 122 | this.text.delete(startPos, itemCount); 123 | } 124 | this.send({ type: "delete", items }); 125 | } 126 | 127 | receive(message: TextCrdtMessage): void { 128 | switch (message.type) { 129 | case "delete": 130 | for (const [startPos, count] of message.items) { 131 | // Mark each position as seen immediately, even if we don't have metadata 132 | // for its bunch yet. Okay because this.seen is a PositionSet instead of an Outline. 133 | this.seen.add(startPos, count); 134 | 135 | // Delete the positions if present. 136 | // If the bunch is unknown, it's definitely not present, and we 137 | // should skip calling text.has to avoid a "Missing metadata" error. 138 | if (this.text.order.getNode(startPos.bunchID) !== undefined) { 139 | // For future events, we may need to delete individually. Do it now for consistency. 140 | for (const pos of expandPositions(startPos, count)) { 141 | if (this.text.has(pos)) { 142 | this.text.delete(pos); 143 | } 144 | } 145 | } 146 | } 147 | break; 148 | case "set": { 149 | const bunchID = message.startPos.bunchID; 150 | if (message.meta) { 151 | const parentID = message.meta.parentID; 152 | if (this.text.order.getNode(parentID) === undefined) { 153 | // The meta can't be processed yet because its parent bunch is unknown. 154 | // Add it to pending. 155 | this.addToPending(parentID, message); 156 | return; 157 | } else this.text.order.addMetas([message.meta]); 158 | } 159 | 160 | if (this.text.order.getNode(bunchID) === undefined) { 161 | // The message can't be processed yet because its bunch is unknown. 162 | // Add it to pending. 163 | this.addToPending(bunchID, message); 164 | return; 165 | } 166 | 167 | // At this point, BunchMeta dependencies are satisfied. Process the message. 168 | 169 | // Note that the insertion may have already been (partly) seen, due to 170 | // redundant or out-of-order messages; 171 | // only unseen positions need to be inserted. 172 | const poss = expandPositions(message.startPos, message.chars.length); 173 | const toInsert: number[] = []; 174 | for (let i = 0; i < poss.length; i++) { 175 | if (!this.seen.has(poss[i])) toInsert.push(i); 176 | } 177 | if (toInsert.length === message.chars.length) { 178 | // All need inserting (normal case). 179 | this.text.set(message.startPos, message.chars); 180 | } else { 181 | for (const i of toInsert) { 182 | this.text.set(poss[i], message.chars[i]); 183 | } 184 | } 185 | 186 | this.seen.add(message.startPos, message.chars.length); 187 | 188 | if (message.meta) { 189 | // The meta may have unblocked pending messages. 190 | const unblocked = this.pending.get(message.meta.bunchID); 191 | if (unblocked !== undefined) { 192 | this.pending.delete(message.meta.bunchID); 193 | // TODO: if you unblock a long dependency chain (unlikely), 194 | // this recursion could overflow the stack. 195 | for (const msg2 of unblocked) this.receive(msg2); 196 | } 197 | } 198 | break; 199 | } 200 | } 201 | } 202 | 203 | private addToPending(bunchID: string, message: TextCrdtMessage): void { 204 | let bunchPending = this.pending.get(bunchID); 205 | if (bunchPending === undefined) { 206 | bunchPending = new Set(); 207 | this.pending.set(bunchID, bunchPending); 208 | } 209 | bunchPending.add(message); 210 | } 211 | 212 | save(): TextCrdtSavedState { 213 | const buffer: TextCrdtMessage[] = []; 214 | for (const messageSet of this.pending.values()) { 215 | buffer.push(...messageSet); 216 | } 217 | return { 218 | order: this.text.order.save(), 219 | text: this.text.save(), 220 | seen: this.seen.save(), 221 | buffer, 222 | }; 223 | } 224 | 225 | load(savedState: TextCrdtSavedState): void { 226 | if (this.seen.state.size === 0) { 227 | // Never been used, so okay to load directly instead of doing a state-based 228 | // merge. 229 | this.text.order.load(savedState.order); 230 | this.text.load(savedState.text); 231 | this.seen.load(savedState.seen); 232 | } else { 233 | // TODO: benchmark merging. 234 | const otherText = new Text(); 235 | const otherSeen = new Outline(otherText.order); 236 | otherText.order.load(savedState.order); 237 | otherText.load(savedState.text); 238 | otherSeen.load(savedState.seen); 239 | 240 | // Loop over all positions that had been inserted or deleted into 241 | // the other list. 242 | this.text.order.load(savedState.order); 243 | for (const pos of otherSeen) { 244 | if (!this.seen.has(pos)) { 245 | // pos is new to us. Copy its state from the other list. 246 | if (otherText.has(pos)) this.text.set(pos, otherText.get(pos)!); 247 | this.seen.add(pos); 248 | } else { 249 | // We already know of pos. If it's deleted in the other list, 250 | // ensure it's deleted here too. 251 | if (!otherText.has(pos)) this.text.delete(pos); 252 | } 253 | } 254 | } 255 | 256 | // In either case, process buffer by re-delivering all of its messages. 257 | for (const message of savedState.buffer) { 258 | this.receive(message); 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /benchmarks/internal/util.ts: -------------------------------------------------------------------------------- 1 | import { gzipSync, gunzipSync } from "fflate"; 2 | 3 | export function getMemUsed() { 4 | if (global.gc) { 5 | // Experimentally, calling gc() twice makes memory msmts more reliable - 6 | // otherwise may get negative diffs (last trial getting GC'd in the middle?). 7 | global.gc(); 8 | global.gc(); 9 | } 10 | return process.memoryUsage().heapUsed; 11 | } 12 | 13 | export function avg(values: number[]): number { 14 | if (values.length === 0) return 0; 15 | return values.reduce((a, b) => a + b, 0) / values.length; 16 | } 17 | 18 | export async function sleep(ms: number) { 19 | await new Promise((resolve) => setTimeout(resolve, ms)); 20 | } 21 | 22 | /** 23 | * @param percentiles Out of 100 24 | * @returns Nearest-rank percentiles 25 | */ 26 | export function percentiles(values: number[], percentiles: number[]): number[] { 27 | if (values.length === 0) return new Array(percentiles.length).fill(0); 28 | 29 | values.sort((a, b) => a - b); 30 | const ans: number[] = []; 31 | for (const perc of percentiles) { 32 | ans.push(values[Math.ceil(values.length * (perc / 100)) - 1]); 33 | } 34 | return ans; 35 | } 36 | 37 | export function gzipString(str: string): Uint8Array { 38 | return gzipSync(new TextEncoder().encode(str)); 39 | } 40 | 41 | export function gunzipString(data: Uint8Array): string { 42 | return new TextDecoder().decode(gunzipSync(data)); 43 | } 44 | -------------------------------------------------------------------------------- /benchmarks/list_custom_encoding.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { List, ListSavedState, OrderSavedState, Position } from "../src"; 3 | import realTextTraceEdits from "./internal/real_text_trace_edits.json"; 4 | import { 5 | avg, 6 | getMemUsed, 7 | sleep, 8 | gunzipString, 9 | gzipString, 10 | } from "./internal/util"; 11 | 12 | const { edits, finalText } = realTextTraceEdits as unknown as { 13 | finalText: string; 14 | edits: Array<[number, number, string | undefined]>; 15 | }; 16 | 17 | type SavedState = { 18 | order: OrderSavedState; 19 | list: ListSavedState; 20 | }; 21 | 22 | export async function listCustomEncoding() { 23 | console.log("\n## List Direct w/ Custom Encoding\n"); 24 | console.log( 25 | "Use `List` and send updates directly over a reliable link (e.g. WebSocket)." 26 | ); 27 | console.log( 28 | "Updates use a custom string encoding; saved states use JSON with optional GZIP.\n" 29 | ); 30 | // TODO: custom savedState encoding too 31 | 32 | // Perform the whole trace, sending all updates. 33 | const updates: string[] = []; 34 | let startTime = process.hrtime.bigint(); 35 | const sender = new List(); 36 | for (const edit of edits) { 37 | // Update format is a concatenated string with space-separated parts. 38 | // We use the fact that strings don't contain space except for set chars. 39 | // Numbers are base-36 encoded. 40 | // - Set: "s", bunchID, innerIndex, [meta parent, meta, ] char 41 | // - Delete: "d", bunchID, innerIndex 42 | let update: string; 43 | if (edit[2] !== undefined) { 44 | const [pos, newMeta] = sender.insertAt(edit[0], edit[2]); 45 | update = "s " + pos.bunchID + " " + pos.innerIndex.toString(36) + " "; 46 | if (newMeta !== null) { 47 | update += newMeta.parentID + " " + newMeta.offset.toString(36) + " "; 48 | } 49 | update += edit[2]; 50 | } else { 51 | const pos = sender.positionAt(edit[0]); 52 | sender.delete(pos); 53 | update = "d " + pos.bunchID + " " + pos.innerIndex.toString(36); 54 | } 55 | 56 | updates.push(update); 57 | } 58 | 59 | console.log( 60 | "- Sender time (ms):", 61 | Math.round( 62 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 63 | ) 64 | ); 65 | console.log( 66 | "- Avg update size (bytes):", 67 | avg(updates.map((message) => message.length)).toFixed(1) 68 | ); 69 | assert.strictEqual(sender.slice().join(""), finalText); 70 | 71 | // Receive all updates. 72 | startTime = process.hrtime.bigint(); 73 | const receiver = new List(); 74 | for (const update of updates) { 75 | if (update[0] === "s") { 76 | const char = update.at(-1)!; 77 | const parts = update.slice(2, -2).split(" "); 78 | if (parts.length === 4) { 79 | receiver.order.addMetas([ 80 | { 81 | bunchID: parts[0], 82 | parentID: parts[2], 83 | offset: Number.parseInt(parts[3], 36), 84 | }, 85 | ]); 86 | } 87 | const pos: Position = { 88 | bunchID: parts[0], 89 | innerIndex: Number.parseInt(parts[1], 36), 90 | }; 91 | receiver.set(pos, char); 92 | // To simulate events, also compute the inserted index. 93 | void receiver.indexOfPosition(pos); 94 | } else { 95 | // type "delete" 96 | const parts = update.slice(2).split(" "); 97 | const pos: Position = { 98 | bunchID: parts[0], 99 | innerIndex: Number.parseInt(parts[1], 36), 100 | }; 101 | if (receiver.has(pos)) { 102 | // To simulate events, also compute the inserted index. 103 | void receiver.indexOfPosition(pos); 104 | receiver.delete(pos); // Also okay to call outside of the "has" guard. 105 | } 106 | } 107 | } 108 | 109 | console.log( 110 | "- Receiver time (ms):", 111 | Math.round( 112 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 113 | ) 114 | ); 115 | assert.strictEqual(receiver.slice().join(""), finalText); 116 | 117 | const savedState = (await saveLoad(receiver, false)) as string; 118 | await saveLoad(receiver, true); 119 | 120 | await memory(savedState); 121 | } 122 | 123 | async function saveLoad( 124 | saver: List, 125 | gzip: boolean 126 | ): Promise { 127 | // Save. 128 | let startTime = process.hrtime.bigint(); 129 | const savedStateObj: SavedState = { 130 | order: saver.order.save(), 131 | list: saver.save(), 132 | }; 133 | const savedState = gzip 134 | ? gzipString(JSON.stringify(savedStateObj)) 135 | : JSON.stringify(savedStateObj); 136 | 137 | console.log( 138 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 139 | Math.round( 140 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 141 | ) 142 | ); 143 | console.log( 144 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 145 | savedState.length 146 | ); 147 | 148 | // Load the saved state. 149 | startTime = process.hrtime.bigint(); 150 | const loader = new List(); 151 | const toLoadStr = gzip 152 | ? gunzipString(savedState as Uint8Array) 153 | : (savedState as string); 154 | const toLoadObj: SavedState = JSON.parse(toLoadStr); 155 | // Important to load Order first. 156 | loader.order.load(toLoadObj.order); 157 | loader.load(toLoadObj.list); 158 | 159 | console.log( 160 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 161 | Math.round( 162 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 163 | ) 164 | ); 165 | 166 | return savedState; 167 | } 168 | 169 | async function memory(savedState: string) { 170 | // Measure memory usage of loading the saved state. 171 | 172 | // Pause (& separate function)seems to make GC more consistent - 173 | // less likely to get negative diffs. 174 | await sleep(1000); 175 | const startMem = getMemUsed(); 176 | 177 | const loader = new List(); 178 | // Keep the parsed saved state in a separate scope so it can be GC'd 179 | // before we measure memory. 180 | (function () { 181 | const savedStateObj: SavedState = JSON.parse(savedState); 182 | // Important to load Order first. 183 | loader.order.load(savedStateObj.order); 184 | loader.load(savedStateObj.list); 185 | })(); 186 | 187 | console.log( 188 | "- Mem used estimate (MB):", 189 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 190 | ); 191 | 192 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 193 | void loader; 194 | void savedState; 195 | } 196 | -------------------------------------------------------------------------------- /benchmarks/list_direct.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { 3 | BunchMeta, 4 | List, 5 | ListSavedState, 6 | OrderSavedState, 7 | Position, 8 | } from "../src"; 9 | import realTextTraceEdits from "./internal/real_text_trace_edits.json"; 10 | import { 11 | avg, 12 | getMemUsed, 13 | sleep, 14 | gunzipString, 15 | gzipString, 16 | } from "./internal/util"; 17 | 18 | const { edits, finalText } = realTextTraceEdits as unknown as { 19 | finalText: string; 20 | edits: Array<[number, number, string | undefined]>; 21 | }; 22 | 23 | type Update = 24 | | { 25 | type: "set"; 26 | pos: Position; 27 | value: string; 28 | meta?: BunchMeta; 29 | } 30 | | { type: "delete"; pos: Position }; 31 | 32 | type SavedState = { 33 | order: OrderSavedState; 34 | list: ListSavedState; 35 | }; 36 | 37 | export async function listDirect() { 38 | console.log("\n## List Direct\n"); 39 | console.log( 40 | "Use `List` and send updates directly over a reliable link (e.g. WebSocket)." 41 | ); 42 | console.log( 43 | "Updates and saved states use JSON encoding, with optional GZIP for saved states.\n" 44 | ); 45 | 46 | // Perform the whole trace, sending all updates. 47 | const updates: string[] = []; 48 | let startTime = process.hrtime.bigint(); 49 | const sender = new List(); 50 | for (const edit of edits) { 51 | let updateObj: Update; 52 | if (edit[2] !== undefined) { 53 | const [pos, newMeta] = sender.insertAt(edit[0], edit[2]); 54 | updateObj = { type: "set", pos, value: edit[2] }; 55 | if (newMeta !== null) updateObj.meta = newMeta; 56 | } else { 57 | const pos = sender.positionAt(edit[0]); 58 | sender.delete(pos); 59 | updateObj = { type: "delete", pos }; 60 | } 61 | 62 | updates.push(JSON.stringify(updateObj)); 63 | } 64 | 65 | console.log( 66 | "- Sender time (ms):", 67 | Math.round( 68 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 69 | ) 70 | ); 71 | console.log( 72 | "- Avg update size (bytes):", 73 | avg(updates.map((message) => message.length)).toFixed(1) 74 | ); 75 | assert.strictEqual(sender.slice().join(""), finalText); 76 | 77 | // Receive all updates. 78 | startTime = process.hrtime.bigint(); 79 | const receiver = new List(); 80 | for (const update of updates) { 81 | const updateObj: Update = JSON.parse(update); 82 | if (updateObj.type === "set") { 83 | if (updateObj.meta) receiver.order.addMetas([updateObj.meta]); 84 | receiver.set(updateObj.pos, updateObj.value); 85 | // To simulate events, also compute the inserted index. 86 | void receiver.indexOfPosition(updateObj.pos); 87 | } else { 88 | // type "delete" 89 | if (receiver.has(updateObj.pos)) { 90 | // To simulate events, also compute the inserted index. 91 | void receiver.indexOfPosition(updateObj.pos); 92 | receiver.delete(updateObj.pos); // Also okay to call outside of the "has" guard. 93 | } 94 | } 95 | } 96 | 97 | console.log( 98 | "- Receiver time (ms):", 99 | Math.round( 100 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 101 | ) 102 | ); 103 | assert.strictEqual(receiver.slice().join(""), finalText); 104 | 105 | const savedState = (await saveLoad(receiver, false)) as string; 106 | await saveLoad(receiver, true); 107 | 108 | await memory(savedState); 109 | } 110 | 111 | async function saveLoad( 112 | saver: List, 113 | gzip: boolean 114 | ): Promise { 115 | // Save. 116 | let startTime = process.hrtime.bigint(); 117 | const savedStateObj: SavedState = { 118 | order: saver.order.save(), 119 | list: saver.save(), 120 | }; 121 | const savedState = gzip 122 | ? gzipString(JSON.stringify(savedStateObj)) 123 | : JSON.stringify(savedStateObj); 124 | 125 | console.log( 126 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 127 | Math.round( 128 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 129 | ) 130 | ); 131 | console.log( 132 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 133 | savedState.length 134 | ); 135 | 136 | // Load the saved state. 137 | startTime = process.hrtime.bigint(); 138 | const loader = new List(); 139 | const toLoadStr = gzip 140 | ? gunzipString(savedState as Uint8Array) 141 | : (savedState as string); 142 | const toLoadObj: SavedState = JSON.parse(toLoadStr); 143 | // Important to load Order first. 144 | loader.order.load(toLoadObj.order); 145 | loader.load(toLoadObj.list); 146 | 147 | console.log( 148 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 149 | Math.round( 150 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 151 | ) 152 | ); 153 | 154 | return savedState; 155 | } 156 | 157 | async function memory(savedState: string) { 158 | // Measure memory usage of loading the saved state. 159 | 160 | // Pause (& separate function)seems to make GC more consistent - 161 | // less likely to get negative diffs. 162 | await sleep(1000); 163 | const startMem = getMemUsed(); 164 | 165 | const loader = new List(); 166 | // Keep the parsed saved state in a separate scope so it can be GC'd 167 | // before we measure memory. 168 | (function () { 169 | const savedStateObj: SavedState = JSON.parse(savedState); 170 | // Important to load Order first. 171 | loader.order.load(savedStateObj.order); 172 | loader.load(savedStateObj.list); 173 | })(); 174 | 175 | console.log( 176 | "- Mem used estimate (MB):", 177 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 178 | ); 179 | 180 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 181 | void loader; 182 | void savedState; 183 | } 184 | -------------------------------------------------------------------------------- /benchmarks/main.ts: -------------------------------------------------------------------------------- 1 | import { crdt } from "./crdt"; 2 | import { absListDirect } from "./abs_list_direct"; 3 | import { listCustomEncoding } from "./list_custom_encoding"; 4 | import { listDirect } from "./list_direct"; 5 | import { outlineDirect } from "./outline_direct"; 6 | import { textDirect } from "./text_direct"; 7 | import { TextCrdt } from "./internal/text_crdt"; 8 | import { ListCrdt } from "./internal/list_crdt"; 9 | 10 | (async function () { 11 | console.log("# Benchmark Results"); 12 | console.log( 13 | "Output of\n```bash\nnpm run benchmarks -s > benchmark_results.md\n```" 14 | ); 15 | console.log( 16 | "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" 17 | ); 18 | console.log( 19 | "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" 20 | ); 21 | 22 | await listDirect(); 23 | await absListDirect(); 24 | await listCustomEncoding(); 25 | await textDirect(); 26 | await outlineDirect(); 27 | await crdt(TextCrdt); 28 | await crdt(ListCrdt); 29 | })(); 30 | -------------------------------------------------------------------------------- /benchmarks/outline_direct.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BunchMeta, 3 | OrderSavedState, 4 | Outline, 5 | OutlineSavedState, 6 | Position, 7 | } from "../src"; 8 | import realTextTraceEdits from "./internal/real_text_trace_edits.json"; 9 | import { 10 | avg, 11 | getMemUsed, 12 | sleep, 13 | gunzipString, 14 | gzipString, 15 | } from "./internal/util"; 16 | 17 | const { edits } = realTextTraceEdits as unknown as { 18 | finalText: string; 19 | edits: Array<[number, number, string | undefined]>; 20 | }; 21 | 22 | type Update = 23 | | { 24 | type: "set"; 25 | pos: Position; 26 | meta?: BunchMeta; 27 | } 28 | | { type: "delete"; pos: Position }; 29 | 30 | type SavedState = { 31 | order: OrderSavedState; 32 | list: OutlineSavedState; 33 | }; 34 | 35 | export async function outlineDirect() { 36 | console.log("\n## Outline Direct\n"); 37 | console.log( 38 | "Use `Outline` and send updates directly over a reliable link (e.g. WebSocket)." 39 | ); 40 | console.log( 41 | "Updates and saved states use JSON encoding, with optional GZIP for saved states." 42 | ); 43 | console.log("Neither updates nor saved states include values (chars).\n"); 44 | 45 | // Perform the whole trace, sending all updates. 46 | const updates: string[] = []; 47 | let startTime = process.hrtime.bigint(); 48 | const sender = new Outline(); 49 | for (const edit of edits) { 50 | let updateObj: Update; 51 | if (edit[2] !== undefined) { 52 | const [pos, newMeta] = sender.insertAt(edit[0]); 53 | updateObj = { type: "set", pos }; 54 | if (newMeta !== null) updateObj.meta = newMeta; 55 | } else { 56 | const pos = sender.positionAt(edit[0]); 57 | sender.delete(pos); 58 | updateObj = { type: "delete", pos }; 59 | } 60 | 61 | updates.push(JSON.stringify(updateObj)); 62 | } 63 | 64 | console.log( 65 | "- Sender time (ms):", 66 | Math.round( 67 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 68 | ) 69 | ); 70 | console.log( 71 | "- Avg update size (bytes):", 72 | avg(updates.map((message) => message.length)).toFixed(1) 73 | ); 74 | 75 | // Receive all updates. 76 | startTime = process.hrtime.bigint(); 77 | const receiver = new Outline(); 78 | for (const update of updates) { 79 | const updateObj: Update = JSON.parse(update); 80 | if (updateObj.type === "set") { 81 | if (updateObj.meta) receiver.order.addMetas([updateObj.meta]); 82 | receiver.add(updateObj.pos); 83 | // To simulate events, also compute the inserted index. 84 | void receiver.indexOfPosition(updateObj.pos); 85 | } else { 86 | // type "delete" 87 | if (receiver.has(updateObj.pos)) { 88 | // To simulate events, also compute the inserted index. 89 | void receiver.indexOfPosition(updateObj.pos); 90 | receiver.delete(updateObj.pos); // Also okay to call outside of the "has" guard. 91 | } 92 | } 93 | } 94 | 95 | console.log( 96 | "- Receiver time (ms):", 97 | Math.round( 98 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 99 | ) 100 | ); 101 | 102 | const savedState = (await saveLoad(receiver, false)) as string; 103 | await saveLoad(receiver, true); 104 | 105 | await memory(savedState); 106 | } 107 | 108 | async function saveLoad( 109 | saver: Outline, 110 | gzip: boolean 111 | ): Promise { 112 | // Save. 113 | let startTime = process.hrtime.bigint(); 114 | const savedStateObj: SavedState = { 115 | order: saver.order.save(), 116 | list: saver.save(), 117 | }; 118 | const savedState = gzip 119 | ? gzipString(JSON.stringify(savedStateObj)) 120 | : JSON.stringify(savedStateObj); 121 | 122 | console.log( 123 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 124 | Math.round( 125 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 126 | ) 127 | ); 128 | console.log( 129 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 130 | savedState.length 131 | ); 132 | 133 | // Load the saved state. 134 | startTime = process.hrtime.bigint(); 135 | const loader = new Outline(); 136 | const toLoadStr = gzip 137 | ? gunzipString(savedState as Uint8Array) 138 | : (savedState as string); 139 | const toLoadObj: SavedState = JSON.parse(toLoadStr); 140 | // Important to load Order first. 141 | loader.order.load(toLoadObj.order); 142 | loader.load(toLoadObj.list); 143 | 144 | console.log( 145 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 146 | Math.round( 147 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 148 | ) 149 | ); 150 | 151 | return savedState; 152 | } 153 | 154 | async function memory(savedState: string) { 155 | // Measure memory usage of loading the saved state. 156 | 157 | // Pause (& separate function)seems to make GC more consistent - 158 | // less likely to get negative diffs. 159 | await sleep(1000); 160 | const startMem = getMemUsed(); 161 | 162 | const loader = new Outline(); 163 | // Keep the parsed saved state in a separate scope so it can be GC'd 164 | // before we measure memory. 165 | (function () { 166 | const savedStateObj: SavedState = JSON.parse(savedState); 167 | // Important to load Order first. 168 | loader.order.load(savedStateObj.order); 169 | loader.load(savedStateObj.list); 170 | })(); 171 | 172 | console.log( 173 | "- Mem used estimate (MB):", 174 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 175 | ); 176 | 177 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 178 | void loader; 179 | void savedState; 180 | } 181 | -------------------------------------------------------------------------------- /benchmarks/text_direct.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { 3 | BunchMeta, 4 | OrderSavedState, 5 | Position, 6 | Text, 7 | TextSavedState, 8 | } from "../src"; 9 | import realTextTraceEdits from "./internal/real_text_trace_edits.json"; 10 | import { 11 | avg, 12 | getMemUsed, 13 | sleep, 14 | gunzipString, 15 | gzipString, 16 | } from "./internal/util"; 17 | 18 | const { edits, finalText } = realTextTraceEdits as unknown as { 19 | finalText: string; 20 | edits: Array<[number, number, string | undefined]>; 21 | }; 22 | 23 | type Update = 24 | | { 25 | type: "set"; 26 | pos: Position; 27 | value: string; 28 | meta?: BunchMeta; 29 | } 30 | | { type: "delete"; pos: Position }; 31 | 32 | type SavedState = { 33 | order: OrderSavedState; 34 | text: TextSavedState; 35 | }; 36 | 37 | export async function textDirect() { 38 | console.log("\n## Text Direct\n"); 39 | console.log( 40 | "Use `Text` and send updates directly over a reliable link (e.g. WebSocket)." 41 | ); 42 | console.log( 43 | "Updates and saved states use JSON encoding, with optional GZIP for saved states.\n" 44 | ); 45 | 46 | // Perform the whole trace, sending all updates. 47 | const updates: string[] = []; 48 | let startTime = process.hrtime.bigint(); 49 | const sender = new Text(); 50 | for (const edit of edits) { 51 | let updateObj: Update; 52 | if (edit[2] !== undefined) { 53 | const [pos, newMeta] = sender.insertAt(edit[0], edit[2]); 54 | updateObj = { type: "set", pos, value: edit[2] }; 55 | if (newMeta !== null) updateObj.meta = newMeta; 56 | } else { 57 | const pos = sender.positionAt(edit[0]); 58 | sender.delete(pos); 59 | updateObj = { type: "delete", pos }; 60 | } 61 | 62 | updates.push(JSON.stringify(updateObj)); 63 | } 64 | 65 | console.log( 66 | "- Sender time (ms):", 67 | Math.round( 68 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 69 | ) 70 | ); 71 | console.log( 72 | "- Avg update size (bytes):", 73 | avg(updates.map((message) => message.length)).toFixed(1) 74 | ); 75 | assert.strictEqual(sender.toString(), finalText); 76 | 77 | // Receive all updates. 78 | startTime = process.hrtime.bigint(); 79 | const receiver = new Text(); 80 | for (const update of updates) { 81 | const updateObj: Update = JSON.parse(update); 82 | if (updateObj.type === "set") { 83 | if (updateObj.meta) receiver.order.addMetas([updateObj.meta]); 84 | receiver.set(updateObj.pos, updateObj.value); 85 | // To simulate events, also compute the inserted index. 86 | void receiver.indexOfPosition(updateObj.pos); 87 | } else { 88 | // type "delete" 89 | if (receiver.has(updateObj.pos)) { 90 | // To simulate events, also compute the inserted index. 91 | void receiver.indexOfPosition(updateObj.pos); 92 | receiver.delete(updateObj.pos); // Also okay to call outside of the "has" guard. 93 | } 94 | } 95 | } 96 | 97 | console.log( 98 | "- Receiver time (ms):", 99 | Math.round( 100 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 101 | ) 102 | ); 103 | assert.strictEqual(receiver.toString(), finalText); 104 | 105 | const savedState = (await saveLoad(receiver, false)) as string; 106 | await saveLoad(receiver, true); 107 | 108 | await memory(savedState); 109 | } 110 | 111 | async function saveLoad( 112 | saver: Text, 113 | gzip: boolean 114 | ): Promise { 115 | // Save. 116 | let startTime = process.hrtime.bigint(); 117 | const savedStateObj: SavedState = { 118 | order: saver.order.save(), 119 | text: saver.save(), 120 | }; 121 | const savedState = gzip 122 | ? gzipString(JSON.stringify(savedStateObj)) 123 | : JSON.stringify(savedStateObj); 124 | 125 | console.log( 126 | `- Save time ${gzip ? "GZIP'd " : ""}(ms):`, 127 | Math.round( 128 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 129 | ) 130 | ); 131 | console.log( 132 | `- Save size ${gzip ? "GZIP'd " : ""}(bytes):`, 133 | savedState.length 134 | ); 135 | 136 | // Load the saved state. 137 | startTime = process.hrtime.bigint(); 138 | const loader = new Text(); 139 | const toLoadStr = gzip 140 | ? gunzipString(savedState as Uint8Array) 141 | : (savedState as string); 142 | const toLoadObj: SavedState = JSON.parse(toLoadStr); 143 | // Important to load Order first. 144 | loader.order.load(toLoadObj.order); 145 | loader.load(toLoadObj.text); 146 | 147 | console.log( 148 | `- Load time ${gzip ? "GZIP'd " : ""}(ms):`, 149 | Math.round( 150 | new Number(process.hrtime.bigint() - startTime).valueOf() / 1000000 151 | ) 152 | ); 153 | 154 | return savedState; 155 | } 156 | 157 | async function memory(savedState: string) { 158 | // Measure memory usage of loading the saved state. 159 | 160 | // Pause (& separate function)seems to make GC more consistent - 161 | // less likely to get negative diffs. 162 | await sleep(1000); 163 | const startMem = getMemUsed(); 164 | 165 | const loader = new Text(); 166 | // Keep the parsed saved state in a separate scope so it can be GC'd 167 | // before we measure memory. 168 | (function () { 169 | const savedStateObj: SavedState = JSON.parse(savedState); 170 | // Important to load Order first. 171 | loader.order.load(savedStateObj.order); 172 | loader.load(savedStateObj.text); 173 | })(); 174 | 175 | console.log( 176 | "- Mem used estimate (MB):", 177 | ((getMemUsed() - startMem) / 1000000).toFixed(1) 178 | ); 179 | 180 | // Keep stuff in scope so we don't accidentally subtract its memory usage. 181 | void loader; 182 | void savedState; 183 | } 184 | -------------------------------------------------------------------------------- /images/fugue_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mweidner037/list-positions/383b84479594641a8864f43e781382e5be8cd845/images/fugue_tree.png -------------------------------------------------------------------------------- /images/positions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mweidner037/list-positions/383b84479594641a8864f43e781382e5be8cd845/images/positions.png -------------------------------------------------------------------------------- /images/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mweidner037/list-positions/383b84479594641a8864f43e781382e5be8cd845/images/tree.png -------------------------------------------------------------------------------- /internals.md: -------------------------------------------------------------------------------- 1 | # Internals 2 | 3 | ## Tree Order 4 | 5 | list-positions' core concept is a special kind of tree. Here is an example: 6 | 7 | ![Example tree](./images/tree.png) 8 | 9 | In general, the tree alternates between two kinds of layers: 10 | 11 | - **Bunch layers** in which each node is labeled by a _bunchID_ - a string that is unique among the whole tree. (Blue nodes in the image.) 12 | - **Offset layers** in which each node is labeled by a nonnegative integer _offset_. (Orange nodes in the image.) 13 | 14 | The tree's root is a bunch node with bunchID `"ROOT"` (`BunchIDs.ROOT`). 15 | 16 | The tree's nodes are totally ordered using a depth-first search: visit the root, then traverse each of its child nodes recursively. A node's children are traversed in the order: 17 | 18 | - For bunch layers, visit the children in order by bunchID (lexicographically). 19 | - For offset layers, visit the children in order by offset. 20 | 21 | Each Order instance stores a tree of the above form. The tree's bunch nodes correspond to the bunches described in the [readme](./README.md#bunches). A bunch's BunchMeta `{ bunchID, parentID, offset }` says: I am a grandchild of the bunch node `parentID`, a child of its child `offset`. 22 | 23 | The Position `{ bunchID, innerIndex }` indicates the offset node that is a child of `bunchID` and has offset `2 * innerIndex + 1`. Note that its offset is _not_ `innerIndex` (for reasons explained [later](#details)), but we still get an infinite sequence of Positions for each bunch. The sort order on Positions is just their order in the tree. 24 | 25 | ![Positions in the above example tree](./images/positions.png) 26 | 27 | Now you can see why a Position depends on the BunchMeta of its bunch and all ancestors: you need these to know where the Position is in the tree. Once two Orders agree on some Positions' BunchMeta, they'll agree on the relative order of those Positions. 28 | 29 | ### Min and Max Positions 30 | 31 | As a special case, the offset layer below the root always has exactly two nodes: 32 | 33 | - Offset 1, which is `MIN_POSITION` (innerIndex 0) and the ancestor of all other nodes. 34 | - Offset 3, which is `MAX_POSITION` (innerIndex 1). 35 | 36 | This ensures that all other Positions are strictly between the min and max. 37 | 38 | ## Representing the Tree 39 | 40 | We could choose to represent the tree literally, with one object per node and a pointer to its parent. But this would use a lot of memory and storage. 41 | 42 | Instead, Order only stores an object per bunch node, of type [BunchNode](./README.md#interface-bunchnode); offset nodes are implied. Each BunchNode stores a pointer to the bunch node's "parent bunch node" (actually its grandparent), its offset (which tells you the actual parent node), and pointers to its "children bunch nodes" in tree order (actually its grandchildren). This info is sufficient to compare Positions and traverse the tree. 43 | 44 | List, Text, Outline, and AbsList likewise avoid storing an object per Position/value. Instead, they store a map (BunchNode -> sparse array), where the sparse array represents the sub-map (innerIndex -> value) corresponding to that bunch. The sparse arrays come from the [sparse-array-rled](https://github.com/mweidner037/sparse-array-rled#readme) package, which uses run-length encoded deletions, both in memory and in saved states. 45 | 46 | ## AbsPositions 47 | 48 | > For code implementing this section, see [AbsPositions' source code](./src/abs_position.ts). 49 | 50 | An AbsPosition encodes a Position together with all of its dependent metadata: the offsets and bunchIDs on its path to root. (The position's `innerIndex` is stored separately.) 51 | 52 | We could store this path as two arrays, `bunchIDs: string[]` and `offsets: number[]`. For compactness, we instead use a few encoding tricks: 53 | 54 | - Since the last bunchID is always `"ROOT"` and the last offset is always 1, we omit those. 55 | - Instead of storing `bunchIDs: string[]` directly, we attempt to parse each bunchID into the default form `` `${replicaID}_${counter.toString(36)}` ``. (If a bunchID is not of this form, we treat it as `replicaID = bunchID`, `counter = -1`). Then we store the parts as: 56 | - `replicaIDs`: All replicaIDs that occur, each only once (deduplicated). 57 | - `replicaIndices`: Indices into `replicaIDs`, in order up the tree. 58 | - `counterIncs`: Counters-plus-1 (so that they are all uints), in order up the tree. 59 | 60 | The actual format is: 61 | 62 | ```ts 63 | type AbsPosition = { 64 | bunchMeta: AbsBunchMeta; 65 | innerIndex: number; 66 | }; 67 | 68 | type AbsBunchMeta = { 69 | /** 70 | * Deduplicated replicaIDs, indexed into by replicaIndices. 71 | */ 72 | replicaIDs: readonly string[]; 73 | /** 74 | * Non-negative integers. 75 | */ 76 | replicaIndices: readonly number[]; 77 | /** 78 | * Non-negative integers. Same length as replicaIndices. 79 | */ 80 | counterIncs: readonly number[]; 81 | /** 82 | * Non-negative integers. One shorter than replicaIndices, unless both are empty. 83 | */ 84 | offsets: readonly number[]; 85 | }; 86 | ``` 87 | 88 | ## Lexicographic strings 89 | 90 | > For code implementing this section, see [lexicographicString's source code](./src/lexicographic_string.ts). 91 | 92 | We can address any node in the tree by the sequence of node labels on the path from the root to that node: 93 | 94 | ```ts 95 | [bunchID0 = "ROOT", offset0, bunchID1, offset1, bunchID2, offset2, ...] 96 | ``` 97 | 98 | For positions besides the min and max, we always have `bunchID0 = "ROOT"` and `offset0 = 1`, so we can skip those. The rest we can combine into a string: 99 | 100 | ```ts 101 | `${bunchID1},${offset1}.${bunchID2},${offset2}.${bunchID3},${...}` 102 | ``` 103 | 104 | It turns out that the lexicographic order on these strings _almost_ matches the tree order. Thus with a few corrections, we obtain the `lexicographicString` function, which inputs an AbsPosition and outputs an equivalently-ordered string. 105 | 106 | As special cases, we encode `MIN_POSITION` as `""` and `MAX_POSITION` as `"~"`. These are less/greater than all other lexicographic strings. 107 | 108 | The corrections are: 109 | 110 | 1. We can't encode offsets directly as strings, because the lexicographic order on numeric strings doesn't match the numeric order: `2 < 11` but `"2" > "11"`. Instead, we use the [lex-sequence](https://github.com/mweidner037/lex-sequence/#readme) package to convert offsets to strings that have the correct lexicographic order, while still growing slowly for large numbers (the encoding of `n` has `O(log(n))` chars). 111 | 2. Consider the case when one bunchID is a prefix of another, e.g., `"abc"` vs `"abcde"`. If these bunches are siblings, the tree will sort them prefix-first: `"abc" < "abcde"`. 112 | 113 | In the lexicographic strings, the bunchIDs may be followed by other chars, starting with a `','` delimiter: `abc,rest_of_string` vs `abcde,rest_of_string`. So the lexicographic order is really comparing `"abc,"` to `"abcde,"`. If `'d'` were replaced by a character less than `','`, we would get the wrong answer here. 114 | 115 | To fix this, we escape bunchID chars <= `','`, prefixing them with a `'-'`. (We then also need to escape `'-'`.) 116 | 117 | 3. To ensure that all strings are less than the max position's `"~"`, we also escape the first char in a bunchID if it is >= `'~'`, prefixing it with `'}'`. (We then also need to escape `'}'`.) 118 | 119 | ## Creating Positions 120 | 121 | You don't need to understand this section for the Applications below, but it's here for curiosity. 122 | 123 | `Order.createPositions`, and its wrappers like `List.insertAt`, return Positions with the following guarantees: 124 | 125 | 1. They are unique among all Positions returned by this Order and its replicas. This holds even if a replica on a different device concurrently creates Positions at the same place. 126 | 2. Non-interleaving: If two replicas concurrently insert a (forward or backward) sequence of Positions at the same place, their sequences will not be interleaved. 127 | 3. The returned Positions will re-use an existing bunch if possible, to reduce metadata overhead. (You can override this behavior by supplying `options.bunchID`.) 128 | 129 | To do this, we map Order's tree to a double-sided [Fugue list CRDT](https://arxiv.org/abs/2305.00583) tree, use a variant of Fugue's insert logic to create new nodes, then map those nodes back to Order's tree. Since Fugue is non-interleaving, so is list-positions. (Except in rare situations where Fugue interleaves backward insertions, documented in the linked paper.) 130 | 131 | ### Details 132 | 133 | The conversion from list-position's tree to a Fugue tree is: 134 | 135 | - Each Position becomes a Fugue node. 136 | - A Position with nonzero innerIndex is a right child of the Position with one lower innerIndex. So each bunch's Positions form a rightward chain. 137 | - For a bunch's first Position `pos = { bunchID, innerIndex: 0 }`, let the bunch's BunchMeta be `{ bunchID, parentID, offset }`. 138 | - If `offset` is even, then `pos` is a _left_ child of `{ bunchID: parentID, innerIndex: offset / 2 }`. 139 | - If `offset` is odd, then `pos` is a _right_ child of `{ bunchID: parentID, innerIndex: (offset - 1) / 2 }`. 140 | 141 | ![Fugue subtree corresponding to a bunch's Positions](./images/fugue_tree.png) 142 | 143 | Observe that the relation `offset = 2 * innerIndex + 1` lets each Position have both left and right children. Furthermore, there is a distinction between `innerIndex`'s right children and `(innerIndex + 1)`'s left children; that is necessary to prevent some backward interleaving. 144 | 145 | Pedantic notes: 146 | 147 | - list-positions makes different choices than the Fugue paper when inserting around tombstones. See the comments in [Order.createPositions' source code](./src/order.ts). 148 | - The converted tree uses a slightly different sort order on same-side siblings than the Fugue paper: same-side siblings are in order by bunchID, except that a right-side child created by the same Order as its parent is always last (because it increments `innerIndex` instead of creating a new right-child bunch). This does not affect non-interleaving because Fugue treats the same-side sibling sort order as arbitrary. 149 | 150 | ## Applications 151 | 152 | Here are some advanced things you can do once you understand list-positions' internals. To request more info, or to ask about your own ideas, feel free to open an [issue](https://github.com/mweidner037/list-positions/issues). 153 | 154 | 1. Manipulate BunchMetas to make a custom tree. For example, to insert some initial text identically for all users - without explicitly loading the same state - you can start each session by creating "the same" bunch and setting the text there. Order.createPositions's `bunchID` option can help here: 155 | 156 | ```ts 157 | const INITIAL_TEXT = "Type something here."; 158 | const text = new Text(); 159 | // Creates a new bunch with bunchID "INIT" that is a child of MIN_POSITION, 160 | // with identical BunchMeta every time. 161 | const [initStartPos] = text.order.createPositions( 162 | MIN_POSITION, 163 | MAX_POSITION, 164 | INITIAL_TEXT.length, 165 | { bunchID: "INIT" } 166 | ); 167 | text.set(initStartPos, INITIAL_TEXT); 168 | // Now use text normally... 169 | ``` 170 | 171 | 2. Rewrite list-positions in another language, with compatible Positions and AbsPositions. 172 | 173 | 3. Supply a custom `newBunchID: (parent: BunchNode, offset: number) => string` function to Order's constructor that incorporates a hash of `parent.bunchID`, `offset`, and the local replicaID. That way, a malicious user cannot reuse the same (valid) bunchID for two different bunches. 174 | 4. Write your own analog of our List class - e.g., to use a more efficient data representation, or to add new low-level features. You can use Order's BunchNodes to access the tree structure - this is needed for traversals, computing a Position's current index, etc. 175 | 5. Store a List's state in a database table that can be queried in order. For efficiency, you can probably store one _item_ per row, instead of just one value - see `List.items()`. Note that you'll have to "split" an item when Positions are inserted between its values. To allow in-order queries, each item could store a reference to its neighboring items in the list order (forming a doubly-linked list), or the [lexicographic string](./README.md#lexicographic-strings) of its first entry. 176 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "list-positions", 3 | "version": "2.0.0", 4 | "description": "Efficient \"positions\" for lists and text - enabling rich documents and collaboration", 5 | "author": "Matthew Weidner", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/mweidner037/list-positions/issues" 9 | }, 10 | "homepage": "https://github.com/mweidner037/list-positions/tree/master/#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/mweidner037/list-positions.git" 14 | }, 15 | "keywords": [ 16 | "text editing", 17 | "ordered map", 18 | "collaboration", 19 | "CRDT" 20 | ], 21 | "module": "build/esm/index.js", 22 | "browser": "build/esm/index.js", 23 | "types": "build/esm/index.d.ts", 24 | "main": "build/commonjs/index.js", 25 | "files": [ 26 | "/build", 27 | "/src" 28 | ], 29 | "directories": { 30 | "lib": "src" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "sideEffects": false, 36 | "dependencies": { 37 | "lex-sequence": "^2.0.0", 38 | "maybe-random-string": "^1.0.0", 39 | "sparse-array-rled": "^2.0.1" 40 | }, 41 | "devDependencies": { 42 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 43 | "@types/chai": "^4.3.4", 44 | "@types/functional-red-black-tree": "^1.0.6", 45 | "@types/mocha": "^10.0.1", 46 | "@types/seedrandom": "^3.0.8", 47 | "@typescript-eslint/eslint-plugin": "^5.52.0", 48 | "@typescript-eslint/parser": "^5.52.0", 49 | "chai": "^4.3.7", 50 | "eslint": "^8.34.0", 51 | "eslint-config-prettier": "^8.6.0", 52 | "eslint-plugin-import": "^2.27.5", 53 | "fflate": "^0.8.2", 54 | "functional-red-black-tree": "^1.0.1", 55 | "mocha": "^10.2.0", 56 | "npm-run-all": "^4.1.5", 57 | "nyc": "^15.1.0", 58 | "prettier": "^2.8.4", 59 | "seedrandom": "^3.0.5", 60 | "ts-node": "^10.9.1", 61 | "typedoc": "^0.25.3", 62 | "typescript": "^4.9.5" 63 | }, 64 | "scripts": { 65 | "prepack": "npm run clean && npm run build && npm run test", 66 | "build": "npm-run-all build:*", 67 | "build:ts": "tsc -p tsconfig.json && tsc -p tsconfig.commonjs.json", 68 | "test": "npm-run-all test:*", 69 | "test:lint": "eslint --ext .ts,.js .", 70 | "test:unit": "TS_NODE_PROJECT='./tsconfig.dev.json' mocha", 71 | "test:format": "prettier --check .", 72 | "coverage": "npm-run-all coverage:*", 73 | "coverage:run": "nyc npm run test:unit", 74 | "coverage:open": "open coverage/index.html > /dev/null 2>&1 &", 75 | "fix": "npm-run-all fix:*", 76 | "fix:format": "prettier --write .", 77 | "docs": "typedoc --options typedoc.json src/index.ts", 78 | "benchmarks": "TS_NODE_PROJECT='./tsconfig.dev.json' node -r ts-node/register --expose-gc benchmarks/main.ts", 79 | "clean": "rm -rf build docs coverage .nyc_output" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lists/abs_list"; 2 | export * from "./lists/list"; 3 | export * from "./lists/outline"; 4 | export * from "./lists/text"; 5 | export * from "./order/abs_position"; 6 | export * from "./order/bunch"; 7 | export * from "./order/bunch_ids"; 8 | export * from "./order/lexicographic_string"; 9 | export * from "./order/order"; 10 | export * from "./order/position"; 11 | export * from "./unordered_collections/position_char_map"; 12 | export * from "./unordered_collections/position_map"; 13 | export * from "./unordered_collections/position_set"; 14 | -------------------------------------------------------------------------------- /src/internal/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Normalizes the range so that start < end and they are both in bounds 3 | * (possibly start=end=length), following Array.slice. 4 | */ 5 | export function normalizeSliceRange( 6 | length: number, 7 | start?: number, 8 | end?: number 9 | ): [start: number, end: number] { 10 | if (start === undefined || start < -length) start = 0; 11 | else if (start < 0) start += length; 12 | else if (start >= length) return [length, length]; 13 | 14 | if (end === undefined || end >= length) end = length; 15 | else if (end < -length) end = 0; 16 | else if (end < 0) end += length; 17 | 18 | if (end <= start) return [start, start]; 19 | return [start, end]; 20 | } 21 | 22 | /** 23 | * Base (radix) for stringifying the counter in bunchIDs that are formatted as dot IDs. 24 | * 25 | * Higher is better (shorter nodeIDs), but JavaScript only supports up to 36 26 | * by default. 27 | */ 28 | const COUNTER_BASE = 36; 29 | 30 | /** 31 | * Stringifies a dot ID, in the form `` `${replicaID}_${counter.toString(36)}` ``. 32 | * See https://mattweidner.com/2023/09/26/crdt-survey-3.html#unique-ids-dots 33 | * 34 | * If counter is -1, replicaID is returned instead, so that this function is an 35 | * inverse to parseMaybeDotID. 36 | */ 37 | export function stringifyMaybeDotID( 38 | replicaID: string, 39 | counter: number 40 | ): string { 41 | if (counter === -1) return replicaID; 42 | return `${replicaID}_${counter.toString(36)}`; 43 | } 44 | 45 | /** 46 | * Parses a dot ID of the form `` `${replicaID}_${counter.toString(36)}` `` 47 | * if possible, returning `[replicaID, counter]`. 48 | * 49 | * If not possible, returns `[maybeDotID, -1]`. 50 | */ 51 | export function parseMaybeDotID( 52 | maybeDotID: string 53 | ): [replicaID: string, counter: number] { 54 | const underscore = maybeDotID.lastIndexOf("_"); 55 | if (underscore === -1) return [maybeDotID, -1]; 56 | 57 | const counter = Number.parseInt( 58 | maybeDotID.slice(underscore + 1), 59 | COUNTER_BASE 60 | ); 61 | if (!(Number.isSafeInteger(counter) && counter >= 0)) return [maybeDotID, -1]; 62 | 63 | return [maybeDotID.slice(0, underscore), counter]; 64 | } 65 | 66 | export function arrayShallowEquals( 67 | a: readonly T[], 68 | b: readonly T[] 69 | ): boolean { 70 | if (a.length !== b.length) return false; 71 | for (let i = 0; i < a.length; i++) { 72 | if (a[i] !== b[i]) return false; 73 | } 74 | return true; 75 | } 76 | -------------------------------------------------------------------------------- /src/lists/abs_list.ts: -------------------------------------------------------------------------------- 1 | import { AbsBunchMeta, AbsPosition, AbsPositions } from "../order/abs_position"; 2 | import { Order } from "../order/order"; 3 | import { List, ListSavedState } from "./list"; 4 | 5 | /** 6 | * A JSON-serializable saved state for an `AbsList`. 7 | * 8 | * See {@link AbsList.save} and {@link AbsList.load}. 9 | * 10 | * ## Format 11 | * 12 | * For advanced usage, you may read and write AbsListSavedStates directly. 13 | * 14 | * The format is an array containing one entry for each [bunch](https://github.com/mweidner037/list-positions#bunches) 15 | * with AbsPositions present in the list, in no particular order. 16 | * Each bunch's entry contains: 17 | * - `bunchMeta` The bunch's {@link AbsBunchMeta}, which describes the bunch and all of its dependent metadata in a compressed form. 18 | * - `values` The bunch's values in the list, stored as a serialized form of 19 | * the sparse array 20 | * ``` 21 | * innerIndex -> (value at AbsPosition { bunchMeta, innerIndex }) 22 | * ``` 23 | * 24 | * ### Value Format 25 | * 26 | * Each `values` serialized sparse array (type `(T[] | number)[]`) 27 | * uses a compact JSON representation with run-length encoded deletions, identical to `SerializedSparseArray` from the 28 | * [sparse-array-rled](https://github.com/mweidner037/sparse-array-rled#readme) package. 29 | * It alternates between: 30 | * - arrays of present values, and 31 | * - numbers, representing that number of deleted indices (empty slots). 32 | * 33 | * For example, the sparse array `["foo", "bar", , , , "X", "yy"]` serializes to 34 | * `[["foo", "bar"], 3, ["X", "yy"]]`. 35 | * 36 | * Trivial entries (empty arrays, 0s, & trailing deletions) are always omitted. 37 | * For example, the sparse array `[, , "biz", "baz"]` serializes to `[2, ["biz", "baz"]]`. 38 | */ 39 | export type AbsListSavedState = Array<{ 40 | bunchMeta: AbsBunchMeta; 41 | values: (T[] | number)[]; 42 | }>; 43 | 44 | /** 45 | * A list of values of type `T`, represented as an ordered map with AbsPosition keys. 46 | * 47 | * See [AbsList and AbsPosition](https://github.com/mweidner037/list-positions#abslist-and-absposition) in the readme. 48 | * 49 | * AbsList's API is a hybrid between `Array` and `Map`. 50 | * Use `insertAt` or `insert` to insert new values into the list in the style of `Array.splice`. 51 | * 52 | * @typeParam T The value type. 53 | */ 54 | export class AbsList { 55 | /** 56 | * The Order that manages this list's Positions and their metadata. 57 | * 58 | * Unlike with List/Text/Outline, you do not need to [Manage Metadata](https://github.com/mweidner037/list-positions#managing-metadata) 59 | * when using AbsList. However, you can still access the Order 60 | * to convert between AbsPositions and Positions (using `Order.abs` / `Order.unabs`) 61 | * or to share the Order with other data structures. 62 | */ 63 | readonly order: Order; 64 | /** 65 | * The List backing this AbsList. 66 | * 67 | * You can manipulate the List directly. AbsList is merely an API wrapper that converts 68 | * between AbsPositions and Positions on every call. 69 | */ 70 | readonly list: List; 71 | 72 | /** 73 | * Constructs a AbsList, initially empty. 74 | * 75 | * @param order The Order to use for `this.order`. 76 | * Multiple Lists/Texts/Outlines/AbsLists can share an Order; they then automatically 77 | * share metadata. If not provided, a `new Order()` is used. 78 | * 79 | * @see {@link AbsList.fromEntries} To construct a AbsList from an initial set of entries. 80 | */ 81 | constructor(order?: Order); 82 | /** 83 | * Constructs a AbsList wrapping the given List, which is used as `this.list`. 84 | * 85 | * Changes to the List affect this AbsList and vice-versa. 86 | */ 87 | constructor(list: List); 88 | constructor(orderOrList?: Order | List) { 89 | this.list = 90 | orderOrList instanceof List ? orderOrList : new List(orderOrList); 91 | this.order = this.list.order; 92 | } 93 | 94 | /** 95 | * Returns a new AbsList with the given 96 | * ordered-map entries. 97 | * 98 | * @param order Optionally, the Order to use for the AbsList's `order`. 99 | * Unlike with List.from, you do not need to deliver metadata to this 100 | * Order beforehand. 101 | */ 102 | static fromEntries( 103 | entries: Iterable<[pos: AbsPosition, value: T]>, 104 | order?: Order 105 | ): AbsList { 106 | const list = new AbsList(order); 107 | for (const [pos, value] of entries) { 108 | list.set(pos, value); 109 | } 110 | return list; 111 | } 112 | 113 | /** 114 | * Returns a new AbsList with the given 115 | * items (as defined by {@link AbsList.items}). 116 | * 117 | * @param order Optionally, the Order to use for the AbsList's `order`. 118 | * Unlike with List.from, you do not need to deliver metadata to this 119 | * Order beforehand. 120 | */ 121 | static fromItems( 122 | items: Iterable<[startPos: AbsPosition, values: T[]]>, 123 | order?: Order 124 | ): AbsList { 125 | const list = new AbsList(order); 126 | for (const [startPos, values] of items) { 127 | list.set(startPos, ...values); 128 | } 129 | return list; 130 | } 131 | 132 | // ---------- 133 | // Mutators 134 | // ---------- 135 | 136 | /** 137 | * Sets the value at the given position. 138 | * 139 | * If the position is already present, its value is overwritten. 140 | * Otherwise, later values in the list shift right 141 | * (increment their index). 142 | */ 143 | set(pos: AbsPosition, value: T): void; 144 | /** 145 | * Sets the values at a sequence of AbsPositions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 146 | * 147 | * The AbsPositions start at `startPos` and have the same `bunchMeta` but increasing `innerIndex`. 148 | * Note that these positions might not be contiguous anymore, if later 149 | * positions were created between them. 150 | * 151 | * @see {@link AbsPositions.expandPositions} 152 | */ 153 | set(startPos: AbsPosition, ...sameBunchValues: T[]): void; 154 | set(startPos: AbsPosition, ...values: T[]): void { 155 | this.list.set(this.order.unabs(startPos), ...values); 156 | } 157 | 158 | /** 159 | * Sets the value at the given index (equivalently, at AbsPosition `this.positionAt(index)`), 160 | * overwriting the existing value. 161 | * 162 | * @throws If index is not in `[0, this.length)`. 163 | */ 164 | setAt(index: number, value: T): void { 165 | this.list.setAt(index, value); 166 | } 167 | 168 | /** 169 | * Deletes the given position, making it and its value no longer present in the list. 170 | * 171 | * If the position was indeed present, later values in the list shift left (decrement their index). 172 | */ 173 | delete(pos: AbsPosition): void; 174 | /** 175 | * Deletes a sequence of AbsPositions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 176 | * 177 | * The AbsPositions start at `startPos` and have the same `bunchMeta` but increasing `innerIndex`. 178 | * Note that these positions might not be contiguous anymore, if later 179 | * positions were created between them. 180 | * 181 | * @see {@link AbsPositions.expandPositions} 182 | */ 183 | delete(startPos: AbsPosition, sameBunchCount?: number): void; 184 | delete(startPos: AbsPosition, count = 1): void { 185 | this.list.delete(this.order.unabs(startPos), count); 186 | } 187 | 188 | /** 189 | * Deletes `count` values starting at `index`. 190 | * 191 | * @throws If any of `index`, ..., `index + count - 1` are not in `[0, this.length)`. 192 | */ 193 | deleteAt(index: number, count = 1): void { 194 | this.list.deleteAt(index, count); 195 | } 196 | 197 | /** 198 | * Deletes every value in the list, making it empty. 199 | * 200 | * `this.order` is unaffected (retains all metadata). 201 | */ 202 | clear() { 203 | this.list.clear(); 204 | } 205 | 206 | /** 207 | * Inserts the given value just after prevPos, at a new AbsPosition. 208 | 209 | * Later values in the list shift right 210 | * (increment their index). 211 | * 212 | * In a collaborative setting, the new AbsPosition is *globally unique*, even 213 | * if other users call `List.insert` (or similar methods) concurrently. 214 | * 215 | * @returns The new AbsPosition. 216 | * @throws If prevPos is AbsPositions.MAX_POSITION. 217 | */ 218 | insert(prevPos: AbsPosition, value: T): AbsPosition; 219 | /** 220 | * Inserts the given values just after prevPos, at a series of new AbsPositions. 221 | * 222 | * The new AbsPositions all use the same [bunch](https://github.com/mweidner037/list-positions#bunches), with sequential 223 | * `innerIndex` (starting at the returned position). 224 | * They are originally contiguous, but may become non-contiguous in the future, 225 | * if new positions are created between them. 226 | * 227 | * @returns The starting AbsPosition. 228 | * Use {@link AbsPositions.expandPositions} to convert (returned position, values.length) to an array of AbsPositions. 229 | * @throws If prevPos is AbsPositions.MAX_POSITION. 230 | * @throws If no values are provided. 231 | */ 232 | insert(prevPos: AbsPosition, ...values: T[]): AbsPosition; 233 | insert(prevPos: AbsPosition, ...values: T[]): AbsPosition { 234 | const [startPos] = this.list.insert(this.order.unabs(prevPos), ...values); 235 | return this.order.abs(startPos); 236 | } 237 | 238 | /** 239 | * Inserts the given values at `index` (i.e., between the values at `index - 1` and `index`), at a series of new AbsPositions. 240 | 241 | * Later values in the list shift right 242 | * (increase their index). 243 | * 244 | * In a collaborative setting, the new AbsPositions are *globally unique*, even 245 | * if other users call `AbsList.insert` (or similar methods) concurrently. 246 | * 247 | * @returns The starting AbsPosition. Use {@link AbsPositions.expandPositions} to convert 248 | * (returned position, values.length) to an array of AbsPositions. 249 | * @throws If index is not in `[0, this.length]`. The index `this.length` is allowed and will cause an append. 250 | * @throws If no values are provided. 251 | */ 252 | insertAt(index: number, ...values: T[]): AbsPosition { 253 | const [startPos] = this.list.insertAt(index, ...values); 254 | return this.order.abs(startPos); 255 | } 256 | 257 | // ---------- 258 | // Accessors 259 | // ---------- 260 | 261 | /** 262 | * Returns the value at the given position, or undefined if it is not currently present. 263 | */ 264 | get(pos: AbsPosition): T | undefined { 265 | return this.list.get(this.order.unabs(pos)); 266 | } 267 | 268 | /** 269 | * Returns the value currently at index. 270 | * 271 | * @throws If index is not in `[0, this.length)`. 272 | */ 273 | getAt(index: number): T { 274 | return this.list.getAt(index); 275 | } 276 | 277 | /** 278 | * Returns whether the given position is currently present in the list. 279 | */ 280 | has(pos: AbsPosition): boolean { 281 | return this.list.has(this.order.unabs(pos)); 282 | } 283 | 284 | /** 285 | * Returns the current index of the given position. 286 | * 287 | * If pos is not currently present in the list, 288 | * then the result depends on searchDir: 289 | * - "none" (default): Returns -1. 290 | * - "left": Returns the next index to the left of pos. 291 | * If there are no values to the left of pos, 292 | * returns -1. 293 | * - "right": Returns the next index to the right of pos. 294 | * If there are no values to the right of pos, 295 | * returns `this.length`. 296 | * 297 | * To find the index where a position would be if 298 | * present, use `searchDir = "right"`. 299 | */ 300 | indexOfPosition( 301 | pos: AbsPosition, 302 | searchDir: "none" | "left" | "right" = "none" 303 | ): number { 304 | return this.list.indexOfPosition(this.order.unabs(pos), searchDir); 305 | } 306 | 307 | /** 308 | * Returns the position currently at index. 309 | * 310 | * @throws If index is not in `[0, this.length)`. 311 | */ 312 | positionAt(index: number): AbsPosition { 313 | return this.order.abs(this.list.positionAt(index)); 314 | } 315 | 316 | /** 317 | * The length of the list. 318 | */ 319 | get length() { 320 | return this.list.length; 321 | } 322 | 323 | /** 324 | * Returns the cursor at `index` within the list, i.e., in the gap between the positions at `index - 1` and `index`. 325 | * See [Cursors](https://github.com/mweidner037/list-positions#cursors). 326 | * 327 | * Invert with {@link indexOfCursor}, possibly on a different AbsList/List/Text/Outline or a different device. 328 | * (For non-AbsLists, you will need to convert it to a Position using {@link Order.unabs}.) 329 | * 330 | * @param bind Whether to bind to the left or the right side of the gap, in case positions 331 | * later appear between `index - 1` and `index`. Default: `"left"`, which is typical for text cursors. 332 | * @throws If index is not in the range `[0, list.length]`. 333 | */ 334 | cursorAt(index: number, bind?: "left" | "right"): AbsPosition { 335 | return this.order.abs(this.list.cursorAt(index, bind)); 336 | } 337 | 338 | /** 339 | * Returns the current index of `cursor` within the list. 340 | * That is, the cursor is between the list elements at `index - 1` and `index`. 341 | * 342 | * Inverts {@link cursorAt}. 343 | * 344 | * @param bind The `bind` value that was used with {@link cursorAt}, if any. 345 | */ 346 | indexOfCursor(cursor: AbsPosition, bind?: "left" | "right"): number { 347 | return this.list.indexOfCursor(this.order.unabs(cursor), bind); 348 | } 349 | 350 | // ---------- 351 | // Iterators 352 | // ---------- 353 | 354 | /** Iterates over values in the list, in list order. */ 355 | [Symbol.iterator](): IterableIterator { 356 | return this.values(); 357 | } 358 | 359 | /** 360 | * Iterates over values in the list, in list order. 361 | * 362 | * Optionally, you may specify a range of indices `[start, end)` instead of 363 | * iterating the entire list. 364 | * 365 | * @throws If `start < 0`, `end > this.length`, or `start > end`. 366 | */ 367 | values(start?: number, end?: number): IterableIterator { 368 | return this.list.values(start, end); 369 | } 370 | 371 | /** 372 | * Returns a copy of a section of this list, as an array. 373 | * 374 | * Arguments are as in [Array.slice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice). 375 | */ 376 | slice(start?: number, end?: number): T[] { 377 | return this.list.slice(start, end); 378 | } 379 | 380 | /** 381 | * Iterates over present positions, in list order. 382 | * 383 | * Optionally, you may specify a range of indices `[start, end)` instead of 384 | * iterating the entire list. 385 | * 386 | * @throws If `start < 0`, `end > this.length`, or `start > end`. 387 | */ 388 | *positions(start?: number, end?: number): IterableIterator { 389 | for (const pos of this.list.positions(start, end)) 390 | yield this.order.abs(pos); 391 | } 392 | 393 | /** 394 | * Iterates over [pos, value] pairs in the list, in list order. These are its entries as an ordered map. 395 | * 396 | * Optionally, you may specify a range of indices `[start, end)` instead of 397 | * iterating the entire list. 398 | * 399 | * @throws If `start < 0`, `end > this.length`, or `start > end`. 400 | */ 401 | *entries( 402 | start?: number, 403 | end?: number 404 | ): IterableIterator<[pos: AbsPosition, value: T]> { 405 | for (const [pos, value] of this.list.entries(start, end)) { 406 | yield [this.order.abs(pos), value]; 407 | } 408 | } 409 | 410 | /** 411 | * Iterates over items, in list order. 412 | * 413 | * Each *item* is a series of entries that have contiguous positions 414 | * from the same [bunch](https://github.com/mweidner037/list-positions#bunches). 415 | * Specifically, for an item [startPos, values], the positions start at `startPos` 416 | * and have the same `bunchMeta` but increasing `innerIndex`. 417 | * 418 | * You can use this method as an optimized version of other iterators, or as 419 | * an alternative save format that is in list order (see {@link AbsList.fromItems}). 420 | * 421 | * Optionally, you may specify a range of indices `[start, end)` instead of 422 | * iterating the entire list. 423 | * 424 | * @throws If `start < 0`, `end > this.length`, or `start > end`. 425 | */ 426 | *items( 427 | start?: number, 428 | end?: number 429 | ): IterableIterator<[startPos: AbsPosition, values: T[]]> { 430 | for (const [pos, values] of this.list.items(start, end)) { 431 | yield [this.order.abs(pos), values]; 432 | } 433 | } 434 | 435 | // ---------- 436 | // Save & Load 437 | // ---------- 438 | 439 | /** 440 | * Returns a saved state for this AbsList. 441 | * 442 | * The saved state describes our current (AbsPosition -> value) map in JSON-serializable form. 443 | * You can load this state on another AbsList by calling `load(savedState)`, 444 | * possibly in a different session or on a different device. 445 | * 446 | * Note: You can instead use `Object.fromEntries(this.entries())` as a simple, 447 | * easy-to-interpret saved state, and load it with `AbsList.from`. 448 | * However, `save` and `load` use a more compact representation. 449 | */ 450 | save(): AbsListSavedState { 451 | const savedState: AbsListSavedState = []; 452 | for (const [bunchID, values] of Object.entries(this.list.save())) { 453 | savedState.push({ 454 | bunchMeta: AbsPositions.encodeMetas( 455 | this.order.getNode(bunchID)!.dependencies() 456 | ), 457 | values, 458 | }); 459 | } 460 | return savedState; 461 | } 462 | 463 | /** 464 | * Loads a saved state returned by another AbsList's `save()` method. 465 | * 466 | * Loading sets our (AbsPosition -> value) map to match the saved AbsList's, *overwriting* 467 | * our current state. 468 | * 469 | * Unlike with List.load, you do not need to deliver metadata to `this.order` 470 | * beforehand. 471 | */ 472 | load(savedState: AbsListSavedState): void { 473 | const listSavedState: ListSavedState = {}; 474 | for (const { bunchMeta, values } of savedState) { 475 | this.order.addMetas(AbsPositions.decodeMetas(bunchMeta)); 476 | listSavedState[AbsPositions.getBunchID(bunchMeta)] = values; 477 | } 478 | this.list.load(listSavedState); 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /src/lists/outline.ts: -------------------------------------------------------------------------------- 1 | import { SparseIndices } from "sparse-array-rled"; 2 | import { ItemList, SparseItemsFactory } from "../internal/item_list"; 3 | import { BunchMeta } from "../order/bunch"; 4 | import { Order } from "../order/order"; 5 | import { Position } from "../order/position"; 6 | 7 | const sparseIndicesFactory: SparseItemsFactory = { 8 | // eslint-disable-next-line @typescript-eslint/unbound-method 9 | new: SparseIndices.new, 10 | // eslint-disable-next-line @typescript-eslint/unbound-method 11 | deserialize: SparseIndices.deserialize, 12 | length(item) { 13 | return item; 14 | }, 15 | slice(_item, start, end) { 16 | return end - start; 17 | }, 18 | } as const; 19 | 20 | /** 21 | * A JSON-serializable saved state for an Outline. 22 | * 23 | * See {@link Outline.save} and {@link Outline.load}. 24 | * 25 | * ## Format 26 | * 27 | * For advanced usage, you may read and write OutlineSavedStates directly. 28 | * 29 | * The format is: For each [bunch](https://github.com/mweidner037/list-positions#bunches) 30 | * with Positions present in the Outline, map its bunchID to a serialized form of the sparse array 31 | * ``` 32 | * innerIndex -> (true if Position { bunchID, innerIndex } is present) 33 | * ``` 34 | * The bunches are in no particular order. 35 | * 36 | * ### Per-Bunch Format 37 | * 38 | * Each bunch's serialized sparse array (type `number[]`) 39 | * uses a compact JSON representation with run-length encoding, identical to `SerializedSparseIndices` from the 40 | * [sparse-array-rled](https://github.com/mweidner037/sparse-array-rled#readme) package. 41 | * It alternates between: 42 | * - counts of present values (even indices), and 43 | * - counts of deleted values (odd indices). 44 | * 45 | * For example, the sparse array `[true, true, , , , true, true]` serializes to `[2, 3, 2]`. 46 | * 47 | * Trivial entries (0s & trailing deletions) are always omitted, 48 | * except that the 0th entry may be 0. 49 | * For example, the sparse array `[, , true, true, true]` serializes to `[0, 2, 3]`. 50 | */ 51 | export type OutlineSavedState = { 52 | [bunchID: string]: number[]; 53 | }; 54 | 55 | /** 56 | * An outline for a list of values. It represents an ordered set of Positions. Unlike List, 57 | * it only tracks which Positions are present - not their associated values. 58 | * 59 | * See [Outline](https://github.com/mweidner037/list-positions#outline) in the readme. 60 | * 61 | * Outline's API is a hybrid between `Array` and `Set`. 62 | * Use `insertAt` or `insert` to insert new Positions into the list in the style of `Array.splice`. 63 | */ 64 | export class Outline { 65 | /** 66 | * The Order that manages this list's Positions and their metadata. 67 | * See [Managing Metadata](https://github.com/mweidner037/list-positions#managing-metadata). 68 | */ 69 | readonly order: Order; 70 | private readonly itemList: ItemList; 71 | 72 | /** 73 | * Constructs an Outline, initially empty. 74 | * 75 | * @param order The Order to use for `this.order`. 76 | * Multiple Lists/Texts/Outlines/AbsLists can share an Order; they then automatically 77 | * share metadata. If not provided, a `new Order()` is used. 78 | * 79 | * @see {@link Outline.fromPositions} To construct an Outline from an initial set of Positions. 80 | */ 81 | constructor(order?: Order) { 82 | this.order = order ?? new Order(); 83 | this.itemList = new ItemList(this.order, sparseIndicesFactory); 84 | } 85 | 86 | /** 87 | * Returns a new Outline using the given Order and with the given set of Positions. 88 | * 89 | * Like when loading a saved state, you must deliver all of the Positions' 90 | * dependent metadata to `order` before calling this method. 91 | */ 92 | static fromPositions(positions: Iterable, order: Order): Outline { 93 | const outline = new Outline(order); 94 | for (const pos of positions) { 95 | outline.add(pos); 96 | } 97 | return outline; 98 | } 99 | 100 | /** 101 | * Returns a new Outline using the given Order and with the given 102 | * items (as defined by {@link Outline.items}). 103 | * 104 | * Like when loading a saved state, you must deliver all of the Positions' 105 | * dependent metadata to `order` before calling this method. 106 | */ 107 | static fromItems( 108 | items: Iterable<[startPos: Position, count: number]>, 109 | order: Order 110 | ): Outline { 111 | const outline = new Outline(order); 112 | for (const [startPos, count] of items) { 113 | outline.add(startPos, count); 114 | } 115 | return outline; 116 | } 117 | 118 | // ---------- 119 | // Mutators 120 | // ---------- 121 | 122 | /** 123 | * Adds the given Position. 124 | * 125 | * If the position is already present, nothing happens. 126 | * Otherwise, later positions in the list shift right 127 | * (increment their index). 128 | */ 129 | add(pos: Position): void; 130 | /** 131 | * Adds a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 132 | * 133 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 134 | * Note that these Positions might not be contiguous anymore, if later 135 | * Positions were created between them. 136 | * 137 | * @see {@link expandPositions} 138 | */ 139 | add(startPos: Position, sameBunchCount?: number): void; 140 | add(startPos: Position, count = 1): void { 141 | this.itemList.set(startPos, count); 142 | } 143 | 144 | /** 145 | * Deletes the given position, making it no longer present in the list. 146 | * 147 | * If the position was indeed present, later positions in the list shift left (decrement their index). 148 | */ 149 | delete(pos: Position): void; 150 | /** 151 | * Deletes a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 152 | * 153 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 154 | * Note that these Positions might not be contiguous anymore, if later 155 | * Positions were created between them. 156 | * 157 | * @see {@link expandPositions} 158 | */ 159 | delete(startPos: Position, sameBunchCount?: number): void; 160 | delete(startPos: Position, count = 1): void { 161 | this.itemList.delete(startPos, count); 162 | } 163 | 164 | /** 165 | * Deletes `count` positions starting at `index`. 166 | * 167 | * @throws If any of `index`, ..., `index + count - 1` are not in `[0, this.length)`. 168 | */ 169 | deleteAt(index: number, count = 1): void { 170 | const toDelete: Position[] = []; 171 | for (let i = 0; i < count; i++) { 172 | toDelete.push(this.positionAt(index + i)); 173 | } 174 | for (const pos of toDelete) this.itemList.delete(pos, 1); 175 | } 176 | 177 | /** 178 | * Deletes every Position in the list, making it empty. 179 | * 180 | * `this.order` is unaffected (retains all metadata). 181 | */ 182 | clear() { 183 | this.itemList.clear(); 184 | } 185 | 186 | /** 187 | * Inserts a new Position just after prevPos. 188 | 189 | * Later positions in the list shift right 190 | * (increment their index). 191 | * 192 | * In a collaborative setting, the new Position is *globally unique*, even 193 | * if other users call `Outline.insert` (or similar methods) concurrently. 194 | * 195 | * @returns [new Position, [new bunch's BunchMeta](https://github.com/mweidner037/list-positions#newMeta) (or null)]. 196 | * @throws If prevPos is MAX_POSITION. 197 | */ 198 | insert(prevPos: Position): [pos: Position, newMeta: BunchMeta | null]; 199 | /** 200 | * Inserts `count` new Positions just after prevPos. 201 | * 202 | * The new Positions all use the same [bunch](https://github.com/mweidner037/list-positions#bunches), with sequential 203 | * `innerIndex` (starting at the returned startPos). 204 | * They are originally contiguous, but may become non-contiguous in the future, 205 | * if new Positions are created between them. 206 | * 207 | * @returns [starting Position, [new bunch's BunchMeta](https://github.com/mweidner037/list-positions#newMeta) (or null)]. 208 | * Use {@link expandPositions} to convert (startPos, count) to an array of Positions. 209 | * @throws If prevPos is MAX_POSITION. 210 | * @throws If no values are provided. 211 | */ 212 | insert( 213 | prevPos: Position, 214 | count?: number 215 | ): [startPos: Position, newMeta: BunchMeta | null]; 216 | insert( 217 | prevPos: Position, 218 | count = 1 219 | ): [startPos: Position, newMeta: BunchMeta | null] { 220 | return this.itemList.insert(prevPos, count); 221 | } 222 | 223 | /** 224 | * Inserts a new Position at `index` (i.e., between the positions at `index - 1` and `index`). 225 | * 226 | * Later positions in the list shift right 227 | * (increment their index). 228 | * 229 | * In a collaborative setting, the new Position is *globally unique*, even 230 | * if other users call `Outline.insertAt` (or similar methods) concurrently. 231 | * 232 | * @returns [new Position, [new bunch's BunchMeta](https://github.com/mweidner037/list-positions#newMeta) (or null)]. 233 | * @throws If index is not in `[0, this.length]`. The index `this.length` is allowed and will cause an append. 234 | */ 235 | insertAt(index: number): [pos: Position, newMeta: BunchMeta | null]; 236 | /** 237 | * Inserts `count` new Positions at `index` (i.e., between the values at `index - 1` and `index`). 238 | * 239 | * The new Positions all use the same [bunch](https://github.com/mweidner037/list-positions#bunches), with sequential 240 | * `innerIndex` (starting at the returned startPos). 241 | * They are originally contiguous, but may become non-contiguous in the future, 242 | * if new Positions are created between them. 243 | * 244 | * @returns [starting Position, [new bunch's BunchMeta](https://github.com/mweidner037/list-positions#newMeta) (or null)]. 245 | * Use {@link expandPositions} to convert (startPos, count) to an array of Positions. 246 | * @throws If index is not in `[0, this.length]`. The index `this.length` is allowed and will cause an append. 247 | * @throws If count is 0. 248 | */ 249 | insertAt( 250 | index: number, 251 | count?: number 252 | ): [startPos: Position, newMeta: BunchMeta | null]; 253 | insertAt( 254 | index: number, 255 | count = 1 256 | ): [startPos: Position, newMeta: BunchMeta | null] { 257 | return this.itemList.insertAt(index, count); 258 | } 259 | 260 | // ---------- 261 | // Accessors 262 | // ---------- 263 | 264 | /** 265 | * Returns whether the given position is currently present in the list. 266 | */ 267 | has(pos: Position): boolean { 268 | return this.itemList.has(pos); 269 | } 270 | 271 | /** 272 | * Returns the current index of the given position. 273 | * 274 | * If pos is not currently present in the list, 275 | * then the result depends on searchDir: 276 | * - "none" (default): Returns -1. 277 | * - "left": Returns the next index to the left of pos. 278 | * If there are no values to the left of pos, 279 | * returns -1. 280 | * - "right": Returns the next index to the right of pos. 281 | * If there are no values to the right of pos, 282 | * returns `this.length`. 283 | * 284 | * To find the index where a position would be if 285 | * present, use `searchDir = "right"`. 286 | */ 287 | indexOfPosition( 288 | pos: Position, 289 | searchDir: "none" | "left" | "right" = "none" 290 | ): number { 291 | return this.itemList.indexOfPosition(pos, searchDir); 292 | } 293 | 294 | /** 295 | * Returns the position currently at index. 296 | * 297 | * @throws If index is not in `[0, this.length)`. 298 | */ 299 | positionAt(index: number): Position { 300 | return this.itemList.positionAt(index); 301 | } 302 | 303 | /** 304 | * The length of the list. 305 | */ 306 | get length() { 307 | return this.itemList.length; 308 | } 309 | 310 | /** 311 | * Returns the cursor at `index` within the list, i.e., in the gap between the positions at `index - 1` and `index`. 312 | * See [Cursors](https://github.com/mweidner037/list-positions#cursors). 313 | * 314 | * Invert with {@link indexOfCursor}, possibly on a different List/Text/Outline/AbsList or a different device. 315 | * 316 | * @param bind Whether to bind to the left or the right side of the gap, in case positions 317 | * later appear between `index - 1` and `index`. Default: `"left"`, which is typical for text cursors. 318 | * @throws If index is not in the range `[0, list.length]`. 319 | */ 320 | cursorAt(index: number, bind?: "left" | "right"): Position { 321 | return this.itemList.cursorAt(index, bind); 322 | } 323 | 324 | /** 325 | * Returns the current index of `cursor` within the list. 326 | * That is, the cursor is between the list elements at `index - 1` and `index`. 327 | * 328 | * Inverts {@link cursorAt}. 329 | * 330 | * @param bind The `bind` value that was used with {@link cursorAt}, if any. 331 | */ 332 | indexOfCursor(cursor: Position, bind?: "left" | "right"): number { 333 | return this.itemList.indexOfCursor(cursor, bind); 334 | } 335 | 336 | // ---------- 337 | // Iterators 338 | // ---------- 339 | 340 | /** 341 | * Iterates over present positions, in list order. 342 | */ 343 | [Symbol.iterator](): IterableIterator { 344 | return this.positions(); 345 | } 346 | 347 | /** 348 | * Iterates over present positions, in list order. 349 | * 350 | * Optionally, you may specify a range of indices `[start, end)` instead of 351 | * iterating the entire list. 352 | * 353 | * @throws If `start < 0`, `end > this.length`, or `start > end`. 354 | */ 355 | *positions(start?: number, end?: number): IterableIterator { 356 | for (const [ 357 | { bunchID, innerIndex: startInnerIndex }, 358 | item, 359 | ] of this.itemList.items(start, end)) { 360 | for (let i = 0; i < item; i++) { 361 | yield { bunchID, innerIndex: startInnerIndex + i }; 362 | } 363 | } 364 | } 365 | 366 | /** 367 | * Iterates over items, in list order. 368 | * 369 | * Each *item* is a series of entries that have contiguous positions 370 | * from the same [bunch](https://github.com/mweidner037/list-positions#bunches). 371 | * Specifically, for an item [startPos, count], the positions start at `startPos` 372 | * and have the same `bunchID` but increasing `innerIndex`. 373 | * 374 | * You can use this method as an optimized version of other iterators, or as 375 | * an alternative save format that is in list order (see {@link Outline.fromItems}). 376 | * 377 | * Optionally, you may specify a range of indices `[start, end)` instead of 378 | * iterating the entire list. 379 | * 380 | * @throws If `start < 0`, `end > this.length`, or `start > end`. 381 | */ 382 | items( 383 | start?: number, 384 | end?: number 385 | ): IterableIterator<[startPos: Position, count: number]> { 386 | return this.itemList.items(start, end); 387 | } 388 | 389 | /** 390 | * Iterates over all dependencies of the current state, 391 | * in no particular order. 392 | * 393 | * These are the combined dependencies of all 394 | * currently-present Positions - see [Managing Metadata](https://github.com/mweidner037/list-positions#save-load). 395 | * 396 | * As an optimization, you can save just these dependencies instead of the entire Order's state. 397 | * Be cautious, though, because that may omit BunchMetas that you 398 | * need for other reasons - e.g., to understand a cursor stored separately, 399 | * or a concurrent message from a collaborator. 400 | */ 401 | dependencies(): IterableIterator { 402 | return this.itemList.dependencies(); 403 | } 404 | 405 | // ---------- 406 | // Save & Load 407 | // ---------- 408 | 409 | /** 410 | * Returns a saved state for this Outline. 411 | * 412 | * The saved state describes our current set of Positions in JSON-serializable form. 413 | * You can load this state on another Outline by calling `load(savedState)`, 414 | * possibly in a different session or on a different device. 415 | */ 416 | save(): OutlineSavedState { 417 | return this.itemList.save(); 418 | } 419 | 420 | /** 421 | * Loads a saved state returned by another Outline's `save()` method. 422 | * 423 | * Loading sets our set of Positions to match the saved Outline's, *overwriting* 424 | * our current state. 425 | * 426 | * **Before loading a saved state, you must deliver its dependent metadata 427 | * to this.order**. For example, you could save and load the Order's state 428 | * alongside the Outline's state, making sure to load the Order first. 429 | * See [Managing Metadata](https://github.com/mweidner037/list-positions#save-load) for an example 430 | * with List (Outline is analogous). 431 | */ 432 | load(savedState: OutlineSavedState): void { 433 | this.itemList.load(savedState); 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/order/abs_position.ts: -------------------------------------------------------------------------------- 1 | import { 2 | arrayShallowEquals, 3 | parseMaybeDotID, 4 | stringifyMaybeDotID, 5 | } from "../internal/util"; 6 | import { BunchMeta } from "./bunch"; 7 | import { BunchIDs } from "./bunch_ids"; 8 | 9 | /** 10 | * AbsPosition analog of a BunchMeta. 11 | * 12 | * It encodes a bunch's ID together with all of its dependent metadata, 13 | * in a compressed form. 14 | * 15 | * The precise encoding is described in 16 | * [Internals.md](https://github.com/mweidner037/list-positions/blob/master/internals.md#abspositions). 17 | * 18 | * @see {@link AbsPositions} Utilities for manipulating AbsBunchMetas and AbsPositions. 19 | */ 20 | export type AbsBunchMeta = { 21 | /** 22 | * Deduplicated replicaIDs, indexed into by replicaIndices. 23 | */ 24 | readonly replicaIDs: readonly string[]; 25 | /** 26 | * Non-negative integers. 27 | */ 28 | readonly replicaIndices: readonly number[]; 29 | /** 30 | * Non-negative integers. Same length as replicaIndices. 31 | */ 32 | readonly counterIncs: readonly number[]; 33 | /** 34 | * Non-negative integers. One shorter than replicaIndices, unless both are empty. 35 | */ 36 | readonly offsets: readonly number[]; 37 | }; 38 | 39 | // Not exported because I have yet to use it externally. 40 | // Normally you should compare AbsBunchMetas by their bunchIDs alone. 41 | /** 42 | * Returns whether two AbsBunchMetas are equal, i.e., they have equal contents. 43 | */ 44 | function absBunchMetaEquals(a: AbsBunchMeta, b: AbsBunchMeta): boolean { 45 | if (a === b) return true; 46 | return ( 47 | arrayShallowEquals(a.replicaIndices, b.replicaIndices) && 48 | arrayShallowEquals(a.counterIncs, b.counterIncs) && 49 | arrayShallowEquals(a.offsets, b.offsets) && 50 | arrayShallowEquals(a.replicaIDs, b.replicaIDs) 51 | ); 52 | } 53 | 54 | /** 55 | * A position in a list, as a JSON object that embeds all of its dependent metadata. 56 | * 57 | * AbsPositions let you treat a list as an ordered map `(position -> value)`, 58 | * where a value's *position* doesn't change over time - unlike an array index. 59 | * 60 | * Unlike with this library's Positions, you do not need to [Manage Metadata](https://github.com/mweidner037/list-positions#managing-metadata) 61 | * when using AbsPositions. However, an AbsPosition is larger than its corresponding Position. See the 62 | * [readme](https://github.com/mweidner037/list-positions#abslist-and-absposition) for details. 63 | * 64 | * @see {@link AbsPositions} Utilities for manipulating AbsPositions. 65 | */ 66 | export type AbsPosition = { 67 | /** 68 | * A description of the [bunch](https://github.com/mweidner037/list-positions#bunches) containing this position. 69 | * 70 | * It encodes the bunch's ID together with all of its dependent metadata, 71 | * in a compressed form. 72 | */ 73 | readonly bunchMeta: AbsBunchMeta; 74 | /** 75 | * The index of this position within its [bunch](https://github.com/mweidner037/list-positions#bunches). 76 | * A nonnegative integer. 77 | */ 78 | readonly innerIndex: number; 79 | }; 80 | 81 | const ROOT_BUNCH_META: AbsBunchMeta = { 82 | replicaIDs: [], 83 | replicaIndices: [], 84 | counterIncs: [], 85 | offsets: [], 86 | }; 87 | 88 | const MIN_POSITION: AbsPosition = { 89 | bunchMeta: ROOT_BUNCH_META, 90 | innerIndex: 0, 91 | }; 92 | 93 | const MAX_POSITION: AbsPosition = { 94 | bunchMeta: ROOT_BUNCH_META, 95 | innerIndex: 1, 96 | }; 97 | 98 | /** 99 | * Utilities for working with AbsPositions. 100 | */ 101 | export const AbsPositions = { 102 | /** 103 | * The special root node's AbsBunchMeta. 104 | * 105 | * The only valid AbsPositions using this are {@link AbsPositions.MIN_POSITION} and {@link AbsPositions.MAX_POSITION}. 106 | */ 107 | ROOT_BUNCH_META, 108 | 109 | /** 110 | * The minimum AbsPosition in any Order. 111 | * 112 | * This position is defined to be less than all other positions. 113 | * Its value is 114 | * ``` 115 | * { bunchMeta: AbsPositions.ROOT_BUNCH_META, innerIndex: 0 } 116 | * ``` 117 | */ 118 | MIN_POSITION, 119 | 120 | /** 121 | * The maximum AbsPosition in any Order. 122 | * 123 | * This position is defined to be greater than all other positions. 124 | * Its value is 125 | * ``` 126 | * { bunchMeta: AbsPositions.ROOT_BUNCH_META, innerIndex: 1 } 127 | * ``` 128 | */ 129 | MAX_POSITION, 130 | 131 | /** 132 | * Encodes a bunch's ID and dependencies as an AbsBunchMeta. 133 | * 134 | * Typically, you will not call this method directly, instead using either 135 | * `Order.abs` or `BunchNode.absMeta`. 136 | * 137 | * Invert with {@link AbsPositions.decodeMetas}. 138 | * 139 | * @param pathToRoot The BunchMetas on the bunch's path to the root, as returned 140 | * by `BunchNode.dependencies`. 141 | * @throws If `pathToRoot` is not a valid path, i.e., one entry's `parentID` 142 | * does not match the next entry's `bunchID`. 143 | */ 144 | encodeMetas(pathToRoot: Iterable): AbsBunchMeta { 145 | // Encode the pathToRoot in order, deduplicating replicaIDs. 146 | // See https://github.com/mweidner037/list-positions/blob/master/internals.md#abspositions 147 | // for a description of the format. 148 | 149 | const replicaIDs: string[] = []; 150 | const replicaIDsInv = new Map(); 151 | const replicaIndices: number[] = []; 152 | const counterIncs: number[] = []; 153 | const offsets: number[] = []; 154 | 155 | let prevParentID: string | null = null; 156 | for (const bunchMeta of pathToRoot) { 157 | if (prevParentID !== null && bunchMeta.bunchID !== prevParentID) { 158 | throw new Error( 159 | `Invalid pathToRoot: bunchID "${bunchMeta.bunchID}" does not match previous parentID "${prevParentID}"` 160 | ); 161 | } 162 | prevParentID = bunchMeta.parentID; 163 | 164 | const [replicaID, counter] = parseMaybeDotID(bunchMeta.bunchID); 165 | let replicaIndex = replicaIDsInv.get(replicaID); 166 | if (replicaIndex === undefined) { 167 | replicaIndex = replicaIDs.length; 168 | replicaIDs.push(replicaID); 169 | replicaIDsInv.set(replicaID, replicaIndex); 170 | } 171 | 172 | replicaIndices.push(replicaIndex); 173 | counterIncs.push(counter + 1); 174 | offsets.push(bunchMeta.offset); 175 | } 176 | 177 | if (prevParentID === null) { 178 | // Empty iterator => it was the root node. 179 | // Reuse the existing object instead of creating a new one. 180 | return ROOT_BUNCH_META; 181 | } 182 | 183 | // The last node must be a child of MIN_POSITION. 184 | if (!(prevParentID === BunchIDs.ROOT && offsets.at(-1) === 1)) { 185 | throw new Error("Invalid pathToRoot: does not end at root"); 186 | } 187 | // We omit the last offset because it is always 1. 188 | offsets.pop(); 189 | 190 | return { replicaIDs, replicaIndices, counterIncs, offsets }; 191 | }, 192 | 193 | /** 194 | * Decodes an AbsBunchMeta, returning the corresponding bunch's dependencies. 195 | * 196 | * Inverse of {@link AbsPositions.encodeMetas}. 197 | * 198 | * @see {@link AbsPositions.getBunchID} Function to quickly return just the bunch's ID. 199 | */ 200 | decodeMetas(absBunchMeta: AbsBunchMeta): BunchMeta[] { 201 | if (absBunchMeta.replicaIndices.length === 0) return []; 202 | 203 | const bunchMetas: BunchMeta[] = []; 204 | let nextBunchID = stringifyMaybeDotID( 205 | absBunchMeta.replicaIDs[absBunchMeta.replicaIndices[0]], 206 | absBunchMeta.counterIncs[0] - 1 207 | ); 208 | for (let i = 0; i < absBunchMeta.replicaIndices.length - 1; i++) { 209 | const parentID = stringifyMaybeDotID( 210 | absBunchMeta.replicaIDs[absBunchMeta.replicaIndices[i + 1]], 211 | absBunchMeta.counterIncs[i + 1] - 1 212 | ); 213 | bunchMetas.push({ 214 | bunchID: nextBunchID, 215 | parentID, 216 | offset: absBunchMeta.offsets[i], 217 | }); 218 | nextBunchID = parentID; 219 | } 220 | // The last bunch is a child of MIN_POSITION. 221 | bunchMetas.push({ 222 | bunchID: nextBunchID, 223 | parentID: BunchIDs.ROOT, 224 | offset: 1, 225 | }); 226 | return bunchMetas; 227 | }, 228 | 229 | /** 230 | * Returns the bunchID corresponding to the given AbsBunchMeta. 231 | */ 232 | getBunchID(absBunchMeta: AbsBunchMeta): string { 233 | if (absBunchMeta.replicaIndices.length === 0) { 234 | return BunchIDs.ROOT; 235 | } 236 | 237 | const replicaID = absBunchMeta.replicaIDs[absBunchMeta.replicaIndices[0]]; 238 | const counterInc = absBunchMeta.counterIncs[0]; 239 | return stringifyMaybeDotID(replicaID, counterInc - 1); 240 | }, 241 | 242 | /** 243 | * Returns whether two Positions are equal, i.e., they have equal contents. 244 | */ 245 | positionEquals(a: AbsPosition, b: AbsPosition): boolean { 246 | return ( 247 | a.innerIndex === b.innerIndex && 248 | absBunchMetaEquals(a.bunchMeta, b.bunchMeta) 249 | ); 250 | }, 251 | 252 | /** 253 | * Returns an array of AbsPositions that start at `startPos` and have 254 | * sequentially increasing `innerIndex`. 255 | * 256 | * You can use this method to expand on the `startPos` returned by 257 | * the bulk versions of `AbsList.insertAt`, etc. 258 | */ 259 | expandPositions( 260 | startPos: AbsPosition, 261 | sameBunchCount: number 262 | ): AbsPosition[] { 263 | const ans: AbsPosition[] = []; 264 | for (let i = 0; i < sameBunchCount; i++) { 265 | ans.push({ 266 | bunchMeta: startPos.bunchMeta, 267 | innerIndex: startPos.innerIndex + i, 268 | }); 269 | } 270 | return ans; 271 | }, 272 | } as const; 273 | -------------------------------------------------------------------------------- /src/order/bunch.ts: -------------------------------------------------------------------------------- 1 | import { AbsBunchMeta } from "./abs_position"; 2 | 3 | /** 4 | * Metadata for a [bunch](https://github.com/mweidner037/list-positions#bunches) 5 | * of Positions, as a JSON object. 6 | * 7 | * In scenarios with multiple related Lists (e.g., collaborative text editing), 8 | * you often need to use a Position with a List different from the List that 9 | * created it. Before doing so, you must call `list.order.addMetas` with the 10 | * BunchMeta corresponding to that Position's bunch and its ancestors. 11 | * See [Managing Metadata](https://github.com/mweidner037/list-positions#managing-metadata). 12 | */ 13 | export type BunchMeta = { 14 | /** 15 | * The bunch's ID. 16 | */ 17 | readonly bunchID: string; 18 | /** 19 | * The parent bunch's ID. 20 | * 21 | * A bunch depends on its parent's metadata. So before (or at the same time 22 | * as) you call `list.order.addMetas` on this BunchMeta, 23 | * you must do so for the parent's BunchMeta, unless `parentID == "ROOT"`. 24 | * 25 | * Parent relations form a tree that is used to order 26 | * this bunch's Positions. See [Internals](https://github.com/mweidner037/list-positions/tree/master/internals.md) for details. 27 | */ 28 | readonly parentID: string; 29 | /** 30 | * A non-negative integer offset. 31 | * 32 | * Offsets are used by the tree to order the 33 | * bunch's Positions. 34 | * They are not necessarily assigned in counting order for a given parentID. 35 | * See [Internals](https://github.com/mweidner037/list-positions/tree/master/internals.md) for details. 36 | */ 37 | readonly offset: number; 38 | }; 39 | 40 | /** 41 | * An Order's internal tree node corresponding to a [bunch](https://github.com/mweidner037/list-positions#bunches) of Positions. 42 | * 43 | * You can access a bunch's BunchNode to retrieve its dependent metadata, using the `meta()` and `dependencies()` methods. 44 | * For advanced usage, BunchNode also gives low-level access to an Order's 45 | * [internal tree](https://github.com/mweidner037/list-positions/blob/master/internals.md). 46 | * 47 | * Obtain BunchNodes using `Order.getNode` or `Order.getNodeFor`. 48 | * 49 | * Note: BunchNodes are **not** JSON-serializable, unlike Position and BunchMeta. 50 | * 51 | * @see {@link Order.rootNode} An Order's root BunchNode, which has `bunchID == "ROOT"`. 52 | */ 53 | export interface BunchNode { 54 | /** 55 | * The bunch's ID. 56 | */ 57 | readonly bunchID: string; 58 | /** 59 | * The parent bunch's BunchNode. 60 | * 61 | * null for the root node. 62 | */ 63 | readonly parent: BunchNode | null; 64 | /** 65 | * The bunch's offset within its parent. 66 | * 67 | * @see {@link BunchNode.nextInnerIndex} 68 | */ 69 | readonly offset: number; 70 | /** 71 | * The bunch's depth in the tree. 72 | * 73 | * 0 for the root node, 1 for its children, etc. 74 | */ 75 | readonly depth: number; 76 | 77 | /** 78 | * The innerIndex of the next parent Position after this bunch. 79 | * 80 | * All of this bunch's Positions, and its descendants' Positions, 81 | * appear between the parent's Positions 82 | * ```ts 83 | * { 84 | * bunchID: this.parent.bunchID, 85 | * innerIndex: this.nextInnerIndex - 1 86 | * } 87 | * ``` 88 | * and 89 | * ```ts 90 | * { 91 | * bunchID: this.parent.bunchID, 92 | * innerIndex: this.nextInnerIndex 93 | * } 94 | * ``` 95 | * (If `nextInnerIndex == 0`, they are less than all of the parent's Positions.) 96 | */ 97 | readonly nextInnerIndex: number; 98 | 99 | /** 100 | * Returns the bunch's BunchMeta. 101 | * 102 | * @throws If this is the root node, which has no BunchMeta. 103 | */ 104 | meta(): BunchMeta; 105 | 106 | /** 107 | * Iterates over the bunch's dependencies. 108 | * 109 | * These are the bunch's BunchMeta, its parent's BunchMeta, 110 | * etc., up the tree until reaching the root (exclusive). 111 | * They are iterated in upwards order. 112 | */ 113 | dependencies(): IterableIterator; 114 | 115 | /** 116 | * Returns the bunch's AbsBunchMeta: a struct that encodes all of its dependencies in a 117 | * compressed form. 118 | * 119 | * AbsBunchMeta is used internally by AbsPosition/AbsList. You can also use it independently, 120 | * as an efficient substitute for `[...this.dependencies()]`. 121 | * 122 | * @see {@link AbsPositions.decodeMetas} To convert the AbsBunchMeta back into the array 123 | * `[...this.dependencies()]`, e.g., for passing to `Order.addMetas`. 124 | */ 125 | absMeta(): AbsBunchMeta; 126 | 127 | /** 128 | * The number of child nodes in the Order's current tree. 129 | * 130 | * This may increase as more BunchMetas are delivered to `Order.addMetas`. 131 | */ 132 | readonly childrenLength: number; 133 | /** 134 | * Returns the `index`-th child node in the Order's current tree. 135 | * 136 | * The children are in sort order, i.e., all of child 0's Positions are less 137 | * than all of child 1's Positions. 138 | * Note that some of this bunch's own Positions may be between between adjacent children, 139 | * and new children may be inserted as more BunchMetas are delivered to `Order.addMetas`. 140 | */ 141 | getChild(index: number): BunchNode; 142 | 143 | toString(): string; 144 | } 145 | 146 | /** 147 | * [Compare function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#comparefn) 148 | * for **sibling** BunchNodes in an Order, i.e., BunchNodes with the same `parent`. 149 | * 150 | * You do not need to call this function unless you are doing something advanced. 151 | * To compare Positions, instead use `Order.compare` or a List. To iterate over 152 | * a BunchNode's children in order, instead use its childrenLength and getChild properties. 153 | * 154 | * The sort order is: 155 | * - First, sort siblings by `offset`. 156 | * - To break ties, sort lexicographically by `bunchID`. 157 | */ 158 | export function compareSiblingNodes(a: BunchNode, b: BunchNode): number { 159 | if (a.parent !== b.parent) { 160 | throw new Error( 161 | `Inputs to compareSiblingNodes must have the same parent, not a=${a}, b=${b}` 162 | ); 163 | } 164 | 165 | // Sibling sort order: first by offset, then by id. 166 | if (a.offset !== b.offset) { 167 | return a.offset - b.offset; 168 | } 169 | if (a.bunchID !== b.bunchID) { 170 | return a.bunchID > b.bunchID ? 1 : -1; 171 | } 172 | return 0; 173 | } 174 | -------------------------------------------------------------------------------- /src/order/bunch_ids.ts: -------------------------------------------------------------------------------- 1 | import { maybeRandomString } from "maybe-random-string"; 2 | import { parseMaybeDotID, stringifyMaybeDotID } from "../internal/util"; 3 | 4 | /** 5 | * Utilities for generating `bunchIDs`. 6 | * 7 | * Use these with Order's `newBunchID` constructor option. 8 | */ 9 | export const BunchIDs = { 10 | /** 11 | * Reserved for the special root bunch's ID. 12 | */ 13 | ROOT: "ROOT", 14 | 15 | /** 16 | * Returns a `newBunchID` function for Order's constructor that uses 17 | * [dot IDs](https://mattweidner.com/2023/09/26/crdt-survey-3.html#unique-ids-dots): 18 | * each bunchID has the form `` `${replicaID}_${counter.toString(36)}` ``, 19 | * where replicaID is a globally-unique string and the counter counts from 0. 20 | * 21 | * @param replicaID The replicaID to use. 22 | * Default: A random alphanumeric string from the 23 | * [maybe-random-string](https://github.com/mweidner037/maybe-random-string#readme) package. 24 | * @see {@link BunchIDs.parseUsingReplicaID} 25 | */ 26 | usingReplicaID(replicaID?: string): () => string { 27 | const theReplicaID = replicaID ?? maybeRandomString(); 28 | 29 | let counter = 0; 30 | return function () { 31 | const bunchID = stringifyMaybeDotID(theReplicaID, counter); 32 | counter++; 33 | return bunchID; 34 | }; 35 | }, 36 | 37 | /** 38 | * Parses a bunchID created by BunchIDs.usingReplicaID into its pair (replicaID, counter). 39 | * 40 | * In advanced usage, parsing may allow you to optimize the size of saved 41 | * states that include many bunchIDs. 42 | * For example, you could turn ListSavedState's map 43 | * ```ts 44 | * { 45 | * [bunchID: string]: (T[] | number)[]; 46 | * } 47 | * ``` 48 | * into a double map 49 | * ```ts 50 | * { 51 | * [replicaID: string]: { 52 | * [counter: number]: (T[] | number)[]; 53 | * } 54 | * } 55 | * ``` 56 | * in order to deduplicate the often-repeated replicaIDs. (However, GZIP may work 57 | * just as well.) 58 | */ 59 | parseUsingReplicaID(bunchID: string): [replicaID: string, counter: number] { 60 | const [replicaID, counter] = parseMaybeDotID(bunchID); 61 | if (counter === -1) { 62 | throw new Error( 63 | `bunchID is not from BunchIDs.usingReplicaID: "${bunchID}"` 64 | ); 65 | } 66 | return [replicaID, counter]; 67 | }, 68 | } as const; 69 | -------------------------------------------------------------------------------- /src/order/lexicographic_string.ts: -------------------------------------------------------------------------------- 1 | import { sequence } from "lex-sequence"; 2 | import { stringifyMaybeDotID } from "../internal/util"; 3 | import { AbsPosition } from "./abs_position"; 4 | 5 | const OFFSET_BASE = 36; 6 | 7 | // This function is on its own, instead of a method on Order or AbsPositions, 8 | // to avoid bringing in the lex-sequence dependency unless you actually 9 | // call lexicographicString. 10 | 11 | /** 12 | * Returns a string with the property: The lexicographic order on strings matches 13 | * the list order on positions. 14 | * 15 | * Lexicographic strings are useful as an escape hatch for interacting with systems 16 | * that cannot use this library directly but still want to access the list order. 17 | * E.g., you can `ORDER BY` the lexicographic strings in a database table. 18 | * 19 | * However, storing a set of (lexicographic string, value) pairs directly uses much 20 | * more memory than our built-in data structures (List etc.). 21 | * Also, a lexicographic string is generally somewhat larger than its corresponding AbsPosition. 22 | * Thus the strings are best used sparingly, or for short lists only. 23 | * 24 | * - If you plan to use lexicographic strings exclusively, consider using the 25 | * [position-strings](https://github.com/mweidner037/position-strings#readme) 26 | * package instead, which is optimized for that use case (smaller JS bundle & more compact strings). 27 | * 28 | * To call this function on a Position `pos` belonging to an Order `order`, use `lexicographicString(order.abs(pos))`. 29 | */ 30 | export function lexicographicString(pos: AbsPosition): string { 31 | const { replicaIndices, replicaIDs, counterIncs, offsets } = pos.bunchMeta; 32 | 33 | // See https://github.com/mweidner037/list-positions/blob/master/internals.md 34 | // for a description of the string format. 35 | 36 | if (replicaIndices.length === 0) { 37 | // Root bunch. MIN_POSITION -> "", MAX_POSITION -> "~". 38 | return pos.innerIndex === 0 ? "" : "~"; 39 | } 40 | 41 | let ans = ""; 42 | for (let i = replicaIndices.length - 1; i >= 0; i--) { 43 | if (i !== replicaIndices.length - 1) { 44 | // Offset layer. 45 | const offset = offsets[i]; 46 | ans += sequence(offset, OFFSET_BASE).toString(OFFSET_BASE) + "."; 47 | } 48 | 49 | // BunchID layer. 50 | const bunchID = stringifyMaybeDotID( 51 | replicaIDs[replicaIndices[i]], 52 | counterIncs[i] - 1 53 | ); 54 | // If the first char is >= 125 ('}'), escape it with '}', so that all strings 55 | // are less than the "~" used for MAX_POSITION. 56 | if (bunchID.length !== 0 && bunchID.charCodeAt(0) >= 125) { 57 | ans += "}"; 58 | } 59 | let lastB = 0; 60 | for (let b = 0; b < bunchID.length; b++) { 61 | // If a char is <= 45 ('-'), escape it with '-', so that it compares as greater than ',', 62 | // the end-of-bunchID delimiter. That way, bunchID prefixes sort normally. 63 | if (bunchID.charCodeAt(b) <= 45) { 64 | ans += bunchID.slice(lastB, b) + "-"; 65 | lastB = b; 66 | } 67 | } 68 | ans += bunchID.slice(lastB) + ","; 69 | } 70 | 71 | // Final innerIndex, converted to an offset. 72 | ans += sequence(2 * pos.innerIndex + 1, OFFSET_BASE).toString(OFFSET_BASE); 73 | 74 | return ans; 75 | } 76 | -------------------------------------------------------------------------------- /src/order/position.ts: -------------------------------------------------------------------------------- 1 | import { BunchIDs } from "./bunch_ids"; 2 | 3 | /** 4 | * A position in a list, as a JSON object. 5 | * 6 | * Positions let you treat a list as an ordered map `(position -> value)`, 7 | * where a value's *position* doesn't change over time - unlike an array index. 8 | * 9 | * Type Position is used with the library's List, Text, and Outline data structures. 10 | * You can also work with Positions independent of a specific list using an Order. 11 | * See the [readme](https://github.com/mweidner037/list-positions#list-position-and-order) 12 | * for details. 13 | * 14 | * See also: 15 | * - {@link positionEquals}: Equality function for Positions. 16 | * - {@link AbsPosition}: An alternative representation of positions that is easier to work with, used with AbsList. 17 | */ 18 | export type Position = { 19 | /** 20 | * The ID of the [bunch](https://github.com/mweidner037/list-positions#bunches) containing this Position. 21 | */ 22 | readonly bunchID: string; 23 | /** 24 | * The index of this Position within its [bunch](https://github.com/mweidner037/list-positions#bunches). 25 | * A nonnegative integer. 26 | */ 27 | readonly innerIndex: number; 28 | }; 29 | 30 | /** 31 | * The minimum Position in any Order. 32 | * 33 | * This Position is defined to be less than all other Positions. 34 | * Its value is 35 | * ``` 36 | * { bunchID: "ROOT", innerIndex: 0 } 37 | * ``` 38 | */ 39 | export const MIN_POSITION: Position = { 40 | bunchID: BunchIDs.ROOT, 41 | innerIndex: 0, 42 | } as const; 43 | 44 | /** 45 | * The maximum Position in any Order. 46 | * 47 | * This Position is defined to be greater than all other Positions. 48 | * Its value is 49 | * ``` 50 | * { bunchID: "ROOT", innerIndex: 1 } 51 | * ``` 52 | */ 53 | export const MAX_POSITION: Position = { 54 | bunchID: BunchIDs.ROOT, 55 | innerIndex: 1, 56 | } as const; 57 | 58 | /** 59 | * Returns whether two Positions are equal, i.e., they have equal contents. 60 | */ 61 | export function positionEquals(a: Position, b: Position): boolean { 62 | return a.bunchID === b.bunchID && a.innerIndex === b.innerIndex; 63 | } 64 | 65 | /** 66 | * Returns an array of Positions that start at `startPos` and have 67 | * sequentially increasing `innerIndex`. 68 | * 69 | * You can use this method to expand on the `startPos` returned by 70 | * `Order.createPositions` (and the bulk versions of `List.insertAt`, etc.). 71 | */ 72 | export function expandPositions( 73 | startPos: Position, 74 | sameBunchCount: number 75 | ): Position[] { 76 | const ans: Position[] = []; 77 | for (let i = 0; i < sameBunchCount; i++) { 78 | ans.push({ 79 | bunchID: startPos.bunchID, 80 | innerIndex: startPos.innerIndex + i, 81 | }); 82 | } 83 | return ans; 84 | } 85 | -------------------------------------------------------------------------------- /src/unordered_collections/position_char_map.ts: -------------------------------------------------------------------------------- 1 | import { SerializedSparseString, SparseString } from "sparse-array-rled"; 2 | import { TextSavedState } from "../lists/text"; 3 | import { Position } from "../order/position"; 4 | 5 | /** 6 | * A map from Positions to characters, **without ordering info**. 7 | * 8 | * This class is a simplified version of Text that does not consider the list order. 9 | * As a result, it does not require managing metadata, and it is slightly more efficient 10 | * than Text. 11 | * 12 | * For example, you can use a PositionCharMap to accumulate changes to save in a batch later. 13 | * There the list order is unnecessary and managing metadata could be inconvenient. 14 | * 15 | * The map values may also be embedded objects of type `E`. 16 | * Each embed takes the place of a single character. You can use embeds to represent 17 | * non-text content, like images and videos, that may appear inline in a text document. 18 | * If you do not specify the generic type `E`, it defaults to `never`, i.e., no embeds are allowed. 19 | * 20 | * @typeParam E - The type of embeds, or `never` (no embeds allowed) if not specified. 21 | * Embeds must be non-null objects. 22 | */ 23 | export class PositionCharMap { 24 | /** 25 | * The internal state of this PositionCharMap: A map from bunchID 26 | * to the [SparseString](https://github.com/mweidner037/sparse-array-rled#readme) 27 | * of values for that bunch. 28 | * 29 | * You are free to manipulate this state directly. 30 | */ 31 | readonly state: Map>; 32 | 33 | constructor() { 34 | this.state = new Map(); 35 | } 36 | 37 | // ---------- 38 | // Mutators 39 | // ---------- 40 | 41 | /** 42 | * Sets the char (or embed) at the given position. 43 | */ 44 | set(pos: Position, charOrEmbed: string | E): void; 45 | /** 46 | * Sets the chars at a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 47 | * 48 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 49 | * 50 | * @see {@link expandPositions} 51 | */ 52 | set(startPos: Position, chars: string): void; 53 | set(startPos: Position, charsOrEmbed: string | E): void { 54 | let arr = this.state.get(startPos.bunchID); 55 | if (arr === undefined) { 56 | arr = SparseString.new(); 57 | this.state.set(startPos.bunchID, arr); 58 | } 59 | arr.set(startPos.innerIndex, charsOrEmbed); 60 | } 61 | 62 | /** 63 | * Deletes the given position, making it and its char no longer present in the list. 64 | */ 65 | delete(pos: Position): void; 66 | /** 67 | * Deletes a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 68 | * 69 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 70 | * 71 | * @see {@link expandPositions} 72 | */ 73 | delete(startPos: Position, sameBunchCount?: number): void; 74 | delete(startPos: Position, count = 1): void { 75 | const arr = this.state.get(startPos.bunchID); 76 | if (arr === undefined) { 77 | // Already deleted. 78 | return; 79 | } 80 | arr.delete(startPos.innerIndex, count); 81 | 82 | // Clean up empty bunches. 83 | // Note: the invariant "empty => not present" might not hold if the 84 | // user directly manipulates this.state. 85 | if (arr.isEmpty()) this.state.delete(startPos.bunchID); 86 | } 87 | 88 | /** 89 | * Deletes every char in the list, making it empty. 90 | */ 91 | clear() { 92 | this.state.clear(); 93 | } 94 | 95 | // ---------- 96 | // Accessors 97 | // ---------- 98 | 99 | /** 100 | * Returns the char at the given position, or undefined if it is not currently present. 101 | */ 102 | get(pos: Position): string | E | undefined { 103 | return this.state.get(pos.bunchID)?.get(pos.innerIndex); 104 | } 105 | 106 | /** 107 | * Returns whether the given position is currently present in the map. 108 | */ 109 | has(pos: Position): boolean { 110 | return this.state.get(pos.bunchID)?.has(pos.innerIndex) ?? false; 111 | } 112 | 113 | // ---------- 114 | // Iterators 115 | // ---------- 116 | 117 | /** 118 | * Iterates over [pos, char (or embed)] pairs in the map, **in no particular order**. 119 | */ 120 | [Symbol.iterator](): IterableIterator< 121 | [pos: Position, charOrEmbed: string | E] 122 | > { 123 | return this.entries(); 124 | } 125 | 126 | /** 127 | * Iterates over [pos, char (or embed)] pairs in the map, **in no particular order**. 128 | */ 129 | *entries(): IterableIterator<[pos: Position, charOrEmbed: string | E]> { 130 | for (const [bunchID, arr] of this.state) { 131 | for (const [innerIndex, value] of arr.entries()) { 132 | yield [{ bunchID, innerIndex }, value]; 133 | } 134 | } 135 | } 136 | 137 | // ---------- 138 | // Save & Load 139 | // ---------- 140 | 141 | /** 142 | * Returns a saved state for this map, which is identical to its saved state as a Text. 143 | * 144 | * The saved state describes our current (Position -> char/embed) map in JSON-serializable form. 145 | * You can load this state on another PositionCharMap (or Text) by calling `load(savedState)`, 146 | * possibly in a different session or on a different device. 147 | */ 148 | save(): TextSavedState { 149 | const savedState: { [bunchID: string]: SerializedSparseString } = {}; 150 | for (const [bunchID, arr] of this.state) { 151 | if (!arr.isEmpty()) { 152 | savedState[bunchID] = arr.serialize(); 153 | } 154 | } 155 | return savedState; 156 | } 157 | 158 | /** 159 | * Loads a saved state returned by another PositionCharMap's (or Text's) `save()` method. 160 | * 161 | * Loading sets our (Position -> char/embed) map to match the saved PositionCharMap, *overwriting* 162 | * our current state. 163 | */ 164 | load(savedState: TextSavedState): void { 165 | this.clear(); 166 | 167 | for (const [bunchID, savedArr] of Object.entries(savedState)) { 168 | this.state.set(bunchID, SparseString.deserialize(savedArr)); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/unordered_collections/position_map.ts: -------------------------------------------------------------------------------- 1 | import { SerializedSparseArray, SparseArray } from "sparse-array-rled"; 2 | import { ListSavedState } from "../lists/list"; 3 | import { Position } from "../order/position"; 4 | 5 | /** 6 | * A map from Positions to values of type `T`, **without ordering info**. 7 | * 8 | * This class is a simplified version of `List` that does not consider the list order. 9 | * As a result, it does not require managing metadata, and it is slightly more efficient 10 | * than `List`. 11 | * 12 | * For example, you can use a PositionMap to accumulate changes to save in a batch later. 13 | * There the list order is unnecessary and managing metadata could be inconvenient. 14 | * 15 | * @typeParam T The value type. 16 | */ 17 | export class PositionMap { 18 | /** 19 | * The internal state of this PositionMap: A map from bunchID 20 | * to the [SparseArray](https://github.com/mweidner037/sparse-array-rled#readme) 21 | * of values for that bunch. 22 | * 23 | * You are free to manipulate this state directly. 24 | */ 25 | readonly state: Map>; 26 | 27 | constructor() { 28 | this.state = new Map(); 29 | } 30 | 31 | // ---------- 32 | // Mutators 33 | // ---------- 34 | 35 | /** 36 | * Sets the value at the given position. 37 | */ 38 | set(pos: Position, value: T): void; 39 | /** 40 | * Sets the values at a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 41 | * 42 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 43 | * 44 | * @see {@link expandPositions} 45 | */ 46 | set(startPos: Position, ...sameBunchValues: T[]): void; 47 | set(startPos: Position, ...values: T[]): void { 48 | let arr = this.state.get(startPos.bunchID); 49 | if (arr === undefined) { 50 | arr = SparseArray.new(); 51 | this.state.set(startPos.bunchID, arr); 52 | } 53 | arr.set(startPos.innerIndex, ...values); 54 | } 55 | 56 | /** 57 | * Deletes the given position, making it and its value no longer present in the map. 58 | */ 59 | delete(pos: Position): void; 60 | /** 61 | * Deletes a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 62 | * 63 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 64 | * 65 | * @see {@link expandPositions} 66 | */ 67 | delete(startPos: Position, sameBunchCount?: number): void; 68 | delete(startPos: Position, count = 1): void { 69 | const arr = this.state.get(startPos.bunchID); 70 | if (arr === undefined) { 71 | // Already deleted. 72 | return; 73 | } 74 | arr.delete(startPos.innerIndex, count); 75 | 76 | // Clean up empty bunches. 77 | // Note: the invariant "empty => not present" might not hold if the 78 | // user directly manipulates this.state. 79 | if (arr.isEmpty()) this.state.delete(startPos.bunchID); 80 | } 81 | 82 | /** 83 | * Deletes every position in the map, making it empty. 84 | */ 85 | clear() { 86 | this.state.clear(); 87 | } 88 | 89 | // ---------- 90 | // Accessors 91 | // ---------- 92 | 93 | /** 94 | * Returns the value at the given position, or undefined if it is not currently present. 95 | */ 96 | get(pos: Position): T | undefined { 97 | return this.state.get(pos.bunchID)?.get(pos.innerIndex); 98 | } 99 | 100 | /** 101 | * Returns whether the given position is currently present in the map. 102 | */ 103 | has(pos: Position): boolean { 104 | return this.state.get(pos.bunchID)?.has(pos.innerIndex) ?? false; 105 | } 106 | 107 | // ---------- 108 | // Iterators 109 | // ---------- 110 | 111 | /** 112 | * Iterates over [pos, value] pairs in the map, **in no particular order**. 113 | */ 114 | [Symbol.iterator](): IterableIterator<[pos: Position, value: T]> { 115 | return this.entries(); 116 | } 117 | 118 | /** 119 | * Iterates over [pos, value] pairs in the map, **in no particular order**. 120 | */ 121 | *entries(): IterableIterator<[pos: Position, value: T]> { 122 | for (const [bunchID, arr] of this.state) { 123 | for (const [innerIndex, value] of arr.entries()) { 124 | yield [{ bunchID, innerIndex }, value]; 125 | } 126 | } 127 | } 128 | 129 | // ---------- 130 | // Save & Load 131 | // ---------- 132 | 133 | /** 134 | * Returns a saved state for this map, which is identical to its saved state as a `List`. 135 | * 136 | * The saved state describes our current (Position -> value) map in JSON-serializable form. 137 | * You can load this state on another PositionMap (or List) by calling `load(savedState)`, 138 | * possibly in a different session or on a different device. 139 | */ 140 | save(): ListSavedState { 141 | const savedState: { [bunchID: string]: SerializedSparseArray } = {}; 142 | for (const [bunchID, arr] of this.state) { 143 | if (!arr.isEmpty()) { 144 | savedState[bunchID] = arr.serialize(); 145 | } 146 | } 147 | return savedState; 148 | } 149 | 150 | /** 151 | * Loads a saved state returned by another PositionMap's (or List's) `save()` method. 152 | * 153 | * Loading sets our (Position -> value) map to match the saved PositionMap, *overwriting* 154 | * our current state. 155 | */ 156 | load(savedState: ListSavedState): void { 157 | this.clear(); 158 | 159 | for (const [bunchID, savedArr] of Object.entries(savedState)) { 160 | this.state.set(bunchID, SparseArray.deserialize(savedArr)); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/unordered_collections/position_set.ts: -------------------------------------------------------------------------------- 1 | import { SerializedSparseIndices, SparseIndices } from "sparse-array-rled"; 2 | import { OutlineSavedState } from "../lists/outline"; 3 | import { Position } from "../order/position"; 4 | 5 | /** 6 | * A set of Positions, **without ordering info**. 7 | * 8 | * This class is a simplified version of Outline that does not consider the list order. 9 | * As a result, it does not require managing metadata, and it is slightly more efficient 10 | * than Outline. 11 | * 12 | * For example, you can use a PositionSet to track the set of deleted Positions in a CRDT. 13 | * See the [ListCrdt implementation](https://github.com/mweidner037/list-positions-crdts/blob/master/src/list_crdt.ts) 14 | * in @list-positions/crdts for sample code. 15 | */ 16 | export class PositionSet { 17 | /** 18 | * The internal state of this PositionSet: A map from bunchID 19 | * to the [SparseIndices](https://github.com/mweidner037/sparse-array-rled#readme) 20 | * corresponding to that bunch's present Positions. 21 | * 22 | * You are free to manipulate this state directly. 23 | */ 24 | readonly state: Map; 25 | 26 | constructor() { 27 | this.state = new Map(); 28 | } 29 | 30 | // ---------- 31 | // Mutators 32 | // ---------- 33 | 34 | /** 35 | * Adds the given Position. 36 | */ 37 | add(pos: Position): void; 38 | /** 39 | * Adds a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 40 | * 41 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 42 | * 43 | * @see {@link expandPositions} 44 | */ 45 | add(startPos: Position, sameBunchCount?: number): void; 46 | add(startPos: Position, count = 1): void { 47 | let arr = this.state.get(startPos.bunchID); 48 | if (arr === undefined) { 49 | arr = SparseIndices.new(); 50 | this.state.set(startPos.bunchID, arr); 51 | } 52 | arr.set(startPos.innerIndex, count); 53 | } 54 | 55 | /** 56 | * Deletes the given position, making it no longer present in the set. 57 | */ 58 | delete(pos: Position): void; 59 | /** 60 | * Deletes a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). 61 | * 62 | * The Positions start at `startPos` and have the same `bunchID` but increasing `innerIndex`. 63 | * 64 | * @see {@link expandPositions} 65 | */ 66 | delete(startPos: Position, sameBunchCount?: number): void; 67 | delete(startPos: Position, count = 1): void { 68 | const arr = this.state.get(startPos.bunchID); 69 | if (arr === undefined) { 70 | // Already deleted. 71 | return; 72 | } 73 | arr.delete(startPos.innerIndex, count); 74 | 75 | // Clean up empty bunches. 76 | // Note: the invariant "empty => not present" might not hold if the 77 | // user directly manipulates this.state. 78 | if (arr.isEmpty()) this.state.delete(startPos.bunchID); 79 | } 80 | 81 | /** 82 | * Deletes every position in the set, making it empty. 83 | */ 84 | clear() { 85 | this.state.clear(); 86 | } 87 | 88 | // ---------- 89 | // Accessors 90 | // ---------- 91 | 92 | /** 93 | * Returns whether the given position is currently present in the set. 94 | */ 95 | has(pos: Position): boolean { 96 | return this.state.get(pos.bunchID)?.has(pos.innerIndex) ?? false; 97 | } 98 | 99 | // ---------- 100 | // Iterators 101 | // ---------- 102 | 103 | /** 104 | * Iterates over present positions, **in no particular order**. 105 | */ 106 | [Symbol.iterator](): IterableIterator { 107 | return this.positions(); 108 | } 109 | 110 | /** 111 | * Iterates over present positions, **in no particular order**. 112 | */ 113 | *positions(): IterableIterator { 114 | for (const [bunchID, arr] of this.state) { 115 | for (const innerIndex of arr.keys()) { 116 | yield { bunchID, innerIndex }; 117 | } 118 | } 119 | } 120 | 121 | // ---------- 122 | // Save & Load 123 | // ---------- 124 | 125 | /** 126 | * Returns a saved state for this set, which is identical to its saved state as an Outline. 127 | * 128 | * The saved state describes our current set of Positions in JSON-serializable form. 129 | * You can load this state on another PositionSet (or Outline) by calling `load(savedState)`, 130 | * possibly in a different session or on a different device. 131 | */ 132 | save(): OutlineSavedState { 133 | const savedState: { [bunchID: string]: SerializedSparseIndices } = {}; 134 | for (const [bunchID, arr] of this.state) { 135 | if (!arr.isEmpty()) { 136 | savedState[bunchID] = arr.serialize(); 137 | } 138 | } 139 | return savedState; 140 | } 141 | 142 | /** 143 | * Loads a saved state returned by another PositionSet's (or Outline's) `save()` method. 144 | * 145 | * Loading sets our set of Positions to match the saved PositionSet's, *overwriting* 146 | * our current state. 147 | */ 148 | load(savedState: OutlineSavedState): void { 149 | this.clear(); 150 | 151 | for (const [bunchID, savedArr] of Object.entries(savedState)) { 152 | this.state.set(bunchID, SparseIndices.deserialize(savedArr)); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /test/bunch_ids.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { maybeRandomString } from "maybe-random-string"; 3 | import { describe, test } from "mocha"; 4 | import seedrandom from "seedrandom"; 5 | import { BunchIDs } from "../src"; 6 | 7 | describe("BunchIDs", () => { 8 | let prng!: seedrandom.PRNG; 9 | 10 | beforeEach(() => { 11 | prng = seedrandom("42"); 12 | }); 13 | 14 | describe("usingReplicaID", () => { 15 | test("distinct", () => { 16 | const previous = new Set(); 17 | for (let i = 0; i < 100; i++) { 18 | const newBunchID = BunchIDs.usingReplicaID( 19 | maybeRandomString({ prng, length: 10 }) 20 | ); 21 | for (let j = 0; j < 100; j++) { 22 | const bunchID = newBunchID(); 23 | assert(!previous.has(bunchID)); 24 | previous.add(bunchID); 25 | } 26 | } 27 | }); 28 | 29 | test("parses", () => { 30 | for (let i = 0; i < 100; i++) { 31 | const replicaID = maybeRandomString({ prng, length: 10 }); 32 | const newBunchID = BunchIDs.usingReplicaID(replicaID); 33 | for (let j = 0; j < 100; j++) { 34 | const bunchID = newBunchID(); 35 | assert.deepStrictEqual(BunchIDs.parseUsingReplicaID(bunchID), [ 36 | replicaID, 37 | j, 38 | ]); 39 | } 40 | } 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/lists/fuzz.test.ts: -------------------------------------------------------------------------------- 1 | import { maybeRandomString } from "maybe-random-string"; 2 | import seedrandom from "seedrandom"; 3 | import { Order } from "../../src"; 4 | import { Checker } from "./util"; 5 | 6 | describe("lists - fuzz", () => { 7 | describe("single user", () => { 8 | let prng!: seedrandom.PRNG; 9 | let checker!: Checker; 10 | 11 | beforeEach(() => { 12 | prng = seedrandom("42"); 13 | const replicaID = maybeRandomString({ prng }); 14 | checker = new Checker(new Order({ replicaID })); 15 | }); 16 | 17 | it("single-char at methods", function () { 18 | this.timeout(5000); 19 | for (let i = 0; i < 500; i++) { 20 | if (checker.list.length === 0 || prng() < 0.5) { 21 | // 1/2: insertAt 22 | checker.insertAt( 23 | Math.floor(prng() * (checker.list.length + 1)), 24 | Math.floor(prng() * 10000) 25 | ); 26 | // eslint-disable-next-line no-dupe-else-if 27 | } else if (prng() < 0.5) { 28 | // 1/4: setAt 29 | checker.setAt( 30 | Math.floor(prng() * checker.list.length), 31 | Math.floor(prng() * 10000) 32 | ); 33 | } else { 34 | // 1/4: deleteAt 35 | checker.deleteAt(Math.floor(prng() * checker.list.length)); 36 | } 37 | } 38 | }); 39 | 40 | it("bulk ops", function () { 41 | this.timeout(10000); 42 | for (let i = 0; i < 200; i++) { 43 | if (checker.list.length === 0 || prng() < 0.5) { 44 | // 1/2: insertAt bulk 45 | checker.insertAt( 46 | Math.floor(prng() * (checker.list.length + 1)), 47 | ...new Array(1 + Math.floor(prng() * 10)).fill( 48 | Math.floor(prng() * 10000) 49 | ) 50 | ); 51 | // eslint-disable-next-line no-dupe-else-if 52 | } else if (prng() < 0.5) { 53 | // 1/4: setAt 54 | checker.setAt( 55 | Math.floor(prng() * checker.list.length), 56 | Math.floor(prng() * 10000) 57 | ); 58 | } else { 59 | // 1/4: deleteAt bulk 60 | const index = Math.floor(prng() * checker.list.length); 61 | const count = Math.min( 62 | checker.list.length - index, 63 | Math.floor(prng() * 10) 64 | ); 65 | checker.deleteAt(index, count); 66 | } 67 | } 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/lists/manual.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { maybeRandomString } from "maybe-random-string"; 3 | import { describe, test } from "mocha"; 4 | import seedrandom from "seedrandom"; 5 | import { List, MAX_POSITION, MIN_POSITION, Order, Text } from "../../src"; 6 | import { Checker } from "./util"; 7 | 8 | describe("lists - manual", () => { 9 | let prng!: seedrandom.PRNG; 10 | 11 | beforeEach(() => { 12 | prng = seedrandom("42"); 13 | }); 14 | 15 | // TODO: test lists containing min/max Position. 16 | 17 | describe("indexOfPosition", () => { 18 | let list!: List; 19 | 20 | beforeEach(() => { 21 | const replicaID = maybeRandomString({ prng }); 22 | list = new List(new Order({ replicaID })); 23 | }); 24 | 25 | test("contains min and max", () => { 26 | list.set(MIN_POSITION, 0); 27 | list.set(MAX_POSITION, 1); 28 | 29 | assert.isTrue(list.has(MIN_POSITION)); 30 | assert.isTrue(list.has(MAX_POSITION)); 31 | 32 | assert.deepStrictEqual( 33 | [...list.positions()], 34 | [MIN_POSITION, MAX_POSITION] 35 | ); 36 | 37 | assert.deepStrictEqual(list.positionAt(0), MIN_POSITION); 38 | assert.deepStrictEqual(list.positionAt(1), MAX_POSITION); 39 | 40 | assert.strictEqual(list.indexOfPosition(MIN_POSITION), 0); 41 | assert.strictEqual(list.indexOfPosition(MAX_POSITION), 1); 42 | 43 | const between = list.order.createPositions( 44 | MIN_POSITION, 45 | MAX_POSITION, 46 | 1 47 | )[0]; 48 | assert.strictEqual(list.indexOfPosition(between), -1); 49 | assert.strictEqual(list.indexOfPosition(between, "left"), 0); 50 | assert.strictEqual(list.indexOfPosition(between, "right"), 1); 51 | }); 52 | }); 53 | 54 | describe("cursors", () => { 55 | let list!: List; 56 | 57 | beforeEach(() => { 58 | const replicaID = maybeRandomString({ prng }); 59 | list = new List(new Order({ replicaID })); 60 | // 10 elements 61 | list.insertAt(0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9); 62 | }); 63 | 64 | function bindIndependent(bind: "left" | "right" | undefined) { 65 | test("errors", () => { 66 | assert.throws(() => list.cursorAt(-1, bind)); 67 | assert.throws(() => list.cursorAt(list.length + 1, bind)); 68 | 69 | assert.doesNotThrow(() => list.cursorAt(0, bind)); 70 | assert.doesNotThrow(() => list.cursorAt(list.length, bind)); 71 | }); 72 | 73 | test("inverses", () => { 74 | for (let i = 0; i <= list.length; i++) { 75 | const cursor = list.cursorAt(i, bind); 76 | assert.strictEqual(list.indexOfCursor(cursor, bind), i); 77 | } 78 | }); 79 | 80 | test("delete on left", () => { 81 | const midCursor = list.cursorAt(5, bind); 82 | 83 | list.deleteAt(0); 84 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 4); 85 | 86 | // Delete left-binding position. 87 | if (bind === "left") { 88 | assert.deepStrictEqual(midCursor, list.positionAt(3)); 89 | } 90 | list.deleteAt(3); 91 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 3); 92 | 93 | list.deleteAt(0); 94 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 2); 95 | 96 | list.clear(); 97 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 0); 98 | }); 99 | 100 | test("delete on right", () => { 101 | const midCursor = list.cursorAt(5, bind); 102 | 103 | list.deleteAt(9); 104 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 5); 105 | 106 | // Delete right-binding position. 107 | if (bind === "right") { 108 | assert.deepStrictEqual(midCursor, list.positionAt(5)); 109 | } 110 | list.deleteAt(5); 111 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 5); 112 | 113 | list.deleteAt(7); 114 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 5); 115 | 116 | list.clear(); 117 | assert.strictEqual(list.indexOfCursor(midCursor, bind), 0); 118 | }); 119 | } 120 | 121 | describe("bind left", () => { 122 | bindIndependent("left"); 123 | 124 | test("insert in gap", () => { 125 | const midCursor = list.cursorAt(5, "left"); 126 | 127 | // Gap at cursor: bind dependent. 128 | list.insertAt(5, 100); 129 | assert.strictEqual(list.indexOfCursor(midCursor, "left"), 5); 130 | 131 | // Gap before cursor: always shifts. 132 | list.insertAt(4, 101); 133 | assert.strictEqual(list.indexOfCursor(midCursor, "left"), 6); 134 | 135 | // Gap after cursor: never shifts. 136 | list.insertAt(7, 102); 137 | assert.strictEqual(list.indexOfCursor(midCursor, "left"), 6); 138 | }); 139 | 140 | test("min position", () => { 141 | const cursor = list.cursorAt(0, "left"); 142 | assert.deepStrictEqual(cursor, MIN_POSITION); 143 | 144 | list.insertAt(0, 101); 145 | assert.strictEqual(list.indexOfCursor(cursor, "left"), 0); 146 | 147 | list.deleteAt(0, 2); 148 | assert.strictEqual(list.indexOfCursor(cursor, "left"), 0); 149 | 150 | list.clear(); 151 | assert.strictEqual(list.indexOfCursor(cursor, "left"), 0); 152 | }); 153 | }); 154 | 155 | describe("bind right", () => { 156 | bindIndependent("right"); 157 | 158 | test("insert in gap", () => { 159 | const midCursor = list.cursorAt(5, "right"); 160 | 161 | // Gap at cursor: bind dependent. 162 | list.insertAt(5, 100); 163 | assert.strictEqual(list.indexOfCursor(midCursor, "right"), 6); 164 | 165 | // Gap before cursor: always shifts. 166 | list.insertAt(5, 101); 167 | assert.strictEqual(list.indexOfCursor(midCursor, "right"), 7); 168 | 169 | // Gap after cursor: never shifts. 170 | list.insertAt(8, 102); 171 | assert.strictEqual(list.indexOfCursor(midCursor, "right"), 7); 172 | }); 173 | 174 | test("max position", () => { 175 | const cursor = list.cursorAt(list.length, "right"); 176 | assert.deepStrictEqual(cursor, MAX_POSITION); 177 | 178 | list.insertAt(list.length, 101); 179 | assert.strictEqual(list.indexOfCursor(cursor, "right"), list.length); 180 | 181 | list.deleteAt(list.length - 2, 2); 182 | assert.strictEqual(list.indexOfCursor(cursor, "right"), list.length); 183 | 184 | list.clear(); 185 | assert.strictEqual(list.indexOfCursor(cursor, "right"), list.length); 186 | }); 187 | }); 188 | 189 | describe("bind default", () => { 190 | bindIndependent(undefined); 191 | 192 | test("is left", () => { 193 | assert.deepStrictEqual(list.cursorAt(5), list.cursorAt(5, "left")); 194 | }); 195 | }); 196 | }); 197 | 198 | describe("set and delete", () => { 199 | let checker!: Checker; 200 | 201 | beforeEach(() => { 202 | const replicaID = maybeRandomString({ prng }); 203 | checker = new Checker(new Order({ replicaID })); 204 | }); 205 | 206 | describe("bulk set", () => { 207 | test("basic", () => { 208 | checker.insertAt(0, 0, 1, 2, 3); 209 | checker.set(checker.list.positionAt(0), 4, 5, 6, 7); 210 | checker.set(checker.list.positionAt(1), 8, 9); 211 | }); 212 | 213 | test("replace partial", () => { 214 | // Test parentValuesBefore update logic by doing a set whose 215 | // replaced values are neither full nor empty, with interspersed children. 216 | checker.insertAt(0, ...new Array(20).fill(31)); 217 | const positions = [...checker.list.positions()]; 218 | 219 | // Interspersed children. 220 | for (let i = 19; i >= 0; i -= 3) { 221 | checker.insertAt(i, 100 + i); 222 | } 223 | 224 | // Partially fill positions. 225 | for (let i = 0; i < 20; i += 2) { 226 | checker.delete(positions[i], 1); 227 | } 228 | 229 | // Overwrite partially-filled positions. 230 | checker.set(positions[4], ...new Array(10).fill(25)); 231 | }); 232 | }); 233 | 234 | describe("bulk delete", () => { 235 | test("basic", () => { 236 | checker.insertAt(0, 0, 1, 2, 3); 237 | checker.delete(checker.list.positionAt(1), 2); 238 | checker.delete(checker.list.positionAt(0), 4); 239 | }); 240 | 241 | test("replace partial", () => { 242 | // Test parentValuesBefore update logic by doing a delete whose 243 | // replaced values are neither full nor empty, with interspersed children. 244 | checker.insertAt(0, ...new Array(20).fill(31)); 245 | const positions = [...checker.list.positions()]; 246 | 247 | // Interspersed children. 248 | for (let i = 19; i >= 0; i -= 3) { 249 | checker.insertAt(i, 100 + i); 250 | } 251 | 252 | // Partially fill positions. 253 | for (let i = 0; i < 20; i += 2) { 254 | checker.delete(positions[i], 1); 255 | } 256 | 257 | // Overwrite partially-filled positions. 258 | checker.delete(positions[4], 10); 259 | }); 260 | }); 261 | }); 262 | 263 | describe("items", () => { 264 | let list!: List; 265 | 266 | beforeEach(() => { 267 | let bunchIdCount = 0; 268 | list = new List(new Order({ newBunchID: () => `b${bunchIdCount++}` })); 269 | }); 270 | 271 | test("whole list", () => { 272 | list.insertAt(0, 0, 1, 2, 3); 273 | assert.deepStrictEqual( 274 | [...list.items()], 275 | [[{ bunchID: "b0", innerIndex: 0 }, [0, 1, 2, 3]]] 276 | ); 277 | 278 | list.insertAt(2, 5, 6, 7, 8); 279 | assert.deepStrictEqual( 280 | [...list.items()], 281 | [ 282 | [{ bunchID: "b0", innerIndex: 0 }, [0, 1]], 283 | [{ bunchID: "b1", innerIndex: 0 }, [5, 6, 7, 8]], 284 | [{ bunchID: "b0", innerIndex: 2 }, [2, 3]], 285 | ] 286 | ); 287 | 288 | list.delete({ bunchID: "b1", innerIndex: 2 }); 289 | assert.deepStrictEqual( 290 | [...list.items()], 291 | [ 292 | [{ bunchID: "b0", innerIndex: 0 }, [0, 1]], 293 | [{ bunchID: "b1", innerIndex: 0 }, [5, 6]], 294 | [{ bunchID: "b1", innerIndex: 3 }, [8]], 295 | [{ bunchID: "b0", innerIndex: 2 }, [2, 3]], 296 | ] 297 | ); 298 | }); 299 | 300 | test("range args", () => { 301 | list.insertAt(0, 0, 1, 2, 3); 302 | assert.deepStrictEqual( 303 | [...list.items(0)], 304 | [[{ bunchID: "b0", innerIndex: 0 }, [0, 1, 2, 3]]] 305 | ); 306 | assert.deepStrictEqual( 307 | [...list.items(undefined, 3)], 308 | [[{ bunchID: "b0", innerIndex: 0 }, [0, 1, 2]]] 309 | ); 310 | assert.deepStrictEqual( 311 | [...list.items(1, 3)], 312 | [[{ bunchID: "b0", innerIndex: 1 }, [1, 2]]] 313 | ); 314 | 315 | list.insertAt(2, 5, 6, 7, 8); 316 | assert.deepStrictEqual( 317 | [...list.items(0, 3)], 318 | [ 319 | [{ bunchID: "b0", innerIndex: 0 }, [0, 1]], 320 | [{ bunchID: "b1", innerIndex: 0 }, [5]], 321 | ] 322 | ); 323 | assert.deepStrictEqual( 324 | [...list.items(1, 7)], 325 | [ 326 | [{ bunchID: "b0", innerIndex: 1 }, [1]], 327 | [{ bunchID: "b1", innerIndex: 0 }, [5, 6, 7, 8]], 328 | [{ bunchID: "b0", innerIndex: 2 }, [2]], 329 | ] 330 | ); 331 | 332 | list.delete({ bunchID: "b1", innerIndex: 2 }); 333 | assert.deepStrictEqual( 334 | [...list.items(3, 7)], 335 | [ 336 | [{ bunchID: "b1", innerIndex: 1 }, [6]], 337 | [{ bunchID: "b1", innerIndex: 3 }, [8]], 338 | [{ bunchID: "b0", innerIndex: 2 }, [2, 3]], 339 | ] 340 | ); 341 | assert.deepStrictEqual( 342 | [...list.items(2, 3)], 343 | [[{ bunchID: "b1", innerIndex: 0 }, [5]]] 344 | ); 345 | }); 346 | 347 | test("fromItems inverse", () => { 348 | list.insertAt(0, 0, 1, 2, 3); 349 | list.insertAt(2, 5, 6, 7, 8); 350 | list.delete({ bunchID: "b1", innerIndex: 2 }); 351 | 352 | let newList = List.fromItems(list.items(), list.order); 353 | assert.deepStrictEqual([...newList.entries()], [...list.entries()]); 354 | assert.deepStrictEqual([...newList.items()], [...list.items()]); 355 | 356 | for (const [start, end] of [ 357 | [0, 4], 358 | [2, 5], 359 | [3, 7], 360 | [4, 5], 361 | ]) { 362 | newList = List.fromItems(list.items(start, end), list.order); 363 | assert.deepStrictEqual( 364 | [...newList.entries()], 365 | [...list.entries(start, end)] 366 | ); 367 | assert.deepStrictEqual( 368 | [...newList.items()], 369 | [...list.items(start, end)] 370 | ); 371 | } 372 | }); 373 | }); 374 | 375 | describe("Text embeds", () => { 376 | interface Embed { 377 | a?: string; 378 | b?: string; 379 | } 380 | 381 | let text!: Text; 382 | 383 | beforeEach(() => { 384 | const replicaID = maybeRandomString({ prng }); 385 | text = new Text(new Order({ replicaID })); 386 | 387 | // Create mis-aligned bunches and string sections. 388 | text.insertAt(0, "hello world"); 389 | text.setAt(5, { a: "foo" }); 390 | text.insertAt(8, "RLD WO"); 391 | }); 392 | 393 | test("slice and sliceWithEmbeds", () => { 394 | assert.strictEqual(text.slice(), "hello\uFFFCwoRLD WOrld"); 395 | assert.deepStrictEqual(text.sliceWithEmbeds(), [ 396 | "hello", 397 | { a: "foo" }, 398 | "woRLD WOrld", 399 | ]); 400 | assert.strictEqual(text.slice().length, text.length); 401 | }); 402 | 403 | test("save and load", () => { 404 | // Check the exact saved state. 405 | const bunchId0 = text.positionAt(0).bunchID; 406 | const bunchId1 = text.positionAt(8).bunchID; 407 | assert.notStrictEqual(bunchId0, bunchId1); 408 | assert.deepStrictEqual(text.save(), { 409 | [bunchId0]: ["hello", { a: "foo" }, "world"], 410 | [bunchId1]: ["RLD WO"], 411 | }); 412 | 413 | // Load on another instance. 414 | const text2 = new Text(text.order); 415 | text2.load(text.save()); 416 | 417 | assert.deepStrictEqual([...text.entries()], [...text2.entries()]); 418 | assert.deepStrictEqual(text.save(), text2.save()); 419 | assert.deepStrictEqual(text.saveOutline(), text2.saveOutline()); 420 | }); 421 | 422 | test("saveOutline and loadOutline", () => { 423 | const text2 = new Text(text.order); 424 | text2.loadOutline(text.saveOutline(), text.sliceWithEmbeds()); 425 | 426 | assert.deepStrictEqual([...text.entries()], [...text2.entries()]); 427 | assert.deepStrictEqual(text.save(), text2.save()); 428 | assert.deepStrictEqual(text.saveOutline(), text2.saveOutline()); 429 | }); 430 | 431 | test("loadOutline errors", () => { 432 | let text2 = new Text(text.order); 433 | assert.throws(() => 434 | text2.loadOutline(text.saveOutline(), [...text.sliceWithEmbeds(), "X"]) 435 | ); 436 | 437 | text2 = new Text(text.order); 438 | assert.throws(() => 439 | text2.loadOutline(text.saveOutline(), [ 440 | ...text.sliceWithEmbeds(), 441 | { a: "wrong" }, 442 | ]) 443 | ); 444 | 445 | const short = text.sliceWithEmbeds(); 446 | short.pop(); 447 | text2 = new Text(text.order); 448 | assert.throws(() => text2.loadOutline(text.saveOutline(), short)); 449 | 450 | const extraChars = text.sliceWithEmbeds(); 451 | extraChars[extraChars.length - 1] += "X"; 452 | text2 = new Text(text.order); 453 | assert.throws(() => text2.loadOutline(text.saveOutline(), extraChars)); 454 | 455 | const missingChars = text.sliceWithEmbeds(); 456 | missingChars[0] = (missingChars[0] as string).slice(1); 457 | text2 = new Text(text.order); 458 | assert.throws(() => text2.loadOutline(text.saveOutline(), missingChars)); 459 | }); 460 | }); 461 | }); 462 | -------------------------------------------------------------------------------- /test/lists/util.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import createRBTree, { Tree } from "functional-red-black-tree"; 3 | import { 4 | AbsList, 5 | List, 6 | Order, 7 | Outline, 8 | Position, 9 | expandPositions, 10 | lexicographicString, 11 | } from "../../src"; 12 | 13 | /** 14 | * Compares a List (and an equivalent Outline and AbsList) to another library's 15 | * ordered map after each operation, to make sure it had the expected effect. 16 | */ 17 | export class Checker { 18 | readonly list: List; 19 | readonly outline: Outline; 20 | readonly absList: AbsList; 21 | // Lexicographic strings. 22 | tree: Tree; 23 | 24 | constructor(readonly order: Order) { 25 | this.list = new List(order); 26 | this.absList = new AbsList(); 27 | this.outline = new Outline(order); 28 | this.tree = createRBTree(); 29 | } 30 | 31 | check() { 32 | // Check that all list values are equivalent. 33 | assert.deepStrictEqual([...this.list.values()], this.tree.values); 34 | assert.deepStrictEqual([...this.absList.values()], this.tree.values); 35 | 36 | // Check that all list positions are equivalent. 37 | const positions = [...this.list.positions()]; 38 | assert.deepStrictEqual([...this.outline.positions()], positions); 39 | assert.deepStrictEqual( 40 | [...this.absList.positions()], 41 | positions.map((pos) => this.order.abs(pos)) 42 | ); 43 | assert.deepStrictEqual( 44 | this.tree.keys, 45 | positions.map((pos) => lexicographicString(this.order.abs(pos))) 46 | ); 47 | 48 | // Check that individual accessors agree. 49 | // We skip AbsList b/c it is the same code as List. 50 | assert.strictEqual(this.list.length, this.tree.length); 51 | assert.strictEqual(this.outline.length, this.tree.length); 52 | for (let i = 0; i < this.list.length; i++) { 53 | const iter = this.tree.at(i); 54 | const pos = this.list.positionAt(i); 55 | assert.strictEqual(this.list.getAt(i), iter.value!); 56 | assert.deepStrictEqual( 57 | iter.key!, 58 | lexicographicString(this.order.abs(pos)) 59 | ); 60 | assert.strictEqual(this.list.get(pos), iter.value); 61 | assert.strictEqual(this.list.indexOfPosition(pos), i); 62 | assert.deepStrictEqual(this.outline.positionAt(i), pos); 63 | assert.strictEqual(this.outline.indexOfPosition(pos), i); 64 | } 65 | 66 | // Test that saving and loading gives equivalent states. 67 | const newOrder = new Order(); 68 | newOrder.load(this.order.save()); 69 | 70 | const newList = new List(newOrder); 71 | newList.load(this.list.save()); 72 | assert.deepStrictEqual([...newList.entries()], [...this.list.entries()]); 73 | 74 | const newOutline = new Outline(newOrder); 75 | newOutline.load(this.outline.save()); 76 | assert.deepStrictEqual( 77 | [...newOutline.positions()], 78 | [...this.outline.positions()] 79 | ); 80 | 81 | // Also test saveOutline and loadOutline. 82 | const savedOutline = this.list.saveOutline(); 83 | assert.deepStrictEqual(savedOutline, this.outline.save()); 84 | 85 | const newListFromOutline = new List(newOrder); 86 | newListFromOutline.loadOutline(savedOutline, this.list.slice()); 87 | assert.deepStrictEqual( 88 | [...newListFromOutline.entries()], 89 | [...this.list.entries()] 90 | ); 91 | } 92 | 93 | set(startPos: Position, ...sameBunchValues: number[]): void { 94 | this.list.set(startPos, ...sameBunchValues); 95 | this.outline.add(startPos, sameBunchValues.length); 96 | const positions = expandPositions(startPos, sameBunchValues.length); 97 | for (let i = 0; i < positions.length; i++) { 98 | const absPos = this.order.abs(positions[i]); 99 | this.absList.set(absPos, sameBunchValues[i]); 100 | const lex = lexicographicString(absPos); 101 | this.tree = this.tree.find(lex).remove().insert(lex, sameBunchValues[i]); 102 | } 103 | 104 | assert(this.list.has(startPos)); 105 | this.check(); 106 | } 107 | 108 | setAt(index: number, value: number) { 109 | // console.log("\tsetAt", index, value, this.list.slice()); 110 | this.list.setAt(index, value); 111 | this.absList.setAt(index, value); 112 | const key = this.tree.at(index).key!; 113 | this.tree = this.tree.find(key).remove().insert(key, value); 114 | 115 | this.check(); 116 | } 117 | 118 | delete(startPos: Position, sameBunchCount: number): void { 119 | this.list.delete(startPos, sameBunchCount); 120 | this.outline.delete(startPos, sameBunchCount); 121 | const positions = expandPositions(startPos, sameBunchCount); 122 | for (let i = 0; i < positions.length; i++) { 123 | const absPos = this.order.abs(positions[i]); 124 | this.absList.delete(absPos); 125 | const lex = lexicographicString(absPos); 126 | this.tree = this.tree.find(lex).remove(); 127 | } 128 | 129 | assert(!this.list.has(startPos)); 130 | this.check(); 131 | } 132 | 133 | deleteAt(index: number, count = 1) { 134 | // console.log("\tdeleteAt", index, this.list.slice()); 135 | this.list.deleteAt(index, count); 136 | this.outline.deleteAt(index, count); 137 | this.absList.deleteAt(index, count); 138 | const keys: string[] = []; 139 | for (let i = 0; i < count; i++) { 140 | keys.push(this.tree.at(index + i).key!); 141 | } 142 | for (const key of keys) { 143 | this.tree = this.tree.find(key).remove(); 144 | } 145 | 146 | this.check(); 147 | } 148 | 149 | clear() { 150 | this.list.clear(); 151 | this.outline.clear(); 152 | this.absList.clear(); 153 | this.tree = createRBTree(); 154 | 155 | this.check(); 156 | } 157 | 158 | insert(prevPos: Position, ...values: number[]): void { 159 | // Since insert creates Positions on the shared order, we can only 160 | // call it one of the data structures. 161 | const [startPos] = this.list.insert(prevPos, ...values); 162 | this.outline.add(startPos, values.length); 163 | const positions = expandPositions(startPos, values.length); 164 | for (let i = 0; i < positions.length; i++) { 165 | const absPos = this.order.abs(positions[i]); 166 | this.absList.set(absPos, values[i]); 167 | const lex = lexicographicString(absPos); 168 | this.tree = this.tree.find(lex).remove().insert(lex, values[i]); 169 | } 170 | 171 | assert(this.list.has(startPos)); 172 | this.check(); 173 | } 174 | 175 | insertAt(index: number, ...values: number[]) { 176 | // console.log("\tinsertAt", index, values[0], this.list.slice()); 177 | const before = this.tree.values; 178 | // Since insertAt creates Positions on the shared order, we can only 179 | // call it one of the data structures. 180 | const [startPos] = this.list.insertAt(index, ...values); 181 | // console.log(startPos); 182 | this.outline.add(startPos, values.length); 183 | const positions = expandPositions(startPos, values.length); 184 | for (let i = 0; i < positions.length; i++) { 185 | const absPos = this.order.abs(positions[i]); 186 | this.absList.set(absPos, values[i]); 187 | const lex = lexicographicString(absPos); 188 | this.tree = this.tree.find(lex).remove().insert(lex, values[i]); 189 | } 190 | 191 | // insertAt should be equivalent to a splice. 192 | before.splice(index, 0, ...values); 193 | assert.deepStrictEqual(this.tree.values, before); 194 | this.check(); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /test/order/fuzz.test.ts: -------------------------------------------------------------------------------- 1 | import seedrandom from "seedrandom"; 2 | import { 3 | MAX_POSITION, 4 | MIN_POSITION, 5 | Position, 6 | expandPositions, 7 | } from "../../src"; 8 | import { assertIsOrdered, newOrders, testUniqueAfterDelete } from "./util"; 9 | 10 | describe("Order - fuzz", () => { 11 | describe("sequential", () => { 12 | describe("1 user", () => sequential(1)); 13 | describe("10 users", () => sequential(10)); 14 | }); 15 | }); 16 | 17 | function sequential(numUsers: number) { 18 | let prng!: seedrandom.PRNG; 19 | 20 | beforeEach(() => { 21 | prng = seedrandom("42"); 22 | }); 23 | 24 | it("random", () => { 25 | const orders = newOrders(prng, numUsers, true); 26 | 27 | // Randomly create positions in a single list, simulating sequential access. 28 | const list: Position[] = []; 29 | for (let i = 0; i < 1000; i++) { 30 | const source = orders[Math.floor(prng() * orders.length)]; 31 | const index = Math.floor(prng() * (list.length + 1)); 32 | const [newPosition] = source.createPositions( 33 | list[index - 1] ?? MIN_POSITION, 34 | list[index] ?? MAX_POSITION, 35 | 1 36 | ); 37 | list.splice(index, 0, newPosition); 38 | } 39 | 40 | for (const source of orders) assertIsOrdered(list, source); 41 | testUniqueAfterDelete(list, orders[0]); 42 | }); 43 | 44 | it("random LtR runs", () => { 45 | const orders = newOrders(prng, numUsers, true); 46 | 47 | // Randomly create positions in a single list, simulating sequential access. 48 | // This time, create short LtR runs at a time. 49 | const list: Position[] = []; 50 | for (let i = 0; i < 200; i++) { 51 | const source = orders[Math.floor(prng() * orders.length)]; 52 | const index = Math.floor(prng() * (list.length + 1)); 53 | const [startPos] = source.createPositions( 54 | list[index - 1] ?? MIN_POSITION, 55 | list[index] ?? MAX_POSITION, 56 | 5 57 | ); 58 | list.splice(index, 0, ...expandPositions(startPos, 5)); 59 | } 60 | 61 | for (const source of orders) assertIsOrdered(list, source); 62 | testUniqueAfterDelete(list, orders[0]); 63 | }); 64 | 65 | it("random RtL runs", () => { 66 | const orders = newOrders(prng, numUsers, true); 67 | 68 | // Randomly create positions in a single list, simulating sequential access. 69 | // This time, create short RtL runs at a time. 70 | const list: Position[] = []; 71 | for (let i = 0; i < 200; i++) { 72 | const source = orders[Math.floor(prng() * orders.length)]; 73 | const index = Math.floor(prng() * (list.length + 1)); 74 | const [startPos] = source.createPositions( 75 | list[index - 1] ?? MIN_POSITION, 76 | list[index] ?? MAX_POSITION, 77 | 5 78 | ); 79 | list.splice(index, 0, ...expandPositions(startPos, 5)); 80 | } 81 | 82 | for (const source of orders) assertIsOrdered(list, source); 83 | testUniqueAfterDelete(list, orders[0]); 84 | }); 85 | 86 | it("biased", () => { 87 | const orders = newOrders(prng, numUsers, true); 88 | 89 | // Randomly create positions in a single list, simulating sequential access. 90 | // This time, bias towards smaller indices using a sqrt. 91 | const list: Position[] = []; 92 | for (let i = 0; i < 1000; i++) { 93 | const source = 94 | orders[Math.floor(Math.sqrt(prng() * orders.length * orders.length))]; 95 | const index = Math.floor(prng() * (list.length + 1)); 96 | const [newPosition] = source.createPositions( 97 | list[index - 1] ?? MIN_POSITION, 98 | list[index] ?? MAX_POSITION, 99 | 1 100 | ); 101 | list.splice(index, 0, newPosition); 102 | } 103 | 104 | for (const source of orders) assertIsOrdered(list, source); 105 | testUniqueAfterDelete(list, orders[0]); 106 | }); 107 | } 108 | -------------------------------------------------------------------------------- /test/order/manual.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { maybeRandomString } from "maybe-random-string"; 3 | import seedrandom from "seedrandom"; 4 | import { 5 | MAX_POSITION, 6 | MIN_POSITION, 7 | Order, 8 | Position, 9 | expandPositions, 10 | lexicographicString, 11 | } from "../../src"; 12 | import { assertIsOrdered, testUniqueAfterDelete } from "./util"; 13 | 14 | describe("Order - manual", () => { 15 | const prng = seedrandom("42"); 16 | const randomName = maybeRandomString({ prng }); 17 | const randomAlice = maybeRandomString({ prng }); 18 | const randomBobby = maybeRandomString({ prng }); 19 | const randomBob = maybeRandomString({ prng, length: 5 }); 20 | 21 | describe("single user", () => { 22 | describe("random replicaID", () => { 23 | testSingleUser(randomName); 24 | }); 25 | describe("alphabetic replicaID", () => { 26 | testSingleUser("alice"); 27 | }); 28 | describe("numeric replicaID", () => { 29 | testSingleUser("0"); 30 | }); 31 | describe("empty replicaID", () => { 32 | testSingleUser(""); 33 | }); 34 | }); 35 | 36 | describe("two users", () => { 37 | describe("random replicaIDs", () => { 38 | testTwoUsers(randomAlice, randomBobby); 39 | }); 40 | describe("random replicaIDs, unequal lengths", () => { 41 | testTwoUsers(randomAlice, randomBob); 42 | }); 43 | describe("random replicaIDs, prefixes", () => { 44 | testTwoUsers(randomBobby, randomBob); 45 | }); 46 | describe("numeric replicaIDs", () => { 47 | testTwoUsers("57834", "00143"); 48 | }); 49 | describe("random and empty replicaIDs", () => { 50 | testTwoUsers(randomAlice, ""); 51 | }); 52 | }); 53 | }); 54 | 55 | function testSingleUser(replicaID: string) { 56 | let alice!: Order; 57 | 58 | beforeEach(() => { 59 | alice = new Order({ replicaID }); 60 | }); 61 | 62 | it("LtR", () => { 63 | let previous = MIN_POSITION; 64 | const list: Position[] = []; 65 | for (let i = 0; i < 20; i++) { 66 | [previous] = alice.createPositions(previous, MAX_POSITION, 1); 67 | list.push(previous); 68 | } 69 | assertIsOrdered(list, alice); 70 | }); 71 | 72 | it("RtL", () => { 73 | let previous = MAX_POSITION; 74 | const list: Position[] = []; 75 | for (let i = 0; i < 20; i++) { 76 | [previous] = alice.createPositions(MIN_POSITION, previous, 1); 77 | list.unshift(previous); 78 | } 79 | assertIsOrdered(list, alice); 80 | }); 81 | 82 | it("restart", () => { 83 | const list: Position[] = []; 84 | for (let j = 0; j < 5; j++) { 85 | let previous = MIN_POSITION; 86 | const after = list[0] ?? MAX_POSITION; 87 | for (let i = 0; i < 10; i++) { 88 | [previous] = alice.createPositions(previous, after, 1); 89 | list.splice(i, 0, previous); 90 | } 91 | } 92 | assertIsOrdered(list, alice); 93 | }); 94 | 95 | it("LtR bulk", () => { 96 | const [startPos] = alice.createPositions(MIN_POSITION, MAX_POSITION, 1000); 97 | const list = expandPositions(startPos, 1000); 98 | assertIsOrdered(list, alice); 99 | // Lexicographic string efficiency check. 100 | assert.isBelow(lexicographicString(alice.abs(list.at(-1)!)).length, 30); 101 | }); 102 | 103 | it("LtR long", () => { 104 | let previous = MIN_POSITION; 105 | const list: Position[] = []; 106 | for (let i = 0; i < 1000; i++) { 107 | [previous] = alice.createPositions(previous, MAX_POSITION, 1); 108 | list.push(previous); 109 | } 110 | assertIsOrdered(list, alice); 111 | // Lexicographic string efficiency check. 112 | assert.isBelow(lexicographicString(alice.abs(list.at(-1)!)).length, 30); 113 | }); 114 | 115 | it("RtL long", () => { 116 | let previous = MAX_POSITION; 117 | const list: Position[] = []; 118 | for (let i = 0; i < 1000; i++) { 119 | [previous] = alice.createPositions(MIN_POSITION, previous, 1); 120 | list.unshift(previous); 121 | } 122 | assertIsOrdered(list, alice); 123 | }); 124 | 125 | it("LtR, mid LtR", () => { 126 | let previous = MIN_POSITION; 127 | const list: Position[] = []; 128 | for (let i = 0; i < 20; i++) { 129 | [previous] = alice.createPositions(previous, MAX_POSITION, 1); 130 | list.push(previous); 131 | } 132 | const midRight = list[10]; 133 | previous = list[9]; 134 | for (let i = 0; i < 20; i++) { 135 | [previous] = alice.createPositions(previous, midRight, 1); 136 | list.splice(10 + i, 0, previous); 137 | } 138 | assertIsOrdered(list, alice); 139 | }); 140 | 141 | it("LtR, mid RtL", () => { 142 | let previous = MIN_POSITION; 143 | const list: Position[] = []; 144 | for (let i = 0; i < 20; i++) { 145 | [previous] = alice.createPositions(previous, MAX_POSITION, 1); 146 | list.push(previous); 147 | } 148 | const midLeft = list[9]; 149 | previous = list[10]; 150 | for (let i = 0; i < 20; i++) { 151 | [previous] = alice.createPositions(midLeft, previous, 1); 152 | list.splice(10, 0, previous); 153 | } 154 | assertIsOrdered(list, alice); 155 | }); 156 | 157 | it("unique after delete", () => { 158 | let previous = MIN_POSITION; 159 | const list: Position[] = []; 160 | for (let i = 0; i < 20; i++) { 161 | [previous] = alice.createPositions(previous, MAX_POSITION, 1); 162 | list.push(previous); 163 | } 164 | const midLeft = list[9]; 165 | previous = list[10]; 166 | for (let i = 0; i < 20; i++) { 167 | [previous] = alice.createPositions(midLeft, previous, 1); 168 | list.splice(10, 0, previous); 169 | } 170 | 171 | testUniqueAfterDelete(list, alice); 172 | }); 173 | 174 | it("bulk vs sequential", () => { 175 | // One way to create bulk positions: one createPositions call. 176 | const [startPos] = alice.createPositions(MIN_POSITION, MAX_POSITION, 100); 177 | const list1 = expandPositions(startPos, 100); 178 | // 2nd way to create bulk positions: series of calls. 179 | const alice2 = new Order({ 180 | replicaID, 181 | }); 182 | const list2: Position[] = []; 183 | let previous = MIN_POSITION; 184 | for (let i = 0; i < 100; i++) { 185 | [previous] = alice2.createPositions(previous, MAX_POSITION, 1); 186 | list2.push(previous); 187 | } 188 | assert.deepStrictEqual(list2, list1); 189 | 190 | // A bunch with a specified bunchID cannot be reused after the initial call, 191 | // unlike the above behavior. 192 | const specOrder = new Order(); 193 | const [specPos] = specOrder.createPositions(MIN_POSITION, MAX_POSITION, 1, { 194 | bunchID: "specified", 195 | }); 196 | assert.strictEqual(specPos.bunchID, "specified"); 197 | const [afterPos] = specOrder.createPositions(specPos, MAX_POSITION, 1); 198 | assert.notStrictEqual(afterPos.bunchID, specPos.bunchID); 199 | }); 200 | } 201 | 202 | function testTwoUsers(replicaID1: string, replicaID2: string) { 203 | let alice!: Order; 204 | let bob!: Order; 205 | 206 | beforeEach(() => { 207 | alice = new Order({ replicaID: replicaID1 }); 208 | bob = new Order({ replicaID: replicaID2 }); 209 | // Automatically share metadata. 210 | alice.onNewMeta = (meta) => bob.addMetas([meta]); 211 | bob.onNewMeta = (meta) => alice.addMetas([meta]); 212 | }); 213 | 214 | it("LtR sequential", () => { 215 | let previous = MIN_POSITION; 216 | const list: Position[] = []; 217 | for (let i = 0; i < 40; i++) { 218 | const user = i >= 20 ? bob : alice; 219 | [previous] = user.createPositions(previous, MAX_POSITION, 1); 220 | list.push(previous); 221 | } 222 | assertIsOrdered(list, alice); 223 | assertIsOrdered(list, bob); 224 | }); 225 | 226 | it("LtR alternating", () => { 227 | let previous = MIN_POSITION; 228 | const list: Position[] = []; 229 | for (let i = 0; i < 40; i++) { 230 | const user = i % 2 == 0 ? bob : alice; 231 | [previous] = user.createPositions(previous, MAX_POSITION, 1); 232 | list.push(previous); 233 | } 234 | assertIsOrdered(list, alice); 235 | assertIsOrdered(list, bob); 236 | }); 237 | 238 | it("RtL sequential", () => { 239 | let previous = MAX_POSITION; 240 | const list: Position[] = []; 241 | for (let i = 0; i < 40; i++) { 242 | const user = i >= 20 ? bob : alice; 243 | [previous] = user.createPositions(MIN_POSITION, previous, 1); 244 | list.unshift(previous); 245 | } 246 | assertIsOrdered(list, alice); 247 | assertIsOrdered(list, bob); 248 | }); 249 | 250 | it("RtL alternating", () => { 251 | let previous = MAX_POSITION; 252 | const list: Position[] = []; 253 | for (let i = 0; i < 40; i++) { 254 | const user = i % 2 == 0 ? bob : alice; 255 | [previous] = user.createPositions(MIN_POSITION, previous, 1); 256 | list.unshift(previous); 257 | } 258 | assertIsOrdered(list, alice); 259 | assertIsOrdered(list, bob); 260 | }); 261 | 262 | it("restart alternating", () => { 263 | const list: Position[] = []; 264 | for (let j = 0; j < 5; j++) { 265 | let previous = MIN_POSITION; 266 | const after = list[0] ?? MAX_POSITION; 267 | for (let i = 0; i < 10; i++) { 268 | const user = i % 2 === 0 ? bob : alice; 269 | [previous] = user.createPositions(previous, after, 1); 270 | list.splice(i, 0, previous); 271 | } 272 | } 273 | assertIsOrdered(list, alice); 274 | assertIsOrdered(list, bob); 275 | }); 276 | 277 | it("LtR concurrent", () => { 278 | let previous = MIN_POSITION; 279 | const list1: Position[] = []; 280 | for (let i = 0; i < 20; i++) { 281 | [previous] = alice.createPositions(previous, MAX_POSITION, 1); 282 | list1.push(previous); 283 | } 284 | previous = MIN_POSITION; 285 | const list2: Position[] = []; 286 | for (let i = 0; i < 20; i++) { 287 | [previous] = bob.createPositions(previous, MAX_POSITION, 1); 288 | list2.push(previous); 289 | } 290 | // list1 and list2 should be sorted one after the other, according 291 | // to their first element (non-interleaving). 292 | let list: Position[]; 293 | if (alice.compare(list1[0], list2[0]) < 0) { 294 | // list1 < list2 295 | list = [...list1, ...list2]; 296 | } else list = [...list2, ...list1]; 297 | assertIsOrdered(list, alice); 298 | assertIsOrdered(list, bob); 299 | }); 300 | 301 | it("RtL concurrent", () => { 302 | let previous = MAX_POSITION; 303 | const list1: Position[] = []; 304 | for (let i = 0; i < 20; i++) { 305 | [previous] = alice.createPositions(MIN_POSITION, previous, 1); 306 | list1.unshift(previous); 307 | } 308 | previous = MAX_POSITION; 309 | const list2: Position[] = []; 310 | for (let i = 0; i < 20; i++) { 311 | [previous] = bob.createPositions(MIN_POSITION, previous, 1); 312 | list2.unshift(previous); 313 | } 314 | // list1 and list2 should be sorted one after the other, according 315 | // to their first element (non-interleaving). 316 | let list: Position[]; 317 | if (alice.compare(list1[0], list2[0]) < 0) { 318 | // list1 < list2 319 | list = [...list1, ...list2]; 320 | } else list = [...list2, ...list1]; 321 | assertIsOrdered(list, alice); 322 | assertIsOrdered(list, bob); 323 | }); 324 | 325 | it("insert between concurrent", () => { 326 | // "Hard case" from the blog post - see 327 | // https://mattweidner.com/2022/10/05/basic-list-crdt.html#between-concurrent 328 | const [startPos] = alice.createPositions(MIN_POSITION, MAX_POSITION, 2); 329 | const [a, b] = expandPositions(startPos, 2); 330 | 331 | let [c] = alice.createPositions(a, b, 1); 332 | let [d] = bob.createPositions(a, b, 1); 333 | // Order so c < d. 334 | if (alice.compare(d, c) < 0) [c, d] = [d, c]; 335 | 336 | // Try making e on both alice and bob. 337 | const [e1] = alice.createPositions(c, d, 1); 338 | const [e2] = bob.createPositions(c, d, 1); 339 | 340 | assert.notDeepEqual(e1, e2); 341 | assertIsOrdered([a, c, e1, d, b], alice); 342 | assertIsOrdered([a, c, e1, d, b], bob); 343 | assertIsOrdered([a, c, e2, d, b], alice); 344 | assertIsOrdered([a, c, e2, d, b], bob); 345 | }); 346 | 347 | it("unique after delete", () => { 348 | const list: Position[] = []; 349 | for (let j = 0; j < 5; j++) { 350 | let previous = MIN_POSITION; 351 | const after = list[0] ?? MAX_POSITION; 352 | for (let i = 0; i < 10; i++) { 353 | const user = i % 2 === 0 ? bob : alice; 354 | [previous] = user.createPositions(previous, after, 1); 355 | list.splice(i, 0, previous); 356 | } 357 | } 358 | assertIsOrdered(list, alice); 359 | assertIsOrdered(list, bob); 360 | 361 | testUniqueAfterDelete(list, alice); 362 | testUniqueAfterDelete(list, bob); 363 | }); 364 | 365 | it("left children", () => { 366 | const [gParent] = alice.createPositions(MIN_POSITION, MAX_POSITION, 1); 367 | // Each parent is a child of gParent with the same bunch but 368 | // a range of valueIndex's. 369 | const [startPos] = bob.createPositions(gParent, MAX_POSITION, 500); 370 | const parents = expandPositions(startPos, 500); 371 | const list = [gParent, ...parents]; 372 | // Create positions between gParent and the parents; since parent 373 | // starts with gParent, they'll be left children of parent. 374 | for (let i = 0; i < parents.length; i++) { 375 | const [child] = bob.createPositions(gParent, parents[i], 1); 376 | list.splice(2 * i + 1, 0, child); 377 | } 378 | assertIsOrdered(list, alice); 379 | assertIsOrdered(list, bob); 380 | 381 | testUniqueAfterDelete(list, alice); 382 | testUniqueAfterDelete(list, bob); 383 | }); 384 | } 385 | -------------------------------------------------------------------------------- /test/order/util.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { maybeRandomString } from "maybe-random-string"; 3 | import seedrandom from "seedrandom"; 4 | import { 5 | MAX_POSITION, 6 | MIN_POSITION, 7 | Order, 8 | Position, 9 | lexicographicString, 10 | } from "../../src"; 11 | 12 | /** 13 | * Asserts that the Positions are ordered under Order.compare, 14 | * their lexicographic strings are ordered lexicographically, 15 | * and that abs/unabs work correctly. 16 | */ 17 | export function assertIsOrdered(positions: Position[], order: Order) { 18 | for (let i = 0; i < positions.length - 1; i++) { 19 | assert( 20 | order.compare(positions[i], positions[i + 1]) < 0, 21 | `Out of order @ ${i}: ${JSON.stringify(positions[i])} !< ${JSON.stringify( 22 | positions[i + 1] 23 | )}` 24 | ); 25 | } 26 | for (let i = 0; i < positions.length - 1; i++) { 27 | const lexA = lexicographicString(order.abs(positions[i])); 28 | const lexB = lexicographicString(order.abs(positions[i + 1])); 29 | assert(lexA < lexB, `Out of order @ ${i}: ${lexA} !< ${lexB}`); 30 | } 31 | for (let i = 0; i < positions.length; i++) { 32 | const pos = positions[i]; 33 | const absPos = order.abs(pos); 34 | const pos2 = order.unabs(absPos); 35 | assert.deepStrictEqual(pos2, pos); 36 | 37 | const newOrder = new Order(); 38 | const pos3 = newOrder.unabs(absPos); 39 | assert.deepStrictEqual(pos3, pos); 40 | const absPos2 = newOrder.abs(pos3); 41 | assert.deepStrictEqual(absPos2, absPos); 42 | } 43 | } 44 | 45 | export function newOrders( 46 | prng: seedrandom.PRNG, 47 | count: number, 48 | linkedMeta: boolean 49 | ): Order[] { 50 | const orders: Order[] = []; 51 | for (let i = 0; i < count; i++) { 52 | const order = new Order({ 53 | replicaID: maybeRandomString({ prng }), 54 | }); 55 | if (linkedMeta) { 56 | order.onNewMeta = (meta) => orders.forEach((o) => o.addMetas([meta])); 57 | } 58 | orders.push(order); 59 | } 60 | return orders; 61 | } 62 | 63 | export function testUniqueAfterDelete(positions: Position[], order: Order) { 64 | // In each slot, create two Positions with same left & right, 65 | // simulating that the first was deleted. Then make sure they 66 | // are still distinct, in case the first is resurrected. 67 | for (let i = 0; i <= positions.length; i++) { 68 | const [a] = order.createPositions( 69 | positions[i - 1] ?? MIN_POSITION, 70 | positions[i] ?? MAX_POSITION, 71 | 1 72 | ); 73 | const [b] = order.createPositions( 74 | positions[i - 1] ?? MIN_POSITION, 75 | positions[i] ?? MAX_POSITION, 76 | 1 77 | ); 78 | assert.notDeepEqual(a, b); 79 | assert.notStrictEqual( 80 | lexicographicString(order.abs(a)), 81 | lexicographicString(order.abs(b)) 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /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 | "resolveJsonModule": true 6 | }, 7 | "include": ["src", "test", "benchmarks"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "build/esm", 5 | "target": "es2021", 6 | "module": "es2015", 7 | /* Needed with module: es2015 or else stuff breaks. */ 8 | "moduleResolution": "node", 9 | /* Enable strict type checking. */ 10 | "strict": true, 11 | /* Enable interop with dependencies using different module systems. */ 12 | "esModuleInterop": true, 13 | /* Emit .d.ts files. */ 14 | "declaration": true, 15 | /* Emit sourcemap files. */ 16 | "sourceMap": true 17 | /* Don't turn on importHelpers, so we can avoid tslib dependency. */ 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "excludePrivate": true, 3 | "logLevel": "Warn" 4 | } 5 | --------------------------------------------------------------------------------