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/src/txLog.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as Y from "yjs"
|
|
2
|
+
import { type CheckpointRecord, pruneCheckpoints } from "./checkpoints"
|
|
3
|
+
import { getFinalizedEpochAndCheckpoint } from "./checkpointUtils"
|
|
4
|
+
import { ClientState } from "./clientState"
|
|
5
|
+
import { JSONObject } from "./json"
|
|
6
|
+
import { Op } from "./operations"
|
|
7
|
+
import { SortedTxEntry } from "./SortedTxEntry"
|
|
8
|
+
import { isTransactionInCheckpoint } from "./StateCalculator"
|
|
9
|
+
import { TxRecord } from "./TxRecord"
|
|
10
|
+
import { type TxTimestamp, type TxTimestampKey, txTimestampToKey } from "./txTimestamp"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Changes to transaction keys from a Y.YMapEvent.
|
|
14
|
+
* - added: keys that were added
|
|
15
|
+
* - deleted: keys that were deleted
|
|
16
|
+
*/
|
|
17
|
+
export type TxKeyChanges = {
|
|
18
|
+
added: readonly TxTimestampKey[]
|
|
19
|
+
deleted: readonly TxTimestampKey[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Appends a new transaction to the log.
|
|
24
|
+
*
|
|
25
|
+
* @param originalKey - Optional reference to the original transaction key for re-emits.
|
|
26
|
+
* Used by syncLog to preserve transactions missed by checkpoints.
|
|
27
|
+
*/
|
|
28
|
+
export function appendTx(
|
|
29
|
+
ops: readonly Op[],
|
|
30
|
+
yTx: Y.Map<TxRecord>,
|
|
31
|
+
activeEpoch: number,
|
|
32
|
+
myClientId: string,
|
|
33
|
+
clientState: ClientState,
|
|
34
|
+
originalKey?: TxTimestampKey
|
|
35
|
+
): TxTimestampKey {
|
|
36
|
+
const calc = clientState.stateCalculator
|
|
37
|
+
|
|
38
|
+
// 1. Advance logical clock (Lamport) based on all seen traffic
|
|
39
|
+
const clock = Math.max(clientState.localClock, calc.getMaxSeenClock()) + 1
|
|
40
|
+
clientState.localClock = clock
|
|
41
|
+
|
|
42
|
+
// 2. Generate Key with WallClock for future pruning safety
|
|
43
|
+
const ts: TxTimestamp = {
|
|
44
|
+
epoch: activeEpoch,
|
|
45
|
+
clock,
|
|
46
|
+
clientId: myClientId,
|
|
47
|
+
wallClock: Date.now(),
|
|
48
|
+
}
|
|
49
|
+
const key = txTimestampToKey(ts)
|
|
50
|
+
|
|
51
|
+
// 3. Write to Yjs (Atomic)
|
|
52
|
+
const record: TxRecord = { ops, originalTxKey: originalKey }
|
|
53
|
+
yTx.set(key, record)
|
|
54
|
+
|
|
55
|
+
return key
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Synchronizes the transaction log with the current checkpoint.
|
|
60
|
+
* Re-emits missed transactions and prunes old ones.
|
|
61
|
+
*
|
|
62
|
+
* Should be called BEFORE updateState to ensure log is clean and complete.
|
|
63
|
+
*
|
|
64
|
+
* @returns true if any transactions were re-emitted or deleted, which may invalidate lastAppliedIndex
|
|
65
|
+
*/
|
|
66
|
+
function syncLog(
|
|
67
|
+
yTx: Y.Map<TxRecord>,
|
|
68
|
+
myClientId: string,
|
|
69
|
+
clientState: ClientState,
|
|
70
|
+
finalizedEpoch: number,
|
|
71
|
+
baseCP: CheckpointRecord | null,
|
|
72
|
+
newKeys?: readonly TxTimestampKey[] // keys added in this update
|
|
73
|
+
): void {
|
|
74
|
+
const calc = clientState.stateCalculator
|
|
75
|
+
const activeEpoch = finalizedEpoch + 1
|
|
76
|
+
const watermarks = baseCP?.watermarks ?? {}
|
|
77
|
+
|
|
78
|
+
// Deterministic Reference Time: Use stored minWallClock if available, otherwise 0 (nothing ancient).
|
|
79
|
+
const referenceTime = baseCP?.minWallClock ?? 0
|
|
80
|
+
|
|
81
|
+
// Helper to check if a transaction should be pruned
|
|
82
|
+
const shouldPrune = (ts: TxTimestamp, dedupTs: TxTimestamp): boolean => {
|
|
83
|
+
const isAncient = referenceTime - ts.wallClock > clientState.retentionWindowMs
|
|
84
|
+
if (isAncient) return true
|
|
85
|
+
return isTransactionInCheckpoint(dedupTs, watermarks)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const toDelete: TxTimestampKey[] = []
|
|
89
|
+
const toReEmit: Array<{ originalKey: TxTimestampKey; tx: TxRecord }> = []
|
|
90
|
+
|
|
91
|
+
// 1. Helper to decide what to do with each transaction
|
|
92
|
+
const processEntry = (entry: SortedTxEntry): boolean => {
|
|
93
|
+
if (shouldPrune(entry.txTimestamp, entry.dedupTxTimestamp)) {
|
|
94
|
+
toDelete.push(entry.txTimestampKey)
|
|
95
|
+
return false // deleted
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (entry.txTimestamp.epoch <= finalizedEpoch) {
|
|
99
|
+
// Not in checkpoint and still fresh - re-emit it to the active epoch
|
|
100
|
+
toReEmit.push({ originalKey: entry.dedupTxTimestampKey, tx: entry.txRecord })
|
|
101
|
+
toDelete.push(entry.txTimestampKey)
|
|
102
|
+
return false // re-emitted
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return true // active/fresh
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Scan local cache (sortedTxs) to identify missing/ancient transactions
|
|
109
|
+
for (const entry of calc.getSortedTxs()) {
|
|
110
|
+
if (processEntry(entry)) {
|
|
111
|
+
// Optimization: Physical keys are sorted. If we hit the active/fresh territory, we can stop.
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const processKeyByTimestampKey = (txTimestampKey: TxTimestampKey): void => {
|
|
117
|
+
// Only process if it actually exists in Yjs Map
|
|
118
|
+
if (yTx.has(txTimestampKey)) {
|
|
119
|
+
processEntry(new SortedTxEntry(txTimestampKey, yTx))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. Scan NEW keys from Yjs to handle incoming transactions (sync)
|
|
124
|
+
// Any client can re-emit - deduplication via originalTxKey handles duplicates.
|
|
125
|
+
if (newKeys) {
|
|
126
|
+
for (const key of newKeys) {
|
|
127
|
+
processKeyByTimestampKey(key)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 4. Re-emit missed transactions BEFORE pruning
|
|
132
|
+
for (const { originalKey, tx } of toReEmit) {
|
|
133
|
+
const newKey = appendTx(tx.ops, yTx, activeEpoch, myClientId, clientState, originalKey)
|
|
134
|
+
calc.insertTx(newKey, yTx)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Prune old/finalized/redundant transactions from Yjs Map
|
|
138
|
+
for (const key of toDelete) {
|
|
139
|
+
yTx.delete(key)
|
|
140
|
+
}
|
|
141
|
+
calc.removeTxs(toDelete)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The primary update function that maintains current state.
|
|
146
|
+
*
|
|
147
|
+
* @param txChanges - Changes from Y.YMapEvent. If undefined (first run), performs a full scan.
|
|
148
|
+
*/
|
|
149
|
+
export function updateState(
|
|
150
|
+
doc: Y.Doc,
|
|
151
|
+
yTx: Y.Map<TxRecord>,
|
|
152
|
+
yCheckpoint: Y.Map<CheckpointRecord>,
|
|
153
|
+
myClientId: string,
|
|
154
|
+
clientState: ClientState,
|
|
155
|
+
txChanges: TxKeyChanges | undefined
|
|
156
|
+
): { state: JSONObject; getAppliedOps: () => readonly Op[] } {
|
|
157
|
+
const calc = clientState.stateCalculator
|
|
158
|
+
|
|
159
|
+
// Always calculate fresh finalized epoch and checkpoint to handle sync race conditions
|
|
160
|
+
const { finalizedEpoch, checkpoint: baseCP } = getFinalizedEpochAndCheckpoint(yCheckpoint)
|
|
161
|
+
|
|
162
|
+
// Update read-cache
|
|
163
|
+
clientState.cachedFinalizedEpoch = finalizedEpoch
|
|
164
|
+
|
|
165
|
+
// Set base checkpoint (this handles invalidation if checkpoint changed)
|
|
166
|
+
calc.setBaseCheckpoint(baseCP)
|
|
167
|
+
|
|
168
|
+
// Track if we need to rebuild sorted cache (first run or missing delta)
|
|
169
|
+
// Optimization: We don't need to rebuild just because checkpoint changed,
|
|
170
|
+
// as long as we have txChanges to keep cache in sync.
|
|
171
|
+
const needsRebuildSortedCache = calc.getCachedState() === null || !txChanges
|
|
172
|
+
|
|
173
|
+
// Rebuild sorted cache before syncLog if needed
|
|
174
|
+
if (needsRebuildSortedCache) {
|
|
175
|
+
calc.rebuildFromYjs(yTx)
|
|
176
|
+
} else {
|
|
177
|
+
// If not rebuilding, immediately process deletions to avoid "ghost" transactions
|
|
178
|
+
// in syncLog (e.g. attempting to access a transaction that was just deleted).
|
|
179
|
+
calc.removeTxs(txChanges.deleted)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Sync and prune within transaction
|
|
183
|
+
doc.transact(() => {
|
|
184
|
+
syncLog(yTx, myClientId, clientState, finalizedEpoch, baseCP, txChanges?.added)
|
|
185
|
+
|
|
186
|
+
// Safe to use local finalizedEpoch here
|
|
187
|
+
pruneCheckpoints(yCheckpoint, finalizedEpoch)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (needsRebuildSortedCache) {
|
|
191
|
+
// Full recompute (calculator handles this)
|
|
192
|
+
return calc.calculateState()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Incremental update using only changed keys (calculator handles this)
|
|
196
|
+
// txChanges is guaranteed to exist here since !txChanges implies needsRebuildSortedCache
|
|
197
|
+
|
|
198
|
+
// Update sorted cache with new keys from txChanges
|
|
199
|
+
// This must happen after syncLog which may have deleted some of these keys
|
|
200
|
+
for (const key of txChanges.added) {
|
|
201
|
+
// CRITICAL: Check yTx.has(key)! syncLog might have just pruned it.
|
|
202
|
+
if (yTx.has(key) && !calc.hasTx(key)) {
|
|
203
|
+
calc.insertTx(key, yTx)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return calc.calculateState()
|
|
208
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { failure } from "./error"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parsed tx timestamp components.
|
|
5
|
+
*/
|
|
6
|
+
export type TxTimestamp = {
|
|
7
|
+
epoch: number
|
|
8
|
+
clock: number
|
|
9
|
+
clientId: string
|
|
10
|
+
wallClock: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Unique tx ID (Composite Key).
|
|
15
|
+
*/
|
|
16
|
+
export type TxTimestampKey = string
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts a timestamp object to a TransactionTimestampKey string.
|
|
20
|
+
*/
|
|
21
|
+
export function txTimestampToKey(ts: TxTimestamp): TxTimestampKey {
|
|
22
|
+
return `${ts.epoch};${ts.clock};${ts.clientId};${ts.wallClock}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Helper to parse tx timestamp keys.
|
|
27
|
+
* Throws if key is malformed.
|
|
28
|
+
*/
|
|
29
|
+
export function parseTxTimestampKey(key: TxTimestampKey): TxTimestamp {
|
|
30
|
+
const i1 = key.indexOf(";")
|
|
31
|
+
const i2 = key.indexOf(";", i1 + 1)
|
|
32
|
+
const i3 = key.indexOf(";", i2 + 1)
|
|
33
|
+
|
|
34
|
+
if (i1 === -1 || i2 === -1 || i3 === -1) {
|
|
35
|
+
failure(`Malformed timestamp key: ${key}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
epoch: Number.parseInt(key.substring(0, i1), 10),
|
|
40
|
+
clock: Number.parseInt(key.substring(i1 + 1, i2), 10),
|
|
41
|
+
clientId: key.substring(i2 + 1, i3),
|
|
42
|
+
wallClock: Number.parseInt(key.substring(i3 + 1), 10),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compares two tx timestamps for deterministic ordering.
|
|
48
|
+
* Sort order: epoch (asc) → clock (asc) → clientId (asc)
|
|
49
|
+
*/
|
|
50
|
+
export function compareTxTimestamps(a: TxTimestamp, b: TxTimestamp): number {
|
|
51
|
+
if (a.epoch !== b.epoch) return a.epoch - b.epoch
|
|
52
|
+
if (a.clock !== b.clock) return a.clock - b.clock
|
|
53
|
+
if (a.clientId < b.clientId) return -1
|
|
54
|
+
if (a.clientId > b.clientId) return 1
|
|
55
|
+
return 0
|
|
56
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import equal from "fast-deep-equal"
|
|
2
|
+
import { nanoid } from "nanoid"
|
|
3
|
+
import rfdc from "rfdc"
|
|
4
|
+
import type { JSONValue } from "./json"
|
|
5
|
+
|
|
6
|
+
const clone = rfdc({ proto: true })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Deep equality check for JSONValues.
|
|
10
|
+
* Used for addToSet / deleteFromSet operations.
|
|
11
|
+
*/
|
|
12
|
+
export function deepEqual(a: JSONValue, b: JSONValue): boolean {
|
|
13
|
+
return equal(a, b)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a unique ID using nanoid.
|
|
18
|
+
*/
|
|
19
|
+
export function generateID(): string {
|
|
20
|
+
return nanoid()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Checks if a value is an object (typeof === "object" && !== null).
|
|
25
|
+
*/
|
|
26
|
+
export function isObject(value: unknown): value is object {
|
|
27
|
+
return value !== null && typeof value === "object"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deep clones a JSON-serializable value.
|
|
32
|
+
* Optimized: primitives are returned as-is.
|
|
33
|
+
*/
|
|
34
|
+
export function deepClone<T>(value: T): T {
|
|
35
|
+
// Primitives don't need cloning
|
|
36
|
+
if (value === null || typeof value !== "object") {
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
39
|
+
return clone(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Creates a lazy memoized getter.
|
|
44
|
+
*/
|
|
45
|
+
export function lazy<T>(fn: () => T): () => T {
|
|
46
|
+
let computed = false
|
|
47
|
+
let value: T
|
|
48
|
+
return () => {
|
|
49
|
+
if (!computed) {
|
|
50
|
+
value = fn()
|
|
51
|
+
computed = true
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
}
|