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