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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as Y from "yjs"
|
|
2
|
+
import { ClientId } from "./ClientId"
|
|
3
|
+
import { getFinalizedEpochAndCheckpoint } from "./checkpointUtils"
|
|
4
|
+
import { ClientState } from "./clientState"
|
|
5
|
+
import { failure } from "./error"
|
|
6
|
+
import { JSONObject } from "./json"
|
|
7
|
+
import { TxRecord } from "./TxRecord"
|
|
8
|
+
import { TxTimestampKey } from "./txTimestamp"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Watermarking for deduplication and pruning.
|
|
12
|
+
* - maxClock: All txs from this client with clock <= maxClock are FINALIZED.
|
|
13
|
+
* - maxWallClock: The last time we saw this client active (for pruning).
|
|
14
|
+
*/
|
|
15
|
+
type ClientWatermark = Readonly<{
|
|
16
|
+
maxClock: number
|
|
17
|
+
maxWallClock: number
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Watermarks for all clients.
|
|
22
|
+
*/
|
|
23
|
+
export type ClientWatermarks = Record<ClientId, ClientWatermark>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A snapshot of the state at the end of a specific epoch.
|
|
27
|
+
*/
|
|
28
|
+
export type CheckpointRecord = {
|
|
29
|
+
state: JSONObject // The document state
|
|
30
|
+
watermarks: ClientWatermarks // Dedup/Pruning info
|
|
31
|
+
txCount: number // Tie-breaker for canonical selection
|
|
32
|
+
minWallClock: number // Reference time for this epoch (deterministic pruning)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unique ID for a checkpoint.
|
|
37
|
+
* Format: `${epoch};${txCount};${clientId}`
|
|
38
|
+
*/
|
|
39
|
+
export type CheckpointKey = string
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Data extracted from a checkpoint key.
|
|
43
|
+
*/
|
|
44
|
+
export type CheckpointKeyData = {
|
|
45
|
+
epoch: number
|
|
46
|
+
txCount: number
|
|
47
|
+
clientId: ClientId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Converts checkpoint key data components to a key string.
|
|
52
|
+
*/
|
|
53
|
+
function checkpointKeyDataToKey(data: CheckpointKeyData): CheckpointKey {
|
|
54
|
+
return `${data.epoch};${data.txCount};${data.clientId}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Helper to parse checkpoint keys.
|
|
59
|
+
* Checkpoint keys have format: `${epoch};${txCount};${clientId}`
|
|
60
|
+
* Throws if key is malformed.
|
|
61
|
+
*/
|
|
62
|
+
export function parseCheckpointKey(key: CheckpointKey): CheckpointKeyData {
|
|
63
|
+
const i1 = key.indexOf(";")
|
|
64
|
+
const i2 = key.indexOf(";", i1 + 1)
|
|
65
|
+
|
|
66
|
+
if (i1 === -1 || i2 === -1) {
|
|
67
|
+
failure(`Malformed checkpoint key: ${key}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
epoch: Number.parseInt(key.substring(0, i1), 10),
|
|
72
|
+
txCount: Number.parseInt(key.substring(i1 + 1, i2), 10),
|
|
73
|
+
clientId: key.substring(i2 + 1),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Called periodically (e.g. by a server or leader client) to finalize the epoch.
|
|
79
|
+
*/
|
|
80
|
+
export function createCheckpoint(
|
|
81
|
+
yTx: Y.Map<TxRecord>,
|
|
82
|
+
yCheckpoint: Y.Map<CheckpointRecord>,
|
|
83
|
+
clientState: ClientState,
|
|
84
|
+
activeEpoch: number,
|
|
85
|
+
currentState: JSONObject,
|
|
86
|
+
myClientId: string
|
|
87
|
+
): void {
|
|
88
|
+
// 1. Start with previous watermarks (from finalized epoch = activeEpoch - 1)
|
|
89
|
+
const { checkpoint: prevCP } = getFinalizedEpochAndCheckpoint(yCheckpoint)
|
|
90
|
+
const newWatermarks = prevCP ? { ...prevCP.watermarks } : {}
|
|
91
|
+
|
|
92
|
+
// Get active txs using cached sorted order (filter by epoch)
|
|
93
|
+
// FILTER IS REQUIRED:
|
|
94
|
+
// Although we are finalizing 'activeEpoch', other peers may have already
|
|
95
|
+
// advanced to the next epoch and started syncing those txs.
|
|
96
|
+
// We must ensure this checkpoint ONLY contains txs from 'activeEpoch'.
|
|
97
|
+
// Using stateCalculator.getSortedTxs avoids redundant key parsing (timestamps are cached).
|
|
98
|
+
//
|
|
99
|
+
// OPTIMIZATION: Since sortedTxs is sorted by epoch (primary key) and past epochs
|
|
100
|
+
// are pruned, we only need to find the right boundary. Future epochs are rare,
|
|
101
|
+
// so a simple linear search from the right is efficient (typically 0-1 iterations).
|
|
102
|
+
const sortedTxs = clientState.stateCalculator.getSortedTxs()
|
|
103
|
+
|
|
104
|
+
// Find end boundary by searching from right (skip any future epoch entries)
|
|
105
|
+
let endIndex = sortedTxs.length
|
|
106
|
+
while (endIndex > 0 && sortedTxs[endIndex - 1].txTimestamp.epoch > activeEpoch) {
|
|
107
|
+
endIndex--
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Slice from start to endIndex (past epochs are pruned, so these are all activeEpoch)
|
|
111
|
+
const activeTxs = sortedTxs.slice(0, endIndex)
|
|
112
|
+
|
|
113
|
+
if (activeTxs.length === 0) {
|
|
114
|
+
return // Do nothing if no txs (prevents empty epochs)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Update watermarks based on OBSERVED active txs and calculate minWallClock
|
|
118
|
+
// NOTE: We cannot use activeTxs[0].txTimestamp.wallClock for minWallClock because
|
|
119
|
+
// txs are sorted by Lamport clock (epoch → clock → clientId), not by wallClock.
|
|
120
|
+
// A client may have a high Lamport clock but early wallClock due to clock drift
|
|
121
|
+
// or receiving many messages before emitting.
|
|
122
|
+
let minWallClock = Number.POSITIVE_INFINITY
|
|
123
|
+
let txCount = 0
|
|
124
|
+
for (const entry of activeTxs) {
|
|
125
|
+
const ts = entry.txTimestamp
|
|
126
|
+
|
|
127
|
+
// Track min wallClock for deterministic pruning reference
|
|
128
|
+
if (ts.wallClock < minWallClock) {
|
|
129
|
+
minWallClock = ts.wallClock
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const newWm = newWatermarks[ts.clientId]
|
|
133
|
+
? { ...newWatermarks[ts.clientId] }
|
|
134
|
+
: { maxClock: -1, maxWallClock: 0 }
|
|
135
|
+
|
|
136
|
+
if (ts.clock > newWm.maxClock) {
|
|
137
|
+
newWm.maxClock = ts.clock
|
|
138
|
+
newWm.maxWallClock = ts.wallClock
|
|
139
|
+
}
|
|
140
|
+
newWatermarks[ts.clientId] = newWm
|
|
141
|
+
txCount++
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Prune Inactive Watermarks (Deterministic)
|
|
145
|
+
// Uses minWallClock so all clients agree on exactly who to prune.
|
|
146
|
+
for (const clientId in newWatermarks) {
|
|
147
|
+
if (minWallClock - newWatermarks[clientId].maxWallClock > clientState.retentionWindowMs) {
|
|
148
|
+
delete newWatermarks[clientId]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. Save Checkpoint
|
|
153
|
+
const cpKey = checkpointKeyDataToKey({
|
|
154
|
+
epoch: activeEpoch,
|
|
155
|
+
txCount,
|
|
156
|
+
clientId: myClientId,
|
|
157
|
+
})
|
|
158
|
+
yCheckpoint.set(cpKey, {
|
|
159
|
+
state: currentState, // Responsibility for cloning is moved to the caller if needed
|
|
160
|
+
watermarks: newWatermarks,
|
|
161
|
+
txCount,
|
|
162
|
+
minWallClock,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// 5. Early tx pruning (Optimization)
|
|
166
|
+
// Delete all txs from the now-finalized epoch
|
|
167
|
+
// This reduces memory pressure instead of waiting for cleanupLog
|
|
168
|
+
const keysToDelete: TxTimestampKey[] = []
|
|
169
|
+
for (const entry of activeTxs) {
|
|
170
|
+
yTx.delete(entry.txTimestampKey)
|
|
171
|
+
keysToDelete.push(entry.txTimestampKey)
|
|
172
|
+
}
|
|
173
|
+
clientState.stateCalculator.removeTxs(keysToDelete)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Garbage collects old checkpoints.
|
|
178
|
+
* Should be called periodically to prevent unbounded growth of yCheckpoint.
|
|
179
|
+
*
|
|
180
|
+
* Keeps only the canonical checkpoint for the finalized epoch.
|
|
181
|
+
* Everything else is deleted (old epochs + non-canonical).
|
|
182
|
+
*
|
|
183
|
+
* Note: The active epoch never has checkpoints - creating a checkpoint
|
|
184
|
+
* for an epoch immediately makes it finalized.
|
|
185
|
+
*/
|
|
186
|
+
export function pruneCheckpoints(
|
|
187
|
+
yCheckpoint: Y.Map<CheckpointRecord>,
|
|
188
|
+
finalizedEpoch: number
|
|
189
|
+
): void {
|
|
190
|
+
// Find the canonical checkpoint and its key in one pass
|
|
191
|
+
let canonicalKey: CheckpointKey | null = null
|
|
192
|
+
let bestTxCount = -1
|
|
193
|
+
|
|
194
|
+
for (const [key] of yCheckpoint.entries()) {
|
|
195
|
+
const { epoch, txCount } = parseCheckpointKey(key)
|
|
196
|
+
if (epoch === finalizedEpoch && txCount > bestTxCount) {
|
|
197
|
+
canonicalKey = key
|
|
198
|
+
bestTxCount = txCount
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Delete everything except the canonical checkpoint
|
|
203
|
+
for (const key of yCheckpoint.keys()) {
|
|
204
|
+
if (key !== canonicalKey) {
|
|
205
|
+
yCheckpoint.delete(key)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { JSONObject } from "./json"
|
|
2
|
+
import { ValidateFn } from "./operations"
|
|
3
|
+
import { StateCalculator } from "./StateCalculator"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Client-side state including clocks and calculator for state management
|
|
7
|
+
*/
|
|
8
|
+
export interface ClientState {
|
|
9
|
+
// Lamport clocks (monotonic, never reset)
|
|
10
|
+
localClock: number
|
|
11
|
+
|
|
12
|
+
// Cached finalized epoch (null = not yet initialized, recalculated only when checkpoint map changes)
|
|
13
|
+
cachedFinalizedEpoch: number | null
|
|
14
|
+
|
|
15
|
+
// State calculator (manages sorted tx cache, state calculation, and invalidation)
|
|
16
|
+
stateCalculator: StateCalculator
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Timestamp retention window in milliseconds.
|
|
20
|
+
*/
|
|
21
|
+
retentionWindowMs: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Factory to create an initial ClientState
|
|
26
|
+
*/
|
|
27
|
+
export function createClientState(
|
|
28
|
+
validateFn: ValidateFn<JSONObject> | undefined,
|
|
29
|
+
retentionWindowMs: number
|
|
30
|
+
): ClientState {
|
|
31
|
+
return {
|
|
32
|
+
localClock: 0,
|
|
33
|
+
cachedFinalizedEpoch: null, // Will be recalculated on first run
|
|
34
|
+
stateCalculator: new StateCalculator(validateFn),
|
|
35
|
+
retentionWindowMs,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import * as Y from "yjs"
|
|
2
|
+
import { CheckpointRecord, createCheckpoint } from "./checkpoints"
|
|
3
|
+
import { createClientState } from "./clientState"
|
|
4
|
+
import { failure } from "./error"
|
|
5
|
+
import { JSONObject } from "./json"
|
|
6
|
+
|
|
7
|
+
import { Op, ValidateFn } from "./operations"
|
|
8
|
+
|
|
9
|
+
import { computeReconcileOps } from "./reconcile"
|
|
10
|
+
import { SortedTxEntry } from "./SortedTxEntry"
|
|
11
|
+
import { TxRecord } from "./TxRecord"
|
|
12
|
+
import { appendTx, TxKeyChanges, updateState } from "./txLog"
|
|
13
|
+
import { TxTimestampKey } from "./txTimestamp"
|
|
14
|
+
import { generateID } from "./utils"
|
|
15
|
+
|
|
16
|
+
export const getSortedTxsSymbol = Symbol("getSortedTxs")
|
|
17
|
+
|
|
18
|
+
export interface StateSyncLogOptions<State extends JSONObject> {
|
|
19
|
+
/**
|
|
20
|
+
* The Y.js Document to bind to.
|
|
21
|
+
*/
|
|
22
|
+
yDoc: Y.Doc
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Name for the txs Y.Map.
|
|
26
|
+
* Default: "state-sync-log-tx"
|
|
27
|
+
*/
|
|
28
|
+
yTxMapName?: string
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Name for the checkpoint Y.Map.
|
|
32
|
+
* Default: "state-sync-log-checkpoint"
|
|
33
|
+
*/
|
|
34
|
+
yCheckpointMapName?: string
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unique identifier for this client.
|
|
38
|
+
* If omitted, a random UUID (nanoid) will be generated.
|
|
39
|
+
* NOTE: If you need to resume a session (keep local clock/watermark), you MUST provide a stable ID.
|
|
40
|
+
* MUST NOT contain semicolons.
|
|
41
|
+
*/
|
|
42
|
+
clientId?: string
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Origin tag for Y.js txs created by this library.
|
|
46
|
+
*/
|
|
47
|
+
yjsOrigin?: unknown
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Optional validation function.
|
|
51
|
+
* Runs after each tx's ops are applied.
|
|
52
|
+
* If it returns false, the tx is rejected (state reverts).
|
|
53
|
+
* MUST be deterministic and consistent across all clients.
|
|
54
|
+
*/
|
|
55
|
+
validate?: (state: State) => boolean
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Timestamp retention window in milliseconds.
|
|
59
|
+
* Txs older than this window are considered "Ancient" and pruned.
|
|
60
|
+
*
|
|
61
|
+
* Default: Infinity (No pruning).
|
|
62
|
+
* Recommended: 14 days (1209600000 ms).
|
|
63
|
+
*/
|
|
64
|
+
retentionWindowMs: number | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StateSyncLogController<State extends JSONObject> {
|
|
68
|
+
/**
|
|
69
|
+
* Returns the current state.
|
|
70
|
+
*/
|
|
71
|
+
getState(): State
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Subscribes to state changes.
|
|
75
|
+
*/
|
|
76
|
+
subscribe(callback: (newState: State, getAppliedOps: () => readonly Op[]) => void): () => void
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Emits a new tx (list of operations) to the log.
|
|
80
|
+
*/
|
|
81
|
+
emit(ops: Op[]): void
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reconciles the current state with the target state.
|
|
85
|
+
*/
|
|
86
|
+
reconcileState(targetState: State): void
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Manually triggers epoch compaction (Checkpointing).
|
|
90
|
+
*/
|
|
91
|
+
compact(): void
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cleans up observers and releases memory.
|
|
95
|
+
*/
|
|
96
|
+
dispose(): void
|
|
97
|
+
|
|
98
|
+
// --- Observability & Stats ---
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns the current active epoch number.
|
|
102
|
+
*/
|
|
103
|
+
getActiveEpoch(): number
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns the number of txs currently in the active epoch.
|
|
107
|
+
*/
|
|
108
|
+
getActiveEpochTxCount(): number
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Returns the wallClock timestamp of the first tx in the active epoch.
|
|
112
|
+
*/
|
|
113
|
+
getActiveEpochStartTime(): number | undefined
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns true if the log is completely empty.
|
|
117
|
+
*/
|
|
118
|
+
isLogEmpty(): boolean
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Internal/Testing: Returns all txs currently in the log, sorted.
|
|
122
|
+
*/
|
|
123
|
+
[getSortedTxsSymbol](): readonly SortedTxEntry[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Creates a StateSyncLog controller.
|
|
128
|
+
*/
|
|
129
|
+
export function createStateSyncLog<State extends JSONObject>(
|
|
130
|
+
options: StateSyncLogOptions<State>
|
|
131
|
+
): StateSyncLogController<State> {
|
|
132
|
+
const {
|
|
133
|
+
yDoc,
|
|
134
|
+
yTxMapName = "state-sync-log-tx",
|
|
135
|
+
yCheckpointMapName = "state-sync-log-checkpoint",
|
|
136
|
+
clientId = generateID(),
|
|
137
|
+
yjsOrigin,
|
|
138
|
+
validate,
|
|
139
|
+
retentionWindowMs,
|
|
140
|
+
} = options
|
|
141
|
+
|
|
142
|
+
if (clientId.includes(";")) {
|
|
143
|
+
failure(`clientId MUST NOT contain semicolons: ${clientId}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const yTx = yDoc.getMap<TxRecord>(yTxMapName)
|
|
147
|
+
const yCheckpoint = yDoc.getMap<CheckpointRecord>(yCheckpointMapName)
|
|
148
|
+
|
|
149
|
+
// Cast validate to basic type to match internal ClientState
|
|
150
|
+
const clientState = createClientState(
|
|
151
|
+
validate as unknown as ValidateFn<JSONObject>,
|
|
152
|
+
retentionWindowMs ?? Number.POSITIVE_INFINITY
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Listeners
|
|
156
|
+
const subscribers = new Set<(state: State, getAppliedOps: () => readonly Op[]) => void>()
|
|
157
|
+
|
|
158
|
+
const notifySubscribers = (state: State, getAppliedOps: () => readonly Op[]) => {
|
|
159
|
+
for (const sub of subscribers) {
|
|
160
|
+
sub(state, getAppliedOps)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Helper to extract key changes from YMapEvent
|
|
165
|
+
const extractTxChanges = (event: Y.YMapEvent<TxRecord>): TxKeyChanges => {
|
|
166
|
+
const added: TxTimestampKey[] = []
|
|
167
|
+
const deleted: TxTimestampKey[] = []
|
|
168
|
+
|
|
169
|
+
for (const [key, change] of event.changes.keys) {
|
|
170
|
+
if (change.action === "add") {
|
|
171
|
+
added.push(key)
|
|
172
|
+
} else if (change.action === "delete") {
|
|
173
|
+
deleted.push(key)
|
|
174
|
+
} else if (change.action === "update") {
|
|
175
|
+
deleted.push(key)
|
|
176
|
+
added.push(key)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { added, deleted }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Empty txChanges object for checkpoint observer (no tx keys changed)
|
|
184
|
+
const emptyTxChanges: TxKeyChanges = { added: [], deleted: [] }
|
|
185
|
+
|
|
186
|
+
// Update Logic with incremental changes
|
|
187
|
+
const runUpdate = (txChanges: TxKeyChanges | undefined) => {
|
|
188
|
+
const { state, getAppliedOps } = updateState(
|
|
189
|
+
yDoc,
|
|
190
|
+
yTx,
|
|
191
|
+
yCheckpoint,
|
|
192
|
+
clientId,
|
|
193
|
+
clientState,
|
|
194
|
+
txChanges
|
|
195
|
+
)
|
|
196
|
+
notifySubscribers(state as State, getAppliedOps)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Tx observer
|
|
200
|
+
const txObserver = (event: Y.YMapEvent<TxRecord>, _transaction: Y.Transaction) => {
|
|
201
|
+
const txChanges = extractTxChanges(event)
|
|
202
|
+
runUpdate(txChanges)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Checkpoint observer
|
|
206
|
+
const checkpointObserver = (
|
|
207
|
+
_event: Y.YMapEvent<CheckpointRecord>,
|
|
208
|
+
_transaction: Y.Transaction
|
|
209
|
+
) => {
|
|
210
|
+
runUpdate(emptyTxChanges)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
yCheckpoint.observe(checkpointObserver)
|
|
214
|
+
yTx.observe(txObserver)
|
|
215
|
+
|
|
216
|
+
// Initial run (full recompute, treat as checkpoint change to initialize epoch cache)
|
|
217
|
+
runUpdate(undefined)
|
|
218
|
+
|
|
219
|
+
// Track disposal state
|
|
220
|
+
let disposed = false
|
|
221
|
+
|
|
222
|
+
const assertNotDisposed = () => {
|
|
223
|
+
if (disposed) {
|
|
224
|
+
failure("StateSyncLog has been disposed and cannot be used")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const getActiveEpochInternal = () => {
|
|
229
|
+
if (clientState.cachedFinalizedEpoch === null) {
|
|
230
|
+
failure("cachedFinalizedEpoch is null - this should not happen after initialization")
|
|
231
|
+
}
|
|
232
|
+
return clientState.cachedFinalizedEpoch + 1
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
getState(): State {
|
|
237
|
+
assertNotDisposed()
|
|
238
|
+
return (clientState.stateCalculator.getCachedState() ?? {}) as State
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
subscribe(callback: (newState: State, getAppliedOps: () => readonly Op[]) => void): () => void {
|
|
242
|
+
assertNotDisposed()
|
|
243
|
+
subscribers.add(callback)
|
|
244
|
+
return () => {
|
|
245
|
+
subscribers.delete(callback)
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
emit(ops: Op[]): void {
|
|
250
|
+
assertNotDisposed()
|
|
251
|
+
yDoc.transact(() => {
|
|
252
|
+
const activeEpoch = getActiveEpochInternal()
|
|
253
|
+
appendTx(ops, yTx, activeEpoch, clientId, clientState)
|
|
254
|
+
}, yjsOrigin)
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
reconcileState(targetState: State): void {
|
|
258
|
+
assertNotDisposed()
|
|
259
|
+
const currentState = (clientState.stateCalculator.getCachedState() ?? {}) as State
|
|
260
|
+
const ops = computeReconcileOps(currentState, targetState)
|
|
261
|
+
if (ops.length > 0) {
|
|
262
|
+
this.emit(ops)
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
compact(): void {
|
|
267
|
+
assertNotDisposed()
|
|
268
|
+
yDoc.transact(() => {
|
|
269
|
+
const activeEpoch = getActiveEpochInternal()
|
|
270
|
+
const currentState = clientState.stateCalculator.getCachedState() ?? {}
|
|
271
|
+
createCheckpoint(yTx, yCheckpoint, clientState, activeEpoch, currentState, clientId)
|
|
272
|
+
}, yjsOrigin)
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
dispose(): void {
|
|
276
|
+
if (disposed) return // Already disposed, no-op
|
|
277
|
+
disposed = true
|
|
278
|
+
yTx.unobserve(txObserver)
|
|
279
|
+
yCheckpoint.unobserve(checkpointObserver)
|
|
280
|
+
subscribers.clear()
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
getActiveEpoch(): number {
|
|
284
|
+
assertNotDisposed()
|
|
285
|
+
return getActiveEpochInternal()
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
getActiveEpochTxCount(): number {
|
|
289
|
+
assertNotDisposed()
|
|
290
|
+
const activeEpoch = getActiveEpochInternal()
|
|
291
|
+
let count = 0
|
|
292
|
+
// Only current or future epochs exist in sortedTxs (past epochs are pruned during updateState).
|
|
293
|
+
// Future epochs appear if we receive txs before the corresponding checkpoint.
|
|
294
|
+
for (const entry of clientState.stateCalculator.getSortedTxs()) {
|
|
295
|
+
const ts = entry.txTimestamp
|
|
296
|
+
if (ts.epoch === activeEpoch) {
|
|
297
|
+
count++
|
|
298
|
+
} else if (ts.epoch > activeEpoch) {
|
|
299
|
+
break // Optimization: sorted order means we can stop early
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return count
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
getActiveEpochStartTime(): number | undefined {
|
|
306
|
+
assertNotDisposed()
|
|
307
|
+
const activeEpoch = getActiveEpochInternal()
|
|
308
|
+
// Only current or future epochs exist in sortedTxs (past epochs are pruned during updateState).
|
|
309
|
+
for (const entry of clientState.stateCalculator.getSortedTxs()) {
|
|
310
|
+
const ts = entry.txTimestamp
|
|
311
|
+
if (ts.epoch === activeEpoch) {
|
|
312
|
+
return ts.wallClock
|
|
313
|
+
} else if (ts.epoch > activeEpoch) {
|
|
314
|
+
break // Optimization: sorted order means we can stop early
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return undefined
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
isLogEmpty(): boolean {
|
|
321
|
+
assertNotDisposed()
|
|
322
|
+
return yTx.size === 0 && yCheckpoint.size === 0
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
[getSortedTxsSymbol](): readonly SortedTxEntry[] {
|
|
326
|
+
assertNotDisposed()
|
|
327
|
+
return clientState.stateCalculator.getSortedTxs()
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
}
|