state-sync-log 0.9.0 → 0.10.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 (38) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +368 -277
  3. package/dist/state-sync-log.esm.js +929 -136
  4. package/dist/state-sync-log.esm.mjs +929 -136
  5. package/dist/state-sync-log.umd.js +928 -135
  6. package/dist/types/createOps/constant.d.ts +6 -0
  7. package/dist/types/createOps/createOps.d.ts +25 -0
  8. package/dist/types/createOps/current.d.ts +13 -0
  9. package/dist/types/createOps/draft.d.ts +14 -0
  10. package/dist/types/createOps/draftify.d.ts +5 -0
  11. package/dist/types/createOps/index.d.ts +12 -0
  12. package/dist/types/createOps/interface.d.ts +74 -0
  13. package/dist/types/createOps/original.d.ts +15 -0
  14. package/dist/types/createOps/pushOp.d.ts +9 -0
  15. package/dist/types/createOps/setHelpers.d.ts +25 -0
  16. package/dist/types/createOps/utils.d.ts +95 -0
  17. package/dist/types/draft.d.ts +2 -2
  18. package/dist/types/index.d.ts +1 -0
  19. package/dist/types/json.d.ts +1 -1
  20. package/dist/types/operations.d.ts +2 -2
  21. package/dist/types/utils.d.ts +5 -0
  22. package/package.json +1 -1
  23. package/src/createOps/constant.ts +10 -0
  24. package/src/createOps/createOps.ts +97 -0
  25. package/src/createOps/current.ts +85 -0
  26. package/src/createOps/draft.ts +606 -0
  27. package/src/createOps/draftify.ts +45 -0
  28. package/src/createOps/index.ts +18 -0
  29. package/src/createOps/interface.ts +95 -0
  30. package/src/createOps/original.ts +24 -0
  31. package/src/createOps/pushOp.ts +42 -0
  32. package/src/createOps/setHelpers.ts +93 -0
  33. package/src/createOps/utils.ts +325 -0
  34. package/src/draft.ts +306 -288
  35. package/src/index.ts +1 -0
  36. package/src/json.ts +1 -1
  37. package/src/operations.ts +33 -11
  38. package/src/utils.ts +67 -55
package/src/draft.ts CHANGED
@@ -1,288 +1,306 @@
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
- }
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, parseArrayIndex } 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 | number,
143
+ value: JSONValue
144
+ ): void {
145
+ const container = ensureOwnedPath(ctx, path)
146
+ if (!isObject(container)) {
147
+ failure("set requires object or array container")
148
+ }
149
+ let finalKey: string | number = key
150
+ // For arrays, convert string keys to numbers (except "length")
151
+ if (Array.isArray(container) && typeof key === "string" && key !== "length") {
152
+ const numKey = parseArrayIndex(key)
153
+ if (numKey === null) {
154
+ failure(`Cannot set non-numeric property "${key}" on array`)
155
+ }
156
+ finalKey = numKey
157
+ }
158
+ ;(container as Record<string | number, JSONValue>)[finalKey] = value
159
+ }
160
+
161
+ /**
162
+ * Applies a single "delete" operation to the draft with copy-on-write.
163
+ */
164
+ export function draftDelete<T extends JSONObject>(
165
+ ctx: DraftContext<T>,
166
+ path: Path,
167
+ key: string | number
168
+ ): void {
169
+ const container = ensureOwnedPath(ctx, path)
170
+ if (!isObject(container)) {
171
+ failure("delete requires object or array container")
172
+ }
173
+ let finalKey: string | number = key
174
+ // For arrays, convert string keys to numbers
175
+ if (Array.isArray(container) && typeof key === "string") {
176
+ const numKey = parseArrayIndex(key)
177
+ if (numKey === null) {
178
+ failure(`Cannot delete non-numeric property "${key}" from array`)
179
+ }
180
+ finalKey = numKey
181
+ }
182
+ delete (container as Record<string | number, JSONValue>)[finalKey]
183
+ }
184
+
185
+ /**
186
+ * Applies a single "splice" operation to the draft with copy-on-write.
187
+ */
188
+ export function draftSplice<T extends JSONObject>(
189
+ ctx: DraftContext<T>,
190
+ path: Path,
191
+ index: number,
192
+ deleteCount: number,
193
+ inserts: readonly JSONValue[]
194
+ ): void {
195
+ const container = ensureOwnedPath(ctx, path)
196
+ if (!Array.isArray(container)) {
197
+ failure("splice requires array container")
198
+ }
199
+ const safeIndex = Math.min(index, container.length)
200
+ if (inserts.length === 0) {
201
+ container.splice(safeIndex, deleteCount)
202
+ } else if (inserts.length === 1) {
203
+ container.splice(safeIndex, deleteCount, inserts[0])
204
+ } else {
205
+ container.splice(safeIndex, deleteCount, ...inserts)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Applies a single "addToSet" operation to the draft with copy-on-write.
211
+ */
212
+ export function draftAddToSet<T extends JSONObject>(
213
+ ctx: DraftContext<T>,
214
+ path: Path,
215
+ value: JSONValue
216
+ ): void {
217
+ const container = ensureOwnedPath(ctx, path)
218
+ if (!Array.isArray(container)) {
219
+ failure("addToSet requires array container")
220
+ }
221
+ if (!container.some((item) => deepEqual(item, value))) {
222
+ container.push(value)
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Applies a single "deleteFromSet" operation to the draft with copy-on-write.
228
+ */
229
+ export function draftDeleteFromSet<T extends JSONObject>(
230
+ ctx: DraftContext<T>,
231
+ path: Path,
232
+ value: JSONValue
233
+ ): void {
234
+ const container = ensureOwnedPath(ctx, path)
235
+ if (!Array.isArray(container)) {
236
+ failure("deleteFromSet requires array container")
237
+ }
238
+ // Remove all matching items (iterate backwards to avoid index shifting)
239
+ for (let i = container.length - 1; i >= 0; i--) {
240
+ if (deepEqual(container[i], value)) {
241
+ container.splice(i, 1)
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Applies a single operation to the draft with copy-on-write.
248
+ */
249
+ export function applyOpToDraft<T extends JSONObject>(ctx: DraftContext<T>, op: Op): void {
250
+ switch (op.kind) {
251
+ case "set":
252
+ draftSet(ctx, op.path, op.key, op.value)
253
+ break
254
+ case "delete":
255
+ draftDelete(ctx, op.path, op.key)
256
+ break
257
+ case "splice":
258
+ draftSplice(ctx, op.path, op.index, op.deleteCount, op.inserts)
259
+ break
260
+ case "addToSet":
261
+ draftAddToSet(ctx, op.path, op.value)
262
+ break
263
+ case "deleteFromSet":
264
+ draftDeleteFromSet(ctx, op.path, op.value)
265
+ break
266
+ default:
267
+ throw failure(`Unknown operation kind: ${(op as any).kind}`)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Applies a single transaction to a base state immutably.
273
+ *
274
+ * Key benefits over Mutative/Immer:
275
+ * - No proxy overhead - direct object access and copy-on-write cloning
276
+ * - Structural sharing - unchanged subtrees keep their references
277
+ * - Zero-copy on failure - if validation fails, returns original base unchanged
278
+ *
279
+ * @param base - The base state (never mutated)
280
+ * @param tx - The transaction to apply
281
+ * @param validateFn - Optional validation function
282
+ * @returns The final state (if valid) or the original base (if invalid or empty)
283
+ */
284
+ export function applyTxImmutable<T extends JSONObject>(
285
+ base: T,
286
+ tx: Pick<TxRecord, "ops">,
287
+ validateFn?: ValidateFn<T>
288
+ ): T {
289
+ if (tx.ops.length === 0) return base
290
+
291
+ const ctx = createDraft(base)
292
+
293
+ try {
294
+ for (const op of tx.ops) {
295
+ applyOpToDraft(ctx, op)
296
+ }
297
+
298
+ if (validateFn && !validateFn(ctx.root)) {
299
+ return base
300
+ }
301
+
302
+ return ctx.root
303
+ } catch {
304
+ return base
305
+ }
306
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type { CheckpointKey, CheckpointRecord } from "./checkpoints"
2
+ export * from "./createOps"
2
3
  export {
3
4
  createStateSyncLog,
4
5
  type StateSyncLogController,
package/src/json.ts CHANGED
@@ -7,7 +7,7 @@ export type Path = readonly (string | number)[]
7
7
  /**
8
8
  * A JSON primitive.
9
9
  */
10
- export type JSONPrimitive = null | boolean | number | string
10
+ export type JSONPrimitive = undefined | null | boolean | number | string
11
11
 
12
12
  /**
13
13
  * A JSON record.