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
@@ -0,0 +1,95 @@
1
+ /**
2
+ * createOps interface types.
3
+ * Simplified from mutative - removed patches, autoFreeze, strict mode, mark, Map/Set support.
4
+ */
5
+
6
+ import type { JSONPrimitive, JSONValue, Path } from "../json"
7
+ import type { Op } from "../operations"
8
+
9
+ export type { Op, Path, JSONValue }
10
+
11
+ export enum DraftType {
12
+ Object = 0,
13
+ Array = 1,
14
+ }
15
+
16
+ /**
17
+ * Finalities - shared state for the draft tree
18
+ */
19
+ export interface Finalities {
20
+ /** Finalization callbacks (for unwrapping child drafts) */
21
+ draft: (() => void)[]
22
+ /** Revoke functions for all proxies */
23
+ revoke: (() => void)[]
24
+ /** Set of handled objects (for cycle detection) */
25
+ handledSet: WeakSet<object>
26
+ /** Cache of created drafts */
27
+ draftsCache: WeakSet<object>
28
+ /** List of operations performed in this draft session (eager logging) */
29
+ ops: Op[]
30
+ /** Root draft of the tree (set when creating the root draft) */
31
+ rootDraft: ProxyDraft | null
32
+ }
33
+
34
+ /**
35
+ * Internal proxy draft state
36
+ */
37
+ export interface ProxyDraft<T = any> {
38
+ /** Type of the draft (Object or Array) */
39
+ type: DraftType
40
+ /** Whether this draft has been mutated */
41
+ operated?: boolean
42
+ /** Whether finalization has been completed */
43
+ finalized: boolean
44
+ /** The original (unmodified) value */
45
+ original: T
46
+ /** The shallow copy (created on first mutation) */
47
+ copy: T | null
48
+ /** The proxy instance */
49
+ proxy: T | null
50
+ /** Finalities container (shared across draft tree) */
51
+ finalities: Finalities
52
+ /** Parent draft (for path tracking) */
53
+ parent?: ProxyDraft | null
54
+ /** Key in parent */
55
+ key?: string | number
56
+ /** Track which keys have been assigned (key -> true=assigned, false=deleted) */
57
+ assignedMap?: Map<PropertyKey, boolean>
58
+ /** Count of positions this draft exists at (for aliasing optimization) */
59
+ aliasCount: number
60
+ }
61
+
62
+ /**
63
+ * Result of createOps
64
+ */
65
+ export interface CreateOpsResult<T> {
66
+ /** The new immutable state */
67
+ nextState: T
68
+ /** The operations that were performed */
69
+ ops: Op[]
70
+ }
71
+
72
+ // ============================================================================
73
+ // Type Utilities
74
+ // ============================================================================
75
+
76
+ /** Primitive types that don't need drafting */
77
+ type Primitive = JSONPrimitive
78
+
79
+ /**
80
+ * Draft type - makes all properties mutable for editing
81
+ */
82
+ export type Draft<T> = T extends Primitive
83
+ ? T
84
+ : T extends object
85
+ ? { -readonly [K in keyof T]: Draft<T[K]> }
86
+ : T
87
+
88
+ /**
89
+ * Immutable type - makes all properties readonly
90
+ */
91
+ export type Immutable<T> = T extends Primitive
92
+ ? T
93
+ : T extends object
94
+ ? { readonly [K in keyof T]: Immutable<T[K]> }
95
+ : T
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Get the original value from a draft.
3
+ */
4
+
5
+ import { getProxyDraft } from "./utils"
6
+
7
+ /**
8
+ * `original(draft)` to get original state in the draft mutation function.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const { nextState, ops } = createOps(baseState, (draft) => {
13
+ * draft.foo.bar = 'new value';
14
+ * console.log(original(draft.foo)); // { bar: 'old value' }
15
+ * });
16
+ * ```
17
+ */
18
+ export function original<T>(target: T): T {
19
+ const proxyDraft = getProxyDraft(target)
20
+ if (!proxyDraft) {
21
+ throw new Error(`original() is only used for a draft, parameter: ${target}`)
22
+ }
23
+ return proxyDraft.original as T
24
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Eager op generation - ops are pushed immediately when mutations happen.
3
+ *
4
+ * This module provides the pushOp function that records operations at mutation time,
5
+ * rather than diffing at finalization time.
6
+ *
7
+ * When a draft exists at multiple positions (aliasing), ops are emitted for ALL positions
8
+ * to ensure consistency when applied.
9
+ */
10
+
11
+ import { failure } from "../error"
12
+ import type { Op, ProxyDraft } from "./interface"
13
+ import { getAllPathsForDraft } from "./utils"
14
+
15
+ /**
16
+ * Push an operation to the ops log.
17
+ * Values should already be cloned by the caller to avoid aliasing issues.
18
+ *
19
+ * When the target draft exists at multiple positions (due to aliasing),
20
+ * this function emits ops for all positions to maintain consistency.
21
+ */
22
+ export function pushOp(proxyDraft: ProxyDraft, op: Op): void {
23
+ const rootDraft = proxyDraft.finalities.rootDraft
24
+ if (!rootDraft) {
25
+ throw failure("rootDraft is not set - cannot emit op")
26
+ }
27
+
28
+ // Fast path: no aliasing, just emit the single op
29
+ if (proxyDraft.aliasCount <= 1) {
30
+ proxyDraft.finalities.ops.push(op)
31
+ return
32
+ }
33
+
34
+ // Slow path: draft exists at multiple positions, find all paths
35
+ const allPaths = getAllPathsForDraft(rootDraft, proxyDraft)
36
+
37
+ // Emit the op for each path where this draft exists
38
+ for (const path of allPaths) {
39
+ const adjustedOp = { ...op, path }
40
+ proxyDraft.finalities.ops.push(adjustedOp)
41
+ }
42
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Helper functions for set-like array operations.
3
+ * Uses eager op logging - ops are pushed immediately when mutations happen.
4
+ */
5
+
6
+ import type { JSONValue } from "../json"
7
+ import { deepClone, deepEqual } from "../utils"
8
+ import { pushOp } from "./pushOp"
9
+ import { ensureShallowCopy, getPathOrThrow, getProxyDraft, markChanged } from "./utils"
10
+
11
+ /**
12
+ * Add a value to an array if it doesn't already exist (set semantics).
13
+ * Generates an `addToSet` operation.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * createOps(state, (draft) => {
18
+ * addToSet(draft.tags, 'newTag'); // Only adds if not present
19
+ * });
20
+ * ```
21
+ */
22
+ export function addToSet<T extends JSONValue>(draft: T[], value: T): void {
23
+ const proxyDraft = getProxyDraft(draft)
24
+ if (!proxyDraft) {
25
+ throw new Error(`addToSet() can only be used on draft arrays`)
26
+ }
27
+
28
+ // Mark as changed
29
+ ensureShallowCopy(proxyDraft)
30
+ markChanged(proxyDraft)
31
+
32
+ // Check if value already exists
33
+ const arr = proxyDraft.copy as T[]
34
+ if (arr.some((item) => item === value || deepEqual(item, value))) {
35
+ // Value already exists - no op needed
36
+ return
37
+ }
38
+
39
+ // Add the value
40
+ arr.push(value)
41
+
42
+ // Eager op logging
43
+ pushOp(proxyDraft, {
44
+ kind: "addToSet",
45
+ path: getPathOrThrow(proxyDraft),
46
+ value: deepClone(value) as JSONValue,
47
+ })
48
+ }
49
+
50
+ /**
51
+ * Remove a value from an array (set semantics).
52
+ * Generates a `deleteFromSet` operation.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * createOps(state, (draft) => {
57
+ * deleteFromSet(draft.tags, 'oldTag'); // Removes all matching items
58
+ * });
59
+ * ```
60
+ */
61
+ export function deleteFromSet<T extends JSONValue>(draft: T[], value: T): void {
62
+ const proxyDraft = getProxyDraft(draft)
63
+ if (!proxyDraft) {
64
+ throw new Error(`deleteFromSet() can only be used on draft arrays`)
65
+ }
66
+
67
+ // Mark as changed
68
+ ensureShallowCopy(proxyDraft)
69
+ markChanged(proxyDraft)
70
+
71
+ // Check if value exists
72
+ const arr = proxyDraft.copy as T[]
73
+ const hasValue = arr.some((item) => item === value || deepEqual(item, value))
74
+
75
+ if (!hasValue) {
76
+ // Value doesn't exist - no op needed
77
+ return
78
+ }
79
+
80
+ // Remove all matching items (by value equality)
81
+ for (let i = arr.length - 1; i >= 0; i--) {
82
+ if (arr[i] === value || deepEqual(arr[i], value)) {
83
+ arr.splice(i, 1)
84
+ }
85
+ }
86
+
87
+ // Eager op logging
88
+ pushOp(proxyDraft, {
89
+ kind: "deleteFromSet",
90
+ path: getPathOrThrow(proxyDraft),
91
+ value: deepClone(value) as JSONValue,
92
+ })
93
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Utility functions for proxy drafts.
3
+ * Adapted from mutative - removed Map/Set support, mark, deepFreeze.
4
+ */
5
+
6
+ import { failure } from "../error"
7
+ import { PROXY_DRAFT } from "./constant"
8
+ import { DraftType, type ProxyDraft } from "./interface"
9
+
10
+ // ============================================================================
11
+ // Core Draft Utilities
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Get the latest value (copy if exists, otherwise original)
16
+ */
17
+ export function latest<T>(proxyDraft: ProxyDraft<T>): T {
18
+ return (proxyDraft.copy ?? proxyDraft.original) as T
19
+ }
20
+
21
+ /**
22
+ * Check if the value is a draft
23
+ */
24
+ export function isDraft(target: unknown): boolean {
25
+ return !!getProxyDraft(target)
26
+ }
27
+
28
+ /**
29
+ * Get the ProxyDraft from a draft value
30
+ */
31
+ export function getProxyDraft<T>(value: unknown): ProxyDraft<T> | null {
32
+ if (typeof value !== "object" || value === null) return null
33
+ return (value as { [PROXY_DRAFT]?: ProxyDraft<T> })[PROXY_DRAFT] ?? null
34
+ }
35
+
36
+ /**
37
+ * Get the actual value from a draft (copy or original)
38
+ */
39
+ export function getValue<T extends object>(value: T): T {
40
+ const proxyDraft = getProxyDraft(value)
41
+ return proxyDraft ? ((proxyDraft.copy ?? proxyDraft.original) as T) : value
42
+ }
43
+
44
+ /**
45
+ * Check if a value is draftable (plain object or array)
46
+ * We only support plain objects and arrays - no Map, Set, Date, etc.
47
+ */
48
+ export function isDraftable(value: unknown): value is object {
49
+ if (value === null || typeof value !== "object") return false
50
+ return Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype
51
+ }
52
+
53
+ /**
54
+ * Get the draft type
55
+ */
56
+ export function getType(target: unknown): DraftType {
57
+ return Array.isArray(target) ? DraftType.Array : DraftType.Object
58
+ }
59
+
60
+ /**
61
+ * Get a value by key
62
+ */
63
+ export function get(target: object, key: PropertyKey): unknown {
64
+ return (target as Record<PropertyKey, unknown>)[key]
65
+ }
66
+
67
+ /**
68
+ * Set a value by key
69
+ */
70
+ export function set(target: object, key: PropertyKey, value: unknown): void {
71
+ ;(target as Record<PropertyKey, unknown>)[key] = value
72
+ }
73
+
74
+ /**
75
+ * Check if a key exists (own property)
76
+ */
77
+ export function has(target: object, key: PropertyKey): boolean {
78
+ return Object.hasOwn(target, key as string)
79
+ }
80
+
81
+ /**
82
+ * Peek at a value (through drafts)
83
+ */
84
+ export function peek(target: object, key: PropertyKey): unknown {
85
+ const state = getProxyDraft(target)
86
+ const source = state ? latest(state) : target
87
+ return (source as Record<PropertyKey, unknown>)[key]
88
+ }
89
+
90
+ /**
91
+ * SameValue comparison (handles -0 and NaN)
92
+ */
93
+ export function isEqual(x: unknown, y: unknown): boolean {
94
+ if (x === y) {
95
+ return x !== 0 || 1 / (x as number) === 1 / (y as number)
96
+ }
97
+ // biome-ignore lint/suspicious/noSelfCompare: NaN check pattern (NaN !== NaN)
98
+ return x !== x && y !== y
99
+ }
100
+
101
+ /**
102
+ * Revoke all proxies in a draft tree
103
+ */
104
+ export function revokeProxy(proxyDraft: ProxyDraft | null): void {
105
+ if (!proxyDraft) return
106
+ while (proxyDraft.finalities.revoke.length > 0) {
107
+ const revoke = proxyDraft.finalities.revoke.pop()!
108
+ revoke()
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get the path from root to this draft
114
+ */
115
+ export function getPath(
116
+ target: ProxyDraft,
117
+ path: (string | number)[] = []
118
+ ): (string | number)[] | null {
119
+ if (Object.hasOwn(target, "key") && target.key !== undefined) {
120
+ // Check if the parent still has this draft at this key
121
+ const parentCopy = target.parent?.copy
122
+ if (parentCopy) {
123
+ const proxyDraft = getProxyDraft(get(parentCopy as object, target.key))
124
+ if (proxyDraft !== null && proxyDraft.original !== target.original) {
125
+ return null
126
+ }
127
+ }
128
+ path.push(target.key)
129
+ }
130
+ if (target.parent) {
131
+ return getPath(target.parent, path)
132
+ }
133
+ // target is root draft
134
+ path.reverse()
135
+ return path
136
+ }
137
+
138
+ /**
139
+ * Get the path from root to this draft, or throw if not available
140
+ */
141
+ export function getPathOrThrow(target: ProxyDraft): (string | number)[] {
142
+ const path = getPath(target)
143
+ if (!path) {
144
+ throw failure("Cannot determine path for operation")
145
+ }
146
+ return path
147
+ }
148
+
149
+ /**
150
+ * Find all paths to a given draft by searching the tree.
151
+ * This handles aliasing where the same draft exists at multiple positions.
152
+ */
153
+ export function getAllPathsForDraft(
154
+ rootDraft: ProxyDraft,
155
+ targetDraft: ProxyDraft
156
+ ): (string | number)[][] {
157
+ const result: (string | number)[][] = []
158
+
159
+ function search(current: ProxyDraft, currentPath: (string | number)[]): void {
160
+ if (current === targetDraft) {
161
+ result.push([...currentPath])
162
+ // Don't return - the same draft could be nested inside itself (unlikely but possible)
163
+ }
164
+
165
+ const source = latest(current) as Record<string | number, unknown>
166
+ if (!source || typeof source !== "object") return
167
+
168
+ const keys: (string | number)[] = Array.isArray(source)
169
+ ? Array.from({ length: source.length }, (_, i) => i)
170
+ : Object.keys(source)
171
+
172
+ for (const key of keys) {
173
+ const value = source[key]
174
+ const childDraft = getProxyDraft(value)
175
+ if (childDraft) {
176
+ search(childDraft, [...currentPath, key])
177
+ }
178
+ }
179
+ }
180
+
181
+ search(rootDraft, [])
182
+ return result
183
+ }
184
+
185
+ /**
186
+ * Get a property descriptor from the prototype chain
187
+ */
188
+ export function getDescriptor(target: object, key: PropertyKey): PropertyDescriptor | undefined {
189
+ if (key in target) {
190
+ let prototype = Reflect.getPrototypeOf(target)
191
+ while (prototype) {
192
+ const descriptor = Reflect.getOwnPropertyDescriptor(prototype, key)
193
+ if (descriptor) return descriptor
194
+ prototype = Reflect.getPrototypeOf(prototype)
195
+ }
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ // ============================================================================
201
+ // Copy Utilities
202
+ // ============================================================================
203
+
204
+ const propIsEnum = Object.prototype.propertyIsEnumerable
205
+
206
+ /**
207
+ * Create a shallow copy of an object or array
208
+ */
209
+ export function shallowCopy<T>(original: T): T {
210
+ if (Array.isArray(original)) {
211
+ return Array.prototype.concat.call(original) as T
212
+ }
213
+ // Plain object - use optimized copy
214
+ const copy: Record<string | symbol, unknown> = {}
215
+ for (const key of Object.keys(original as object)) {
216
+ copy[key] = (original as Record<string, unknown>)[key]
217
+ }
218
+ for (const key of Object.getOwnPropertySymbols(original as object)) {
219
+ if (propIsEnum.call(original, key)) {
220
+ copy[key] = (original as Record<symbol, unknown>)[key]
221
+ }
222
+ }
223
+ return copy as T
224
+ }
225
+
226
+ /**
227
+ * Ensure a draft has a shallow copy
228
+ */
229
+ export function ensureShallowCopy(target: ProxyDraft): void {
230
+ if (target.copy) return
231
+ target.copy = shallowCopy(target.original)
232
+ target.assignedMap = target.assignedMap ?? new Map()
233
+ }
234
+
235
+ /**
236
+ * Deep clone a value, unwrapping any drafts
237
+ */
238
+ export function deepClone<T>(target: T): T {
239
+ if (!isDraftable(target)) {
240
+ return isDraft(target) ? (getValue(target as object) as T) : target
241
+ }
242
+ if (Array.isArray(target)) {
243
+ return target.map(deepClone) as T
244
+ }
245
+ const copy: Record<string, unknown> = {}
246
+ for (const key in target) {
247
+ if (has(target, key)) {
248
+ copy[key] = deepClone((target as Record<string, unknown>)[key])
249
+ }
250
+ }
251
+ return copy as T
252
+ }
253
+
254
+ /**
255
+ * Clone if the value is a draft, otherwise return as-is
256
+ */
257
+ export function cloneIfNeeded<T>(target: T): T {
258
+ return isDraft(target) ? deepClone(target) : target
259
+ }
260
+
261
+ // ============================================================================
262
+ // Draft State Utilities
263
+ // ============================================================================
264
+
265
+ /**
266
+ * Mark a draft as changed (operated)
267
+ */
268
+ export function markChanged(target: ProxyDraft): void {
269
+ if (!target.operated) {
270
+ target.operated = true
271
+ if (target.parent) {
272
+ markChanged(target.parent)
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Iterate over object/array entries
279
+ */
280
+ export function forEach<T extends object>(
281
+ target: T,
282
+ callback: (key: PropertyKey, value: unknown, target: T) => void
283
+ ): void {
284
+ if (Array.isArray(target)) {
285
+ for (let i = 0; i < target.length; i++) {
286
+ callback(i, target[i], target)
287
+ }
288
+ } else {
289
+ for (const key of Reflect.ownKeys(target)) {
290
+ callback(key, (target as Record<PropertyKey, unknown>)[key], target)
291
+ }
292
+ }
293
+ }
294
+
295
+ // ============================================================================
296
+ // Finalization Utilities
297
+ // ============================================================================
298
+
299
+ /**
300
+ * Handle nested values during finalization
301
+ */
302
+ export function handleValue(target: unknown, handledSet: WeakSet<object>): void {
303
+ if (
304
+ !isDraftable(target) ||
305
+ isDraft(target) ||
306
+ handledSet.has(target as object) ||
307
+ Object.isFrozen(target)
308
+ ) {
309
+ return
310
+ }
311
+
312
+ handledSet.add(target as object)
313
+
314
+ forEach(target as object, (key, value) => {
315
+ if (isDraft(value)) {
316
+ const proxyDraft = getProxyDraft(value)!
317
+ ensureShallowCopy(proxyDraft)
318
+ const updatedValue =
319
+ proxyDraft.assignedMap?.size || proxyDraft.operated ? proxyDraft.copy : proxyDraft.original
320
+ set(target as object, key, updatedValue)
321
+ } else {
322
+ handleValue(value, handledSet)
323
+ }
324
+ })
325
+ }