state-sync-log 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/state-sync-log.esm.js +1339 -0
- package/dist/state-sync-log.esm.mjs +1339 -0
- package/dist/state-sync-log.umd.js +1343 -0
- package/dist/types/ClientId.d.ts +1 -0
- package/dist/types/SortedTxEntry.d.ts +44 -0
- package/dist/types/StateCalculator.d.ts +141 -0
- package/dist/types/TxRecord.d.ts +14 -0
- package/dist/types/checkpointUtils.d.ts +15 -0
- package/dist/types/checkpoints.d.ts +62 -0
- package/dist/types/clientState.d.ts +19 -0
- package/dist/types/createStateSyncLog.d.ts +97 -0
- package/dist/types/draft.d.ts +69 -0
- package/dist/types/error.d.ts +4 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/json.d.ts +23 -0
- package/dist/types/operations.d.ts +64 -0
- package/dist/types/reconcile.d.ts +7 -0
- package/dist/types/txLog.d.ts +32 -0
- package/dist/types/txTimestamp.d.ts +27 -0
- package/dist/types/utils.d.ts +23 -0
- package/package.json +94 -0
- package/src/ClientId.ts +1 -0
- package/src/SortedTxEntry.ts +83 -0
- package/src/StateCalculator.ts +407 -0
- package/src/TxRecord.ts +15 -0
- package/src/checkpointUtils.ts +44 -0
- package/src/checkpoints.ts +208 -0
- package/src/clientState.ts +37 -0
- package/src/createStateSyncLog.ts +330 -0
- package/src/draft.ts +288 -0
- package/src/error.ts +12 -0
- package/src/index.ts +8 -0
- package/src/json.ts +25 -0
- package/src/operations.ts +157 -0
- package/src/reconcile.ts +124 -0
- package/src/txLog.ts +208 -0
- package/src/txTimestamp.ts +56 -0
- package/src/utils.ts +55 -0
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "state-sync-log",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Validated Replicated State Machine built on Yjs. Combine CRDT offline capabilities with strict business logic validation, state reconciliation, and audit trails.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"state-sync",
|
|
7
|
+
"collaboration",
|
|
8
|
+
"crdt",
|
|
9
|
+
"log",
|
|
10
|
+
"yjs",
|
|
11
|
+
"yjs-middleware",
|
|
12
|
+
"replicated-state-machine",
|
|
13
|
+
"event-sourcing",
|
|
14
|
+
"validation",
|
|
15
|
+
"verification",
|
|
16
|
+
"audit-trail",
|
|
17
|
+
"offline-first",
|
|
18
|
+
"optimistic-ui",
|
|
19
|
+
"collaborative-state"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/xaviergonz/state-sync-log.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/xaviergonz/state-sync-log/issues"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Javier González Garcés",
|
|
30
|
+
"source": "./src/index.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
"./package.json": "./package.json",
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/types/index.d.ts",
|
|
35
|
+
"script": "./dist/state-sync-log.umd.js",
|
|
36
|
+
"import": "./dist/state-sync-log.esm.mjs",
|
|
37
|
+
"require": "./dist/state-sync-log.umd.js",
|
|
38
|
+
"default": "./dist/state-sync-log.esm.mjs"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"esmodule": "./dist/state-sync-log.esm.js",
|
|
42
|
+
"module": "./dist/state-sync-log.esm.js",
|
|
43
|
+
"jsnext:main": "./dist/state-sync-log.esm.js",
|
|
44
|
+
"react-native": "./dist/state-sync-log.umd.js",
|
|
45
|
+
"umd:main": "./dist/state-sync-log.umd.js",
|
|
46
|
+
"unpkg": "./dist/state-sync-log.umd.js",
|
|
47
|
+
"jsdelivr": "./dist/state-sync-log.umd.js",
|
|
48
|
+
"main": "./dist/state-sync-log.umd.js",
|
|
49
|
+
"types": "./dist/types/index.d.ts",
|
|
50
|
+
"typings": "./dist/types/index.d.ts",
|
|
51
|
+
"sideEffects": false,
|
|
52
|
+
"files": [
|
|
53
|
+
"src",
|
|
54
|
+
"dist",
|
|
55
|
+
"LICENSE",
|
|
56
|
+
"CHANGELOG.md",
|
|
57
|
+
"README.md"
|
|
58
|
+
],
|
|
59
|
+
"scripts": {
|
|
60
|
+
"quick-build": "tsc",
|
|
61
|
+
"quick-build-tests": "tsc -p test",
|
|
62
|
+
"copy-root-files": "shx cp ../../README.md . && shx cp ../../LICENSE . && shx cp ../../CHANGELOG.md .",
|
|
63
|
+
"build": "pnpm quick-build && pnpm copy-root-files && shx rm -rf dist && vite build && shx cp dist/state-sync-log.esm.mjs dist/state-sync-log.esm.js",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:ci": "vitest run --coverage --exclude **/fuzzySync.test.ts",
|
|
66
|
+
"test:fuzzy-loop": "./run_fuzzy_1000.sh",
|
|
67
|
+
"build-docs": "shx rm -rf api-docs && typedoc --options ./typedocconfig.js src/index.ts"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@types/node": "^25.0.3",
|
|
71
|
+
"@types/rfdc": "^1.2.0",
|
|
72
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
73
|
+
"shx": "^0.4.0",
|
|
74
|
+
"ts-node": "^10.9.2",
|
|
75
|
+
"typedoc": "^0.28.15",
|
|
76
|
+
"typescript": "^5.9.3",
|
|
77
|
+
"vite": "^7.2.6",
|
|
78
|
+
"vite-plugin-dts": "^4.5.4",
|
|
79
|
+
"vitest": "^4.0.15",
|
|
80
|
+
"yjs": "^13.6.27"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"yjs": "^13.0.0"
|
|
84
|
+
},
|
|
85
|
+
"dependencies": {
|
|
86
|
+
"fast-deep-equal": "^3.1.3",
|
|
87
|
+
"nanoid": "^5.1.6",
|
|
88
|
+
"rfdc": "^1.4.1",
|
|
89
|
+
"tslib": "^2.8.1"
|
|
90
|
+
},
|
|
91
|
+
"directories": {
|
|
92
|
+
"test": "test"
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/ClientId.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ClientId = string
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type * as Y from "yjs"
|
|
2
|
+
import { failure } from "./error"
|
|
3
|
+
import { TxRecord } from "./TxRecord"
|
|
4
|
+
import { parseTxTimestampKey, type TxTimestamp, type TxTimestampKey } from "./txTimestamp"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A cached tx entry with lazy parsing and optional tx caching.
|
|
8
|
+
* The timestamp is parsed on first access and cached.
|
|
9
|
+
* The tx record can be fetched lazily and cached.
|
|
10
|
+
*/
|
|
11
|
+
export class SortedTxEntry {
|
|
12
|
+
private _txTimestamp?: TxTimestamp
|
|
13
|
+
private _originalTxTimestampKey?: TxTimestampKey | null
|
|
14
|
+
private _originalTxTimestamp?: TxTimestamp | null
|
|
15
|
+
private _txRecord?: TxRecord
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
readonly txTimestampKey: TxTimestampKey,
|
|
19
|
+
private readonly _yTx: Y.Map<TxRecord>
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gets the parsed timestamp, lazily parsing and caching on first access.
|
|
24
|
+
*/
|
|
25
|
+
get txTimestamp(): TxTimestamp {
|
|
26
|
+
if (!this._txTimestamp) {
|
|
27
|
+
this._txTimestamp = parseTxTimestampKey(this.txTimestampKey)
|
|
28
|
+
}
|
|
29
|
+
return this._txTimestamp
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gets the original tx timestamp key, lazily and caching on first access.
|
|
34
|
+
*/
|
|
35
|
+
get originalTxTimestampKey(): TxTimestampKey | null {
|
|
36
|
+
if (this._originalTxTimestampKey === undefined) {
|
|
37
|
+
const tx = this.txRecord
|
|
38
|
+
this._originalTxTimestampKey = tx.originalTxKey ?? null
|
|
39
|
+
}
|
|
40
|
+
return this._originalTxTimestampKey
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Gets the parsed original tx timestamp, lazily parsing and caching on first access.
|
|
45
|
+
*/
|
|
46
|
+
get originalTxTimestamp(): TxTimestamp | null {
|
|
47
|
+
if (this._originalTxTimestamp === undefined) {
|
|
48
|
+
const key = this.originalTxTimestampKey
|
|
49
|
+
this._originalTxTimestamp = key ? parseTxTimestampKey(key) : null
|
|
50
|
+
}
|
|
51
|
+
return this._originalTxTimestamp
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Gets the logical (deduplicated) tx timestamp key.
|
|
56
|
+
* This is the original tx key if it exists, otherwise the physical key.
|
|
57
|
+
*/
|
|
58
|
+
get dedupTxTimestampKey(): TxTimestampKey {
|
|
59
|
+
return this.originalTxTimestampKey ?? this.txTimestampKey
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the logical (deduplicated) parsed tx timestamp.
|
|
64
|
+
* This is the original tx timestamp if it exists, otherwise the physical timestamp.
|
|
65
|
+
*/
|
|
66
|
+
get dedupTxTimestamp(): TxTimestamp {
|
|
67
|
+
return this.originalTxTimestamp ?? this.txTimestamp
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Gets the tx record, lazily fetching and caching on first access.
|
|
72
|
+
* Returns undefined if the tx doesn't exist.
|
|
73
|
+
*/
|
|
74
|
+
get txRecord(): TxRecord {
|
|
75
|
+
if (!this._txRecord) {
|
|
76
|
+
this._txRecord = this._yTx.get(this.txTimestampKey)
|
|
77
|
+
if (!this._txRecord) {
|
|
78
|
+
throw failure(`SortedTxEntry: TxRecord not found for key ${this.txTimestampKey}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return this._txRecord
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type * as Y from "yjs"
|
|
2
|
+
import { type CheckpointRecord, type ClientWatermarks } from "./checkpoints"
|
|
3
|
+
import { applyTxImmutable } from "./draft"
|
|
4
|
+
import { JSONObject } from "./json"
|
|
5
|
+
import { Op, ValidateFn } from "./operations"
|
|
6
|
+
import { computeReconcileOps } from "./reconcile"
|
|
7
|
+
import { SortedTxEntry } from "./SortedTxEntry"
|
|
8
|
+
import { TxRecord } from "./TxRecord"
|
|
9
|
+
import { compareTxTimestamps, type TxTimestamp, type TxTimestampKey } from "./txTimestamp"
|
|
10
|
+
import { lazy } from "./utils"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Checks if a transaction is covered by the checkpoint watermarks.
|
|
14
|
+
*/
|
|
15
|
+
export function isTransactionInCheckpoint(ts: TxTimestamp, watermarks: ClientWatermarks): boolean {
|
|
16
|
+
const wm = watermarks[ts.clientId]
|
|
17
|
+
if (!wm) return false
|
|
18
|
+
return ts.clock <= wm.maxClock
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* StateCalculator encapsulates the sorted transaction cache and state calculation logic.
|
|
23
|
+
*
|
|
24
|
+
* It maintains:
|
|
25
|
+
* - A sorted array of transaction entries
|
|
26
|
+
* - A map for O(1) lookup
|
|
27
|
+
* - A tracking index indicating up to which tx the state has been calculated
|
|
28
|
+
* - The cached calculated state
|
|
29
|
+
* - The base checkpoint
|
|
30
|
+
*
|
|
31
|
+
* The tracking index is invalidated (set to null) when:
|
|
32
|
+
* - A transaction is inserted before the already-calculated slice
|
|
33
|
+
* - A transaction is deleted from the already-calculated slice
|
|
34
|
+
* - The base checkpoint changes
|
|
35
|
+
*/
|
|
36
|
+
export class StateCalculator {
|
|
37
|
+
/** Sorted tx cache (ALL active/future txs, kept sorted by timestamp) */
|
|
38
|
+
private sortedTxs: SortedTxEntry[] = []
|
|
39
|
+
|
|
40
|
+
/** O(1) existence check and lookup */
|
|
41
|
+
private sortedTxsMap: Map<TxTimestampKey, SortedTxEntry> = new Map()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Index of the last transaction applied to cachedState.
|
|
45
|
+
* - null: state needs full recalculation from checkpoint
|
|
46
|
+
* - -1: no transactions have been applied yet (state === checkpoint state)
|
|
47
|
+
* - >= 0: transactions up to and including this index have been applied
|
|
48
|
+
*/
|
|
49
|
+
private lastAppliedIndex: number | null = null
|
|
50
|
+
|
|
51
|
+
/** The cached calculated state */
|
|
52
|
+
private cachedState: JSONObject | null = null
|
|
53
|
+
|
|
54
|
+
/** The base checkpoint to calculate state from */
|
|
55
|
+
private baseCheckpoint: CheckpointRecord | null = null
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Applied dedup keys - tracks which LOGICAL txs have been applied.
|
|
59
|
+
* This is the originalTxKey (or physical key if no original) for each applied tx.
|
|
60
|
+
* Used to properly deduplicate re-emits.
|
|
61
|
+
*/
|
|
62
|
+
private appliedTxKeys: Set<TxTimestampKey> = new Set()
|
|
63
|
+
|
|
64
|
+
/** Max clock seen from any transaction (for Lamport clock updates) */
|
|
65
|
+
private maxSeenClock = 0
|
|
66
|
+
|
|
67
|
+
/** Validation function (optional) */
|
|
68
|
+
private validateFn?: ValidateFn<JSONObject>
|
|
69
|
+
|
|
70
|
+
constructor(validateFn?: ValidateFn<JSONObject>) {
|
|
71
|
+
this.validateFn = validateFn
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sets the base checkpoint. Invalidates cached state if checkpoint changed.
|
|
76
|
+
* @returns true if the checkpoint changed
|
|
77
|
+
*/
|
|
78
|
+
setBaseCheckpoint(checkpoint: CheckpointRecord | null): boolean {
|
|
79
|
+
if (checkpoint === this.baseCheckpoint) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.baseCheckpoint = checkpoint
|
|
84
|
+
this.invalidate()
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Gets the current base checkpoint.
|
|
90
|
+
*/
|
|
91
|
+
getBaseCheckpoint(): CheckpointRecord | null {
|
|
92
|
+
return this.baseCheckpoint
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Clears all transactions and rebuilds from yTx map.
|
|
97
|
+
* This is used when the checkpoint changes and we need a fresh start.
|
|
98
|
+
*/
|
|
99
|
+
rebuildFromYjs(yTx: Y.Map<TxRecord>): void {
|
|
100
|
+
this.sortedTxs = []
|
|
101
|
+
this.sortedTxsMap.clear()
|
|
102
|
+
|
|
103
|
+
// Collect all entries, build the map and max clock
|
|
104
|
+
for (const key of yTx.keys()) {
|
|
105
|
+
const entry = new SortedTxEntry(key, yTx)
|
|
106
|
+
this.sortedTxs.push(entry)
|
|
107
|
+
|
|
108
|
+
this.sortedTxsMap.set(entry.txTimestampKey, entry)
|
|
109
|
+
if (entry.txTimestamp.clock > this.maxSeenClock) {
|
|
110
|
+
this.maxSeenClock = entry.txTimestamp.clock
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sort once - O(n log n)
|
|
115
|
+
this.sortedTxs.sort((a, b) => compareTxTimestamps(a.txTimestamp, b.txTimestamp))
|
|
116
|
+
|
|
117
|
+
// Invalidate cached state since we rebuilt
|
|
118
|
+
this.invalidate()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Inserts a transaction into the sorted cache.
|
|
123
|
+
* Invalidates cached state if the transaction was inserted before the calculated slice.
|
|
124
|
+
*
|
|
125
|
+
* @returns true if this caused invalidation (out-of-order insert)
|
|
126
|
+
*/
|
|
127
|
+
insertTx(key: TxTimestampKey, yTx: Y.Map<TxRecord>): boolean {
|
|
128
|
+
if (this.sortedTxsMap.has(key)) {
|
|
129
|
+
return false // Already exists
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entry = new SortedTxEntry(key, yTx)
|
|
133
|
+
const ts = entry.txTimestamp
|
|
134
|
+
|
|
135
|
+
// Update max seen clock for Lamport clock mechanism
|
|
136
|
+
if (ts.clock > this.maxSeenClock) {
|
|
137
|
+
this.maxSeenClock = ts.clock
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sortedTxs = this.sortedTxs
|
|
141
|
+
|
|
142
|
+
// Find insertion position (search from end since new txs typically have higher timestamps)
|
|
143
|
+
let insertIndex = sortedTxs.length // Default: append at end
|
|
144
|
+
for (let i = sortedTxs.length - 1; i >= 0; i--) {
|
|
145
|
+
const existingTs = sortedTxs[i].txTimestamp
|
|
146
|
+
if (compareTxTimestamps(ts, existingTs) >= 0) {
|
|
147
|
+
insertIndex = i + 1
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
if (i === 0) {
|
|
151
|
+
insertIndex = 0 // Insert at beginning
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Insert at the found position
|
|
156
|
+
sortedTxs.splice(insertIndex, 0, entry)
|
|
157
|
+
this.sortedTxsMap.set(key, entry)
|
|
158
|
+
|
|
159
|
+
// Check if this invalidates our cached state
|
|
160
|
+
// If we inserted before or at the last applied index, we need to recalculate
|
|
161
|
+
if (this.lastAppliedIndex !== null && insertIndex <= this.lastAppliedIndex) {
|
|
162
|
+
this.invalidate()
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Removes multiple transactions from the sorted cache.
|
|
171
|
+
* @returns the number of keys that were actually removed
|
|
172
|
+
*/
|
|
173
|
+
removeTxs(keys: readonly TxTimestampKey[]): number {
|
|
174
|
+
if (keys.length === 0) return 0
|
|
175
|
+
|
|
176
|
+
let removedCount = 0
|
|
177
|
+
let minRemovedIndex = Number.POSITIVE_INFINITY
|
|
178
|
+
|
|
179
|
+
// Build set of keys to delete and track their indices
|
|
180
|
+
const toDelete = new Set<TxTimestampKey>()
|
|
181
|
+
for (const key of keys) {
|
|
182
|
+
const entry = this.sortedTxsMap.get(key)
|
|
183
|
+
if (entry) {
|
|
184
|
+
this.sortedTxsMap.delete(key)
|
|
185
|
+
toDelete.add(key)
|
|
186
|
+
|
|
187
|
+
// Find index for invalidation check
|
|
188
|
+
const index = this.sortedTxs.indexOf(entry)
|
|
189
|
+
if (index !== -1 && index < minRemovedIndex) {
|
|
190
|
+
minRemovedIndex = index
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (toDelete.size === 0) return 0
|
|
196
|
+
|
|
197
|
+
// Single forward pass through sortedTxs, removing matching entries
|
|
198
|
+
const sortedTxs = this.sortedTxs
|
|
199
|
+
let i = 0
|
|
200
|
+
while (i < sortedTxs.length && toDelete.size > 0) {
|
|
201
|
+
if (toDelete.has(sortedTxs[i].txTimestampKey)) {
|
|
202
|
+
toDelete.delete(sortedTxs[i].txTimestampKey)
|
|
203
|
+
sortedTxs.splice(i, 1)
|
|
204
|
+
removedCount++
|
|
205
|
+
} else {
|
|
206
|
+
i++
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check if this invalidates our cached state
|
|
211
|
+
if (this.lastAppliedIndex !== null && minRemovedIndex <= this.lastAppliedIndex) {
|
|
212
|
+
this.invalidate()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return removedCount
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Checks if a transaction key exists in the cache.
|
|
220
|
+
*/
|
|
221
|
+
hasTx(key: TxTimestampKey): boolean {
|
|
222
|
+
return this.sortedTxsMap.has(key)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Gets a transaction entry by key.
|
|
227
|
+
*/
|
|
228
|
+
getTx(key: TxTimestampKey): SortedTxEntry | undefined {
|
|
229
|
+
return this.sortedTxsMap.get(key)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Gets all sorted transaction entries.
|
|
234
|
+
*/
|
|
235
|
+
getSortedTxs(): readonly SortedTxEntry[] {
|
|
236
|
+
return this.sortedTxs
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Gets the number of transactions in the cache.
|
|
241
|
+
*/
|
|
242
|
+
get txCount(): number {
|
|
243
|
+
return this.sortedTxs.length
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Returns true if the state needs full recalculation.
|
|
248
|
+
*/
|
|
249
|
+
needsFullRecalculation(): boolean {
|
|
250
|
+
return this.lastAppliedIndex === null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Invalidates the cached state, forcing a full recalculation on next calculateState().
|
|
255
|
+
* Note: cachedState is kept so computeReconcileOps can diff old vs new state.
|
|
256
|
+
*/
|
|
257
|
+
invalidate(): void {
|
|
258
|
+
this.lastAppliedIndex = null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Calculates and returns the current state, along with a lazy getter for ops that changed from the previous state.
|
|
263
|
+
*
|
|
264
|
+
* - If lastAppliedIndex is null: full recalculation from checkpoint
|
|
265
|
+
* - If lastAppliedIndex >= -1: incremental apply from lastAppliedIndex + 1
|
|
266
|
+
*/
|
|
267
|
+
calculateState(): { state: JSONObject; getAppliedOps: () => readonly Op[] } {
|
|
268
|
+
const baseState: JSONObject = this.baseCheckpoint?.state ?? {}
|
|
269
|
+
const watermarks = this.baseCheckpoint?.watermarks ?? {}
|
|
270
|
+
const hasWatermarks = Object.keys(watermarks).length > 0
|
|
271
|
+
|
|
272
|
+
if (this.lastAppliedIndex === null) {
|
|
273
|
+
// SLOW PATH: Full recalculation
|
|
274
|
+
return this.fullRecalculation(baseState, watermarks, hasWatermarks)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// FAST PATH: Incremental apply
|
|
278
|
+
return this.incrementalApply(watermarks, hasWatermarks)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Full recalculation of state from the base checkpoint.
|
|
283
|
+
*/
|
|
284
|
+
private fullRecalculation(
|
|
285
|
+
baseState: JSONObject,
|
|
286
|
+
watermarks: ClientWatermarks,
|
|
287
|
+
hasWatermarks: boolean
|
|
288
|
+
): { state: JSONObject; getAppliedOps: () => readonly Op[] } {
|
|
289
|
+
const oldState = this.cachedState ?? {}
|
|
290
|
+
|
|
291
|
+
// Reset tracking for full recompute
|
|
292
|
+
this.appliedTxKeys.clear()
|
|
293
|
+
this.lastAppliedIndex = -1
|
|
294
|
+
this.cachedState = baseState
|
|
295
|
+
|
|
296
|
+
// Delegate to incremental apply to replay all transactions
|
|
297
|
+
// We ignore the returned ops because they represent the operations applied from the base state,
|
|
298
|
+
// whereas we want the diff from the *previous cached state*.
|
|
299
|
+
// We pass returnOps=false to avoid collecting ops during replay.
|
|
300
|
+
const { state } = this.incrementalApply(watermarks, hasWatermarks, false)
|
|
301
|
+
|
|
302
|
+
// Lazy load the reconciliation ops (expensive diff)
|
|
303
|
+
const getAppliedOps = lazy(() => computeReconcileOps(oldState, state))
|
|
304
|
+
|
|
305
|
+
return { state, getAppliedOps }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Incremental apply of transactions from lastAppliedIndex + 1.
|
|
310
|
+
* @param returnOps If true, collects applied transactions (to lazy compute ops). If false, skips collection.
|
|
311
|
+
*/
|
|
312
|
+
private incrementalApply(
|
|
313
|
+
watermarks: ClientWatermarks,
|
|
314
|
+
hasWatermarks: boolean,
|
|
315
|
+
returnOps = true
|
|
316
|
+
): { state: JSONObject; getAppliedOps: () => readonly Op[] } {
|
|
317
|
+
let state = this.cachedState as JSONObject
|
|
318
|
+
const appliedTxs: TxRecord[] = []
|
|
319
|
+
const sortedTxs = this.sortedTxs
|
|
320
|
+
const startIndex = this.lastAppliedIndex! + 1
|
|
321
|
+
|
|
322
|
+
for (let i = startIndex; i < sortedTxs.length; i++) {
|
|
323
|
+
const entry = sortedTxs[i]
|
|
324
|
+
const dedupKey = entry.dedupTxTimestampKey
|
|
325
|
+
|
|
326
|
+
// Skip if already applied (deduplication)
|
|
327
|
+
if (this.appliedTxKeys.has(dedupKey)) {
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Skip if in checkpoint
|
|
332
|
+
if (hasWatermarks) {
|
|
333
|
+
const dedupTs = entry.dedupTxTimestamp
|
|
334
|
+
if (isTransactionInCheckpoint(dedupTs, watermarks)) {
|
|
335
|
+
this.appliedTxKeys.add(dedupKey)
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const tx = entry.txRecord
|
|
341
|
+
|
|
342
|
+
// Apply transaction 1-by-1 to avoid draft context pollution on validation failure
|
|
343
|
+
const newState = applyTxImmutable(state, tx, this.validateFn)
|
|
344
|
+
|
|
345
|
+
if (newState !== state) {
|
|
346
|
+
state = newState
|
|
347
|
+
if (returnOps) {
|
|
348
|
+
appliedTxs.push(tx)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.appliedTxKeys.add(dedupKey)
|
|
353
|
+
this.lastAppliedIndex = i
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Update lastAppliedIndex to end even if all txs were skipped
|
|
357
|
+
// This ensures we don't re-process skipped txs on next incremental apply
|
|
358
|
+
if (sortedTxs.length > 0 && this.lastAppliedIndex! < sortedTxs.length - 1) {
|
|
359
|
+
this.lastAppliedIndex = sortedTxs.length - 1
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this.cachedState = state
|
|
363
|
+
|
|
364
|
+
// Lazy getter for ops (flattens applied txs)
|
|
365
|
+
const getAppliedOps = lazy(() => {
|
|
366
|
+
const ops: Op[] = []
|
|
367
|
+
for (const tx of appliedTxs) {
|
|
368
|
+
ops.push(...tx.ops)
|
|
369
|
+
}
|
|
370
|
+
return ops
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
return { state, getAppliedOps }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Gets the max seen clock (for Lamport clock updates).
|
|
378
|
+
*/
|
|
379
|
+
getMaxSeenClock(): number {
|
|
380
|
+
return this.maxSeenClock
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Gets the current cached state without recalculating.
|
|
385
|
+
* Returns null if state has never been calculated.
|
|
386
|
+
*/
|
|
387
|
+
getCachedState(): JSONObject | null {
|
|
388
|
+
return this.cachedState
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Gets the last applied timestamp.
|
|
393
|
+
*/
|
|
394
|
+
getLastAppliedTs(): TxTimestamp | null {
|
|
395
|
+
if (this.lastAppliedIndex === null || this.lastAppliedIndex < 0) {
|
|
396
|
+
return null
|
|
397
|
+
}
|
|
398
|
+
return this.sortedTxs[this.lastAppliedIndex]?.txTimestamp ?? null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Gets the last applied index (for debugging/tracking).
|
|
403
|
+
*/
|
|
404
|
+
getLastAppliedIndex(): number | null {
|
|
405
|
+
return this.lastAppliedIndex
|
|
406
|
+
}
|
|
407
|
+
}
|
package/src/TxRecord.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Op } from "./operations"
|
|
2
|
+
import type { TxTimestampKey } from "./txTimestamp"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The immutable record stored in the Log.
|
|
6
|
+
*/
|
|
7
|
+
export type TxRecord = {
|
|
8
|
+
ops: readonly Op[]
|
|
9
|
+
/**
|
|
10
|
+
* If this is a re-emit of a missed transaction, this field holds the
|
|
11
|
+
* ORIGINAL key. Used for deduplication to prevent applying the same logical
|
|
12
|
+
* action twice.
|
|
13
|
+
*/
|
|
14
|
+
originalTxKey?: TxTimestampKey
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as Y from "yjs"
|
|
2
|
+
import { type CheckpointRecord, parseCheckpointKey } from "./checkpoints"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Determines the finalized epoch and its canonical checkpoint in a single pass.
|
|
6
|
+
*
|
|
7
|
+
* Policy A: The finalized epoch is the most recent epoch with a checkpoint.
|
|
8
|
+
* Canonical checkpoint: The checkpoint with highest txCount for that epoch
|
|
9
|
+
* (tie-break: lowest clientId alphabetically).
|
|
10
|
+
*
|
|
11
|
+
* Returns { finalizedEpoch: -1, checkpoint: null } if no checkpoints exist.
|
|
12
|
+
*/
|
|
13
|
+
export function getFinalizedEpochAndCheckpoint(yCheckpoint: Y.Map<CheckpointRecord>): {
|
|
14
|
+
finalizedEpoch: number
|
|
15
|
+
checkpoint: CheckpointRecord | null
|
|
16
|
+
} {
|
|
17
|
+
let maxEpoch = -1
|
|
18
|
+
let best: CheckpointRecord | null = null
|
|
19
|
+
let bestTxCount = -1
|
|
20
|
+
let bestClientId = ""
|
|
21
|
+
|
|
22
|
+
for (const [key, cp] of yCheckpoint.entries()) {
|
|
23
|
+
const { epoch, clientId } = parseCheckpointKey(key)
|
|
24
|
+
|
|
25
|
+
if (epoch > maxEpoch) {
|
|
26
|
+
// New highest epoch - reset best checkpoint tracking
|
|
27
|
+
maxEpoch = epoch
|
|
28
|
+
best = cp
|
|
29
|
+
bestTxCount = cp.txCount
|
|
30
|
+
bestClientId = clientId
|
|
31
|
+
} else if (epoch === maxEpoch) {
|
|
32
|
+
// Same epoch - check if this is a better canonical checkpoint
|
|
33
|
+
// Primary: higher txCount wins
|
|
34
|
+
// Secondary (tie-break): lower clientId (alphabetically) wins
|
|
35
|
+
if (cp.txCount > bestTxCount || (cp.txCount === bestTxCount && clientId < bestClientId)) {
|
|
36
|
+
best = cp
|
|
37
|
+
bestTxCount = cp.txCount
|
|
38
|
+
bestClientId = clientId
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { finalizedEpoch: maxEpoch, checkpoint: best }
|
|
44
|
+
}
|