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/draft.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { failure } from "./error"
|
|
2
|
+
import type { JSONObject, JSONRecord, JSONValue, Path } from "./json"
|
|
3
|
+
import type { Op, ValidateFn } from "./operations"
|
|
4
|
+
import { TxRecord } from "./TxRecord"
|
|
5
|
+
import { deepEqual, isObject } from "./utils"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A draft context for copy-on-write immutable updates.
|
|
9
|
+
*
|
|
10
|
+
* This provides Immer/Mutative-like semantics without the proxy overhead:
|
|
11
|
+
* - The original base state is never mutated
|
|
12
|
+
* - Objects are cloned lazily on first mutation (copy-on-write)
|
|
13
|
+
* - Once an object is cloned ("owned"), it can be mutated directly
|
|
14
|
+
* - If no changes are made, the original reference is preserved (structural sharing)
|
|
15
|
+
*/
|
|
16
|
+
export interface DraftContext<T extends JSONObject> {
|
|
17
|
+
/** The current root (may be the original base or a cloned version) */
|
|
18
|
+
root: T
|
|
19
|
+
/** The original base state (never mutated) */
|
|
20
|
+
base: T
|
|
21
|
+
/** Set of objects that have been cloned and are safe to mutate */
|
|
22
|
+
ownedObjects: WeakSet<object>
|
|
23
|
+
/** Optimization: fast check if root is already owned */
|
|
24
|
+
isRootOwned: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new draft context from a base state.
|
|
29
|
+
* The base state will never be mutated.
|
|
30
|
+
*/
|
|
31
|
+
export function createDraft<T extends JSONObject>(base: T): DraftContext<T> {
|
|
32
|
+
return {
|
|
33
|
+
root: base,
|
|
34
|
+
base,
|
|
35
|
+
ownedObjects: new Set(),
|
|
36
|
+
isRootOwned: false,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks if the draft was modified (root !== base).
|
|
42
|
+
*/
|
|
43
|
+
export function isDraftModified<T extends JSONObject>(ctx: DraftContext<T>): boolean {
|
|
44
|
+
return ctx.root !== ctx.base
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shallowClone<T extends object>(obj: T): T {
|
|
48
|
+
if (Array.isArray(obj)) {
|
|
49
|
+
return obj.slice() as unknown as T
|
|
50
|
+
}
|
|
51
|
+
const clone = {} as T
|
|
52
|
+
const keys = Object.keys(obj)
|
|
53
|
+
for (let i = 0; i < keys.length; i++) {
|
|
54
|
+
const key = keys[i]
|
|
55
|
+
;(clone as any)[key] = (obj as any)[key]
|
|
56
|
+
}
|
|
57
|
+
return clone
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensures an object is owned (cloned if necessary) and returns the owned version.
|
|
62
|
+
* Also updates the parent to point to the cloned child.
|
|
63
|
+
*/
|
|
64
|
+
function ensureOwned<T extends JSONObject>(
|
|
65
|
+
ctx: DraftContext<T>,
|
|
66
|
+
parent: JSONObject,
|
|
67
|
+
key: string | number,
|
|
68
|
+
child: JSONRecord | JSONValue[]
|
|
69
|
+
): JSONRecord | JSONValue[] {
|
|
70
|
+
if (ctx.ownedObjects.has(child)) {
|
|
71
|
+
return child
|
|
72
|
+
}
|
|
73
|
+
const cloned = shallowClone(child)
|
|
74
|
+
;(parent as any)[key] = cloned
|
|
75
|
+
ctx.ownedObjects.add(cloned)
|
|
76
|
+
return cloned
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ensures all objects along the path are owned (cloned if necessary).
|
|
81
|
+
* Returns the container at the end of the path.
|
|
82
|
+
* Throws if the path is invalid.
|
|
83
|
+
*/
|
|
84
|
+
function ensureOwnedPath<T extends JSONObject>(
|
|
85
|
+
ctx: DraftContext<T>,
|
|
86
|
+
path: Path
|
|
87
|
+
): JSONRecord | JSONValue[] {
|
|
88
|
+
// Ensure root is owned first
|
|
89
|
+
if (!ctx.isRootOwned) {
|
|
90
|
+
ctx.root = shallowClone(ctx.root as unknown as object) as T
|
|
91
|
+
ctx.ownedObjects.add(ctx.root)
|
|
92
|
+
ctx.isRootOwned = true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (path.length === 0) {
|
|
96
|
+
return ctx.root
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let current: JSONRecord | JSONValue[] = ctx.root
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < path.length; i++) {
|
|
102
|
+
const segment = path[i]
|
|
103
|
+
const isArrayIndex = typeof segment === "number"
|
|
104
|
+
|
|
105
|
+
// Validate container type
|
|
106
|
+
if (isArrayIndex) {
|
|
107
|
+
if (!Array.isArray(current)) {
|
|
108
|
+
failure(`Expected array at path segment ${segment}`)
|
|
109
|
+
}
|
|
110
|
+
if (segment < 0 || segment >= current.length) {
|
|
111
|
+
failure(`Index ${segment} out of bounds`)
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
if (!isObject(current) || Array.isArray(current)) {
|
|
115
|
+
failure(`Expected object at path segment "${segment}"`)
|
|
116
|
+
}
|
|
117
|
+
if (!(segment in current)) {
|
|
118
|
+
failure(`Property "${segment}" does not exist`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const child: JSONValue = (current as any)[segment]
|
|
123
|
+
|
|
124
|
+
// Validate child is traversable
|
|
125
|
+
if (child === null || typeof child !== "object") {
|
|
126
|
+
failure(`Cannot traverse through primitive at path segment ${segment}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Ensure child is owned and continue
|
|
130
|
+
current = ensureOwned(ctx, current, segment, child as JSONRecord | JSONValue[])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return current
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Applies a single "set" operation to the draft with copy-on-write.
|
|
138
|
+
*/
|
|
139
|
+
export function draftSet<T extends JSONObject>(
|
|
140
|
+
ctx: DraftContext<T>,
|
|
141
|
+
path: Path,
|
|
142
|
+
key: string,
|
|
143
|
+
value: JSONValue
|
|
144
|
+
): void {
|
|
145
|
+
const container = ensureOwnedPath(ctx, path)
|
|
146
|
+
if (Array.isArray(container)) {
|
|
147
|
+
failure("set requires object container")
|
|
148
|
+
}
|
|
149
|
+
;(container as JSONRecord)[key] = value
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Applies a single "delete" operation to the draft with copy-on-write.
|
|
154
|
+
*/
|
|
155
|
+
export function draftDelete<T extends JSONObject>(
|
|
156
|
+
ctx: DraftContext<T>,
|
|
157
|
+
path: Path,
|
|
158
|
+
key: string
|
|
159
|
+
): void {
|
|
160
|
+
const container = ensureOwnedPath(ctx, path)
|
|
161
|
+
if (Array.isArray(container)) {
|
|
162
|
+
failure("delete requires object container")
|
|
163
|
+
}
|
|
164
|
+
delete (container as JSONRecord)[key]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Applies a single "splice" operation to the draft with copy-on-write.
|
|
169
|
+
*/
|
|
170
|
+
export function draftSplice<T extends JSONObject>(
|
|
171
|
+
ctx: DraftContext<T>,
|
|
172
|
+
path: Path,
|
|
173
|
+
index: number,
|
|
174
|
+
deleteCount: number,
|
|
175
|
+
inserts: readonly JSONValue[]
|
|
176
|
+
): void {
|
|
177
|
+
const container = ensureOwnedPath(ctx, path)
|
|
178
|
+
if (!Array.isArray(container)) {
|
|
179
|
+
failure("splice requires array container")
|
|
180
|
+
}
|
|
181
|
+
const safeIndex = Math.min(index, container.length)
|
|
182
|
+
if (inserts.length === 0) {
|
|
183
|
+
container.splice(safeIndex, deleteCount)
|
|
184
|
+
} else if (inserts.length === 1) {
|
|
185
|
+
container.splice(safeIndex, deleteCount, inserts[0])
|
|
186
|
+
} else {
|
|
187
|
+
container.splice(safeIndex, deleteCount, ...inserts)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Applies a single "addToSet" operation to the draft with copy-on-write.
|
|
193
|
+
*/
|
|
194
|
+
export function draftAddToSet<T extends JSONObject>(
|
|
195
|
+
ctx: DraftContext<T>,
|
|
196
|
+
path: Path,
|
|
197
|
+
value: JSONValue
|
|
198
|
+
): void {
|
|
199
|
+
const container = ensureOwnedPath(ctx, path)
|
|
200
|
+
if (!Array.isArray(container)) {
|
|
201
|
+
failure("addToSet requires array container")
|
|
202
|
+
}
|
|
203
|
+
if (!container.some((item) => deepEqual(item, value))) {
|
|
204
|
+
container.push(value)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Applies a single "deleteFromSet" operation to the draft with copy-on-write.
|
|
210
|
+
*/
|
|
211
|
+
export function draftDeleteFromSet<T extends JSONObject>(
|
|
212
|
+
ctx: DraftContext<T>,
|
|
213
|
+
path: Path,
|
|
214
|
+
value: JSONValue
|
|
215
|
+
): void {
|
|
216
|
+
const container = ensureOwnedPath(ctx, path)
|
|
217
|
+
if (!Array.isArray(container)) {
|
|
218
|
+
failure("deleteFromSet requires array container")
|
|
219
|
+
}
|
|
220
|
+
// Remove all matching items (iterate backwards to avoid index shifting)
|
|
221
|
+
for (let i = container.length - 1; i >= 0; i--) {
|
|
222
|
+
if (deepEqual(container[i], value)) {
|
|
223
|
+
container.splice(i, 1)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Applies a single operation to the draft with copy-on-write.
|
|
230
|
+
*/
|
|
231
|
+
export function applyOpToDraft<T extends JSONObject>(ctx: DraftContext<T>, op: Op): void {
|
|
232
|
+
switch (op.kind) {
|
|
233
|
+
case "set":
|
|
234
|
+
draftSet(ctx, op.path, op.key, op.value)
|
|
235
|
+
break
|
|
236
|
+
case "delete":
|
|
237
|
+
draftDelete(ctx, op.path, op.key)
|
|
238
|
+
break
|
|
239
|
+
case "splice":
|
|
240
|
+
draftSplice(ctx, op.path, op.index, op.deleteCount, op.inserts)
|
|
241
|
+
break
|
|
242
|
+
case "addToSet":
|
|
243
|
+
draftAddToSet(ctx, op.path, op.value)
|
|
244
|
+
break
|
|
245
|
+
case "deleteFromSet":
|
|
246
|
+
draftDeleteFromSet(ctx, op.path, op.value)
|
|
247
|
+
break
|
|
248
|
+
default:
|
|
249
|
+
throw failure(`Unknown operation kind: ${(op as any).kind}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Applies a single transaction to a base state immutably.
|
|
255
|
+
*
|
|
256
|
+
* Key benefits over Mutative/Immer:
|
|
257
|
+
* - No proxy overhead - direct object access and copy-on-write cloning
|
|
258
|
+
* - Structural sharing - unchanged subtrees keep their references
|
|
259
|
+
* - Zero-copy on failure - if validation fails, returns original base unchanged
|
|
260
|
+
*
|
|
261
|
+
* @param base - The base state (never mutated)
|
|
262
|
+
* @param tx - The transaction to apply
|
|
263
|
+
* @param validateFn - Optional validation function
|
|
264
|
+
* @returns The final state (if valid) or the original base (if invalid or empty)
|
|
265
|
+
*/
|
|
266
|
+
export function applyTxImmutable<T extends JSONObject>(
|
|
267
|
+
base: T,
|
|
268
|
+
tx: Pick<TxRecord, "ops">,
|
|
269
|
+
validateFn?: ValidateFn<T>
|
|
270
|
+
): T {
|
|
271
|
+
if (tx.ops.length === 0) return base
|
|
272
|
+
|
|
273
|
+
const ctx = createDraft(base)
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
for (const op of tx.ops) {
|
|
277
|
+
applyOpToDraft(ctx, op)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (validateFn && !validateFn(ctx.root)) {
|
|
281
|
+
return base
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return ctx.root
|
|
285
|
+
} catch {
|
|
286
|
+
return base
|
|
287
|
+
}
|
|
288
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class StateSyncLogError extends Error {
|
|
2
|
+
constructor(msg: string) {
|
|
3
|
+
super(msg)
|
|
4
|
+
|
|
5
|
+
// Set the prototype explicitly for better instanceof support
|
|
6
|
+
Object.setPrototypeOf(this, StateSyncLogError.prototype)
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function failure(message: string): never {
|
|
11
|
+
throw new StateSyncLogError(message)
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { CheckpointKey, CheckpointRecord } from "./checkpoints"
|
|
2
|
+
export {
|
|
3
|
+
createStateSyncLog,
|
|
4
|
+
type StateSyncLogController,
|
|
5
|
+
type StateSyncLogOptions,
|
|
6
|
+
} from "./createStateSyncLog"
|
|
7
|
+
export type { JSONObject, JSONValue, Path } from "./json"
|
|
8
|
+
export { type ApplyOpsOptions, applyOps, type Op, type ValidateFn } from "./operations"
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unique path to a value within the JSON document.
|
|
3
|
+
* Resolution fails if any segment is missing or type mismatch occurs.
|
|
4
|
+
*/
|
|
5
|
+
export type Path = readonly (string | number)[]
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A JSON primitive.
|
|
9
|
+
*/
|
|
10
|
+
export type JSONPrimitive = null | boolean | number | string
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A JSON record.
|
|
14
|
+
*/
|
|
15
|
+
export type JSONRecord = { [k: string]: JSONValue }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A JSON object.
|
|
19
|
+
*/
|
|
20
|
+
export type JSONObject = JSONRecord | JSONValue[]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A JSON value.
|
|
24
|
+
*/
|
|
25
|
+
export type JSONValue = JSONPrimitive | JSONObject
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { failure } from "./error"
|
|
2
|
+
import { JSONObject, JSONValue, Path } from "./json"
|
|
3
|
+
import { deepClone, deepEqual, isObject } from "./utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Supported operations.
|
|
7
|
+
* Applied sequentially within a tx.
|
|
8
|
+
*/
|
|
9
|
+
export type Op =
|
|
10
|
+
| { kind: "set"; path: Path; key: string; value: JSONValue }
|
|
11
|
+
| { kind: "delete"; path: Path; key: string }
|
|
12
|
+
| { kind: "splice"; path: Path; index: number; deleteCount: number; inserts: JSONValue[] }
|
|
13
|
+
| { kind: "addToSet"; path: Path; value: JSONValue }
|
|
14
|
+
| { kind: "deleteFromSet"; path: Path; value: JSONValue }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validation function type.
|
|
18
|
+
*
|
|
19
|
+
* Rules:
|
|
20
|
+
* - Validation MUST depend only on candidateState (and deterministic code).
|
|
21
|
+
* - Validation runs once per tx, after all ops apply.
|
|
22
|
+
* - If validation fails, the x is rejected (state reverts to previous).
|
|
23
|
+
* - If no validator is provided, validation defaults to true.
|
|
24
|
+
*
|
|
25
|
+
* IMPORTANT: Validation outcome is **derived local state** and MUST NOT be replicated.
|
|
26
|
+
* All clients MUST use the same validation logic to ensure consistency.
|
|
27
|
+
*/
|
|
28
|
+
export type ValidateFn<State extends JSONObject> = (candidateState: State) => boolean
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves a path within the state.
|
|
32
|
+
* Throws if any segment is missing or has wrong type.
|
|
33
|
+
*/
|
|
34
|
+
function resolvePath(state: JSONObject, path: Path): JSONValue {
|
|
35
|
+
let current: JSONValue = state
|
|
36
|
+
for (const segment of path) {
|
|
37
|
+
if (typeof segment === "string") {
|
|
38
|
+
if (!isObject(current) || Array.isArray(current)) {
|
|
39
|
+
failure(`Expected object at path segment "${segment}"`)
|
|
40
|
+
}
|
|
41
|
+
if (!(segment in current)) {
|
|
42
|
+
failure(`Property "${segment}" does not exist`)
|
|
43
|
+
}
|
|
44
|
+
current = current[segment]
|
|
45
|
+
} else {
|
|
46
|
+
if (!Array.isArray(current)) {
|
|
47
|
+
failure(`Expected array at path segment ${segment}`)
|
|
48
|
+
}
|
|
49
|
+
if (segment < 0 || segment >= current.length) {
|
|
50
|
+
failure(`Index ${segment} out of bounds`)
|
|
51
|
+
}
|
|
52
|
+
current = current[segment]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return current
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Applies a single operation.
|
|
60
|
+
* (Reference implementation for standard JSON-patch behavior)
|
|
61
|
+
*/
|
|
62
|
+
function applyOp(state: JSONObject, op: Op, cloneValues: boolean): void {
|
|
63
|
+
// Special case: if path is empty, we can't resolve "container".
|
|
64
|
+
// The caller must handle root-level replacement if necessary, but
|
|
65
|
+
// standard Ops usually act ON a container.
|
|
66
|
+
// Exception: if we act on root, handle explicitly or assume path length > 0.
|
|
67
|
+
// For this spec, Ops modify *fields* or *indices*.
|
|
68
|
+
// If path is empty, it means we are acting ON the root object itself?
|
|
69
|
+
// The spec's "set" example: container[op.key] = op.value.
|
|
70
|
+
// This implies we resolve path to get the PARENT container.
|
|
71
|
+
|
|
72
|
+
const container = resolvePath(state, op.path)
|
|
73
|
+
|
|
74
|
+
switch (op.kind) {
|
|
75
|
+
case "set":
|
|
76
|
+
if (!isObject(container) || Array.isArray(container)) {
|
|
77
|
+
failure("set requires object container")
|
|
78
|
+
}
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
;(container as any)[op.key] = cloneValues ? deepClone(op.value) : op.value
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
case "delete":
|
|
84
|
+
if (!isObject(container) || Array.isArray(container)) {
|
|
85
|
+
failure("delete requires object container")
|
|
86
|
+
}
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
|
+
delete (container as any)[op.key]
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
case "splice": {
|
|
92
|
+
if (!Array.isArray(container)) {
|
|
93
|
+
failure("splice requires array container")
|
|
94
|
+
}
|
|
95
|
+
const safeIndex = Math.min(op.index, container.length)
|
|
96
|
+
container.splice(
|
|
97
|
+
safeIndex,
|
|
98
|
+
op.deleteCount,
|
|
99
|
+
...(cloneValues ? op.inserts.map((v) => deepClone(v)) : op.inserts)
|
|
100
|
+
)
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case "addToSet":
|
|
105
|
+
if (!Array.isArray(container)) {
|
|
106
|
+
failure("addToSet requires array container")
|
|
107
|
+
}
|
|
108
|
+
if (!container.some((item) => deepEqual(item, op.value))) {
|
|
109
|
+
container.push(cloneValues ? deepClone(op.value) : op.value)
|
|
110
|
+
}
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
case "deleteFromSet":
|
|
114
|
+
if (!Array.isArray(container)) {
|
|
115
|
+
failure("deleteFromSet requires array container")
|
|
116
|
+
}
|
|
117
|
+
// Remove all matching items using splice (from end to avoid index shifting)
|
|
118
|
+
for (let i = container.length - 1; i >= 0; i--) {
|
|
119
|
+
if (deepEqual(container[i], op.value)) {
|
|
120
|
+
container.splice(i, 1)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
default:
|
|
126
|
+
throw failure(`Unknown operation kind: ${(op as any).kind}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Options for applyOps.
|
|
132
|
+
*/
|
|
133
|
+
export interface ApplyOpsOptions {
|
|
134
|
+
/**
|
|
135
|
+
* Whether to deep clone values before inserting them into the target.
|
|
136
|
+
* - `true` (default): Values are cloned to prevent aliasing between the ops and target.
|
|
137
|
+
* - `false`: Values are used directly for better performance. Only use this if you
|
|
138
|
+
* guarantee the op values won't be mutated after application.
|
|
139
|
+
*/
|
|
140
|
+
cloneValues?: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Applies a list of operations to a mutable target object.
|
|
145
|
+
* Use this to synchronize an external mutable state (e.g., MobX store)
|
|
146
|
+
* with the operations received via subscribe().
|
|
147
|
+
*
|
|
148
|
+
* @param ops - The list of operations to apply.
|
|
149
|
+
* @param target - The mutable object to modify.
|
|
150
|
+
* @param options - Optional settings for controlling cloning behavior.
|
|
151
|
+
*/
|
|
152
|
+
export function applyOps(ops: readonly Op[], target: JSONObject, options?: ApplyOpsOptions): void {
|
|
153
|
+
const cloneValues = options?.cloneValues ?? true
|
|
154
|
+
for (const op of ops) {
|
|
155
|
+
applyOp(target, op, cloneValues)
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { failure } from "./error"
|
|
2
|
+
import { JSONRecord, JSONValue, Path } from "./json"
|
|
3
|
+
import { Op } from "./operations"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reconciles the current state with the target state by computing and emitting
|
|
7
|
+
* the minimal set of operations needed to transform currentState into targetState.
|
|
8
|
+
*/
|
|
9
|
+
export function computeReconcileOps(currentState: JSONValue, targetState: JSONValue): Op[] {
|
|
10
|
+
const ops: Op[] = []
|
|
11
|
+
diffValue(currentState, targetState, [], ops)
|
|
12
|
+
return ops
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function diffValue(current: JSONValue, target: JSONValue, path: Path, ops: Op[]): void {
|
|
16
|
+
// 1. Reference equality (structural sharing)
|
|
17
|
+
if (current === target) return
|
|
18
|
+
|
|
19
|
+
// 2. Handle primitives and null quickly
|
|
20
|
+
const currentType = typeof current
|
|
21
|
+
const targetType = typeof target
|
|
22
|
+
|
|
23
|
+
if (current === null || target === null || currentType !== "object" || targetType !== "object") {
|
|
24
|
+
// At least one is primitive/null, or types don't match
|
|
25
|
+
emitReplace(path, target, ops)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Both are objects (object or array)
|
|
30
|
+
const currentIsArray = Array.isArray(current)
|
|
31
|
+
const targetIsArray = Array.isArray(target)
|
|
32
|
+
|
|
33
|
+
if (currentIsArray !== targetIsArray) {
|
|
34
|
+
// Type mismatch (one array, one object)
|
|
35
|
+
emitReplace(path, target, ops)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (currentIsArray) {
|
|
40
|
+
diffArray(current, target as JSONValue[], path, ops)
|
|
41
|
+
} else {
|
|
42
|
+
diffObject(current as JSONRecord, target as JSONRecord, path, ops)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function diffObject(current: JSONRecord, target: JSONRecord, path: Path, ops: Op[]): void {
|
|
47
|
+
// 1. Delete keys in current but not in target
|
|
48
|
+
for (const key in current) {
|
|
49
|
+
if (Object.hasOwn(current, key) && !Object.hasOwn(target, key)) {
|
|
50
|
+
ops.push({ kind: "delete", path, key })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. Add/Update keys in target
|
|
55
|
+
for (const key in target) {
|
|
56
|
+
if (Object.hasOwn(target, key)) {
|
|
57
|
+
const targetVal = target[key]
|
|
58
|
+
if (!Object.hasOwn(current, key)) {
|
|
59
|
+
ops.push({ kind: "set", path, key, value: targetVal })
|
|
60
|
+
} else if (current[key] !== targetVal) {
|
|
61
|
+
// Only recurse if values differ (reference check first)
|
|
62
|
+
diffValue(current[key], targetVal, [...path, key], ops)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function diffArray(current: JSONValue[], target: JSONValue[], path: Path, ops: Op[]): void {
|
|
69
|
+
const currentLen = current.length
|
|
70
|
+
const targetLen = target.length
|
|
71
|
+
const minLen = currentLen < targetLen ? currentLen : targetLen
|
|
72
|
+
|
|
73
|
+
// Diff common elements
|
|
74
|
+
for (let i = 0; i < minLen; i++) {
|
|
75
|
+
if (current[i] !== target[i]) {
|
|
76
|
+
diffValue(current[i], target[i], [...path, i], ops)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Handle length difference
|
|
81
|
+
if (targetLen > currentLen) {
|
|
82
|
+
ops.push({
|
|
83
|
+
kind: "splice",
|
|
84
|
+
path,
|
|
85
|
+
index: currentLen,
|
|
86
|
+
deleteCount: 0,
|
|
87
|
+
inserts: target.slice(currentLen),
|
|
88
|
+
})
|
|
89
|
+
} else if (currentLen > targetLen) {
|
|
90
|
+
ops.push({
|
|
91
|
+
kind: "splice",
|
|
92
|
+
path,
|
|
93
|
+
index: targetLen,
|
|
94
|
+
deleteCount: currentLen - targetLen,
|
|
95
|
+
inserts: [],
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emitReplace(path: Path, value: JSONValue, ops: Op[]): void {
|
|
101
|
+
if (path.length === 0) {
|
|
102
|
+
// Cannot replace root directly via Ops (unless we define a 'root' op, which we don't)
|
|
103
|
+
// We expect root to be handled by diffObject usually.
|
|
104
|
+
// If we land here, it means root types mismatched (e.g. Obj -> Array).
|
|
105
|
+
failure("StateSyncLog: Cannot replace root state directly via Ops.")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parentPath = path.slice(0, -1)
|
|
109
|
+
const keyToCheck = path[path.length - 1]
|
|
110
|
+
|
|
111
|
+
if (typeof keyToCheck === "string") {
|
|
112
|
+
// Parent is Object
|
|
113
|
+
ops.push({ kind: "set", path: parentPath, key: keyToCheck, value })
|
|
114
|
+
} else {
|
|
115
|
+
// Parent is Array
|
|
116
|
+
ops.push({
|
|
117
|
+
kind: "splice",
|
|
118
|
+
path: parentPath,
|
|
119
|
+
index: keyToCheck,
|
|
120
|
+
deleteCount: 1,
|
|
121
|
+
inserts: [value],
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|