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