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
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
+ }
@@ -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
+ }
@@ -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
+ }