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.
Files changed (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +277 -0
  4. package/dist/state-sync-log.esm.js +1339 -0
  5. package/dist/state-sync-log.esm.mjs +1339 -0
  6. package/dist/state-sync-log.umd.js +1343 -0
  7. package/dist/types/ClientId.d.ts +1 -0
  8. package/dist/types/SortedTxEntry.d.ts +44 -0
  9. package/dist/types/StateCalculator.d.ts +141 -0
  10. package/dist/types/TxRecord.d.ts +14 -0
  11. package/dist/types/checkpointUtils.d.ts +15 -0
  12. package/dist/types/checkpoints.d.ts +62 -0
  13. package/dist/types/clientState.d.ts +19 -0
  14. package/dist/types/createStateSyncLog.d.ts +97 -0
  15. package/dist/types/draft.d.ts +69 -0
  16. package/dist/types/error.d.ts +4 -0
  17. package/dist/types/index.d.ts +4 -0
  18. package/dist/types/json.d.ts +23 -0
  19. package/dist/types/operations.d.ts +64 -0
  20. package/dist/types/reconcile.d.ts +7 -0
  21. package/dist/types/txLog.d.ts +32 -0
  22. package/dist/types/txTimestamp.d.ts +27 -0
  23. package/dist/types/utils.d.ts +23 -0
  24. package/package.json +94 -0
  25. package/src/ClientId.ts +1 -0
  26. package/src/SortedTxEntry.ts +83 -0
  27. package/src/StateCalculator.ts +407 -0
  28. package/src/TxRecord.ts +15 -0
  29. package/src/checkpointUtils.ts +44 -0
  30. package/src/checkpoints.ts +208 -0
  31. package/src/clientState.ts +37 -0
  32. package/src/createStateSyncLog.ts +330 -0
  33. package/src/draft.ts +288 -0
  34. package/src/error.ts +12 -0
  35. package/src/index.ts +8 -0
  36. package/src/json.ts +25 -0
  37. package/src/operations.ts +157 -0
  38. package/src/reconcile.ts +124 -0
  39. package/src/txLog.ts +208 -0
  40. package/src/txTimestamp.ts +56 -0
  41. 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
+ }