@@ -12,18 +12,20 @@ import {
1212import type { FunctionKey } from './helpers.js' ;
1313import {
1414 formatFilterLiteral ,
15+ parseAtomPath ,
1516 atomicPathToAtomPath ,
1617 atomPathToAtomicPath ,
1718 extractKeyFromAtomicPath ,
1819} from './atomPath.js' ;
1920
2021// ─── Types ──────────────────────────────────────────────────────────────────
2122
22- export type AtomOp = 'add' | 'remove' | 'replace' ;
23+ export type AtomOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' ;
2324
2425export interface IAtomOperation {
2526 op : AtomOp ;
2627 path : string ;
28+ from ?: string ;
2729 value ?: any ;
2830 oldValue ?: any ;
2931 [ key : string ] : any ;
@@ -66,7 +68,7 @@ export function validateAtom(atom: unknown): { valid: boolean; errors: string[]
6668 errors . push ( `operations[${ i } ]: must be an object` ) ;
6769 continue ;
6870 }
69- if ( ! [ 'add' , 'remove' , 'replace' ] . includes ( op . op ) ) {
71+ if ( ! [ 'add' , 'remove' , 'replace' , 'move' , 'copy' ] . includes ( op . op ) ) {
7072 errors . push ( `operations[${ i } ]: invalid op '${ op . op } '` ) ;
7173 }
7274 if ( typeof op . path !== 'string' ) {
@@ -86,6 +88,33 @@ export function validateAtom(atom: unknown): { valid: boolean; errors: string[]
8688 if ( op . op === 'replace' && ! ( 'value' in op ) ) {
8789 errors . push ( `operations[${ i } ]: replace operation must have value` ) ;
8890 }
91+ if ( op . op === 'move' ) {
92+ if ( typeof op . from !== 'string' ) {
93+ errors . push ( `operations[${ i } ]: move operation must have from (string)` ) ;
94+ }
95+ if ( 'value' in op ) {
96+ errors . push ( `operations[${ i } ]: move operation must not have value` ) ;
97+ }
98+ if ( 'oldValue' in op ) {
99+ errors . push ( `operations[${ i } ]: move operation must not have oldValue` ) ;
100+ }
101+ if ( typeof op . from === 'string' && typeof op . path === 'string' ) {
102+ if ( op . from === op . path ) {
103+ errors . push ( `operations[${ i } ]: move operation from must not equal path (self-move)` ) ;
104+ }
105+ if ( op . from !== '$' && ( op . path . startsWith ( op . from + '.' ) || op . path . startsWith ( op . from + '[' ) ) ) {
106+ errors . push ( `operations[${ i } ]: move operation path must not be a subtree of from` ) ;
107+ }
108+ }
109+ }
110+ if ( op . op === 'copy' ) {
111+ if ( typeof op . from !== 'string' ) {
112+ errors . push ( `operations[${ i } ]: copy operation must have from (string)` ) ;
113+ }
114+ if ( 'oldValue' in op ) {
115+ errors . push ( `operations[${ i } ]: copy operation must not have oldValue` ) ;
116+ }
117+ }
89118 }
90119 }
91120
@@ -463,6 +492,10 @@ export function fromAtom(atom: IJsonAtom): IAtomicChange[] {
463492 }
464493
465494 return atom . operations . map ( ( op ) => {
495+ if ( op . op === 'move' || op . op === 'copy' ) {
496+ throw new Error ( `${ op . op } operations cannot be converted to v4 atomic changes` ) ;
497+ }
498+
466499 const atomicPath = atomPathToAtomicPath ( op . path ) ;
467500 const key = extractKeyFromAtomicPath ( atomicPath ) ;
468501
@@ -519,14 +552,17 @@ export function invertAtom(atom: IJsonAtom): IJsonAtom {
519552 if ( op . op === 'remove' && ! ( 'oldValue' in op ) ) {
520553 throw new Error ( `operations[${ i } ]: remove operation missing oldValue — atom is not reversible` ) ;
521554 }
555+ if ( op . op === 'copy' && ! ( 'value' in op ) ) {
556+ throw new Error ( `operations[${ i } ]: copy operation missing value — atom is not reversible` ) ;
557+ }
522558 }
523559
524560 // Reverse the operations array and invert each operation
525561 const invertedOps : IAtomOperation [ ] = [ ...atom . operations ] . reverse ( ) . map ( ( op ) => {
526562 // Preserve extension properties (any key not in standard set)
527563 const extensions : Record < string , any > = { } ;
528564 for ( const key of Object . keys ( op ) ) {
529- if ( ! [ 'op' , 'path' , 'value' , 'oldValue' ] . includes ( key ) ) {
565+ if ( ! [ 'op' , 'path' , 'from' , ' value', 'oldValue' ] . includes ( key ) ) {
530566 extensions [ key ] = op [ key ] ;
531567 }
532568 }
@@ -538,6 +574,10 @@ export function invertAtom(atom: IJsonAtom): IJsonAtom {
538574 return { op : 'add' as AtomOp , path : op . path , value : op . oldValue , ...extensions } ;
539575 case 'replace' :
540576 return { op : 'replace' as AtomOp , path : op . path , value : op . oldValue , oldValue : op . value , ...extensions } ;
577+ case 'move' :
578+ return { op : 'move' as AtomOp , from : op . path , path : op . from ! , ...extensions } ;
579+ case 'copy' :
580+ return { op : 'remove' as AtomOp , path : op . path , oldValue : op . value , ...extensions } ;
541581 /* istanbul ignore next -- exhaustive switch */
542582 default :
543583 throw new Error ( `Unknown operation: ${ op . op } ` ) ;
@@ -557,6 +597,55 @@ export function invertAtom(atom: IJsonAtom): IJsonAtom {
557597
558598// ─── applyAtom ─────────────────────────────────────────────────────────────
559599
600+ const NESTED_PATH_RE = / ^ [ a - z A - Z _ ] [ a - z A - Z 0 - 9 _ ] * (?: \. [ a - z A - Z _ ] [ a - z A - Z 0 - 9 _ ] * ) * $ / ;
601+
602+ /**
603+ * Resolve a value at a JSON Atom path within an object.
604+ * Uses parseAtomPath for correct handling of all path forms.
605+ */
606+ function resolveValueAtPath ( obj : any , atomPath : string ) : any {
607+ const segments = parseAtomPath ( atomPath ) ;
608+ let current = obj ;
609+ for ( const seg of segments ) {
610+ switch ( seg . type ) {
611+ case 'root' :
612+ break ;
613+ case 'property' :
614+ if ( current == null || typeof current !== 'object' ) {
615+ throw new Error ( `Cannot access property '${ seg . name } ' on ${ current === null ? 'null' : typeof current } at path: ${ atomPath } ` ) ;
616+ }
617+ current = current [ seg . name ] ;
618+ break ;
619+ case 'index' :
620+ if ( ! Array . isArray ( current ) ) {
621+ throw new Error ( `Cannot access index ${ seg . index } on non-array at path: ${ atomPath } ` ) ;
622+ }
623+ current = current [ seg . index ] ;
624+ break ;
625+ case 'keyFilter' : {
626+ if ( ! Array . isArray ( current ) ) {
627+ throw new Error ( `Cannot apply key filter on non-array at path: ${ atomPath } ` ) ;
628+ }
629+ const prop = seg . property ;
630+ const isPath = ! seg . literalKey && prop . includes ( '.' ) && NESTED_PATH_RE . test ( prop ) ;
631+ current = current . find ( ( el : any ) => {
632+ const resolved = isPath ? prop . split ( '.' ) . reduce ( ( c : any , s : string ) => c ?. [ s ] , el ) : el [ prop ] ;
633+ return JSON . stringify ( resolved ) === JSON . stringify ( seg . value ) ;
634+ } ) ;
635+ break ;
636+ }
637+ case 'valueFilter' : {
638+ if ( ! Array . isArray ( current ) ) {
639+ throw new Error ( `Cannot apply value filter on non-array at path: ${ atomPath } ` ) ;
640+ }
641+ current = current . find ( ( el : any ) => JSON . stringify ( el ) === JSON . stringify ( seg . value ) ) ;
642+ break ;
643+ }
644+ }
645+ }
646+ return current ;
647+ }
648+
560649/**
561650 * Apply a JSON Atom document to an object.
562651 * Processes operations sequentially. Handles root operations directly.
@@ -571,7 +660,36 @@ export function applyAtom(obj: any, atom: IJsonAtom): any {
571660 let result : any = obj ;
572661
573662 for ( const op of atom . operations ) {
574- if ( op . path === '$' ) {
663+ if ( op . op === 'move' ) {
664+ // Read value at from
665+ const value = resolveValueAtPath ( result , op . from ! ) ;
666+ // Remove from source
667+ const removeOp : IAtomOperation = { op : 'remove' , path : op . from ! , oldValue : value } ;
668+ if ( removeOp . path === '$' ) {
669+ result = applyRootOp ( result , removeOp ) ;
670+ } else {
671+ const removeChange = atomOpToAtomicChange ( removeOp ) ;
672+ applyChangeset ( result , unatomizeChangeset ( [ removeChange ] ) ) ;
673+ }
674+ // Add to target
675+ const addOp : IAtomOperation = { op : 'add' , path : op . path , value } ;
676+ if ( addOp . path === '$' ) {
677+ result = applyRootOp ( result , addOp ) ;
678+ } else {
679+ const addChange = atomOpToAtomicChange ( addOp ) ;
680+ applyChangeset ( result , unatomizeChangeset ( [ addChange ] ) ) ;
681+ }
682+ } else if ( op . op === 'copy' ) {
683+ const source = resolveValueAtPath ( result , op . from ! ) ;
684+ const value = source === undefined ? undefined : JSON . parse ( JSON . stringify ( source ) ) ;
685+ const addOp : IAtomOperation = { op : 'add' , path : op . path , value } ;
686+ if ( addOp . path === '$' ) {
687+ result = applyRootOp ( result , addOp ) ;
688+ } else {
689+ const addChange = atomOpToAtomicChange ( addOp ) ;
690+ applyChangeset ( result , unatomizeChangeset ( [ addChange ] ) ) ;
691+ }
692+ } else if ( op . path === '$' ) {
575693 result = applyRootOp ( result , op ) ;
576694 } else {
577695 const atomicChange = atomOpToAtomicChange ( op ) ;
0 commit comments